mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7315d255a | ||
|
|
f003d2931f | ||
|
|
6f7c557065 | ||
|
|
ecb6ee46b1 | ||
|
|
354967010f | ||
|
|
57122f31a3 | ||
|
|
cbbcec0d14 | ||
|
|
de38c35b8a | ||
|
|
def996ddf4 | ||
|
|
790e32e96b | ||
|
|
fd75c4d87f | ||
|
|
411a99cbc4 | ||
|
|
d2c6ab72b2 | ||
|
|
3656584eda | ||
|
|
8be370098d | ||
|
|
45057cb6df | ||
|
|
3f24484d60 | ||
|
|
b6d50a22b4 | ||
|
|
8a658210e1 | ||
|
|
583aaaa080 | ||
|
|
22ca4f64e8 | ||
|
|
32e798fcaa | ||
|
|
ced81c8b50 | ||
|
|
7ec4b71101 | ||
|
|
94aa58d380 | ||
|
|
f8d88e6f97 | ||
|
|
a95f6309b0 | ||
|
|
502de018af | ||
|
|
a3e8daad33 | ||
|
|
78a2f65c94 | ||
|
|
1689a6833a | ||
|
|
6d2f32eadf | ||
|
|
c549dd50c9 | ||
|
|
82312e9421 | ||
|
|
e13b367188 | ||
|
|
d73049cc1b | ||
|
|
4373b23cd3 | ||
|
|
73eb6ccf41 | ||
|
|
6ca48d0d56 | ||
|
|
b82599005e | ||
|
|
b044053674 | ||
|
|
e4f03bcd94 | ||
|
|
ac3797912f | ||
|
|
429a1f6e7e | ||
|
|
fab20e0796 | ||
|
|
1bc6baa0b0 | ||
|
|
996e0748f8 | ||
|
|
ca46fdfaa4 | ||
|
|
b480797e5b | ||
|
|
c47fdf847e | ||
|
|
3ca9562013 | ||
|
|
3104d5de28 | ||
|
|
fd141cbc8c | ||
|
|
aa0c15b6b3 | ||
|
|
4630a3e551 | ||
|
|
b70d5c52f1 | ||
|
|
5d8635ba7b | ||
|
|
27dad9d9d5 | ||
|
|
abb4417b15 | ||
|
|
388cca5591 | ||
|
|
8aa019e25f | ||
|
|
e4061cfbde | ||
|
|
9a1f20e2d4 | ||
|
|
3b45736729 | ||
|
|
1d88fa450a | ||
|
|
31ee6be163 | ||
|
|
096a2ca5f4 | ||
|
|
fb04f6f64d | ||
|
|
d8e54aa347 | ||
|
|
b2b6b421ba | ||
|
|
c6b35f02f0 | ||
|
|
7e43b0a6b8 | ||
|
|
2915b287ba | ||
|
|
ae561db0c9 | ||
|
|
2d132747c4 | ||
|
|
2848163a04 | ||
|
|
76aa6e11d2 | ||
|
|
d377df15b4 | ||
|
|
334c17cc28 | ||
|
|
f148b5bd3a | ||
|
|
54af649d76 | ||
|
|
f6e5a5fa56 | ||
|
|
01aa24d0db | ||
|
|
3e702ef72e | ||
|
|
a31218f7e5 | ||
|
|
5decb3ed95 | ||
|
|
da61436fbb | ||
|
|
b6025af0c8 | ||
|
|
ab914677c4 | ||
|
|
c0b421f812 | ||
|
|
2a446c800f | ||
|
|
dc541c0298 | ||
|
|
4d9b8b5ba4 | ||
|
|
566a07d3a4 | ||
|
|
921ce6f13a | ||
|
|
708e09a8cc | ||
|
|
04154b84f6 | ||
|
|
2bc9b09e58 | ||
|
|
16d517dcd0 | ||
|
|
5e8d09b010 | ||
|
|
6fc3535449 | ||
|
|
9158dba0ce | ||
|
|
7b2c431ca3 | ||
|
|
9deb460cfc | ||
|
|
2ce6f2473b | ||
|
|
04d8444168 | ||
|
|
b539c4ba76 | ||
|
|
473bc089c7 | ||
|
|
50f405eb77 | ||
|
|
fd0d3bcbcf | ||
|
|
f2e8ab0387 | ||
|
|
dfbf2748c7 | ||
|
|
57b0ba5916 | ||
|
|
e668fb0faf | ||
|
|
2702203cb5 | ||
|
|
27f17a2451 | ||
|
|
98c2492d21 | ||
|
|
04339cbb9a | ||
|
|
cdb3b1d257 | ||
|
|
0913769729 | ||
|
|
3a1d5b77b5 | ||
|
|
e12002ce86 | ||
|
|
676a6fe72d | ||
|
|
f29f8938fe | ||
|
|
4c0e147786 | ||
|
|
cba61118de | ||
|
|
32dc6b3665 | ||
|
|
7d98e664e9 | ||
|
|
6763403700 |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -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
92
.github/workflows/docker.yml
vendored
Normal 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
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/update-readme.yml
vendored
6
.github/workflows/update-readme.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
50
CLAUDE.md
50
CLAUDE.md
@@ -10,19 +10,33 @@
|
||||
|
||||
```
|
||||
compose_farm/
|
||||
├── config.py # Pydantic models, YAML loading
|
||||
├── ssh.py # asyncssh execution, streaming
|
||||
└── cli.py # Typer commands
|
||||
├── cli/ # CLI subpackage
|
||||
│ ├── __init__.py # Main Typer app, 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
|
||||
3. **Streaming output**: Real-time stdout/stderr with `[service]` prefix using Rich
|
||||
4. **SSH key auth only**: Uses ssh-agent, no password handling (YAGNI)
|
||||
5. **NFS assumption**: Compose files at same path on all hosts
|
||||
6. **Local execution**: When host is `localhost`/`local`, skip SSH and run locally
|
||||
6. **Local IP auto-detection**: Skips SSH when target host matches local machine's IP
|
||||
7. **State tracking**: Tracks where services are deployed for auto-migration
|
||||
8. **Pre-flight checks**: Verifies NFS mounts and Docker networks exist before starting/migrating
|
||||
|
||||
## Communication Notes
|
||||
|
||||
@@ -36,12 +50,20 @@ compose_farm/
|
||||
|
||||
## Commands Quick Reference
|
||||
|
||||
| Command | Docker Compose Equivalent |
|
||||
|---------|--------------------------|
|
||||
| `up` | `docker compose up -d` |
|
||||
| `down` | `docker compose down` |
|
||||
| `pull` | `docker compose pull` |
|
||||
| `restart` | `down` + `up -d` |
|
||||
CLI available as `cf` or `compose-farm`.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `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` | `docker compose logs` |
|
||||
| `ps` | `docker compose ps` |
|
||||
| `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
16
Dockerfile
Normal 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"]
|
||||
35
PLAN.md
35
PLAN.md
@@ -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`.)
|
||||
317
README.md
317
README.md
@@ -1,5 +1,12 @@
|
||||
# Compose Farm
|
||||
|
||||
[](https://pypi.org/project/compose-farm/)
|
||||
[](https://pypi.org/project/compose-farm/)
|
||||
[](LICENSE)
|
||||
[](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.
|
||||
|
||||
> [!NOTE]
|
||||
@@ -9,44 +16,80 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
|
||||
- [Why Compose Farm?](#why-compose-farm)
|
||||
- [Key Assumption: Shared Storage](#key-assumption-shared-storage)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Requirements](#requirements)
|
||||
- [Limitations & Best Practices](#limitations--best-practices)
|
||||
- [What breaks when you move a service](#what-breaks-when-you-move-a-service)
|
||||
- [Best practices](#best-practices)
|
||||
- [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)
|
||||
- [Requirements](#requirements)
|
||||
- [How It Works](#how-it-works)
|
||||
- [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.
|
||||
|
||||
## Key Assumption: Shared Storage
|
||||
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.
|
||||
|
||||
Compose Farm assumes **all your compose files are accessible at the same path on all hosts**. This is typically achieved via:
|
||||
<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
|
||||
|
||||
1. You run `cf up plex`
|
||||
2. Compose Farm looks up which host runs `plex` (e.g., `server-1`)
|
||||
3. It SSHs to `server-1` (or runs locally if `localhost`)
|
||||
4. It executes `docker compose -f /opt/compose/plex/docker-compose.yml up -d`
|
||||
5. Output is streamed back with `[plex]` prefix
|
||||
|
||||
That's it. No orchestration, no service discovery, no magic.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11+ (we recommend [uv](https://docs.astral.sh/uv/) for installation)
|
||||
- SSH key-based authentication to your hosts (uses ssh-agent)
|
||||
- Docker and Docker Compose installed on all target hosts
|
||||
- **Shared storage**: All compose files must be accessible at the same path on all hosts
|
||||
- **Docker networks**: External networks must exist on all hosts (use `cf init-network` to create)
|
||||
|
||||
Compose Farm assumes your compose files are accessible at the same path on all hosts. This is typically achieved via:
|
||||
|
||||
- **NFS mount** (e.g., `/opt/compose` mounted from a NAS)
|
||||
- **Synced folders** (e.g., Syncthing, rsync)
|
||||
- **Shared filesystem** (e.g., GlusterFS, Ceph)
|
||||
|
||||
```
|
||||
# Example: NFS mount on all hosts
|
||||
nas:/volume1/compose → /opt/compose (on nas01)
|
||||
nas:/volume1/compose → /opt/compose (on nas02)
|
||||
nas:/volume1/compose → /opt/compose (on nas03)
|
||||
# Example: NFS mount on all Docker hosts
|
||||
nas:/volume1/compose → /opt/compose (on server-1)
|
||||
nas:/volume1/compose → /opt/compose (on server-2)
|
||||
nas:/volume1/compose → /opt/compose (on server-3)
|
||||
```
|
||||
|
||||
Compose Farm simply runs `docker compose -f /opt/compose/{service}/docker-compose.yml` on the appropriate host—it doesn't copy or sync files.
|
||||
@@ -85,11 +128,28 @@ If you need containers on different hosts to communicate seamlessly, you need Do
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install compose-farm
|
||||
uv tool install compose-farm
|
||||
# or
|
||||
uv pip 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):
|
||||
@@ -98,74 +158,209 @@ Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in y
|
||||
compose_dir: /opt/compose # Must be the same path on all hosts
|
||||
|
||||
hosts:
|
||||
nas01:
|
||||
server-1:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
nas02:
|
||||
server-2:
|
||||
address: 192.168.1.11
|
||||
# user defaults to current user
|
||||
local: localhost # Run locally without SSH
|
||||
|
||||
services:
|
||||
plex: nas01
|
||||
jellyfin: nas02
|
||||
sonarr: nas01
|
||||
plex: server-1
|
||||
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)
|
||||
compose-farm up plex jellyfin
|
||||
compose-farm up --all
|
||||
cf up plex jellyfin
|
||||
cf up --all
|
||||
cf up --migrate # only services needing migration (state ≠ config)
|
||||
|
||||
# Stop services
|
||||
compose-farm down plex
|
||||
cf down plex
|
||||
|
||||
# Pull latest images
|
||||
compose-farm pull --all
|
||||
cf pull --all
|
||||
|
||||
# Restart (down + up)
|
||||
compose-farm restart plex
|
||||
cf restart plex
|
||||
|
||||
# Update (pull + down + up) - the end-to-end update command
|
||||
compose-farm update --all
|
||||
cf update --all
|
||||
|
||||
# Sync state with reality (discovers running services + captures image digests)
|
||||
compose-farm sync # updates state.yaml and dockerfarm-log.toml
|
||||
compose-farm sync --dry-run # preview without writing
|
||||
cf sync # updates state.yaml and dockerfarm-log.toml
|
||||
cf sync --dry-run # preview without writing
|
||||
|
||||
# Validate config, traefik labels, mounts, and networks
|
||||
cf check # full validation (includes SSH checks)
|
||||
cf check --local # fast validation (skip SSH)
|
||||
cf check jellyfin # check service + show which hosts can run it
|
||||
|
||||
# Create Docker network on new hosts (before migrating services)
|
||||
cf init-network nuc hp # create mynetwork on specific hosts
|
||||
cf init-network # create on all hosts
|
||||
|
||||
# View logs
|
||||
compose-farm logs plex
|
||||
compose-farm logs -f plex # follow
|
||||
cf logs plex
|
||||
cf logs -f plex # follow
|
||||
|
||||
# Show status
|
||||
compose-farm ps
|
||||
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:
|
||||
1. Runs `down` on the old host
|
||||
2. Runs `up -d` on the new host
|
||||
3. Updates state tracking
|
||||
1. Checks that required mounts and networks exist on the new host (aborts if missing)
|
||||
2. Runs `down` on the old host
|
||||
3. Runs `up -d` on the new host
|
||||
4. Updates state tracking
|
||||
|
||||
Use `cf 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 nas01
|
||||
# Before: plex runs on server-1
|
||||
services:
|
||||
plex: nas01
|
||||
plex: server-1
|
||||
|
||||
# After: change to nas02, then run `compose-farm up plex`
|
||||
# After: change to server-2, then run `cf up plex`
|
||||
services:
|
||||
plex: nas02 # Compose Farm will migrate automatically
|
||||
plex: server-2 # Compose Farm will migrate automatically
|
||||
```
|
||||
|
||||
## Traefik Multihost Ingress (File Provider)
|
||||
|
||||
If you run a single Traefik instance on one “front‑door” host and want it to route to
|
||||
If you run a single Traefik instance on one "front‑door" host and want it to route to
|
||||
Compose Farm services on other hosts, Compose Farm can generate a Traefik file‑provider
|
||||
fragment from your existing compose labels.
|
||||
|
||||
@@ -174,11 +369,11 @@ fragment from your existing compose labels.
|
||||
- Your `docker-compose.yml` remains the source of truth. Put normal `traefik.*` labels on
|
||||
the container you want exposed.
|
||||
- Labels and port specs may use `${VAR}` / `${VAR:-default}`; Compose Farm resolves these
|
||||
using the stack’s `.env` file and your current environment, just like Docker Compose.
|
||||
using the stack's `.env` file and your current environment, just like Docker Compose.
|
||||
- Publish a host port for that container (via `ports:`). The generator prefers
|
||||
host‑published ports so Traefik can reach the service across hosts; if none are found,
|
||||
it warns and you’d need L3 reachability to container IPs.
|
||||
- If a router label doesn’t specify `traefik.http.routers.<name>.service` and there’s only
|
||||
it warns and you'd need L3 reachability to container IPs.
|
||||
- If a router label doesn't specify `traefik.http.routers.<name>.service` and there's only
|
||||
one Traefik service defined on that container, Compose Farm wires the router to it.
|
||||
- `compose-farm.yaml` stays unchanged: just `hosts` and `services: service → host`.
|
||||
|
||||
@@ -211,7 +406,7 @@ providers:
|
||||
**Generate the fragment**
|
||||
|
||||
```bash
|
||||
compose-farm traefik-file --all --output /mnt/data/traefik/dynamic.d/compose-farm.yml
|
||||
cf traefik-file --all --output /mnt/data/traefik/dynamic.d/compose-farm.yml
|
||||
```
|
||||
|
||||
Re‑run this after changing Traefik labels, moving a service to another host, or changing
|
||||
@@ -230,15 +425,15 @@ traefik_service: traefik # skip services on same host (docker provider handles
|
||||
hosts:
|
||||
# ...
|
||||
services:
|
||||
traefik: nas01 # Traefik runs here
|
||||
plex: nas02 # Services on other hosts get file-provider entries
|
||||
traefik: server-1 # Traefik runs here
|
||||
plex: server-2 # Services on other hosts get file-provider entries
|
||||
# ...
|
||||
```
|
||||
|
||||
The `traefik_service` option specifies which service runs Traefik. Services on the same host
|
||||
are skipped in the file-provider config since Traefik's docker provider handles them directly.
|
||||
|
||||
Now `compose-farm up plex` will update the Traefik config automatically—no separate
|
||||
Now `cf up plex` will update the Traefik config automatically—no separate
|
||||
`traefik-file` command needed.
|
||||
|
||||
**Combining with existing config**
|
||||
@@ -249,7 +444,7 @@ directory and Traefik will merge all files:
|
||||
```bash
|
||||
mkdir -p /opt/traefik/dynamic.d
|
||||
mv /opt/traefik/dynamic.yml /opt/traefik/dynamic.d/manual.yml
|
||||
compose-farm traefik-file --all -o /opt/traefik/dynamic.d/compose-farm.yml
|
||||
cf traefik-file --all -o /opt/traefik/dynamic.d/compose-farm.yml
|
||||
```
|
||||
|
||||
Update your Traefik config to use directory watching instead of a single file:
|
||||
@@ -263,22 +458,30 @@ Update your Traefik config to use directory watching instead of a single file:
|
||||
- --providers.file.watch=true
|
||||
```
|
||||
|
||||
## Requirements
|
||||
## Comparison with Alternatives
|
||||
|
||||
- Python 3.11+
|
||||
- SSH key-based authentication to your hosts (uses ssh-agent)
|
||||
- Docker and Docker Compose installed on all target hosts
|
||||
- **Shared storage**: All compose files at the same path on all hosts (NFS, Syncthing, etc.)
|
||||
There are many ways to run containers on multiple hosts. Here is where Compose Farm sits:
|
||||
|
||||
## How It Works
|
||||
| | Docker Contexts | K8s / Swarm | Ansible / Terraform | Portainer / Coolify | Compose Farm |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|
|
||||
| No compose rewrites | ✅ | ❌ | ✅ | ✅ | ✅ |
|
||||
| Version controlled | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
| State tracking | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Auto-migration | ❌ | ✅ | ❌ | ❌ | ✅ |
|
||||
| Interactive CLI | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
| Parallel execution | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Agentless | ✅ | ❌ | ✅ | ❌ | ✅ |
|
||||
| High availability | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||
|
||||
1. You run `compose-farm up plex`
|
||||
2. Compose Farm looks up which host runs `plex` (e.g., `nas01`)
|
||||
3. It SSHs to `nas01` (or runs locally if `localhost`)
|
||||
4. It executes `docker compose -f /opt/compose/plex/docker-compose.yml up -d`
|
||||
5. Output is streamed back with `[plex]` prefix
|
||||
**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.
|
||||
|
||||
That's it. No orchestration, no service discovery, no magic.
|
||||
**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
|
||||
|
||||
|
||||
@@ -3,23 +3,28 @@
|
||||
|
||||
compose_dir: /opt/compose
|
||||
|
||||
# Optional: Auto-regenerate Traefik file-provider config after up/down/restart/update
|
||||
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_service: traefik # Skip services on same host (docker provider handles them)
|
||||
|
||||
hosts:
|
||||
# Full form with all options
|
||||
nas01:
|
||||
server-1:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
port: 22
|
||||
|
||||
# Short form (just address, user defaults to current user)
|
||||
nas02: 192.168.1.11
|
||||
server-2: 192.168.1.11
|
||||
|
||||
# Local execution (no SSH)
|
||||
local: localhost
|
||||
|
||||
services:
|
||||
# Map service names to hosts
|
||||
# Compose file expected at: {compose_dir}/{service}/docker-compose.yml
|
||||
plex: nas01
|
||||
jellyfin: nas02
|
||||
sonarr: nas01
|
||||
radarr: nas02
|
||||
# Compose file expected at: {compose_dir}/{service}/compose.yaml
|
||||
traefik: server-1 # Traefik runs here
|
||||
plex: server-2 # Services on other hosts get file-provider entries
|
||||
jellyfin: server-2
|
||||
sonarr: server-1
|
||||
radarr: local
|
||||
|
||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal 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
|
||||
128
docs/dev/future-improvements.md
Normal file
128
docs/dev/future-improvements.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Future Improvements
|
||||
|
||||
Low-priority improvements identified during code review. These are not currently causing issues but could be addressed if they become pain points.
|
||||
|
||||
## 1. State Module Efficiency (LOW)
|
||||
|
||||
**Current:** Every state operation reads and writes the entire file.
|
||||
|
||||
```python
|
||||
def set_service_host(config, service, host):
|
||||
state = load_state(config) # Read file
|
||||
state[service] = host
|
||||
save_state(config, state) # Write file
|
||||
```
|
||||
|
||||
**Impact:** With 87 services, this is fine. With 1000+, it would be slow.
|
||||
|
||||
**Potential fix:** Add batch operations:
|
||||
```python
|
||||
def update_state(config, updates: dict[str, str | None]) -> None:
|
||||
"""Batch update: set services to hosts, None means remove."""
|
||||
state = load_state(config)
|
||||
for service, host in updates.items():
|
||||
if host is None:
|
||||
state.pop(service, None)
|
||||
else:
|
||||
state[service] = host
|
||||
save_state(config, state)
|
||||
```
|
||||
|
||||
**When to do:** Only if state operations become noticeably slow.
|
||||
|
||||
---
|
||||
|
||||
## 2. Remote-Aware Compose Path Resolution (LOW)
|
||||
|
||||
**Current:** `config.get_compose_path()` checks if files exist on the local filesystem:
|
||||
|
||||
```python
|
||||
def get_compose_path(self, service: str) -> Path:
|
||||
for filename in ("compose.yaml", "compose.yml", ...):
|
||||
candidate = service_dir / filename
|
||||
if candidate.exists(): # Local check!
|
||||
return candidate
|
||||
```
|
||||
|
||||
**Why this works:** NFS/shared storage means local = remote.
|
||||
|
||||
**Why it could break:** If running compose-farm from a machine without the NFS mount, it returns `compose.yaml` (the default) even if `docker-compose.yml` exists on the remote host.
|
||||
|
||||
**Potential fix:** Query the remote host for file existence, or accept this limitation and document it.
|
||||
|
||||
**When to do:** Only if users need to run compose-farm from non-NFS machines.
|
||||
|
||||
---
|
||||
|
||||
## 3. Add Integration Tests for CLI Commands (MEDIUM)
|
||||
|
||||
**Current:** No integration tests for the actual CLI commands. Tests cover the underlying functions but not the Typer commands themselves.
|
||||
|
||||
**Potential fix:** Add integration tests using `CliRunner` from Typer:
|
||||
|
||||
```python
|
||||
from typer.testing import CliRunner
|
||||
from compose_farm.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
def test_check_command_validates_config():
|
||||
result = runner.invoke(app, ["check", "--local"])
|
||||
assert result.exit_code == 0
|
||||
```
|
||||
|
||||
**When to do:** When CLI behavior becomes complex enough to warrant dedicated testing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Add Tests for operations.py (MEDIUM)
|
||||
|
||||
**Current:** Operations module has 30% coverage. Most logic is tested indirectly through test_sync.py.
|
||||
|
||||
**Potential fix:** Add dedicated tests for:
|
||||
- `up_services()` with migration scenarios
|
||||
- `preflight_check()`
|
||||
- `check_host_compatibility()`
|
||||
|
||||
**When to do:** When adding new operations or modifying migration logic.
|
||||
|
||||
---
|
||||
|
||||
## 5. Consider Structured Logging (LOW)
|
||||
|
||||
**Current:** Operations print directly to console using Rich. This couples the operations module to the Rich library.
|
||||
|
||||
**Potential fix:** Use Python's logging module with a custom Rich handler:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In operations:
|
||||
logger.info("Migrating %s from %s to %s", service, old_host, new_host)
|
||||
|
||||
# In cli.py - configure Rich handler:
|
||||
from rich.logging import RichHandler
|
||||
logging.basicConfig(handlers=[RichHandler()])
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Operations become testable without capturing stdout
|
||||
- Logs can be redirected to files
|
||||
- Log levels provide filtering
|
||||
|
||||
**When to do:** Only if console output coupling becomes a problem for testing or extensibility.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions to Keep
|
||||
|
||||
These patterns are working well and should be preserved:
|
||||
|
||||
1. **asyncio + asyncssh** - Solid async foundation
|
||||
2. **Pydantic models** - Clean validation
|
||||
3. **Rich for output** - Good UX
|
||||
4. **Test structure** - Good coverage
|
||||
5. **Module separation** - cli/operations/executor/compose pattern
|
||||
6. **KISS principle** - Don't over-engineer
|
||||
169
docs/truenas-nested-nfs.md
Normal file
169
docs/truenas-nested-nfs.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# TrueNAS NFS: Accessing Child ZFS Datasets
|
||||
|
||||
When NFS-exporting a parent ZFS dataset on TrueNAS, child datasets appear as **empty directories** to NFS clients. This document explains the problem and provides a workaround.
|
||||
|
||||
## The Problem
|
||||
|
||||
TrueNAS structures storage as ZFS datasets. A common pattern is:
|
||||
|
||||
```
|
||||
tank/data <- parent dataset (NFS exported)
|
||||
tank/data/app1 <- child dataset
|
||||
tank/data/app2 <- child dataset
|
||||
```
|
||||
|
||||
When you create an NFS share for `tank/data`, clients mount it and see the `app1/` and `app2/` directories—but they're empty. This happens because each ZFS dataset is a separate filesystem, and NFS doesn't traverse into child filesystems by default.
|
||||
|
||||
## The Solution: `crossmnt`
|
||||
|
||||
The NFS `crossmnt` export option tells the server to allow clients to traverse into child filesystems. However, TrueNAS doesn't expose this option in the UI.
|
||||
|
||||
### Workaround Script
|
||||
|
||||
This Python script injects `crossmnt` into `/etc/exports`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add crossmnt to TrueNAS NFS exports for child dataset visibility.
|
||||
|
||||
Usage: fix-nfs-crossmnt.py /mnt/pool/dataset
|
||||
|
||||
Setup:
|
||||
1. scp fix-nfs-crossmnt.py root@truenas.local:/root/
|
||||
2. chmod +x /root/fix-nfs-crossmnt.py
|
||||
3. Test: /root/fix-nfs-crossmnt.py /mnt/pool/dataset
|
||||
4. Add cron job: TrueNAS UI > System > Advanced > Cron Jobs
|
||||
Command: /root/fix-nfs-crossmnt.py /mnt/pool/dataset
|
||||
Schedule: */5 * * * *
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
EXPORTS_FILE = Path("/etc/exports")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print(f"Usage: {sys.argv[0]} /mnt/pool/dataset", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
export_path = sys.argv[1]
|
||||
content = EXPORTS_FILE.read_text()
|
||||
|
||||
if f'"{export_path}"' not in content:
|
||||
print(f"ERROR: {export_path} not found in {EXPORTS_FILE}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
lines = content.splitlines()
|
||||
result = []
|
||||
in_block = False
|
||||
modified = False
|
||||
|
||||
for line in lines:
|
||||
if f'"{export_path}"' in line:
|
||||
in_block = True
|
||||
elif line.startswith('"'):
|
||||
in_block = False
|
||||
|
||||
if in_block and line[:1] in (" ", "\t") and "crossmnt" not in line:
|
||||
line = re.sub(r"\)(\\\s*)?$", r",crossmnt)\1", line)
|
||||
modified = True
|
||||
|
||||
result.append(line)
|
||||
|
||||
if not modified:
|
||||
return 0 # Already applied
|
||||
|
||||
EXPORTS_FILE.write_text("\n".join(result) + "\n")
|
||||
subprocess.run(["exportfs", "-ra"], check=True)
|
||||
print(f"Added crossmnt to {export_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Copy the script to TrueNAS
|
||||
|
||||
```bash
|
||||
scp fix-nfs-crossmnt.py root@truenas.local:/root/
|
||||
ssh root@truenas.local chmod +x /root/fix-nfs-crossmnt.py
|
||||
```
|
||||
|
||||
### 2. Test manually
|
||||
|
||||
```bash
|
||||
ssh root@truenas.local
|
||||
|
||||
# Run the script
|
||||
/root/fix-nfs-crossmnt.py /mnt/tank/data
|
||||
|
||||
# Verify crossmnt was added
|
||||
cat /etc/exports
|
||||
```
|
||||
|
||||
You should see `,crossmnt` added to the client options:
|
||||
|
||||
```
|
||||
"/mnt/tank/data"\
|
||||
192.168.1.10(sec=sys,rw,no_subtree_check,crossmnt)\
|
||||
192.168.1.11(sec=sys,rw,no_subtree_check,crossmnt)
|
||||
```
|
||||
|
||||
### 3. Verify on NFS client
|
||||
|
||||
```bash
|
||||
# Before: empty directory
|
||||
ls /mnt/data/app1/
|
||||
# (nothing)
|
||||
|
||||
# After: actual contents visible
|
||||
ls /mnt/data/app1/
|
||||
# config.yaml data/ logs/
|
||||
```
|
||||
|
||||
### 4. Make it persistent
|
||||
|
||||
TrueNAS regenerates `/etc/exports` when you modify NFS shares in the UI. To survive this, set up a cron job:
|
||||
|
||||
1. Go to **TrueNAS UI → System → Advanced → Cron Jobs → Add**
|
||||
2. Configure:
|
||||
- **Description:** Fix NFS crossmnt
|
||||
- **Command:** `/root/fix-nfs-crossmnt.py /mnt/tank/data`
|
||||
- **Run As User:** root
|
||||
- **Schedule:** `*/5 * * * *` (every 5 minutes)
|
||||
- **Enabled:** checked
|
||||
3. Save
|
||||
|
||||
The script is idempotent—it only modifies the file if `crossmnt` is missing, and skips the write entirely if already applied.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Parses `/etc/exports` to find the specified export block
|
||||
2. Adds `,crossmnt` before the closing `)` on each client line
|
||||
3. Writes the file only if changes were made
|
||||
4. Runs `exportfs -ra` to reload the NFS configuration
|
||||
|
||||
## Why Not Use SMB Instead?
|
||||
|
||||
SMB handles child datasets seamlessly, but:
|
||||
|
||||
- NFS is simpler for Linux-to-Linux with matching UIDs
|
||||
- SMB requires more complex permission mapping for Docker volumes
|
||||
- Many existing setups already use NFS
|
||||
|
||||
## Related Links
|
||||
|
||||
- [TrueNAS Forum: Add crossmnt option to NFS exports](https://forums.truenas.com/t/add-crossmnt-option-to-nfs-exports/10573)
|
||||
- [exports(5) man page](https://man7.org/linux/man-pages/man5/exports.5.html) - see `crossmnt` option
|
||||
|
||||
## Tested On
|
||||
|
||||
- TrueNAS SCALE 24.10
|
||||
65
docs/truenas-nfs-root-squash.md
Normal file
65
docs/truenas-nfs-root-squash.md
Normal 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
|
||||
@@ -1,42 +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
|
||||
# 1. Create the shared network on all hosts
|
||||
compose-farm init-network
|
||||
|
||||
# 2. Start Traefik first (the reverse proxy)
|
||||
compose-farm up traefik
|
||||
|
||||
# 3. Start other services
|
||||
compose-farm up mealie uptime-kuma
|
||||
|
||||
# 4. Check status
|
||||
compose-farm ps
|
||||
|
||||
# Pull images
|
||||
compose-farm pull --all
|
||||
# 5. Generate Traefik file-provider config for cross-host routing
|
||||
compose-farm traefik-file --all
|
||||
|
||||
# Start hello-world (runs and exits)
|
||||
compose-farm up hello
|
||||
# 6. View logs
|
||||
compose-farm logs mealie
|
||||
|
||||
# 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
|
||||
# 7. Stop everything
|
||||
compose-farm down --all
|
||||
```
|
||||
|
||||
## Services
|
||||
## Configuration
|
||||
|
||||
- **hello**: Simple hello-world container (exits immediately)
|
||||
- **nginx**: Nginx web server on port 8080
|
||||
The `compose-farm.yaml` shows a multi-host setup:
|
||||
|
||||
## Config
|
||||
- **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
|
||||
|
||||
The `compose-farm.yaml` in this directory configures both services to run locally (no SSH).
|
||||
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
|
||||
```
|
||||
|
||||
With `traefik_file` configured, compose-farm automatically regenerates the config after `up`, `down`, `restart`, and `update` commands.
|
||||
|
||||
4
examples/autokuma/.env
Normal file
4
examples/autokuma/.env
Normal 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
|
||||
31
examples/autokuma/compose.yaml
Normal file
31
examples/autokuma/compose.yaml
Normal 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
|
||||
9
examples/compose-farm-state.yaml
Normal file
9
examples/compose-farm-state.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
deployed:
|
||||
autokuma:
|
||||
- primary
|
||||
- secondary
|
||||
- local
|
||||
mealie: secondary
|
||||
paperless-ngx: primary
|
||||
traefik: primary
|
||||
uptime-kuma: secondary
|
||||
@@ -1,11 +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: /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
|
||||
# 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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
services:
|
||||
hello:
|
||||
image: hello-world
|
||||
container_name: sdc-hello
|
||||
2
examples/mealie/.env
Normal file
2
examples/mealie/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# Copy to .env and fill in your values
|
||||
DOMAIN=example.com
|
||||
47
examples/mealie/compose.yaml
Normal file
47
examples/mealie/compose.yaml
Normal 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
|
||||
@@ -1,6 +0,0 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: sdc-nginx
|
||||
ports:
|
||||
- "8080:80"
|
||||
3
examples/paperless-ngx/.env
Normal file
3
examples/paperless-ngx/.env
Normal 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
|
||||
60
examples/paperless-ngx/compose.yaml
Normal file
60
examples/paperless-ngx/compose.yaml
Normal 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
5
examples/traefik/.env
Normal 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
|
||||
58
examples/traefik/compose.yaml
Normal file
58
examples/traefik/compose.yaml
Normal 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
|
||||
40
examples/traefik/dynamic.d/compose-farm.yml
Normal file
40
examples/traefik/dynamic.d/compose-farm.yml
Normal file
@@ -0,0 +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
|
||||
2
examples/uptime-kuma/.env
Normal file
2
examples/uptime-kuma/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# Copy to .env and fill in your values
|
||||
DOMAIN=example.com
|
||||
43
examples/uptime-kuma/compose.yaml
Normal file
43
examples/uptime-kuma/compose.yaml
Normal 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
|
||||
@@ -3,10 +3,42 @@ name = "compose-farm"
|
||||
dynamic = ["version"]
|
||||
description = "Compose Farm - run docker compose commands across multiple hosts"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
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,8 +47,16 @@ 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"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
|
||||
@@ -1,443 +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 .logs import snapshot_services
|
||||
from .ssh import (
|
||||
CommandResult,
|
||||
check_service_running,
|
||||
run_compose,
|
||||
run_compose_on_host,
|
||||
run_on_services,
|
||||
run_sequential_on_services,
|
||||
)
|
||||
from .state import get_service_host, load_state, remove_service, save_state, set_service_host
|
||||
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 _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()))
|
||||
cfg.traefik_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg.traefik_file.write_text(yaml.safe_dump(dynamic, sort_keys=False))
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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(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"),
|
||||
]
|
||||
|
||||
|
||||
async def _up_with_migration(
|
||||
cfg: Config,
|
||||
services: list[str],
|
||||
) -> list[CommandResult]:
|
||||
"""Start services with automatic migration if host changed."""
|
||||
results: list[CommandResult] = []
|
||||
|
||||
for service in services:
|
||||
target_host = cfg.services[service]
|
||||
current_host = get_service_host(cfg, service)
|
||||
|
||||
# If service is deployed elsewhere, migrate it
|
||||
if current_host and current_host != target_host:
|
||||
if current_host in cfg.hosts:
|
||||
console.print(
|
||||
f"[cyan]\\[{service}][/] Migrating from "
|
||||
f"[magenta]{current_host}[/] → [magenta]{target_host}[/]..."
|
||||
)
|
||||
down_result = await run_compose_on_host(cfg, service, current_host, "down")
|
||||
if not down_result.success:
|
||||
results.append(down_result)
|
||||
continue
|
||||
else:
|
||||
err_console.print(
|
||||
f"[cyan]\\[{service}][/] [yellow]![/] was on "
|
||||
f"[magenta]{current_host}[/] (not in config), skipping down"
|
||||
)
|
||||
|
||||
# Start on target host
|
||||
up_result = await run_compose(cfg, service, "up -d")
|
||||
results.append(up_result)
|
||||
|
||||
# Update state on success
|
||||
if up_result.success:
|
||||
set_service_host(cfg, service, target_host)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@app.command()
|
||||
def up(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Start services (docker compose up -d). Auto-migrates if host changed."""
|
||||
svc_list, cfg = _get_services(services or [], all_services, config)
|
||||
results = _run_async(_up_with_migration(cfg, svc_list))
|
||||
_maybe_regenerate_traefik(cfg)
|
||||
_report_results(results)
|
||||
|
||||
|
||||
@app.command()
|
||||
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)
|
||||
results = _run_async(run_on_services(cfg, svc_list, "down"))
|
||||
|
||||
# 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()
|
||||
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)
|
||||
results = _run_async(run_on_services(cfg, svc_list, "pull"))
|
||||
_report_results(results)
|
||||
|
||||
|
||||
@app.command()
|
||||
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)
|
||||
results = _run_async(run_sequential_on_services(cfg, svc_list, ["down", "up -d"]))
|
||||
_maybe_regenerate_traefik(cfg)
|
||||
_report_results(results)
|
||||
|
||||
|
||||
@app.command()
|
||||
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)
|
||||
results = _run_async(run_sequential_on_services(cfg, svc_list, ["pull", "down", "up -d"]))
|
||||
_maybe_regenerate_traefik(cfg)
|
||||
_report_results(results)
|
||||
|
||||
|
||||
@app.command()
|
||||
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()
|
||||
def ps(
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Show status of all services."""
|
||||
cfg = load_config(config)
|
||||
results = _run_async(run_on_services(cfg, list(cfg.services.keys()), "ps"))
|
||||
_report_results(results)
|
||||
|
||||
|
||||
@app.command("traefik-file")
|
||||
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}")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
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(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}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def check(
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Check for compose directories not in config (and vice versa)."""
|
||||
cfg = load_config(config)
|
||||
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]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]No compose file found[/] ({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]✓[/] All compose directories are in config.")
|
||||
elif missing_from_config:
|
||||
console.print(f"\n[dim]To add missing services, append to {cfg.config_path}:[/]")
|
||||
for name in missing_from_config:
|
||||
console.print(f"[dim] {name}: docker-debian[/]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
19
src/compose_farm/cli/__init__.py
Normal file
19
src/compose_farm/cli/__init__.py
Normal 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()
|
||||
42
src/compose_farm/cli/app.py
Normal file
42
src/compose_farm/cli/app.py
Normal 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."""
|
||||
203
src/compose_farm/cli/common.py
Normal file
203
src/compose_farm/cli/common.py
Normal 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)
|
||||
265
src/compose_farm/cli/config.py
Normal file
265
src/compose_farm/cli/config.py
Normal 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")
|
||||
144
src/compose_farm/cli/lifecycle.py
Normal file
144
src/compose_farm/cli/lifecycle.py
Normal 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)
|
||||
641
src/compose_farm/cli/management.py
Normal file
641
src/compose_farm/cli/management.py
Normal 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)
|
||||
235
src/compose_farm/cli/monitoring.py
Normal file
235
src/compose_farm/cli/monitoring.py
Normal 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))
|
||||
286
src/compose_farm/compose.py
Normal file
286
src/compose_farm/compose.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Compose file parsing utilities.
|
||||
|
||||
Handles .env loading, variable interpolation, port/volume/network extraction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
|
||||
# Port parsing constants
|
||||
_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_]*)(?::-(.*?))?\}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortMapping:
|
||||
"""Port mapping for a compose service."""
|
||||
|
||||
target: int
|
||||
published: int | None
|
||||
|
||||
|
||||
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,
|
||||
then overlays current environment variables.
|
||||
"""
|
||||
env: dict[str, str] = {}
|
||||
env_path = compose_path.parent / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if (value.startswith('"') and value.endswith('"')) or (
|
||||
value.startswith("'") and value.endswith("'")
|
||||
):
|
||||
value = value[1:-1]
|
||||
env[key] = value
|
||||
env.update({k: v for k, v in os.environ.items() if isinstance(v, str)})
|
||||
return env
|
||||
|
||||
|
||||
def _interpolate(value: str, env: dict[str, str]) -> str:
|
||||
"""Perform ${VAR} and ${VAR:-default} interpolation."""
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
var = match.group(1)
|
||||
default = match.group(2)
|
||||
resolved = env.get(var)
|
||||
if resolved:
|
||||
return resolved
|
||||
return default or ""
|
||||
|
||||
return _VAR_PATTERN.sub(replace, value)
|
||||
|
||||
|
||||
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",
|
||||
and dict formats with target/published keys.
|
||||
"""
|
||||
if raw is None:
|
||||
return []
|
||||
mappings: list[PortMapping] = []
|
||||
|
||||
items = raw if isinstance(raw, list) else [raw]
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
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():
|
||||
target = int(parts[0])
|
||||
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()
|
||||
):
|
||||
published = int(parts[-2])
|
||||
target = int(parts[-1])
|
||||
|
||||
if target is not None:
|
||||
mappings.append(PortMapping(target=target, published=published))
|
||||
elif isinstance(item, dict):
|
||||
target_raw = item.get("target")
|
||||
if isinstance(target_raw, str):
|
||||
target_raw = _interpolate(target_raw, env)
|
||||
if target_raw is None:
|
||||
continue
|
||||
try:
|
||||
target_val = int(str(target_raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
published_raw = item.get("published")
|
||||
if isinstance(published_raw, str):
|
||||
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
|
||||
except (TypeError, ValueError):
|
||||
published_val = None
|
||||
mappings.append(PortMapping(target=target_val, published=published_val))
|
||||
|
||||
return mappings
|
||||
|
||||
|
||||
def _resolve_host_path(host_path: str, compose_dir: Path) -> str | None:
|
||||
"""Resolve a host path from volume mount, returning None for named volumes."""
|
||||
if host_path.startswith("/"):
|
||||
return host_path
|
||||
if host_path.startswith(("./", "../")):
|
||||
return str((compose_dir / host_path).resolve())
|
||||
return None # Named volume
|
||||
|
||||
|
||||
def _parse_volume_item(
|
||||
item: str | dict[str, Any],
|
||||
env: dict[str, str],
|
||||
compose_dir: Path,
|
||||
) -> 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)
|
||||
parts = interpolated.split(":")
|
||||
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)
|
||||
return _resolve_host_path(interpolated, compose_dir)
|
||||
return None
|
||||
|
||||
|
||||
def parse_host_volumes(config: Config, service: str) -> list[str]:
|
||||
"""Extract host bind mount paths from a service's compose file.
|
||||
|
||||
Returns a list of absolute host paths used as volume mounts.
|
||||
Skips named volumes and resolves relative paths.
|
||||
"""
|
||||
compose_path = config.get_compose_path(service)
|
||||
if not compose_path.exists():
|
||||
return []
|
||||
|
||||
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):
|
||||
return []
|
||||
|
||||
paths: list[str] = []
|
||||
compose_dir = compose_path.parent
|
||||
|
||||
for definition in raw_services.values():
|
||||
if not isinstance(definition, dict):
|
||||
continue
|
||||
|
||||
volumes = definition.get("volumes")
|
||||
if not volumes:
|
||||
continue
|
||||
|
||||
items = volumes if isinstance(volumes, list) else [volumes]
|
||||
for item in items:
|
||||
host_path = _parse_volume_item(item, env, compose_dir)
|
||||
if host_path:
|
||||
paths.append(host_path)
|
||||
|
||||
# Return unique paths, preserving order
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for p in paths:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
unique.append(p)
|
||||
return unique
|
||||
|
||||
|
||||
def parse_external_networks(config: Config, service: str) -> list[str]:
|
||||
"""Extract external network names from a service's compose file.
|
||||
|
||||
Returns a list of network names marked as external: true.
|
||||
"""
|
||||
compose_path = config.get_compose_path(service)
|
||||
if not compose_path.exists():
|
||||
return []
|
||||
|
||||
compose_data = yaml.safe_load(compose_path.read_text()) or {}
|
||||
networks = compose_data.get("networks", {})
|
||||
if not isinstance(networks, dict):
|
||||
return []
|
||||
|
||||
external_networks: list[str] = []
|
||||
for name, definition in networks.items():
|
||||
if isinstance(definition, dict) and definition.get("external") is True:
|
||||
external_networks.append(name)
|
||||
|
||||
return external_networks
|
||||
|
||||
|
||||
def load_compose_services(
|
||||
config: Config,
|
||||
stack: str,
|
||||
) -> tuple[dict[str, Any], dict[str, str], str]:
|
||||
"""Load services from a compose file with environment interpolation.
|
||||
|
||||
Returns (services_dict, env_dict, host_address).
|
||||
"""
|
||||
compose_path = config.get_compose_path(stack)
|
||||
if not compose_path.exists():
|
||||
message = f"[{stack}] Compose file not found: {compose_path}"
|
||||
raise FileNotFoundError(message)
|
||||
|
||||
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):
|
||||
return {}, env, config.get_host(stack).address
|
||||
return raw_services, env, config.get_host(stack).address
|
||||
|
||||
|
||||
def normalize_labels(raw: Any, env: dict[str, str]) -> dict[str, str]:
|
||||
"""Normalize labels from list or dict format, with interpolation."""
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return {
|
||||
_interpolate(str(k), env): _interpolate(str(v), env)
|
||||
for k, v in raw.items()
|
||||
if k is not None
|
||||
}
|
||||
if isinstance(raw, list):
|
||||
labels: dict[str, str] = {}
|
||||
for item in raw:
|
||||
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)
|
||||
labels[key] = value
|
||||
return labels
|
||||
return {}
|
||||
|
||||
|
||||
def get_ports_for_service(
|
||||
definition: dict[str, Any],
|
||||
all_services: dict[str, Any],
|
||||
env: dict[str, str],
|
||||
) -> list[PortMapping]:
|
||||
"""Get ports for a service, following network_mode: service:X if present."""
|
||||
network_mode = definition.get("network_mode", "")
|
||||
if isinstance(network_mode, str) and network_mode.startswith("service:"):
|
||||
# Service uses another service's network - get ports from that service
|
||||
ref_service = network_mode[len("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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
6
src/compose_farm/console.py
Normal file
6
src/compose_farm/console.py
Normal 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)
|
||||
89
src/compose_farm/example-config.yaml
Normal file
89
src/compose_farm/example-config.yaml
Normal 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
|
||||
448
src/compose_farm/executor.py
Normal file
448
src/compose_farm/executor.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""Command execution via SSH or locally."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import asyncssh
|
||||
from rich.markup import escape
|
||||
|
||||
from .console import console, err_console
|
||||
|
||||
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
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _get_local_ips() -> frozenset[str]:
|
||||
"""Get all IP addresses of the current machine."""
|
||||
ips: set[str] = set()
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
# Get all addresses for hostname
|
||||
for info in socket.getaddrinfo(hostname, None):
|
||||
addr = info[4][0]
|
||||
if isinstance(addr, str):
|
||||
ips.add(addr)
|
||||
# Also try getting the default outbound IP
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ips.add(s.getsockname()[0])
|
||||
except OSError:
|
||||
pass
|
||||
return frozenset(ips)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution."""
|
||||
|
||||
service: str
|
||||
exit_code: int
|
||||
success: bool
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
def is_local(host: Host) -> bool:
|
||||
"""Check if host should run locally (no SSH)."""
|
||||
addr = host.address.lower()
|
||||
if addr in LOCAL_ADDRESSES:
|
||||
return True
|
||||
# Check if address matches any of this machine's IPs
|
||||
return addr in _get_local_ips()
|
||||
|
||||
|
||||
async def _run_local_command(
|
||||
command: str,
|
||||
service: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Run a command locally with streaming output."""
|
||||
try:
|
||||
if raw:
|
||||
# Run with inherited stdout/stderr for proper \r handling
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=None, # Inherit
|
||||
stderr=None, # Inherit
|
||||
)
|
||||
await proc.wait()
|
||||
return CommandResult(
|
||||
service=service,
|
||||
exit_code=proc.returncode or 0,
|
||||
success=proc.returncode == 0,
|
||||
)
|
||||
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
if stream and proc.stdout and proc.stderr:
|
||||
|
||||
async def read_stream(
|
||||
reader: asyncio.StreamReader,
|
||||
prefix: str,
|
||||
*,
|
||||
is_stderr: bool = False,
|
||||
) -> None:
|
||||
out = err_console if is_stderr else console
|
||||
while True:
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode()
|
||||
if text.strip(): # Skip empty lines
|
||||
out.print(f"[cyan]\\[{prefix}][/] {escape(text)}", end="")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(proc.stdout, service),
|
||||
read_stream(proc.stderr, service, is_stderr=True),
|
||||
)
|
||||
|
||||
stdout_data = b""
|
||||
stderr_data = b""
|
||||
if not stream:
|
||||
stdout_data, stderr_data = await proc.communicate()
|
||||
else:
|
||||
await proc.wait()
|
||||
|
||||
return CommandResult(
|
||||
service=service,
|
||||
exit_code=proc.returncode or 0,
|
||||
success=proc.returncode == 0,
|
||||
stdout=stdout_data.decode() if stdout_data else "",
|
||||
stderr=stderr_data.decode() if stderr_data else "",
|
||||
)
|
||||
except OSError as e:
|
||||
err_console.print(f"[cyan]\\[{service}][/] [red]Local error:[/] {e}")
|
||||
return CommandResult(service=service, exit_code=1, success=False)
|
||||
|
||||
|
||||
async def _run_ssh_command(
|
||||
host: Host,
|
||||
command: str,
|
||||
service: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Run a command on a remote host via SSH with streaming output."""
|
||||
if raw:
|
||||
# Use native ssh with TTY for proper progress bar rendering
|
||||
ssh_args = ["ssh", "-t"]
|
||||
if host.port != _DEFAULT_SSH_PORT:
|
||||
ssh_args.extend(["-p", str(host.port)])
|
||||
ssh_args.extend([f"{host.user}@{host.address}", command])
|
||||
# Run in thread to avoid blocking the event loop
|
||||
result = await asyncio.to_thread(subprocess.run, ssh_args, check=False)
|
||||
return CommandResult(
|
||||
service=service,
|
||||
exit_code=result.returncode,
|
||||
success=result.returncode == 0,
|
||||
)
|
||||
|
||||
proc: asyncssh.SSHClientProcess[Any]
|
||||
try:
|
||||
async with asyncssh.connect( # noqa: SIM117 - conn needed before create_process
|
||||
host.address,
|
||||
port=host.port,
|
||||
username=host.user,
|
||||
known_hosts=None,
|
||||
) as conn:
|
||||
async with conn.create_process(command) as proc:
|
||||
if stream:
|
||||
|
||||
async def read_stream(
|
||||
reader: Any,
|
||||
prefix: str,
|
||||
*,
|
||||
is_stderr: bool = False,
|
||||
) -> None:
|
||||
out = err_console if is_stderr else console
|
||||
async for line in reader:
|
||||
if line.strip(): # Skip empty lines
|
||||
out.print(f"[cyan]\\[{prefix}][/] {escape(line)}", end="")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(proc.stdout, service),
|
||||
read_stream(proc.stderr, service, is_stderr=True),
|
||||
)
|
||||
|
||||
stdout_data = ""
|
||||
stderr_data = ""
|
||||
if not stream:
|
||||
stdout_data = await proc.stdout.read()
|
||||
stderr_data = await proc.stderr.read()
|
||||
|
||||
await proc.wait()
|
||||
return CommandResult(
|
||||
service=service,
|
||||
exit_code=proc.exit_status or 0,
|
||||
success=proc.exit_status == 0,
|
||||
stdout=stdout_data,
|
||||
stderr=stderr_data,
|
||||
)
|
||||
except (OSError, asyncssh.Error) as e:
|
||||
err_console.print(f"[cyan]\\[{service}][/] [red]SSH error:[/] {e}")
|
||||
return CommandResult(service=service, exit_code=1, success=False)
|
||||
|
||||
|
||||
async def run_command(
|
||||
host: Host,
|
||||
command: str,
|
||||
service: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Run a command on a host (locally or via SSH)."""
|
||||
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)
|
||||
|
||||
|
||||
async def run_compose(
|
||||
config: Config,
|
||||
service: str,
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Run a docker compose command for a service."""
|
||||
host = config.get_host(service)
|
||||
compose_path = config.get_compose_path(service)
|
||||
|
||||
command = f"docker compose -f {compose_path} {compose_cmd}"
|
||||
return await run_command(host, command, service, stream=stream, raw=raw)
|
||||
|
||||
|
||||
async def run_compose_on_host(
|
||||
config: Config,
|
||||
service: str,
|
||||
host_name: str,
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Run a docker compose command for a service on a specific host.
|
||||
|
||||
Used for migration - running 'down' on the old host before 'up' on new host.
|
||||
"""
|
||||
host = config.hosts[host_name]
|
||||
compose_path = config.get_compose_path(service)
|
||||
|
||||
command = f"docker compose -f {compose_path} {compose_cmd}"
|
||||
return await run_command(host, command, service, stream=stream, raw=raw)
|
||||
|
||||
|
||||
async def run_on_services(
|
||||
config: Config,
|
||||
services: list[str],
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> 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.
|
||||
"""
|
||||
return await run_sequential_on_services(config, services, [compose_cmd], stream=stream, raw=raw)
|
||||
|
||||
|
||||
async def _run_sequential_commands(
|
||||
config: Config,
|
||||
service: str,
|
||||
commands: list[str],
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> CommandResult:
|
||||
"""Run multiple compose commands sequentially for a service."""
|
||||
for cmd in commands:
|
||||
result = await run_compose(config, service, cmd, stream=stream, raw=raw)
|
||||
if not result.success:
|
||||
return result
|
||||
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],
|
||||
commands: list[str],
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> 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.
|
||||
"""
|
||||
# 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(
|
||||
config: Config,
|
||||
service: str,
|
||||
host_name: str,
|
||||
) -> bool:
|
||||
"""Check if a service has running containers on a specific host."""
|
||||
host = config.hosts[host_name]
|
||||
compose_path = config.get_compose_path(service)
|
||||
|
||||
# Use ps --status running to check for running containers
|
||||
command = f"docker compose -f {compose_path} ps --status running -q"
|
||||
result = await run_command(host, command, service, stream=False)
|
||||
|
||||
# If command succeeded and has output, containers are 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,
|
||||
paths: list[str],
|
||||
) -> dict[str, bool]:
|
||||
"""Check if multiple paths exist on a specific host.
|
||||
|
||||
Returns a dict mapping path -> 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(
|
||||
config: Config,
|
||||
host_name: str,
|
||||
networks: list[str],
|
||||
) -> dict[str, bool]:
|
||||
"""Check if Docker networks exist on a specific host.
|
||||
|
||||
Returns a dict mapping network_name -> 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",
|
||||
)
|
||||
@@ -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 .ssh import run_compose
|
||||
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 .ssh import CommandResult
|
||||
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
|
||||
|
||||
212
src/compose_farm/operations.py
Normal file
212
src/compose_farm/operations.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""High-level operations for compose-farm.
|
||||
|
||||
Contains the business logic for up, down, sync, check, and migration operations.
|
||||
CLI commands are thin wrappers around these functions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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,
|
||||
run_command,
|
||||
run_compose,
|
||||
run_compose_on_host,
|
||||
)
|
||||
from .state import get_service_host, set_multi_host_service, set_service_host
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import Config
|
||||
|
||||
|
||||
def get_service_paths(cfg: Config, service: str) -> list[str]:
|
||||
"""Get all required paths for a service (compose_dir + volumes)."""
|
||||
paths = [str(cfg.compose_dir)]
|
||||
paths.extend(parse_host_volumes(cfg, service))
|
||||
return paths
|
||||
|
||||
|
||||
async def _check_mounts_for_migration(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
target_host: str,
|
||||
) -> list[str]:
|
||||
"""Check if mount paths exist on target host. Returns list of missing paths."""
|
||||
paths = get_service_paths(cfg, service)
|
||||
exists = await check_paths_exist(cfg, target_host, paths)
|
||||
return [p for p, found in exists.items() if not found]
|
||||
|
||||
|
||||
async def _check_networks_for_migration(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
target_host: str,
|
||||
) -> list[str]:
|
||||
"""Check if Docker networks exist on target host. Returns list of missing networks."""
|
||||
networks = parse_external_networks(cfg, service)
|
||||
if not networks:
|
||||
return []
|
||||
exists = await check_networks_exist(cfg, target_host, networks)
|
||||
return [n for n, found in exists.items() if not found]
|
||||
|
||||
|
||||
async def _preflight_check(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
target_host: str,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Run pre-flight checks for a service on target host.
|
||||
|
||||
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)
|
||||
return missing_paths, missing_networks
|
||||
|
||||
|
||||
def _report_preflight_failures(
|
||||
service: str,
|
||||
target_host: str,
|
||||
missing_paths: list[str],
|
||||
missing_networks: list[str],
|
||||
) -> None:
|
||||
"""Report pre-flight check failures."""
|
||||
err_console.print(
|
||||
f"[cyan]\\[{service}][/] [red]✗[/] Cannot start on [magenta]{target_host}[/]:"
|
||||
)
|
||||
for path in missing_paths:
|
||||
err_console.print(f" [red]✗[/] missing path: {path}")
|
||||
for net in missing_networks:
|
||||
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 up_services(
|
||||
cfg: Config,
|
||||
services: list[str],
|
||||
*,
|
||||
raw: bool = False,
|
||||
) -> list[CommandResult]:
|
||||
"""Start services with automatic migration if host changed."""
|
||||
results: list[CommandResult] = []
|
||||
total = len(services)
|
||||
|
||||
for idx, service in enumerate(services, 1):
|
||||
prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{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)
|
||||
if missing_paths or 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}[/]..."
|
||||
)
|
||||
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)
|
||||
continue
|
||||
else:
|
||||
err_console.print(
|
||||
f"{prefix} [yellow]![/] was on "
|
||||
f"[magenta]{current_host}[/] (not in config), skipping down"
|
||||
)
|
||||
|
||||
# Start on target host
|
||||
console.print(f"{prefix} Starting on [magenta]{target_host}[/]...")
|
||||
up_result = await run_compose(cfg, service, "up -d", raw=raw)
|
||||
if raw:
|
||||
print() # Ensure newline after raw output (progress bars end with \r)
|
||||
results.append(up_result)
|
||||
|
||||
# Update state on success
|
||||
if up_result.success:
|
||||
set_service_host(cfg, service, target_host)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def check_host_compatibility(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
) -> dict[str, tuple[int, int, list[str]]]:
|
||||
"""Check which hosts can run a service based on mount paths.
|
||||
|
||||
Returns dict of host_name -> (found_count, total_count, missing_paths).
|
||||
"""
|
||||
paths = get_service_paths(cfg, service)
|
||||
results: dict[str, tuple[int, int, list[str]]] = {}
|
||||
|
||||
for host_name in cfg.hosts:
|
||||
exists = await check_paths_exist(cfg, host_name, paths)
|
||||
found = sum(1 for v in exists.values() if v)
|
||||
missing = [p for p, v in exists.items() if not v]
|
||||
results[host_name] = (found, len(paths), missing)
|
||||
|
||||
return results
|
||||
@@ -1,247 +0,0 @@
|
||||
"""Command execution via SSH or locally."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import asyncssh
|
||||
from rich.console import Console
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import Config, Host
|
||||
|
||||
_console = Console(highlight=False)
|
||||
_err_console = Console(stderr=True, highlight=False)
|
||||
|
||||
LOCAL_ADDRESSES = frozenset({"local", "localhost", "127.0.0.1", "::1"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Result of a command execution."""
|
||||
|
||||
service: str
|
||||
exit_code: int
|
||||
success: bool
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
def _is_local(host: Host) -> bool:
|
||||
"""Check if host should run locally (no SSH)."""
|
||||
return host.address.lower() in LOCAL_ADDRESSES
|
||||
|
||||
|
||||
async def _run_local_command(
|
||||
command: str,
|
||||
service: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
) -> CommandResult:
|
||||
"""Run a command locally with streaming output."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
if stream and proc.stdout and proc.stderr:
|
||||
|
||||
async def read_stream(
|
||||
reader: asyncio.StreamReader,
|
||||
prefix: str,
|
||||
*,
|
||||
is_stderr: bool = False,
|
||||
) -> None:
|
||||
console = _err_console if is_stderr else _console
|
||||
while True:
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
console.print(f"[cyan]\\[{prefix}][/] {line.decode()}", end="")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(proc.stdout, service),
|
||||
read_stream(proc.stderr, service, is_stderr=True),
|
||||
)
|
||||
|
||||
stdout_data = b""
|
||||
stderr_data = b""
|
||||
if not stream:
|
||||
stdout_data, stderr_data = await proc.communicate()
|
||||
else:
|
||||
await proc.wait()
|
||||
|
||||
return CommandResult(
|
||||
service=service,
|
||||
exit_code=proc.returncode or 0,
|
||||
success=proc.returncode == 0,
|
||||
stdout=stdout_data.decode() if stdout_data else "",
|
||||
stderr=stderr_data.decode() if stderr_data else "",
|
||||
)
|
||||
except OSError as e:
|
||||
_err_console.print(f"[cyan]\\[{service}][/] [red]Local error:[/] {e}")
|
||||
return CommandResult(service=service, exit_code=1, success=False)
|
||||
|
||||
|
||||
async def _run_ssh_command(
|
||||
host: Host,
|
||||
command: str,
|
||||
service: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
) -> CommandResult:
|
||||
"""Run a command on a remote host via SSH with streaming output."""
|
||||
proc: asyncssh.SSHClientProcess[Any]
|
||||
try:
|
||||
async with (
|
||||
asyncssh.connect(
|
||||
host.address,
|
||||
port=host.port,
|
||||
username=host.user,
|
||||
known_hosts=None,
|
||||
) as conn,
|
||||
conn.create_process(command) as proc,
|
||||
):
|
||||
if stream:
|
||||
|
||||
async def read_stream(
|
||||
reader: Any,
|
||||
prefix: str,
|
||||
*,
|
||||
is_stderr: bool = False,
|
||||
) -> None:
|
||||
console = _err_console if is_stderr else _console
|
||||
async for line in reader:
|
||||
console.print(f"[cyan]\\[{prefix}][/] {line}", end="")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(proc.stdout, service),
|
||||
read_stream(proc.stderr, service, is_stderr=True),
|
||||
)
|
||||
|
||||
stdout_data = ""
|
||||
stderr_data = ""
|
||||
if not stream:
|
||||
stdout_data = await proc.stdout.read()
|
||||
stderr_data = await proc.stderr.read()
|
||||
|
||||
await proc.wait()
|
||||
return CommandResult(
|
||||
service=service,
|
||||
exit_code=proc.exit_status or 0,
|
||||
success=proc.exit_status == 0,
|
||||
stdout=stdout_data,
|
||||
stderr=stderr_data,
|
||||
)
|
||||
except (OSError, asyncssh.Error) as e:
|
||||
_err_console.print(f"[cyan]\\[{service}][/] [red]SSH error:[/] {e}")
|
||||
return CommandResult(service=service, exit_code=1, success=False)
|
||||
|
||||
|
||||
async def run_command(
|
||||
host: Host,
|
||||
command: str,
|
||||
service: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
) -> CommandResult:
|
||||
"""Run a command on a host (locally or via SSH)."""
|
||||
if _is_local(host):
|
||||
return await _run_local_command(command, service, stream=stream)
|
||||
return await _run_ssh_command(host, command, service, stream=stream)
|
||||
|
||||
|
||||
async def run_compose(
|
||||
config: Config,
|
||||
service: str,
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
) -> CommandResult:
|
||||
"""Run a docker compose command for a service."""
|
||||
host = config.get_host(service)
|
||||
compose_path = config.get_compose_path(service)
|
||||
|
||||
command = f"docker compose -f {compose_path} {compose_cmd}"
|
||||
return await run_command(host, command, service, stream=stream)
|
||||
|
||||
|
||||
async def run_compose_on_host(
|
||||
config: Config,
|
||||
service: str,
|
||||
host_name: str,
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
) -> CommandResult:
|
||||
"""Run a docker compose command for a service on a specific host.
|
||||
|
||||
Used for migration - running 'down' on the old host before 'up' on new host.
|
||||
"""
|
||||
host = config.hosts[host_name]
|
||||
compose_path = config.get_compose_path(service)
|
||||
|
||||
command = f"docker compose -f {compose_path} {compose_cmd}"
|
||||
return await run_command(host, command, service, stream=stream)
|
||||
|
||||
|
||||
async def run_on_services(
|
||||
config: Config,
|
||||
services: list[str],
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
) -> list[CommandResult]:
|
||||
"""Run a docker compose command on multiple services in parallel."""
|
||||
tasks = [run_compose(config, service, compose_cmd, stream=stream) for service in services]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
async def run_sequential_commands(
|
||||
config: Config,
|
||||
service: str,
|
||||
commands: list[str],
|
||||
*,
|
||||
stream: bool = True,
|
||||
) -> CommandResult:
|
||||
"""Run multiple compose commands sequentially for a service."""
|
||||
for cmd in commands:
|
||||
result = await run_compose(config, service, cmd, stream=stream)
|
||||
if not result.success:
|
||||
return result
|
||||
return CommandResult(service=service, exit_code=0, success=True)
|
||||
|
||||
|
||||
async def run_sequential_on_services(
|
||||
config: Config,
|
||||
services: list[str],
|
||||
commands: list[str],
|
||||
*,
|
||||
stream: bool = True,
|
||||
) -> list[CommandResult]:
|
||||
"""Run sequential commands on multiple services in parallel."""
|
||||
tasks = [
|
||||
run_sequential_commands(config, service, commands, stream=stream) for service in services
|
||||
]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
async def check_service_running(
|
||||
config: Config,
|
||||
service: str,
|
||||
host_name: str,
|
||||
) -> bool:
|
||||
"""Check if a service has running containers on a specific host."""
|
||||
host = config.hosts[host_name]
|
||||
compose_path = config.get_compose_path(service)
|
||||
|
||||
# Use ps --status running to check for running containers
|
||||
command = f"docker compose -f {compose_path} ps --status running -q"
|
||||
result = await run_command(host, command, service, stream=False)
|
||||
|
||||
# If command succeeded and has output, containers are running
|
||||
return result.success and bool(result.stdout.strip())
|
||||
@@ -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,32 +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.
|
||||
|
||||
Multi-host services are never considered for migration.
|
||||
"""
|
||||
needs_migration = []
|
||||
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
|
||||
|
||||
@@ -8,32 +8,25 @@ use host-published ports for cross-host reachability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .ssh import LOCAL_ADDRESSES
|
||||
from .compose import (
|
||||
PortMapping,
|
||||
get_ports_for_service,
|
||||
load_compose_services,
|
||||
normalize_labels,
|
||||
)
|
||||
from .executor import LOCAL_ADDRESSES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PortMapping:
|
||||
"""Port mapping for a compose service."""
|
||||
|
||||
target: int
|
||||
published: int | None
|
||||
protocol: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TraefikServiceSource:
|
||||
class _TraefikServiceSource:
|
||||
"""Source information to build an upstream for a Traefik service."""
|
||||
|
||||
traefik_service: str
|
||||
@@ -45,129 +38,9 @@ class TraefikServiceSource:
|
||||
scheme: str | None = None
|
||||
|
||||
|
||||
LIST_VALUE_KEYS = {"entrypoints", "middlewares"}
|
||||
SINGLE_PART = 1
|
||||
PUBLISHED_TARGET_PARTS = 2
|
||||
HOST_PUBLISHED_PARTS = 3
|
||||
MIN_ROUTER_PARTS = 3
|
||||
MIN_SERVICE_LABEL_PARTS = 6
|
||||
_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}")
|
||||
|
||||
|
||||
def _load_env(compose_path: Path) -> dict[str, str]:
|
||||
"""Load environment variables for compose interpolation."""
|
||||
env: dict[str, str] = {}
|
||||
env_path = compose_path.parent / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text().splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if (value.startswith('"') and value.endswith('"')) or (
|
||||
value.startswith("'") and value.endswith("'")
|
||||
):
|
||||
value = value[1:-1]
|
||||
env[key] = value
|
||||
env.update({k: v for k, v in os.environ.items() if isinstance(v, str)})
|
||||
return env
|
||||
|
||||
|
||||
def _interpolate(value: str, env: dict[str, str]) -> str:
|
||||
"""Perform a minimal `${VAR}`/`${VAR:-default}` interpolation."""
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
var = match.group(1)
|
||||
default = match.group(2)
|
||||
resolved = env.get(var)
|
||||
if resolved:
|
||||
return resolved
|
||||
return default or ""
|
||||
|
||||
return _VAR_PATTERN.sub(replace, value)
|
||||
|
||||
|
||||
def _normalize_labels(raw: Any, env: dict[str, str]) -> dict[str, str]:
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return {
|
||||
_interpolate(str(k), env): _interpolate(str(v), env)
|
||||
for k, v in raw.items()
|
||||
if k is not None
|
||||
}
|
||||
if isinstance(raw, list):
|
||||
labels: dict[str, str] = {}
|
||||
for item in raw:
|
||||
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)
|
||||
labels[key] = value
|
||||
return labels
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_ports(raw: Any, env: dict[str, str]) -> list[PortMapping]: # noqa: PLR0912
|
||||
if raw is None:
|
||||
return []
|
||||
mappings: list[PortMapping] = []
|
||||
|
||||
items = raw if isinstance(raw, list) else [raw]
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
interpolated = _interpolate(item, env)
|
||||
port_spec, _, protocol = interpolated.partition("/")
|
||||
parts = port_spec.split(":")
|
||||
published: int | None = None
|
||||
target: int | None = None
|
||||
|
||||
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():
|
||||
published = int(parts[0])
|
||||
target = int(parts[1])
|
||||
elif len(parts) == HOST_PUBLISHED_PARTS and parts[-2].isdigit() and parts[-1].isdigit():
|
||||
published = int(parts[-2])
|
||||
target = int(parts[-1])
|
||||
|
||||
if target is not None:
|
||||
mappings.append(
|
||||
PortMapping(target=target, published=published, protocol=protocol or None)
|
||||
)
|
||||
elif isinstance(item, dict):
|
||||
target_raw = item.get("target")
|
||||
if isinstance(target_raw, str):
|
||||
target_raw = _interpolate(target_raw, env)
|
||||
if target_raw is None:
|
||||
continue
|
||||
try:
|
||||
target_val = int(str(target_raw))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
published_raw = item.get("published")
|
||||
if isinstance(published_raw, str):
|
||||
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
|
||||
except (TypeError, ValueError):
|
||||
published_val = None
|
||||
protocol_val = item.get("protocol")
|
||||
mappings.append(
|
||||
PortMapping(
|
||||
target=target_val,
|
||||
published=published_val,
|
||||
protocol=str(protocol_val) if protocol_val else None,
|
||||
)
|
||||
)
|
||||
|
||||
return mappings
|
||||
_LIST_VALUE_KEYS = {"entrypoints", "middlewares"}
|
||||
_MIN_ROUTER_PARTS = 3
|
||||
_MIN_SERVICE_LABEL_PARTS = 6
|
||||
|
||||
|
||||
def _parse_value(key: str, raw_value: str) -> Any:
|
||||
@@ -178,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
|
||||
@@ -229,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).
|
||||
@@ -265,23 +138,9 @@ def _resolve_published_port(source: TraefikServiceSource) -> tuple[int | None, s
|
||||
)
|
||||
|
||||
|
||||
def _load_stack(config: Config, stack: str) -> tuple[dict[str, Any], dict[str, str], str]:
|
||||
compose_path = config.get_compose_path(stack)
|
||||
if not compose_path.exists():
|
||||
message = f"[{stack}] Compose file not found: {compose_path}"
|
||||
raise FileNotFoundError(message)
|
||||
|
||||
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):
|
||||
return {}, env, config.get_host(stack).address
|
||||
return raw_services, env, config.get_host(stack).address
|
||||
|
||||
|
||||
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():
|
||||
@@ -352,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:]
|
||||
@@ -370,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)
|
||||
@@ -383,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,
|
||||
@@ -404,20 +263,21 @@ def _process_service_labels(
|
||||
stack: str,
|
||||
compose_service: str,
|
||||
definition: dict[str, Any],
|
||||
all_services: dict[str, Any],
|
||||
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)
|
||||
labels = normalize_labels(definition.get("labels"), env)
|
||||
if not labels:
|
||||
return
|
||||
enable_raw = labels.get("traefik.enable")
|
||||
if enable_raw is not None and _parse_value("enable", enable_raw) is False:
|
||||
return
|
||||
|
||||
ports = _parse_ports(definition.get("ports"), env)
|
||||
ports = get_ports_for_service(definition, all_services, env)
|
||||
routers: dict[str, bool] = {}
|
||||
service_names: set[str] = set()
|
||||
|
||||
@@ -452,29 +312,40 @@ def _process_service_labels(
|
||||
def generate_traefik_config(
|
||||
config: Config,
|
||||
services: list[str],
|
||||
*,
|
||||
check_all: bool = False,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Generate Traefik dynamic config from compose labels.
|
||||
|
||||
Args:
|
||||
config: The compose-farm config.
|
||||
services: List of service names to process.
|
||||
check_all: If True, check all services for warnings (ignore host filtering).
|
||||
Used by the check command to validate all traefik labels.
|
||||
|
||||
Returns (config_dict, warnings).
|
||||
|
||||
"""
|
||||
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
|
||||
if config.traefik_service:
|
||||
if config.traefik_service and not check_all:
|
||||
traefik_host = config.services.get(config.traefik_service)
|
||||
|
||||
for stack in services:
|
||||
raw_services, env, host_address = _load_stack(config, stack)
|
||||
raw_services, env, host_address = load_compose_services(config, stack)
|
||||
stack_host = config.services.get(stack)
|
||||
|
||||
# Skip services on Traefik's host - docker provider handles them directly
|
||||
if host_address.lower() in LOCAL_ADDRESSES:
|
||||
continue
|
||||
if traefik_host and stack_host == traefik_host:
|
||||
continue
|
||||
# (unless check_all is True, for validation purposes)
|
||||
if not check_all:
|
||||
if host_address.lower() in LOCAL_ADDRESSES:
|
||||
continue
|
||||
if traefik_host and stack_host == traefik_host:
|
||||
continue
|
||||
|
||||
for compose_service, definition in raw_services.items():
|
||||
if not isinstance(definition, dict):
|
||||
@@ -483,6 +354,7 @@ def generate_traefik_config(
|
||||
stack,
|
||||
compose_service,
|
||||
definition,
|
||||
raw_services,
|
||||
host_address,
|
||||
env,
|
||||
dynamic,
|
||||
@@ -492,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
207
tests/test_cli_logs.py
Normal 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
|
||||
@@ -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
238
tests/test_config_cmd.py
Normal 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()
|
||||
241
tests/test_executor.py
Normal file
241
tests/test_executor.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Tests for executor module."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from compose_farm.config import Config, Host
|
||||
from compose_farm.executor import (
|
||||
CommandResult,
|
||||
_run_local_command,
|
||||
check_networks_exist,
|
||||
check_paths_exist,
|
||||
is_local,
|
||||
run_command,
|
||||
run_compose,
|
||||
run_on_services,
|
||||
)
|
||||
|
||||
# These tests run actual shell commands that only work on Linux
|
||||
linux_only = pytest.mark.skipif(sys.platform != "linux", reason="Linux-only shell commands")
|
||||
|
||||
|
||||
class TestIsLocal:
|
||||
"""Tests for is_local function."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["local", "localhost", "127.0.0.1", "::1", "LOCAL", "LOCALHOST"],
|
||||
)
|
||||
def test_local_addresses(self, address: str) -> None:
|
||||
host = Host(address=address)
|
||||
assert is_local(host) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["192.168.1.10", "nas01.local", "10.0.0.1", "example.com"],
|
||||
)
|
||||
def test_remote_addresses(self, address: str) -> None:
|
||||
host = Host(address=address)
|
||||
assert is_local(host) is False
|
||||
|
||||
|
||||
class TestRunLocalCommand:
|
||||
"""Tests for local command execution."""
|
||||
|
||||
async def test_run_local_command_success(self) -> None:
|
||||
result = await _run_local_command("echo hello", "test-service")
|
||||
assert result.success is True
|
||||
assert result.exit_code == 0
|
||||
assert result.service == "test-service"
|
||||
|
||||
async def test_run_local_command_failure(self) -> None:
|
||||
result = await _run_local_command("exit 1", "test-service")
|
||||
assert result.success is False
|
||||
assert result.exit_code == 1
|
||||
|
||||
async def test_run_local_command_not_found(self) -> None:
|
||||
result = await _run_local_command("nonexistent_command_xyz", "test-service")
|
||||
assert result.success is False
|
||||
assert result.exit_code != 0
|
||||
|
||||
async def test_run_local_command_captures_output(self) -> None:
|
||||
result = await _run_local_command("echo hello", "test-service", stream=False)
|
||||
assert "hello" in result.stdout
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
"""Tests for run_command dispatcher."""
|
||||
|
||||
async def test_run_command_local(self) -> None:
|
||||
host = Host(address="localhost")
|
||||
result = await run_command(host, "echo test", "test-service")
|
||||
assert result.success is True
|
||||
|
||||
async def test_run_command_result_structure(self) -> None:
|
||||
host = Host(address="local")
|
||||
result = await run_command(host, "true", "my-service")
|
||||
assert isinstance(result, CommandResult)
|
||||
assert result.service == "my-service"
|
||||
assert result.exit_code == 0
|
||||
assert result.success is True
|
||||
|
||||
|
||||
class TestRunCompose:
|
||||
"""Tests for compose command execution."""
|
||||
|
||||
async def test_run_compose_builds_correct_command(self, tmp_path: Path) -> None:
|
||||
# Create a minimal compose file
|
||||
compose_dir = tmp_path / "compose"
|
||||
service_dir = compose_dir / "test-service"
|
||||
service_dir.mkdir(parents=True)
|
||||
compose_file = service_dir / "docker-compose.yml"
|
||||
compose_file.write_text("services: {}")
|
||||
|
||||
config = Config(
|
||||
compose_dir=compose_dir,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={"test-service": "local"},
|
||||
)
|
||||
|
||||
# This will fail because docker compose isn't running,
|
||||
# but we can verify the command structure works
|
||||
result = await run_compose(config, "test-service", "config", stream=False)
|
||||
# Command may fail due to no docker, but structure is correct
|
||||
assert result.service == "test-service"
|
||||
|
||||
|
||||
class TestRunOnServices:
|
||||
"""Tests for parallel service execution."""
|
||||
|
||||
async def test_run_on_services_parallel(self) -> None:
|
||||
config = Config(
|
||||
compose_dir=Path("/tmp"),
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={"svc1": "local", "svc2": "local"},
|
||||
)
|
||||
|
||||
# Use a simple command that will work without docker
|
||||
# We'll test the parallelism structure
|
||||
results = await run_on_services(config, ["svc1", "svc2"], "version", stream=False)
|
||||
assert len(results) == 2
|
||||
assert results[0].service == "svc1"
|
||||
assert results[1].service == "svc2"
|
||||
|
||||
|
||||
@linux_only
|
||||
class TestCheckPathsExist:
|
||||
"""Tests for check_paths_exist function (uses 'test -e' shell command)."""
|
||||
|
||||
async def test_check_existing_paths(self, tmp_path: Path) -> None:
|
||||
"""Check paths that exist."""
|
||||
config = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={},
|
||||
)
|
||||
# Create test paths
|
||||
(tmp_path / "dir1").mkdir()
|
||||
(tmp_path / "file1").touch()
|
||||
|
||||
result = await check_paths_exist(
|
||||
config, "local", [str(tmp_path / "dir1"), str(tmp_path / "file1")]
|
||||
)
|
||||
|
||||
assert result[str(tmp_path / "dir1")] is True
|
||||
assert result[str(tmp_path / "file1")] is True
|
||||
|
||||
async def test_check_missing_paths(self, tmp_path: Path) -> None:
|
||||
"""Check paths that don't exist."""
|
||||
config = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={},
|
||||
)
|
||||
|
||||
result = await check_paths_exist(
|
||||
config, "local", [str(tmp_path / "missing1"), str(tmp_path / "missing2")]
|
||||
)
|
||||
|
||||
assert result[str(tmp_path / "missing1")] is False
|
||||
assert result[str(tmp_path / "missing2")] is False
|
||||
|
||||
async def test_check_mixed_paths(self, tmp_path: Path) -> None:
|
||||
"""Check mix of existing and missing paths."""
|
||||
config = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={},
|
||||
)
|
||||
(tmp_path / "exists").mkdir()
|
||||
|
||||
result = await check_paths_exist(
|
||||
config, "local", [str(tmp_path / "exists"), str(tmp_path / "missing")]
|
||||
)
|
||||
|
||||
assert result[str(tmp_path / "exists")] is True
|
||||
assert result[str(tmp_path / "missing")] is False
|
||||
|
||||
async def test_check_empty_paths(self, tmp_path: Path) -> None:
|
||||
"""Empty path list returns empty dict."""
|
||||
config = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={},
|
||||
)
|
||||
|
||||
result = await check_paths_exist(config, "local", [])
|
||||
assert result == {}
|
||||
|
||||
|
||||
@linux_only
|
||||
class TestCheckNetworksExist:
|
||||
"""Tests for check_networks_exist function (requires Docker)."""
|
||||
|
||||
async def test_check_bridge_network_exists(self, tmp_path: Path) -> None:
|
||||
"""The 'bridge' network always exists on Docker hosts."""
|
||||
config = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={},
|
||||
)
|
||||
|
||||
result = await check_networks_exist(config, "local", ["bridge"])
|
||||
assert result["bridge"] is True
|
||||
|
||||
async def test_check_nonexistent_network(self, tmp_path: Path) -> None:
|
||||
"""Check a network that doesn't exist."""
|
||||
config = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={},
|
||||
)
|
||||
|
||||
result = await check_networks_exist(config, "local", ["nonexistent_network_xyz_123"])
|
||||
assert result["nonexistent_network_xyz_123"] is False
|
||||
|
||||
async def test_check_mixed_networks(self, tmp_path: Path) -> None:
|
||||
"""Check mix of existing and non-existing networks."""
|
||||
config = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={},
|
||||
)
|
||||
|
||||
result = await check_networks_exist(
|
||||
config, "local", ["bridge", "nonexistent_network_xyz_123"]
|
||||
)
|
||||
assert result["bridge"] is True
|
||||
assert result["nonexistent_network_xyz_123"] is False
|
||||
|
||||
async def test_check_empty_networks(self, tmp_path: Path) -> None:
|
||||
"""Empty network list returns empty dict."""
|
||||
config = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={},
|
||||
)
|
||||
|
||||
result = await check_networks_exist(config, "local", [])
|
||||
assert result == {}
|
||||
@@ -8,8 +8,15 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from compose_farm.config import Config, Host
|
||||
from compose_farm.logs import _parse_images_output, snapshot_services
|
||||
from compose_farm.ssh import CommandResult
|
||||
from compose_farm.executor import CommandResult
|
||||
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]
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""Tests for ssh module."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from compose_farm.config import Config, Host
|
||||
from compose_farm.ssh import (
|
||||
CommandResult,
|
||||
_is_local,
|
||||
_run_local_command,
|
||||
run_command,
|
||||
run_compose,
|
||||
run_on_services,
|
||||
)
|
||||
|
||||
|
||||
class TestIsLocal:
|
||||
"""Tests for _is_local function."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["local", "localhost", "127.0.0.1", "::1", "LOCAL", "LOCALHOST"],
|
||||
)
|
||||
def test_local_addresses(self, address: str) -> None:
|
||||
host = Host(address=address)
|
||||
assert _is_local(host) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["192.168.1.10", "nas01.local", "10.0.0.1", "example.com"],
|
||||
)
|
||||
def test_remote_addresses(self, address: str) -> None:
|
||||
host = Host(address=address)
|
||||
assert _is_local(host) is False
|
||||
|
||||
|
||||
class TestRunLocalCommand:
|
||||
"""Tests for local command execution."""
|
||||
|
||||
async def test_run_local_command_success(self) -> None:
|
||||
result = await _run_local_command("echo hello", "test-service")
|
||||
assert result.success is True
|
||||
assert result.exit_code == 0
|
||||
assert result.service == "test-service"
|
||||
|
||||
async def test_run_local_command_failure(self) -> None:
|
||||
result = await _run_local_command("exit 1", "test-service")
|
||||
assert result.success is False
|
||||
assert result.exit_code == 1
|
||||
|
||||
async def test_run_local_command_not_found(self) -> None:
|
||||
result = await _run_local_command("nonexistent_command_xyz", "test-service")
|
||||
assert result.success is False
|
||||
assert result.exit_code != 0
|
||||
|
||||
async def test_run_local_command_captures_output(self) -> None:
|
||||
result = await _run_local_command("echo hello", "test-service", stream=False)
|
||||
assert "hello" in result.stdout
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
"""Tests for run_command dispatcher."""
|
||||
|
||||
async def test_run_command_local(self) -> None:
|
||||
host = Host(address="localhost")
|
||||
result = await run_command(host, "echo test", "test-service")
|
||||
assert result.success is True
|
||||
|
||||
async def test_run_command_result_structure(self) -> None:
|
||||
host = Host(address="local")
|
||||
result = await run_command(host, "true", "my-service")
|
||||
assert isinstance(result, CommandResult)
|
||||
assert result.service == "my-service"
|
||||
assert result.exit_code == 0
|
||||
assert result.success is True
|
||||
|
||||
|
||||
class TestRunCompose:
|
||||
"""Tests for compose command execution."""
|
||||
|
||||
async def test_run_compose_builds_correct_command(self, tmp_path: Path) -> None:
|
||||
# Create a minimal compose file
|
||||
compose_dir = tmp_path / "compose"
|
||||
service_dir = compose_dir / "test-service"
|
||||
service_dir.mkdir(parents=True)
|
||||
compose_file = service_dir / "docker-compose.yml"
|
||||
compose_file.write_text("services: {}")
|
||||
|
||||
config = Config(
|
||||
compose_dir=compose_dir,
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={"test-service": "local"},
|
||||
)
|
||||
|
||||
# This will fail because docker compose isn't running,
|
||||
# but we can verify the command structure works
|
||||
result = await run_compose(config, "test-service", "config", stream=False)
|
||||
# Command may fail due to no docker, but structure is correct
|
||||
assert result.service == "test-service"
|
||||
|
||||
|
||||
class TestRunOnServices:
|
||||
"""Tests for parallel service execution."""
|
||||
|
||||
async def test_run_on_services_parallel(self) -> None:
|
||||
config = Config(
|
||||
compose_dir=Path("/tmp"),
|
||||
hosts={"local": Host(address="localhost")},
|
||||
services={"svc1": "local", "svc2": "local"},
|
||||
)
|
||||
|
||||
# Use a simple command that will work without docker
|
||||
# We'll test the parallelism structure
|
||||
results = await run_on_services(config, ["svc1", "svc2"], "version", stream=False)
|
||||
assert len(results) == 2
|
||||
assert results[0].service == "svc1"
|
||||
assert results[1].service == "svc2"
|
||||
@@ -1,16 +1,15 @@
|
||||
"""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 ssh as ssh_module
|
||||
from compose_farm import executor as executor_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.ssh import CommandResult, check_service_running
|
||||
from compose_farm.executor import CommandResult, check_service_running
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -58,7 +57,7 @@ class TestCheckServiceRunning:
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_running(self, mock_config: Config) -> None:
|
||||
"""Returns True when service has running containers."""
|
||||
with patch.object(ssh_module, "run_command", new_callable=AsyncMock) as mock_run:
|
||||
with patch.object(executor_module, "run_command", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
service="plex",
|
||||
exit_code=0,
|
||||
@@ -71,7 +70,7 @@ class TestCheckServiceRunning:
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_not_running(self, mock_config: Config) -> None:
|
||||
"""Returns False when service has no running containers."""
|
||||
with patch.object(ssh_module, "run_command", new_callable=AsyncMock) as mock_run:
|
||||
with patch.object(executor_module, "run_command", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
service="plex",
|
||||
exit_code=0,
|
||||
@@ -84,7 +83,7 @@ class TestCheckServiceRunning:
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_failed(self, mock_config: Config) -> None:
|
||||
"""Returns False when command fails."""
|
||||
with patch.object(ssh_module, "run_command", new_callable=AsyncMock) as mock_run:
|
||||
with patch.object(executor_module, "run_command", new_callable=AsyncMock) as mock_run:
|
||||
mock_run.return_value = CommandResult(
|
||||
service="plex",
|
||||
exit_code=1,
|
||||
@@ -94,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(
|
||||
cli_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 cli_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(
|
||||
cli_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 cli_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=[],
|
||||
@@ -149,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=[],
|
||||
@@ -162,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")],
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from compose_farm.compose import parse_external_networks
|
||||
from compose_farm.config import Config, Host
|
||||
from compose_farm.traefik import generate_traefik_config
|
||||
|
||||
@@ -193,3 +194,145 @@ def test_generate_skips_services_with_enable_false(tmp_path: Path) -> None:
|
||||
|
||||
assert dynamic == {}
|
||||
assert warnings == []
|
||||
|
||||
|
||||
def test_generate_follows_network_mode_service_for_ports(tmp_path: Path) -> None:
|
||||
"""Services using network_mode: service:X should use ports from service X."""
|
||||
cfg = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"nas01": Host(address="192.168.1.10")},
|
||||
services={"vpn-stack": "nas01"},
|
||||
)
|
||||
compose_path = tmp_path / "vpn-stack" / "docker-compose.yml"
|
||||
_write_compose(
|
||||
compose_path,
|
||||
{
|
||||
"services": {
|
||||
"vpn": {
|
||||
"image": "gluetun",
|
||||
"ports": ["5080:5080", "9696:9696"],
|
||||
},
|
||||
"qbittorrent": {
|
||||
"image": "qbittorrent",
|
||||
"network_mode": "service:vpn",
|
||||
"labels": [
|
||||
"traefik.enable=true",
|
||||
"traefik.http.routers.torrent.rule=Host(`torrent.example.com`)",
|
||||
"traefik.http.services.torrent.loadbalancer.server.port=5080",
|
||||
],
|
||||
},
|
||||
"prowlarr": {
|
||||
"image": "prowlarr",
|
||||
"network_mode": "service:vpn",
|
||||
"labels": [
|
||||
"traefik.enable=true",
|
||||
"traefik.http.routers.prowlarr.rule=Host(`prowlarr.example.com`)",
|
||||
"traefik.http.services.prowlarr.loadbalancer.server.port=9696",
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
dynamic, warnings = generate_traefik_config(cfg, ["vpn-stack"])
|
||||
|
||||
assert warnings == []
|
||||
# Both services should get their ports from the vpn service
|
||||
torrent_servers = dynamic["http"]["services"]["torrent"]["loadbalancer"]["servers"]
|
||||
assert torrent_servers == [{"url": "http://192.168.1.10:5080"}]
|
||||
prowlarr_servers = dynamic["http"]["services"]["prowlarr"]["loadbalancer"]["servers"]
|
||||
assert prowlarr_servers == [{"url": "http://192.168.1.10:9696"}]
|
||||
|
||||
|
||||
def test_parse_external_networks_single(tmp_path: Path) -> None:
|
||||
"""Extract a single external network from compose file."""
|
||||
cfg = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"host1": Host(address="192.168.1.10")},
|
||||
services={"app": "host1"},
|
||||
)
|
||||
compose_path = tmp_path / "app" / "compose.yaml"
|
||||
_write_compose(
|
||||
compose_path,
|
||||
{
|
||||
"services": {"app": {"image": "nginx"}},
|
||||
"networks": {"mynetwork": {"external": True}},
|
||||
},
|
||||
)
|
||||
|
||||
networks = parse_external_networks(cfg, "app")
|
||||
assert networks == ["mynetwork"]
|
||||
|
||||
|
||||
def test_parse_external_networks_multiple(tmp_path: Path) -> None:
|
||||
"""Extract multiple external networks from compose file."""
|
||||
cfg = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"host1": Host(address="192.168.1.10")},
|
||||
services={"app": "host1"},
|
||||
)
|
||||
compose_path = tmp_path / "app" / "compose.yaml"
|
||||
_write_compose(
|
||||
compose_path,
|
||||
{
|
||||
"services": {"app": {"image": "nginx"}},
|
||||
"networks": {
|
||||
"frontend": {"external": True},
|
||||
"backend": {"external": True},
|
||||
"internal": {"driver": "bridge"}, # not external
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
networks = parse_external_networks(cfg, "app")
|
||||
assert set(networks) == {"frontend", "backend"}
|
||||
|
||||
|
||||
def test_parse_external_networks_none(tmp_path: Path) -> None:
|
||||
"""No external networks returns empty list."""
|
||||
cfg = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"host1": Host(address="192.168.1.10")},
|
||||
services={"app": "host1"},
|
||||
)
|
||||
compose_path = tmp_path / "app" / "compose.yaml"
|
||||
_write_compose(
|
||||
compose_path,
|
||||
{
|
||||
"services": {"app": {"image": "nginx"}},
|
||||
"networks": {"internal": {"driver": "bridge"}},
|
||||
},
|
||||
)
|
||||
|
||||
networks = parse_external_networks(cfg, "app")
|
||||
assert networks == []
|
||||
|
||||
|
||||
def test_parse_external_networks_no_networks_section(tmp_path: Path) -> None:
|
||||
"""No networks section returns empty list."""
|
||||
cfg = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"host1": Host(address="192.168.1.10")},
|
||||
services={"app": "host1"},
|
||||
)
|
||||
compose_path = tmp_path / "app" / "compose.yaml"
|
||||
_write_compose(
|
||||
compose_path,
|
||||
{"services": {"app": {"image": "nginx"}}},
|
||||
)
|
||||
|
||||
networks = parse_external_networks(cfg, "app")
|
||||
assert networks == []
|
||||
|
||||
|
||||
def test_parse_external_networks_missing_compose(tmp_path: Path) -> None:
|
||||
"""Missing compose file returns empty list."""
|
||||
cfg = Config(
|
||||
compose_dir=tmp_path,
|
||||
hosts={"host1": Host(address="192.168.1.10")},
|
||||
services={"app": "host1"},
|
||||
)
|
||||
# Don't create compose file
|
||||
|
||||
networks = parse_external_networks(cfg, "app")
|
||||
assert networks == []
|
||||
|
||||
Reference in New Issue
Block a user