mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 14:13:26 +00:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031a2af6f3 | ||
|
|
f69eed7721 | ||
|
|
5a1fd4e29f | ||
|
|
26dea691ca | ||
|
|
56d64bfe7a | ||
|
|
5ddbdcdf9e | ||
|
|
dd16becad1 | ||
|
|
df683a223f | ||
|
|
fdb00e7655 | ||
|
|
90657a025f | ||
|
|
7ae8ea0229 | ||
|
|
612242eea9 | ||
|
|
ea650bff8a | ||
|
|
140bca4fd6 | ||
|
|
6dad6be8da | ||
|
|
d7f931e301 | ||
|
|
471936439e | ||
|
|
36e4bef46d | ||
|
|
2cac0bf263 | ||
|
|
3d07cbdff0 | ||
|
|
0f67c17281 | ||
|
|
bd22a1a55e | ||
|
|
cc54e89b33 | ||
|
|
f71e5cffd6 | ||
|
|
0e32729763 | ||
|
|
b0b501fa98 | ||
|
|
7e00596046 | ||
|
|
d1e4d9b05c | ||
|
|
3fbae630f9 | ||
|
|
3e3c919714 | ||
|
|
59b797a89d | ||
|
|
7caf006e07 | ||
|
|
45040b75f1 | ||
|
|
fa1c5c1044 | ||
|
|
67e832f687 | ||
|
|
da986fab6a | ||
|
|
5dd6e2ca05 | ||
|
|
16435065de | ||
|
|
5921b5e405 | ||
|
|
f0cd85b5f5 | ||
|
|
fe95443733 | ||
|
|
8df9288156 | ||
|
|
124bde7575 | ||
|
|
350947ad12 | ||
|
|
bb019bcae6 | ||
|
|
6d50f90344 | ||
|
|
474b7ca044 | ||
|
|
7555d8443b | ||
|
|
de46c3ff0f | ||
|
|
fff064cf03 | ||
|
|
187f83b61d | ||
|
|
d2b9113b9d | ||
|
|
be77eb7c75 | ||
|
|
81e1a482f4 | ||
|
|
435b014251 | ||
|
|
58585ac73c | ||
|
|
5a848ec416 | ||
|
|
b4595cb117 | ||
|
|
5f1c31b780 | ||
|
|
9974f87976 | ||
|
|
8b16484ce2 | ||
|
|
d75f9cca64 | ||
|
|
7ccb0734a2 | ||
|
|
61a845fad8 | ||
|
|
e7efae0153 | ||
|
|
b4ebe15dd1 | ||
|
|
9f55dcdd6e | ||
|
|
0694bbe56d | ||
|
|
3045948d0a | ||
|
|
1fa17b4e07 | ||
|
|
cd25a1914c | ||
|
|
a71200b199 | ||
|
|
967d68b14a | ||
|
|
b7614aeab7 | ||
|
|
d931784935 | ||
|
|
4755065229 | ||
|
|
e86bbf7681 | ||
|
|
be136eb916 | ||
|
|
78a223878f | ||
|
|
f5be23d626 | ||
|
|
3bdc483c2a | ||
|
|
3a3591a0f7 | ||
|
|
7f8ea49d7f | ||
|
|
1e67bde96c | ||
|
|
d8353dbb7e | ||
|
|
2e6146a94b |
6
.envrc.example
Normal file
6
.envrc.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# Run containers as current user (preserves file ownership on NFS mounts)
|
||||
# Copy this file to .envrc and run: direnv allow
|
||||
export CF_UID=$(id -u)
|
||||
export CF_GID=$(id -g)
|
||||
export CF_HOME=$HOME
|
||||
export CF_USER=$USER
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
2
.github/check_readme_commands.py
vendored
2
.github/check_readme_commands.py
vendored
@@ -24,7 +24,7 @@ def get_all_commands(typer_app: typer.Typer, prefix: str = "cf") -> set[str]:
|
||||
continue
|
||||
name = command.name
|
||||
if not name and command.callback:
|
||||
name = command.callback.__name__
|
||||
name = getattr(command.callback, "__name__", None)
|
||||
if name:
|
||||
commands.add(f"{prefix} {name}")
|
||||
|
||||
|
||||
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -27,8 +27,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest
|
||||
- name: Run tests (excluding browser tests)
|
||||
run: uv run pytest -m "not browser"
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
|
||||
@@ -36,6 +36,26 @@ jobs:
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
browser-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install chromium --with-deps
|
||||
|
||||
- name: Run browser tests
|
||||
run: uv run pytest -m browser -n auto -v
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
66
.github/workflows/docs.yml
vendored
Normal file
66
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "zensical.toml"
|
||||
- ".github/workflows/docs.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "zensical.toml"
|
||||
- ".github/workflows/docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages-${{ github.ref }}"
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Install Zensical
|
||||
run: uv tool install zensical
|
||||
|
||||
- name: Build docs
|
||||
run: zensical build
|
||||
|
||||
- name: Setup Pages
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: "./site"
|
||||
|
||||
deploy:
|
||||
if: github.event_name != 'pull_request'
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -44,3 +44,4 @@ compose-farm.yaml
|
||||
coverage.xml
|
||||
.env
|
||||
homepage/
|
||||
site/
|
||||
|
||||
@@ -25,12 +25,18 @@ repos:
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.14.0
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- pydantic>=2.0.0
|
||||
- typer>=0.9.0
|
||||
- asyncssh>=2.14.0
|
||||
- types-PyYAML
|
||||
name: mypy (type checker)
|
||||
entry: uv run mypy src tests
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
- id: ty
|
||||
name: ty (type checker)
|
||||
entry: uv run ty check
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
117
.prompts/docs-review.md
Normal file
117
.prompts/docs-review.md
Normal file
@@ -0,0 +1,117 @@
|
||||
Review documentation for accuracy, completeness, and consistency. Focus on things that require judgment—automated checks handle the rest.
|
||||
|
||||
## What's Already Automated
|
||||
|
||||
Don't waste time on these—CI and pre-commit hooks handle them:
|
||||
|
||||
- **README help output**: `markdown-code-runner` regenerates `cf --help` blocks in CI
|
||||
- **README command table**: Pre-commit hook verifies commands are listed
|
||||
- **Linting/formatting**: Handled by pre-commit
|
||||
|
||||
## What This Review Is For
|
||||
|
||||
Focus on things that require judgment:
|
||||
|
||||
1. **Accuracy**: Does the documentation match what the code actually does?
|
||||
2. **Completeness**: Are there undocumented features, options, or behaviors?
|
||||
3. **Clarity**: Would a new user understand this? Are examples realistic?
|
||||
4. **Consistency**: Do different docs contradict each other?
|
||||
5. **Freshness**: Has the code changed in ways the docs don't reflect?
|
||||
|
||||
## Review Process
|
||||
|
||||
### 1. Check Recent Changes
|
||||
|
||||
```bash
|
||||
# What changed recently that might need doc updates?
|
||||
git log --oneline -20 | grep -iE "feat|fix|add|remove|change|option"
|
||||
|
||||
# What code files changed?
|
||||
git diff --name-only HEAD~20 | grep "\.py$"
|
||||
```
|
||||
|
||||
Look for new features, changed defaults, renamed options, or removed functionality.
|
||||
|
||||
### 2. Verify docs/commands.md Options Tables
|
||||
|
||||
The README auto-updates help output, but `docs/commands.md` has **manually maintained options tables**. These can drift.
|
||||
|
||||
For each command's options table, compare against `cf <command> --help`:
|
||||
- Are all options listed?
|
||||
- Are short flags correct?
|
||||
- Are defaults accurate?
|
||||
- Are descriptions accurate?
|
||||
|
||||
**Pay special attention to subcommands** (`cf config *`, `cf ssh *`)—these have their own options that are easy to miss.
|
||||
|
||||
### 3. Verify docs/configuration.md
|
||||
|
||||
Compare against Pydantic models in the source:
|
||||
|
||||
```bash
|
||||
# Find the config models
|
||||
grep -r "class.*BaseModel" src/ --include="*.py" -A 15
|
||||
```
|
||||
|
||||
Check:
|
||||
- All config keys documented
|
||||
- Types and defaults match code
|
||||
- Config file search order is accurate
|
||||
- Example YAML would actually work
|
||||
|
||||
### 4. Verify docs/architecture.md
|
||||
|
||||
```bash
|
||||
# What source files actually exist?
|
||||
git ls-files "src/**/*.py"
|
||||
```
|
||||
|
||||
Check:
|
||||
- Listed files exist
|
||||
- No files are missing from the list
|
||||
- Descriptions match what the code does
|
||||
|
||||
### 5. Check Examples
|
||||
|
||||
For examples in any doc:
|
||||
- Would the YAML/commands actually work?
|
||||
- Are service names, paths, and options realistic?
|
||||
- Do examples use current syntax (not deprecated options)?
|
||||
|
||||
### 6. Cross-Reference Consistency
|
||||
|
||||
The same info appears in multiple places. Check for conflicts:
|
||||
- README.md vs docs/index.md
|
||||
- docs/commands.md vs CLAUDE.md command tables
|
||||
- Config examples across different docs
|
||||
|
||||
### 7. Self-Check This Prompt
|
||||
|
||||
This prompt can become outdated too. If you notice:
|
||||
- New automated checks that should be listed above
|
||||
- New doc files that need review guidelines
|
||||
- Patterns that caused issues
|
||||
|
||||
Include prompt updates in your fixes.
|
||||
|
||||
## Output Format
|
||||
|
||||
Categorize findings:
|
||||
|
||||
1. **Critical**: Wrong info that would break user workflows
|
||||
2. **Inaccuracy**: Technical errors (wrong defaults, paths, types)
|
||||
3. **Missing**: Undocumented features or options
|
||||
4. **Outdated**: Was true, no longer is
|
||||
5. **Inconsistency**: Docs contradict each other
|
||||
6. **Minor**: Typos, unclear wording
|
||||
|
||||
For each issue, provide a ready-to-apply fix:
|
||||
|
||||
```
|
||||
### Issue: [Brief description]
|
||||
|
||||
- **File**: docs/commands.md:652
|
||||
- **Problem**: `cf ssh setup` has `--config` option but it's not documented
|
||||
- **Fix**: Add `--config, -c PATH` to the options table
|
||||
- **Verify**: `cf ssh setup --help`
|
||||
```
|
||||
51
.prompts/update-demos.md
Normal file
51
.prompts/update-demos.md
Normal file
@@ -0,0 +1,51 @@
|
||||
Update demo recordings to match the current compose-farm.yaml configuration.
|
||||
|
||||
## Key Gotchas
|
||||
|
||||
1. **Never `git checkout` without asking** - check for uncommitted changes first
|
||||
2. **Prefer `nas` stacks** - demos run locally on nas, SSH adds latency
|
||||
3. **Terminal captures keyboard** - use `blur()` to release focus before command palette
|
||||
4. **Clicking sidebar navigates away** - clicking h1 scrolls to top
|
||||
5. **Buttons have icons, not text** - use `[data-tip="..."]` selectors
|
||||
6. **`record.py` auto-restores config** - no manual cleanup needed after CLI demos
|
||||
|
||||
## Stacks Used in Demos
|
||||
|
||||
| Stack | CLI Demos | Web Demos | Notes |
|
||||
|-------|-----------|-----------|-------|
|
||||
| `audiobookshelf` | quickstart, migration, apply | - | Migrates nas→anton |
|
||||
| `grocy` | update | navigation, stack, workflow, console | - |
|
||||
| `immich` | logs, compose | shell | Multiple containers |
|
||||
| `dozzle` | - | workflow | - |
|
||||
|
||||
## CLI Demos
|
||||
|
||||
**Files:** `docs/demos/cli/*.tape`
|
||||
|
||||
Check:
|
||||
- `quickstart.tape`: `bat -r` line ranges match current config structure
|
||||
- `migration.tape`: nvim keystrokes work, stack exists on nas
|
||||
- `compose.tape`: exec commands produce meaningful output
|
||||
|
||||
Run: `python docs/demos/cli/record.py [demo]`
|
||||
|
||||
## Web Demos
|
||||
|
||||
**Files:** `docs/demos/web/demo_*.py`
|
||||
|
||||
Check:
|
||||
- Stack names in demos still exist in config
|
||||
- Selectors match current templates (grep for IDs in `templates/`)
|
||||
- Shell demo uses command palette for ALL navigation
|
||||
|
||||
Run: `python docs/demos/web/record.py [demo]`
|
||||
|
||||
## Before Recording
|
||||
|
||||
```bash
|
||||
# Check for uncommitted config changes
|
||||
git -C /opt/stacks diff compose-farm.yaml
|
||||
|
||||
# Verify stacks are running
|
||||
cf ps audiobookshelf grocy immich dozzle
|
||||
```
|
||||
88
CLAUDE.md
88
CLAUDE.md
@@ -9,44 +9,87 @@
|
||||
## Architecture
|
||||
|
||||
```
|
||||
compose_farm/
|
||||
src/compose_farm/
|
||||
├── cli/ # CLI subpackage
|
||||
│ ├── __init__.py # Imports modules to trigger command registration
|
||||
│ ├── app.py # Shared Typer app instance, version callback
|
||||
│ ├── common.py # Shared helpers, options, progress bar utilities
|
||||
│ ├── config.py # Config subcommand (init, show, path, validate, edit)
|
||||
│ ├── lifecycle.py # up, down, pull, restart, update, apply commands
|
||||
│ ├── config.py # Config subcommand (init, show, path, validate, edit, symlink)
|
||||
│ ├── lifecycle.py # up, down, stop, pull, restart, update, apply, compose commands
|
||||
│ ├── management.py # refresh, check, init-network, traefik-file commands
|
||||
│ └── monitoring.py # logs, ps, stats commands
|
||||
│ ├── monitoring.py # logs, ps, stats commands
|
||||
│ ├── ssh.py # SSH key management (setup, status, keygen)
|
||||
│ └── web.py # Web UI server command
|
||||
├── config.py # Pydantic models, YAML loading
|
||||
├── compose.py # Compose file parsing (.env, ports, volumes, networks)
|
||||
├── console.py # Shared Rich console instances
|
||||
├── executor.py # SSH/local command execution, streaming output
|
||||
├── operations.py # Business logic (up, migrate, discover, preflight checks)
|
||||
├── state.py # Deployment state tracking (which service on which host)
|
||||
├── state.py # Deployment state tracking (which stack on which host)
|
||||
├── logs.py # Image digest snapshots (dockerfarm-log.toml)
|
||||
└── traefik.py # Traefik file-provider config generation from labels
|
||||
├── paths.py # Path utilities, config file discovery
|
||||
├── ssh_keys.py # SSH key path constants and utilities
|
||||
├── traefik.py # Traefik file-provider config generation from labels
|
||||
└── web/ # Web UI (FastAPI + HTMX)
|
||||
```
|
||||
|
||||
## Web UI Icons
|
||||
|
||||
Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templates/partials/icons.html` by copying SVG paths from their site. The `action_btn`, `stat_card`, and `collapse` macros in `components.html` accept an optional `icon` parameter.
|
||||
|
||||
## HTMX Patterns
|
||||
|
||||
- **Multi-element refresh**: Use custom events, not `hx-swap-oob`. Elements have `hx-trigger="cf:refresh from:body"` and JS calls `document.body.dispatchEvent(new CustomEvent('cf:refresh'))`. Simpler to debug/test.
|
||||
- **SPA navigation**: Sidebar uses `hx-boost="true"` to AJAX-ify links.
|
||||
- **Attribute inheritance**: Set `hx-target`/`hx-swap` on parent elements.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Hybrid SSH approach**: asyncssh for parallel streaming with prefixes; native `ssh -t` for raw mode (progress bars)
|
||||
2. **Parallel by default**: Multiple services run concurrently via `asyncio.gather`
|
||||
3. **Streaming output**: Real-time stdout/stderr with `[service]` prefix using Rich
|
||||
2. **Parallel by default**: Multiple stacks run concurrently via `asyncio.gather`
|
||||
3. **Streaming output**: Real-time stdout/stderr with `[stack]` prefix using Rich
|
||||
4. **SSH key auth only**: Uses ssh-agent, no password handling (YAGNI)
|
||||
5. **NFS assumption**: Compose files at same path on all hosts
|
||||
6. **Local IP auto-detection**: Skips SSH when target host matches local machine's IP
|
||||
7. **State tracking**: Tracks where services are deployed for auto-migration
|
||||
7. **State tracking**: Tracks where stacks are deployed for auto-migration
|
||||
8. **Pre-flight checks**: Verifies NFS mounts and Docker networks exist before starting/migrating
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Imports at top level**: Never add imports inside functions unless they are explicitly marked with `# noqa: PLC0415` and a comment explaining it speeds up CLI startup. Heavy modules like `pydantic`, `yaml`, and `rich.table` are lazily imported to keep `cf --help` fast.
|
||||
|
||||
## Development Commands
|
||||
|
||||
Use `just` for common tasks. Run `just` to list available commands:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `just install` | Install dev dependencies |
|
||||
| `just test` | Run all tests |
|
||||
| `just test-cli` | Run CLI tests (parallel) |
|
||||
| `just test-web` | Run web UI tests (parallel) |
|
||||
| `just lint` | Lint, format, and type check |
|
||||
| `just web` | Start web UI (port 9001) |
|
||||
| `just doc` | Build and serve docs (port 9002) |
|
||||
| `just clean` | Clean build artifacts |
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with `just test` or `uv run pytest`. Browser tests require Chromium (system-installed or via `playwright install chromium`):
|
||||
|
||||
```bash
|
||||
# Unit tests only (parallel)
|
||||
uv run pytest -m "not browser" -n auto
|
||||
|
||||
# Browser tests only (parallel)
|
||||
uv run pytest -m browser -n auto
|
||||
|
||||
# All tests
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
Browser tests are marked with `@pytest.mark.browser`. They use Playwright to test HTMX behavior, JavaScript functionality (sidebar filter, command palette, terminals), and content stability during navigation.
|
||||
|
||||
## Communication Notes
|
||||
|
||||
- Clarify ambiguous wording (e.g., homophones like "right"/"write", "their"/"there").
|
||||
@@ -57,6 +100,11 @@ Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templat
|
||||
- **NEVER merge anything into main.** Always commit directly or use fast-forward/rebase.
|
||||
- Never force push.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Never include unchecked checklists (e.g., `- [ ] ...`) in PR descriptions. Either omit the checklist or use checked items.
|
||||
- **NEVER run `gh pr merge`**. PRs are merged via the GitHub UI, not the CLI.
|
||||
|
||||
## Releases
|
||||
|
||||
Use `gh release create` to create releases. The tag is created automatically.
|
||||
@@ -81,17 +129,21 @@ CLI available as `cf` or `compose-farm`.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `up` | Start services (`docker compose up -d`), auto-migrates if host changed |
|
||||
| `down` | Stop services (`docker compose down`). Use `--orphaned` to stop services removed from config |
|
||||
| `up` | Start stacks (`docker compose up -d`), auto-migrates if host changed |
|
||||
| `down` | Stop stacks (`docker compose down`). Use `--orphaned` to stop stacks removed from config |
|
||||
| `stop` | Stop services without removing containers (`docker compose stop`) |
|
||||
| `pull` | Pull latest images |
|
||||
| `restart` | `down` + `up -d` |
|
||||
| `update` | `pull` + `down` + `up -d` |
|
||||
| `apply` | Make reality match config: migrate services + stop orphans. Use `--dry-run` to preview |
|
||||
| `logs` | Show service logs |
|
||||
| `ps` | Show status of all services |
|
||||
| `stats` | Show overview (hosts, services, pending migrations; `--live` for container counts) |
|
||||
| `refresh` | Update state from reality: discover running services, capture image digests |
|
||||
| `update` | `pull` + `build` + `down` + `up -d` |
|
||||
| `apply` | Make reality match config: migrate stacks + stop orphans. Use `--dry-run` to preview |
|
||||
| `compose` | Run any docker compose command on a stack (passthrough) |
|
||||
| `logs` | Show stack logs |
|
||||
| `ps` | Show status of all stacks |
|
||||
| `stats` | Show overview (hosts, stacks, pending migrations; `--live` for container counts) |
|
||||
| `refresh` | Update state from reality: discover running stacks, capture image digests |
|
||||
| `check` | Validate config, traefik labels, mounts, networks; show host compatibility |
|
||||
| `init-network` | Create Docker network on hosts with consistent subnet/gateway |
|
||||
| `traefik-file` | Generate Traefik file-provider config from compose labels |
|
||||
| `config` | Manage config files (init, show, path, validate, edit) |
|
||||
| `config` | Manage config files (init, show, path, validate, edit, symlink) |
|
||||
| `ssh` | Manage SSH keys (setup, status, keygen) |
|
||||
| `web` | Start web UI server |
|
||||
|
||||
@@ -16,5 +16,9 @@ RUN apk add --no-cache openssh-client
|
||||
COPY --from=builder /root/.local/share/uv/tools/compose-farm /root/.local/share/uv/tools/compose-farm
|
||||
COPY --from=builder /usr/local/bin/cf /usr/local/bin/compose-farm /usr/local/bin/
|
||||
|
||||
# Allow non-root users to access the installed tool
|
||||
# (required when running with user: "${CF_UID:-0}:${CF_GID:-0}")
|
||||
RUN chmod 755 /root
|
||||
|
||||
ENTRYPOINT ["cf"]
|
||||
CMD ["--help"]
|
||||
|
||||
514
README.md
514
README.md
@@ -5,12 +5,31 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/basnijholt/compose-farm/stargazers)
|
||||
|
||||
<img src="http://files.nijho.lt/compose-farm.png" align="right" style="width: 300px;" />
|
||||
<img src="https://files.nijho.lt/compose-farm.png" alt="Compose Farm logo" align="right" style="width: 300px;" />
|
||||
|
||||
A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
|
||||
|
||||
> [!NOTE]
|
||||
> Run `docker compose` commands across multiple hosts via SSH. One YAML maps services to hosts. Run `cf apply` and reality matches your config—services start, migrate, or stop as needed. No Kubernetes, no Swarm, no magic.
|
||||
> Agentless multi-host Docker Compose. CLI-first with a web UI. Your files stay as plain folders—version-controllable, no lock-in. Run `cf apply` and reality matches your config.
|
||||
|
||||
**Why Compose Farm?**
|
||||
- **Your files, your control** — Plain folders + YAML, not locked in Portainer. Version control everything.
|
||||
- **Agentless** — Just SSH, no agents to deploy (unlike [Dockge](https://github.com/louislam/dockge)).
|
||||
- **Zero changes required** — Existing compose files work as-is.
|
||||
- **Grows with you** — Start single-host, scale to multi-host seamlessly.
|
||||
- **Declarative** — Change config, run `cf apply`, reality matches.
|
||||
|
||||
## Quick Demo
|
||||
|
||||
**CLI:**
|
||||
|
||||

|
||||
|
||||
**Web UI:**
|
||||
|
||||

|
||||
|
||||
## Table of Contents
|
||||
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
@@ -19,7 +38,7 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
|
||||
- [How It Works](#how-it-works)
|
||||
- [Requirements](#requirements)
|
||||
- [Limitations & Best Practices](#limitations--best-practices)
|
||||
- [What breaks when you move a service](#what-breaks-when-you-move-a-service)
|
||||
- [What breaks when you move a stack](#what-breaks-when-you-move-a-stack)
|
||||
- [Best practices](#best-practices)
|
||||
- [What Compose Farm doesn't do](#what-compose-farm-doesnt-do)
|
||||
- [Installation](#installation)
|
||||
@@ -27,7 +46,9 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
|
||||
- [SSH Agent (default)](#ssh-agent-default)
|
||||
- [Dedicated SSH Key (recommended for Docker/Web UI)](#dedicated-ssh-key-recommended-for-dockerweb-ui)
|
||||
- [Configuration](#configuration)
|
||||
- [Multi-Host Services](#multi-host-services)
|
||||
- [Single-host example](#single-host-example)
|
||||
- [Multi-host example](#multi-host-example)
|
||||
- [Multi-Host Stacks](#multi-host-stacks)
|
||||
- [Config Command](#config-command)
|
||||
- [Usage](#usage)
|
||||
- [CLI `--help` Output](#cli---help-output)
|
||||
@@ -40,14 +61,14 @@ A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
|
||||
|
||||
## Why Compose Farm?
|
||||
|
||||
I used to run 100+ Docker Compose stacks on a single machine that kept running out of memory. I needed a way to distribute services across multiple machines without the complexity of:
|
||||
I used to run 100+ Docker Compose stacks on a single machine that kept running out of memory. I needed a way to distribute stacks across multiple machines without the complexity of:
|
||||
|
||||
- **Kubernetes**: Overkill for my use case. I don't need pods, services, ingress controllers, or YAML manifests 10x the size of my compose files.
|
||||
- **Docker Swarm**: Effectively in maintenance mode—no longer being invested in by Docker.
|
||||
|
||||
Both require changes to your compose files. **Compose Farm requires zero changes**—your existing `docker-compose.yml` files work as-is.
|
||||
|
||||
I also wanted a declarative setup—one config file that defines where everything runs. Change the config, run `cf apply`, and everything reconciles—services start, migrate, or stop as needed. See [Comparison with Alternatives](#comparison-with-alternatives) for how this compares to other approaches.
|
||||
I also wanted a declarative setup—one config file that defines where everything runs. Change the config, run `cf apply`, and everything reconciles—stacks start, migrate, or stop as needed. See [Comparison with Alternatives](#comparison-with-alternatives) for how this compares to other approaches.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://xkcd.com/927/">
|
||||
@@ -59,8 +80,8 @@ Before you say it—no, this is not a new standard. I changed nothing about my e
|
||||
|
||||
Compose Farm just automates what you'd do by hand:
|
||||
- Runs `docker compose` commands over SSH
|
||||
- Tracks which service runs on which host
|
||||
- **One command (`cf apply`) to reconcile everything**—start missing services, migrate moved ones, stop removed ones
|
||||
- Tracks which stack runs on which host
|
||||
- **One command (`cf apply`) to reconcile everything**—start missing stacks, migrate moved ones, stop removed ones
|
||||
- Generates Traefik file-provider config for cross-host routing
|
||||
|
||||
**It's a convenience wrapper, not a new paradigm.**
|
||||
@@ -70,13 +91,13 @@ Compose Farm just automates what you'd do by hand:
|
||||
**The declarative way** — run `cf apply` and reality matches your config:
|
||||
|
||||
1. Compose Farm compares your config to what's actually running
|
||||
2. Services in config but not running? **Starts them**
|
||||
3. Services on the wrong host? **Migrates them** (stops on old host, starts on new)
|
||||
4. Services running but removed from config? **Stops them**
|
||||
2. Stacks in config but not running? **Starts them**
|
||||
3. Stacks on the wrong host? **Migrates them** (stops on old host, starts on new)
|
||||
4. Stacks running but removed from config? **Stops them**
|
||||
|
||||
**Under the hood** — each service operation is just SSH + docker compose:
|
||||
**Under the hood** — each stack operation is just SSH + docker compose:
|
||||
|
||||
1. Look up which host runs the service (e.g., `plex` → `server-1`)
|
||||
1. Look up which host runs the stack (e.g., `plex` → `server-1`)
|
||||
2. SSH to `server-1` (or run locally if `localhost`)
|
||||
3. Execute `docker compose -f /opt/compose/plex/docker-compose.yml up -d`
|
||||
4. Stream output back with `[plex]` prefix
|
||||
@@ -104,13 +125,13 @@ nas:/volume1/compose → /opt/compose (on server-2)
|
||||
nas:/volume1/compose → /opt/compose (on server-3)
|
||||
```
|
||||
|
||||
Compose Farm simply runs `docker compose -f /opt/compose/{service}/docker-compose.yml` on the appropriate host—it doesn't copy or sync files.
|
||||
Compose Farm simply runs `docker compose -f /opt/compose/{stack}/docker-compose.yml` on the appropriate host—it doesn't copy or sync files.
|
||||
|
||||
## Limitations & Best Practices
|
||||
|
||||
Compose Farm moves containers between hosts but **does not provide cross-host networking**. Docker's internal DNS and networks don't span hosts.
|
||||
|
||||
### What breaks when you move a service
|
||||
### What breaks when you move a stack
|
||||
|
||||
- **Docker DNS** - `http://redis:6379` won't resolve from another host
|
||||
- **Docker networks** - Containers can't reach each other via network names
|
||||
@@ -120,7 +141,7 @@ Compose Farm moves containers between hosts but **does not provide cross-host ne
|
||||
|
||||
1. **Keep dependent services together** - If an app needs a database, redis, or worker, keep them in the same compose file on the same host
|
||||
|
||||
2. **Only migrate standalone services** - Services that don't talk to other containers (or only talk to external APIs) are safe to move
|
||||
2. **Only migrate standalone stacks** - Stacks whose services don't talk to other containers (or only talk to external APIs) are safe to move
|
||||
|
||||
3. **Expose ports for cross-host communication** - If services must communicate across hosts, publish ports and use IP addresses instead of container names:
|
||||
```yaml
|
||||
@@ -140,8 +161,11 @@ If you need containers on different hosts to communicate seamlessly, you need Do
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# One-liner (installs uv if needed)
|
||||
curl -fsSL https://compose-farm.nijho.lt/install | sh
|
||||
|
||||
# Or if you already have uv/pip
|
||||
uv tool install compose-farm
|
||||
# or
|
||||
pip install compose-farm
|
||||
```
|
||||
|
||||
@@ -160,6 +184,24 @@ docker run --rm \
|
||||
ghcr.io/basnijholt/compose-farm up --all
|
||||
```
|
||||
|
||||
**Running as non-root user** (recommended for NFS mounts):
|
||||
|
||||
By default, containers run as root. To preserve file ownership on mounted volumes
|
||||
(e.g., `compose-farm-state.yaml`, config edits), set these environment variables:
|
||||
|
||||
```bash
|
||||
# Add to .env file (one-time setup)
|
||||
echo "CF_UID=$(id -u)" >> .env
|
||||
echo "CF_GID=$(id -g)" >> .env
|
||||
echo "CF_HOME=$HOME" >> .env
|
||||
echo "CF_USER=$USER" >> .env
|
||||
```
|
||||
|
||||
Or use [direnv](https://direnv.net/) (copies `.envrc.example` to `.envrc`):
|
||||
```bash
|
||||
cp .envrc.example .envrc && direnv allow
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## SSH Authentication
|
||||
@@ -194,18 +236,18 @@ This creates `~/.ssh/compose-farm/id_ed25519` (ED25519, no passphrase) and copie
|
||||
|
||||
<details><summary>🐳 Docker volume options for SSH keys</summary>
|
||||
|
||||
When running in Docker, mount a volume to persist the SSH keys. Choose ONE option and use it for both `cf` and `web` services:
|
||||
When running in Docker, mount a volume to persist the SSH keys. Choose ONE option and use it for both `cf` and `web` Compose services:
|
||||
|
||||
**Option 1: Host path (default)** - keys at `~/.ssh/compose-farm/id_ed25519`
|
||||
```yaml
|
||||
volumes:
|
||||
- ~/.ssh/compose-farm:/root/.ssh
|
||||
- ~/.ssh/compose-farm:${CF_HOME:-/root}/.ssh
|
||||
```
|
||||
|
||||
**Option 2: Named volume** - managed by Docker
|
||||
```yaml
|
||||
volumes:
|
||||
- cf-ssh:/root/.ssh
|
||||
- cf-ssh:${CF_HOME:-/root}/.ssh
|
||||
```
|
||||
|
||||
Run setup once after starting the container (while the SSH agent still works):
|
||||
@@ -216,12 +258,31 @@ docker compose exec web cf ssh setup
|
||||
|
||||
The keys will persist across restarts.
|
||||
|
||||
**Note:** When running as non-root (with `CF_UID`/`CF_GID`), set `CF_HOME` to your home directory so SSH finds the keys at the correct path.
|
||||
|
||||
</details>
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `~/.config/compose-farm/compose-farm.yaml` (or `./compose-farm.yaml` in your working directory):
|
||||
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`). This keeps config near your stacks. Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config, or symlink from one to the other with `cf config symlink`.
|
||||
|
||||
### Single-host example
|
||||
|
||||
No SSH, shared storage, or Traefik file-provider required.
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/stacks
|
||||
|
||||
hosts:
|
||||
local: localhost # Run locally without SSH
|
||||
|
||||
stacks:
|
||||
plex: local
|
||||
jellyfin: local
|
||||
traefik: local
|
||||
```
|
||||
|
||||
### Multi-host example
|
||||
```yaml
|
||||
compose_dir: /opt/compose # Must be the same path on all hosts
|
||||
|
||||
@@ -232,24 +293,24 @@ hosts:
|
||||
server-2:
|
||||
address: 192.168.1.11
|
||||
# user defaults to current user
|
||||
local: localhost # Run locally without SSH
|
||||
|
||||
services:
|
||||
stacks:
|
||||
plex: server-1
|
||||
jellyfin: server-2
|
||||
sonarr: server-1
|
||||
radarr: local # Runs on the machine where you invoke compose-farm
|
||||
grafana: server-1
|
||||
|
||||
# Multi-host services (run on multiple/all hosts)
|
||||
# Multi-host stacks (run on multiple/all hosts)
|
||||
autokuma: all # Runs on ALL configured hosts
|
||||
dozzle: [server-1, server-2] # Explicit list of hosts
|
||||
```
|
||||
|
||||
Compose files are expected at `{compose_dir}/{service}/compose.yaml` (also supports `compose.yml`, `docker-compose.yml`, `docker-compose.yaml`).
|
||||
For cross-host HTTP routing, add Traefik labels to your compose files and set `traefik_file` so Compose Farm can generate the file-provider config.
|
||||
|
||||
### Multi-Host Services
|
||||
Each entry in `stacks:` maps to a folder under `compose_dir` that contains a compose file. Compose files are expected at `{compose_dir}/{stack}/compose.yaml` (also supports `compose.yml`, `docker-compose.yml`, `docker-compose.yaml`).
|
||||
|
||||
Some services need to run on every host. This is typically required for tools that access **host-local resources** like the Docker socket (`/var/run/docker.sock`), which cannot be accessed remotely without security risks.
|
||||
### Multi-Host Stacks
|
||||
|
||||
Some stacks need to run on every host. This is typically required for tools that access **host-local resources** like the Docker socket (`/var/run/docker.sock`), which cannot be accessed remotely without security risks.
|
||||
|
||||
Common use cases:
|
||||
- **AutoKuma** - auto-creates Uptime Kuma monitors from container labels (needs local Docker socket)
|
||||
@@ -262,7 +323,7 @@ This is the same pattern as Docker Swarm's `deploy.mode: global`.
|
||||
Use the `all` keyword or an explicit list:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
stacks:
|
||||
# Run on all configured hosts
|
||||
autokuma: all
|
||||
dozzle: all
|
||||
@@ -271,9 +332,9 @@ services:
|
||||
node-exporter: [server-1, server-2, server-3]
|
||||
```
|
||||
|
||||
When you run `cf up autokuma`, it starts the service on all hosts in parallel. Multi-host services:
|
||||
When you run `cf up autokuma`, it starts the stack on all hosts in parallel. Multi-host stacks:
|
||||
- Are excluded from migration logic (they always run everywhere)
|
||||
- Show output with `[service@host]` prefix for each host
|
||||
- Show output with `[stack@host]` prefix for each host
|
||||
- Track all running hosts in state
|
||||
|
||||
### Config Command
|
||||
@@ -297,20 +358,21 @@ The CLI is available as both `compose-farm` and the shorter `cf` alias.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| **`cf apply`** | **Make reality match config (start + migrate + stop orphans)** |
|
||||
| `cf up <svc>` | Start service (auto-migrates if host changed) |
|
||||
| `cf down <svc>` | Stop service |
|
||||
| `cf restart <svc>` | down + up |
|
||||
| `cf update <svc>` | pull + down + up |
|
||||
| `cf pull <svc>` | Pull latest images |
|
||||
| `cf logs -f <svc>` | Follow logs |
|
||||
| `cf ps` | Show status of all services |
|
||||
| `cf refresh` | Update state from running services |
|
||||
| `cf up <stack>` | Start stack (auto-migrates if host changed) |
|
||||
| `cf down <stack>` | Stop and remove stack containers |
|
||||
| `cf stop <stack>` | Stop stack without removing containers |
|
||||
| `cf restart <stack>` | down + up |
|
||||
| `cf update <stack>` | pull + build + down + up |
|
||||
| `cf pull <stack>` | Pull latest images |
|
||||
| `cf logs -f <stack>` | Follow logs |
|
||||
| `cf ps` | Show status of all stacks |
|
||||
| `cf refresh` | Update state from running stacks |
|
||||
| `cf check` | Validate config, mounts, networks |
|
||||
| `cf init-network` | Create Docker network on hosts |
|
||||
| `cf traefik-file` | Generate Traefik file-provider config |
|
||||
| `cf config <cmd>` | Manage config files (init, show, path, validate, edit) |
|
||||
| `cf config <cmd>` | Manage config files (init, show, path, validate, edit, symlink) |
|
||||
|
||||
All commands support `--all` to operate on all services.
|
||||
All commands support `--all` to operate on all stacks.
|
||||
|
||||
Each command replaces: look up host → SSH → find compose file → run `ssh host "cd /opt/compose/plex && docker compose up -d"`.
|
||||
|
||||
@@ -318,14 +380,14 @@ Each command replaces: look up host → SSH → find compose file → run `ssh h
|
||||
# The main command: make reality match your config
|
||||
cf apply # start missing + migrate + stop orphans
|
||||
cf apply --dry-run # preview what would change
|
||||
cf apply --no-orphans # skip stopping orphaned services
|
||||
cf apply --full # also refresh all services (picks up config changes)
|
||||
cf apply --no-orphans # skip stopping orphaned stacks
|
||||
cf apply --full # also refresh all stacks (picks up config changes)
|
||||
|
||||
# Or operate on individual services
|
||||
cf up plex jellyfin # start services (auto-migrates if host changed)
|
||||
# Or operate on individual stacks
|
||||
cf up plex jellyfin # start stacks (auto-migrates if host changed)
|
||||
cf up --all
|
||||
cf down plex # stop services
|
||||
cf down --orphaned # stop services removed from config
|
||||
cf down plex # stop stacks
|
||||
cf down --orphaned # stop stacks removed from config
|
||||
|
||||
# Pull latest images
|
||||
cf pull --all
|
||||
@@ -333,19 +395,19 @@ cf pull --all
|
||||
# Restart (down + up)
|
||||
cf restart plex
|
||||
|
||||
# Update (pull + down + up) - the end-to-end update command
|
||||
# Update (pull + build + down + up) - the end-to-end update command
|
||||
cf update --all
|
||||
|
||||
# Update state from reality (discovers running services + captures digests)
|
||||
cf refresh # updates state.yaml and dockerfarm-log.toml
|
||||
# Update state from reality (discovers running stacks + captures digests)
|
||||
cf refresh # updates compose-farm-state.yaml and dockerfarm-log.toml
|
||||
cf refresh --dry-run # preview without writing
|
||||
|
||||
# Validate config, traefik labels, mounts, and networks
|
||||
cf check # full validation (includes SSH checks)
|
||||
cf check --local # fast validation (skip SSH)
|
||||
cf check jellyfin # check service + show which hosts can run it
|
||||
cf check jellyfin # check stack + show which hosts can run it
|
||||
|
||||
# Create Docker network on new hosts (before migrating services)
|
||||
# Create Docker network on new hosts (before migrating stacks)
|
||||
cf init-network nuc hp # create mynetwork on specific hosts
|
||||
cf init-network # create on all hosts
|
||||
|
||||
@@ -388,27 +450,33 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Lifecycle ──────────────────────────────────────────────────────────────────╮
|
||||
│ up Start services (docker compose up -d). Auto-migrates if host │
|
||||
│ up Start stacks (docker compose up -d). Auto-migrates if host │
|
||||
│ changed. │
|
||||
│ down Stop services (docker compose down). │
|
||||
│ down Stop stacks (docker compose down). │
|
||||
│ stop Stop services without removing containers (docker compose │
|
||||
│ stop). │
|
||||
│ pull Pull latest images (docker compose pull). │
|
||||
│ restart Restart services (down + up). │
|
||||
│ update Update services (pull + build + down + up). │
|
||||
│ restart Restart stacks (down + up). With --service, restarts just │
|
||||
│ that service. │
|
||||
│ update Update stacks (pull + build + down + up). With --service, │
|
||||
│ updates just that service. │
|
||||
│ apply Make reality match config (start, migrate, stop as needed). │
|
||||
│ compose Run any docker compose command on a stack. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Configuration ──────────────────────────────────────────────────────────────╮
|
||||
│ traefik-file Generate a Traefik file-provider fragment from compose │
|
||||
│ Traefik labels. │
|
||||
│ refresh Update local state from running services. │
|
||||
│ refresh Update local state from running stacks. │
|
||||
│ check Validate configuration, traefik labels, mounts, and networks. │
|
||||
│ init-network Create Docker network on hosts with consistent settings. │
|
||||
│ config Manage compose-farm configuration files. │
|
||||
│ ssh Manage SSH keys for passwordless authentication. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
|
||||
│ logs Show service logs. │
|
||||
│ ps Show status of all services. │
|
||||
│ stats Show overview statistics for hosts and services. │
|
||||
│ logs Show stack logs. With --service, shows logs for just that │
|
||||
│ service. │
|
||||
│ ps Show status of stacks. │
|
||||
│ stats Show overview statistics for hosts and stacks. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Server ─────────────────────────────────────────────────────────────────────╮
|
||||
│ web Start the web UI server. │
|
||||
@@ -437,18 +505,19 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf up [OPTIONS] [SERVICES]...
|
||||
Usage: cf up [OPTIONS] [STACKS]...
|
||||
|
||||
Start services (docker compose up -d). Auto-migrates if host changed.
|
||||
Start stacks (docker compose up -d). Auto-migrates if host changed.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all services │
|
||||
│ --host -H TEXT Filter to services on this host │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -472,18 +541,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf down [OPTIONS] [SERVICES]...
|
||||
Usage: cf down [OPTIONS] [STACKS]...
|
||||
|
||||
Stop services (docker compose down).
|
||||
Stop stacks (docker compose down).
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all services │
|
||||
│ --orphaned Stop orphaned services (in state but removed from │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --orphaned Stop orphaned stacks (in state but removed from │
|
||||
│ config) │
|
||||
│ --host -H TEXT Filter to services on this host │
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -494,6 +563,41 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>See the output of <code>cf stop --help</code></summary>
|
||||
|
||||
<!-- CODE:BASH:START -->
|
||||
<!-- echo '```yaml' -->
|
||||
<!-- export NO_COLOR=1 -->
|
||||
<!-- export TERM=dumb -->
|
||||
<!-- export TERMINAL_WIDTH=90 -->
|
||||
<!-- cf stop --help -->
|
||||
<!-- echo '```' -->
|
||||
<!-- CODE:END -->
|
||||
<!-- OUTPUT:START -->
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf stop [OPTIONS] [STACKS]...
|
||||
|
||||
Stop services without removing containers (docker compose stop).
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>See the output of <code>cf pull --help</code></summary>
|
||||
|
||||
@@ -509,17 +613,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf pull [OPTIONS] [SERVICES]...
|
||||
Usage: cf pull [OPTIONS] [STACKS]...
|
||||
|
||||
Pull latest images (docker compose pull).
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all services │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -543,17 +648,18 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf restart [OPTIONS] [SERVICES]...
|
||||
Usage: cf restart [OPTIONS] [STACKS]...
|
||||
|
||||
Restart services (down + up).
|
||||
Restart stacks (down + up). With --service, restarts just that service.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all services │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -577,17 +683,19 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf update [OPTIONS] [SERVICES]...
|
||||
Usage: cf update [OPTIONS] [STACKS]...
|
||||
|
||||
Update services (pull + build + down + up).
|
||||
Update stacks (pull + build + down + up). With --service, updates just that
|
||||
service.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all services │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -615,19 +723,21 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
Make reality match config (start, migrate, stop as needed).
|
||||
|
||||
This is the "reconcile" command that ensures running services match your
|
||||
This is the "reconcile" command that ensures running stacks match your
|
||||
config file. It will:
|
||||
1. Stop orphaned services (in state but removed from config) 2. Migrate
|
||||
services on wrong host (host in state ≠ host in config) 3. Start missing
|
||||
services (in config but not in state)
|
||||
Use --dry-run to preview changes before applying. Use --no-orphans to only
|
||||
migrate/start without stopping orphaned services. Use --full to also run 'up'
|
||||
on all services (picks up compose/env changes).
|
||||
|
||||
1. Stop orphaned stacks (in state but removed from config)
|
||||
2. Migrate stacks on wrong host (host in state ≠ host in config)
|
||||
3. Start missing stacks (in config but not in state)
|
||||
|
||||
Use --dry-run to preview changes before applying.
|
||||
Use --no-orphans to only migrate/start without stopping orphaned stacks.
|
||||
Use --full to also run 'up' on all stacks (picks up compose/env changes).
|
||||
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --dry-run -n Show what would change without executing │
|
||||
│ --no-orphans Only migrate, don't stop orphaned services │
|
||||
│ --full -f Also run up on all services to apply config │
|
||||
│ --no-orphans Only migrate, don't stop orphaned stacks │
|
||||
│ --full -f Also run up on all stacks to apply config │
|
||||
│ changes │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
@@ -639,6 +749,53 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>See the output of <code>cf compose --help</code></summary>
|
||||
|
||||
<!-- CODE:BASH:START -->
|
||||
<!-- echo '```yaml' -->
|
||||
<!-- export NO_COLOR=1 -->
|
||||
<!-- export TERM=dumb -->
|
||||
<!-- export TERMINAL_WIDTH=90 -->
|
||||
<!-- cf compose --help -->
|
||||
<!-- echo '```' -->
|
||||
<!-- CODE:END -->
|
||||
<!-- OUTPUT:START -->
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf compose [OPTIONS] STACK COMMAND [ARGS]...
|
||||
|
||||
Run any docker compose command on a stack.
|
||||
|
||||
Passthrough to docker compose for commands not wrapped by cf.
|
||||
Options after COMMAND are passed to docker compose, not cf.
|
||||
|
||||
Examples:
|
||||
cf compose mystack --help - show docker compose help
|
||||
cf compose mystack top - view running processes
|
||||
cf compose mystack images - list images
|
||||
cf compose mystack exec web bash - interactive shell
|
||||
cf compose mystack config - view parsed config
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ * stack TEXT Stack to operate on (use '.' for current dir) │
|
||||
│ [required] │
|
||||
│ * command TEXT Docker compose command [required] │
|
||||
│ args [ARGS]... Additional arguments │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
</details>
|
||||
|
||||
**Configuration**
|
||||
|
||||
<details>
|
||||
@@ -656,15 +813,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf traefik-file [OPTIONS] [SERVICES]...
|
||||
Usage: cf traefik-file [OPTIONS] [STACKS]...
|
||||
|
||||
Generate a Traefik file-provider fragment from compose Traefik labels.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all services │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --output -o PATH Write Traefik file-provider YAML to this path │
|
||||
│ (stdout if omitted) │
|
||||
│ --config -c PATH Path to config file │
|
||||
@@ -692,16 +849,24 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf refresh [OPTIONS]
|
||||
Usage: cf refresh [OPTIONS] [STACKS]...
|
||||
|
||||
Update local state from running services.
|
||||
Update local state from running stacks.
|
||||
|
||||
Discovers which stacks are running on which hosts, updates the state
|
||||
file, and captures image digests. This is a read operation - it updates
|
||||
your local state to match reality, not the other way around.
|
||||
|
||||
Without arguments: refreshes all stacks (same as --all).
|
||||
With stack names: refreshes only those stacks.
|
||||
|
||||
Discovers which services are running on which hosts, updates the state file,
|
||||
and captures image digests. This is a read operation - it updates your local
|
||||
state to match reality, not the other way around.
|
||||
Use 'cf apply' to make reality match your config (stop orphans, migrate).
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all stacks │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --log-path -l PATH Path to Dockerfarm TOML log │
|
||||
│ --dry-run -n Show what would change without writing │
|
||||
@@ -730,16 +895,17 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf check [OPTIONS] [SERVICES]...
|
||||
Usage: cf check [OPTIONS] [STACKS]...
|
||||
|
||||
Validate configuration, traefik labels, mounts, and networks.
|
||||
|
||||
Without arguments: validates all services against configured hosts. With
|
||||
service arguments: validates specific services and shows host compatibility.
|
||||
Without arguments: validates all stacks against configured hosts.
|
||||
With stack arguments: validates specific stacks and shows host compatibility.
|
||||
|
||||
Use --local to skip SSH-based checks for faster validation.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --local Skip SSH-based checks (faster) │
|
||||
@@ -773,9 +939,9 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
Create Docker network on hosts with consistent settings.
|
||||
|
||||
Creates an external Docker network that services can use for cross-host
|
||||
communication. Uses the same subnet/gateway on all hosts to ensure consistent
|
||||
networking.
|
||||
Creates an external Docker network that stacks can use for cross-host
|
||||
communication. Uses the same subnet/gateway on all hosts to ensure
|
||||
consistent networking.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ hosts [HOSTS]... Hosts to create network on (default: all) │
|
||||
@@ -845,6 +1011,26 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- cf ssh --help -->
|
||||
<!-- echo '```' -->
|
||||
<!-- CODE:END -->
|
||||
<!-- OUTPUT:START -->
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf ssh [OPTIONS] COMMAND [ARGS]...
|
||||
|
||||
Manage SSH keys for passwordless authentication.
|
||||
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Commands ───────────────────────────────────────────────────────────────────╮
|
||||
│ keygen Generate SSH key (does not distribute to hosts). │
|
||||
│ setup Generate SSH key and distribute to all configured hosts. │
|
||||
│ status Show SSH key status and host connectivity. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
</details>
|
||||
|
||||
@@ -865,21 +1051,22 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf logs [OPTIONS] [SERVICES]...
|
||||
Usage: cf logs [OPTIONS] [STACKS]...
|
||||
|
||||
Show service logs.
|
||||
Show stack logs. With --service, shows logs for just that service.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ services [SERVICES]... Services to operate on │
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --all -a Run on all services │
|
||||
│ --host -H TEXT Filter to services on this host │
|
||||
│ --follow -f Follow logs │
|
||||
│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 │
|
||||
│ otherwise) │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --follow -f Follow logs │
|
||||
│ --tail -n INTEGER Number of lines (default: 20 for --all, 100 │
|
||||
│ otherwise) │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -904,13 +1091,24 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf ps [OPTIONS]
|
||||
Usage: cf ps [OPTIONS] [STACKS]...
|
||||
|
||||
Show status of all services.
|
||||
Show status of stacks.
|
||||
|
||||
Without arguments: shows all stacks (same as --all).
|
||||
With stack names: shows only those stacks.
|
||||
With --host: shows stacks on that host.
|
||||
With --service: filters to a specific service within the stack.
|
||||
|
||||
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
|
||||
│ stacks [STACKS]... Stacks to operate on │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
│ --all -a Run on all stacks │
|
||||
│ --host -H TEXT Filter to stacks on this host │
|
||||
│ --service -s TEXT Target a specific service within the stack │
|
||||
│ --config -c PATH Path to config file │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
@@ -937,9 +1135,9 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
Usage: cf stats [OPTIONS]
|
||||
|
||||
Show overview statistics for hosts and services.
|
||||
Show overview statistics for hosts and stacks.
|
||||
|
||||
Without --live: Shows config/state info (hosts, services, pending migrations).
|
||||
Without --live: Shows config/state info (hosts, stacks, pending migrations).
|
||||
With --live: Also queries Docker on each host for container counts.
|
||||
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
@@ -967,41 +1165,59 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
<!-- cf web --help -->
|
||||
<!-- echo '```' -->
|
||||
<!-- CODE:END -->
|
||||
<!-- OUTPUT:START -->
|
||||
<!-- ⚠️ This content is auto-generated by `markdown-code-runner`. -->
|
||||
```yaml
|
||||
|
||||
Usage: cf web [OPTIONS]
|
||||
|
||||
Start the web UI server.
|
||||
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --host -H TEXT Host to bind to [default: 0.0.0.0] │
|
||||
│ --port -p INTEGER Port to listen on [default: 8000] │
|
||||
│ --reload -r Enable auto-reload for development │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
|
||||
<!-- OUTPUT:END -->
|
||||
|
||||
</details>
|
||||
|
||||
### Auto-Migration
|
||||
|
||||
When you change a service's host assignment in config and run `up`, Compose Farm automatically:
|
||||
When you change a stack's host assignment in config and run `up`, Compose Farm automatically:
|
||||
1. Checks that required mounts and networks exist on the new host (aborts if missing)
|
||||
2. Runs `down` on the old host
|
||||
3. Runs `up -d` on the new host
|
||||
4. Updates state tracking
|
||||
|
||||
Use `cf apply` to automatically reconcile all services—it finds and migrates services on wrong hosts, stops orphaned services, and starts missing services.
|
||||
Use `cf apply` to automatically reconcile all stacks—it finds and migrates stacks on wrong hosts, stops orphaned stacks, and starts missing stacks.
|
||||
|
||||
```yaml
|
||||
# Before: plex runs on server-1
|
||||
services:
|
||||
stacks:
|
||||
plex: server-1
|
||||
|
||||
# After: change to server-2, then run `cf up plex`
|
||||
services:
|
||||
stacks:
|
||||
plex: server-2 # Compose Farm will migrate automatically
|
||||
```
|
||||
|
||||
**Orphaned services**: When you remove (or comment out) a service from config, it becomes "orphaned"—tracked in state but no longer in config. Use these commands to handle orphans:
|
||||
**Orphaned stacks**: When you remove (or comment out) a stack from config, it becomes "orphaned"—tracked in state but no longer in config. Use these commands to handle orphans:
|
||||
|
||||
- `cf apply` — Migrate services AND stop orphans (the full reconcile)
|
||||
- `cf down --orphaned` — Only stop orphaned services
|
||||
- `cf apply` — Migrate stacks AND stop orphans (the full reconcile)
|
||||
- `cf down --orphaned` — Only stop orphaned stacks
|
||||
- `cf apply --dry-run` — Preview what would change before applying
|
||||
|
||||
This makes the config truly declarative: comment out a service, run `cf apply`, and it stops.
|
||||
This makes the config truly declarative: comment out a stack, run `cf apply`, and it stops.
|
||||
|
||||
## Traefik Multihost Ingress (File Provider)
|
||||
|
||||
If you run a single Traefik instance on one "front‑door" host and want it to route to
|
||||
Compose Farm services on other hosts, Compose Farm can generate a Traefik file‑provider
|
||||
Compose Farm stacks on other hosts, Compose Farm can generate a Traefik file‑provider
|
||||
fragment from your existing compose labels.
|
||||
|
||||
**How it works**
|
||||
@@ -1011,11 +1227,11 @@ fragment from your existing compose labels.
|
||||
- Labels and port specs may use `${VAR}` / `${VAR:-default}`; Compose Farm resolves these
|
||||
using the stack's `.env` file and your current environment, just like Docker Compose.
|
||||
- Publish a host port for that container (via `ports:`). The generator prefers
|
||||
host‑published ports so Traefik can reach the service across hosts; if none are found,
|
||||
host‑published ports so Traefik can reach the stack across hosts; if none are found,
|
||||
it warns and you'd need L3 reachability to container IPs.
|
||||
- If a router label doesn't specify `traefik.http.routers.<name>.service` and there's only
|
||||
one Traefik service defined on that container, Compose Farm wires the router to it.
|
||||
- `compose-farm.yaml` stays unchanged: just `hosts` and `services: service → host`.
|
||||
- `compose-farm.yaml` stays unchanged: just `hosts` and `stacks: stack → host`.
|
||||
|
||||
Example `docker-compose.yml` pattern:
|
||||
|
||||
@@ -1049,7 +1265,7 @@ providers:
|
||||
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
|
||||
Re‑run this after changing Traefik labels, moving a stack to another host, or changing
|
||||
published ports.
|
||||
|
||||
**Auto-regeneration**
|
||||
@@ -1060,17 +1276,17 @@ add `traefik_file` to your config:
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml # auto-regenerate on up/down/restart/update
|
||||
traefik_service: traefik # skip services on same host (docker provider handles them)
|
||||
traefik_stack: traefik # skip stacks on same host (docker provider handles them)
|
||||
|
||||
hosts:
|
||||
# ...
|
||||
services:
|
||||
stacks:
|
||||
traefik: server-1 # Traefik runs here
|
||||
plex: server-2 # Services on other hosts get file-provider entries
|
||||
plex: server-2 # Stacks on other hosts get file-provider entries
|
||||
# ...
|
||||
```
|
||||
|
||||
The `traefik_service` option specifies which service runs Traefik. Services on the same host
|
||||
The `traefik_stack` option specifies which stack runs Traefik. Stacks on the same host
|
||||
are skipped in the file-provider config since Traefik's docker provider handles them directly.
|
||||
|
||||
Now `cf up plex` will update the Traefik config automatically—no separate
|
||||
@@ -1113,11 +1329,11 @@ There are many ways to run containers on multiple hosts. Here is where Compose F
|
||||
| Agentless | ✅ | ✅ | ❌ | ✅ | ❌ |
|
||||
| High availability | ❌ | ❌ | ✅ | ❌ | ❌ |
|
||||
|
||||
**Docker Contexts** — You can use `docker context create remote ssh://...` and `docker compose --context remote up`. But it's manual: you must remember which host runs which service, there's no global view, no parallel execution, and no auto-migration.
|
||||
**Docker Contexts** — You can use `docker context create remote ssh://...` and `docker compose --context remote up`. But it's manual: you must remember which host runs which stack, there's no global view, no parallel execution, and no auto-migration.
|
||||
|
||||
**Kubernetes / Docker Swarm** — Full orchestration that abstracts away the hardware. But they require cluster initialization, separate control planes, and often rewriting compose files. They introduce complexity (consensus, overlay networks) unnecessary for static "pet" servers.
|
||||
|
||||
**Ansible / Terraform** — Infrastructure-as-Code tools that can SSH in and deploy containers. But they're push-based configuration management, not interactive CLIs. Great for setting up state, clumsy for day-to-day operations like `cf logs -f` or quickly restarting a service.
|
||||
**Ansible / Terraform** — Infrastructure-as-Code tools that can SSH in and deploy containers. But they're push-based configuration management, not interactive CLIs. Great for setting up state, clumsy for day-to-day operations like `cf logs -f` or quickly restarting a stack.
|
||||
|
||||
**Portainer / Coolify** — Web-based management UIs. But they're UI-first and often require agents on your servers. Compose Farm is CLI-first and agentless.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ compose_dir: /opt/compose
|
||||
|
||||
# Optional: Auto-regenerate Traefik file-provider config after up/down/restart/update
|
||||
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_service: traefik # Skip services on same host (docker provider handles them)
|
||||
traefik_stack: traefik # Skip stacks on same host (docker provider handles them)
|
||||
|
||||
hosts:
|
||||
# Full form with all options
|
||||
@@ -20,11 +20,11 @@ hosts:
|
||||
# Local execution (no SSH)
|
||||
local: localhost
|
||||
|
||||
services:
|
||||
# Map service names to hosts
|
||||
# Compose file expected at: {compose_dir}/{service}/compose.yaml
|
||||
stacks:
|
||||
# Map stack names to hosts
|
||||
# Compose file expected at: {compose_dir}/{stack}/compose.yaml
|
||||
traefik: server-1 # Traefik runs here
|
||||
plex: server-2 # Services on other hosts get file-provider entries
|
||||
plex: server-2 # Stacks on other hosts get file-provider entries
|
||||
jellyfin: server-2
|
||||
sonarr: server-1
|
||||
radarr: local
|
||||
grafana: server-1
|
||||
nextcloud: local
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
services:
|
||||
cf:
|
||||
image: ghcr.io/basnijholt/compose-farm:latest
|
||||
# Run as current user to preserve file ownership on mounted volumes
|
||||
# Set CF_UID=$(id -u) CF_GID=$(id -g) in your environment or .env file
|
||||
# Defaults to root (0:0) for backwards compatibility
|
||||
user: "${CF_UID:-0}:${CF_GID:-0}"
|
||||
volumes:
|
||||
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
|
||||
# Compose directory (contains compose files AND compose-farm.yaml config)
|
||||
@@ -8,31 +12,43 @@ services:
|
||||
# SSH keys for passwordless auth (generated by `cf ssh setup`)
|
||||
# Choose ONE option below (use the same option for both cf and web services):
|
||||
# Option 1: Host path (default) - keys at ~/.ssh/compose-farm/id_ed25519
|
||||
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
|
||||
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:${CF_HOME:-/root}/.ssh/compose-farm
|
||||
# Option 2: Named volume - managed by Docker, shared between services
|
||||
# - cf-ssh:/root/.ssh
|
||||
# - cf-ssh:${CF_HOME:-/root}/.ssh
|
||||
environment:
|
||||
- SSH_AUTH_SOCK=/ssh-agent
|
||||
# Config file path (state stored alongside it)
|
||||
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
|
||||
# HOME must match the user running the container for SSH to find keys
|
||||
- HOME=${CF_HOME:-/root}
|
||||
# USER is required for SSH when running as non-root (UID not in /etc/passwd)
|
||||
- USER=${CF_USER:-root}
|
||||
|
||||
web:
|
||||
image: ghcr.io/basnijholt/compose-farm:latest
|
||||
restart: unless-stopped
|
||||
command: web --host 0.0.0.0 --port 9000
|
||||
# Run as current user to preserve file ownership on mounted volumes
|
||||
user: "${CF_UID:-0}:${CF_GID:-0}"
|
||||
volumes:
|
||||
- ${SSH_AUTH_SOCK}:/ssh-agent:ro
|
||||
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
|
||||
# SSH keys - use the SAME option as cf service above
|
||||
# Option 1: Host path (default)
|
||||
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:/root/.ssh
|
||||
- ${CF_SSH_DIR:-~/.ssh/compose-farm}:${CF_HOME:-/root}/.ssh/compose-farm
|
||||
# Option 2: Named volume
|
||||
# - cf-ssh:/root/.ssh
|
||||
# - cf-ssh:${CF_HOME:-/root}/.ssh
|
||||
# XDG config dir for backups and image digest logs (persists across restarts)
|
||||
- ${CF_XDG_CONFIG:-~/.config/compose-farm}:${CF_HOME:-/root}/.config/compose-farm
|
||||
environment:
|
||||
- SSH_AUTH_SOCK=/ssh-agent
|
||||
- CF_CONFIG=${CF_COMPOSE_DIR:-/opt/stacks}/compose-farm.yaml
|
||||
# Used to detect self-updates and run via SSH to survive container restart
|
||||
- CF_WEB_SERVICE=compose-farm
|
||||
- CF_WEB_STACK=compose-farm
|
||||
# HOME must match the user running the container for SSH to find keys
|
||||
- HOME=${CF_HOME:-/root}
|
||||
# USER is required for SSH when running as non-root (UID not in /etc/passwd)
|
||||
- USER=${CF_USER:-root}
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.compose-farm.rule=Host(`compose-farm.${DOMAIN}`)
|
||||
|
||||
1
docs/CNAME
Normal file
1
docs/CNAME
Normal file
@@ -0,0 +1 @@
|
||||
compose-farm.nijho.lt
|
||||
345
docs/architecture.md
Normal file
345
docs/architecture.md
Normal file
@@ -0,0 +1,345 @@
|
||||
---
|
||||
icon: lucide/layers
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
This document explains how Compose Farm works under the hood.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
Compose Farm follows three core principles:
|
||||
|
||||
1. **KISS** - Keep it simple. It's a thin wrapper around `docker compose` over SSH.
|
||||
2. **YAGNI** - No orchestration, no service discovery, no health checks until needed.
|
||||
3. **Zero changes** - Your existing compose files work unchanged.
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Compose Farm CLI │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ Config │ │ State │ │Operations│ │ Executor │ │
|
||||
│ │ Parser │ │ Tracker │ │ Logic │ │ (SSH/Local) │ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │
|
||||
└───────┼─────────────┼─────────────┼─────────────────┼───────────┘
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
┌───────────────────────────────────────────────────────────────┐
|
||||
│ SSH / Local │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐
|
||||
│ Host: nuc │ │ Host: hp │
|
||||
│ │ │ │
|
||||
│ docker compose│ │ docker compose│
|
||||
│ up -d │ │ up -d │
|
||||
└───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### Configuration (`src/compose_farm/config.py`)
|
||||
|
||||
Pydantic models for YAML configuration:
|
||||
|
||||
- **Config** - Root configuration with compose_dir, hosts, stacks
|
||||
- **Host** - Host address, SSH user, and port
|
||||
|
||||
Key features:
|
||||
- Validation with Pydantic
|
||||
- Multi-host stack expansion (`all` → list of hosts)
|
||||
- YAML loading with sensible defaults
|
||||
|
||||
### State Tracking (`src/compose_farm/state.py`)
|
||||
|
||||
Tracks deployment state in `compose-farm-state.yaml` (stored alongside the config file):
|
||||
|
||||
```yaml
|
||||
deployed:
|
||||
plex: nuc
|
||||
grafana: nuc
|
||||
```
|
||||
|
||||
Used for:
|
||||
- Detecting migrations (stack moved to different host)
|
||||
- Identifying orphans (stacks removed from config)
|
||||
- `cf ps` status display
|
||||
|
||||
### Operations (`src/compose_farm/operations.py`)
|
||||
|
||||
Business logic for stack operations:
|
||||
|
||||
- **up** - Start stack, handle migration if needed
|
||||
- **down** - Stop stack
|
||||
- **preflight checks** - Verify mounts, networks exist before operations
|
||||
- **discover** - Find running stacks on hosts
|
||||
- **migrate** - Down on old host, up on new host
|
||||
|
||||
### Executor (`src/compose_farm/executor.py`)
|
||||
|
||||
SSH and local command execution:
|
||||
|
||||
- **Hybrid SSH approach**: asyncssh for parallel streaming, native `ssh -t` for raw mode
|
||||
- **Parallel by default**: Multiple stacks via `asyncio.gather`
|
||||
- **Streaming output**: Real-time stdout/stderr with `[stack]` prefix
|
||||
- **Local detection**: Skips SSH when target matches local machine IP
|
||||
|
||||
### CLI (`src/compose_farm/cli/`)
|
||||
|
||||
Typer-based CLI with subcommand modules:
|
||||
|
||||
```
|
||||
cli/
|
||||
├── app.py # Shared Typer app, version callback
|
||||
├── common.py # Shared helpers, options, progress utilities
|
||||
├── config.py # config subcommand (init, show, path, validate, edit, symlink)
|
||||
├── lifecycle.py # up, down, stop, pull, restart, update, apply, compose
|
||||
├── management.py # refresh, check, init-network, traefik-file
|
||||
├── monitoring.py # logs, ps, stats
|
||||
├── ssh.py # SSH key management (setup, status, keygen)
|
||||
└── web.py # Web UI server command
|
||||
```
|
||||
|
||||
## Command Flow
|
||||
|
||||
### cf up plex
|
||||
|
||||
```
|
||||
1. Load configuration
|
||||
└─► Parse compose-farm.yaml
|
||||
└─► Validate stack exists
|
||||
|
||||
2. Check state
|
||||
└─► Load state.yaml
|
||||
└─► Is plex already running?
|
||||
└─► Is it on a different host? (migration needed)
|
||||
|
||||
3. Pre-flight checks
|
||||
└─► SSH to target host
|
||||
└─► Check compose file exists
|
||||
└─► Check required mounts exist
|
||||
└─► Check required networks exist
|
||||
|
||||
4. Execute migration (if needed)
|
||||
└─► SSH to old host
|
||||
└─► Run: docker compose down
|
||||
|
||||
5. Start stack
|
||||
└─► SSH to target host
|
||||
└─► cd /opt/compose/plex
|
||||
└─► Run: docker compose up -d
|
||||
|
||||
6. Update state
|
||||
└─► Write new state to state.yaml
|
||||
|
||||
7. Generate Traefik config (if configured)
|
||||
└─► Regenerate traefik file-provider
|
||||
```
|
||||
|
||||
### cf apply
|
||||
|
||||
```
|
||||
1. Load configuration and state
|
||||
|
||||
2. Compute diff
|
||||
├─► Orphans: in state, not in config
|
||||
├─► Migrations: in both, different host
|
||||
└─► Missing: in config, not in state
|
||||
|
||||
3. Stop orphans
|
||||
└─► For each orphan: cf down
|
||||
|
||||
4. Migrate stacks
|
||||
└─► For each migration: down old, up new
|
||||
|
||||
5. Start missing
|
||||
└─► For each missing: cf up
|
||||
|
||||
6. Update state
|
||||
```
|
||||
|
||||
## SSH Execution
|
||||
|
||||
### Parallel Streaming (asyncssh)
|
||||
|
||||
For most operations, Compose Farm uses asyncssh:
|
||||
|
||||
```python
|
||||
async def run_command(host, command):
|
||||
async with asyncssh.connect(host) as conn:
|
||||
result = await conn.run(command)
|
||||
return result.stdout, result.stderr
|
||||
```
|
||||
|
||||
Multiple stacks run concurrently via `asyncio.gather`.
|
||||
|
||||
### Raw Mode (native ssh)
|
||||
|
||||
For commands needing PTY (progress bars, interactive):
|
||||
|
||||
```bash
|
||||
ssh -t user@host "docker compose pull"
|
||||
```
|
||||
|
||||
### Local Detection
|
||||
|
||||
When target host IP matches local machine:
|
||||
|
||||
```python
|
||||
if is_local(host_address):
|
||||
# Run locally, no SSH
|
||||
subprocess.run(command)
|
||||
else:
|
||||
# SSH to remote
|
||||
ssh.run(command)
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### State File
|
||||
|
||||
Location: `compose-farm-state.yaml` (stored alongside the config file)
|
||||
|
||||
```yaml
|
||||
deployed:
|
||||
plex: nuc
|
||||
grafana: nuc
|
||||
```
|
||||
|
||||
Image digests are stored separately in `dockerfarm-log.toml` (also in the config directory).
|
||||
|
||||
### State Transitions
|
||||
|
||||
```
|
||||
Config Change State Change Action
|
||||
─────────────────────────────────────────────────────
|
||||
Add stack Missing cf up
|
||||
Remove stack Orphaned cf down
|
||||
Change host Migration down old, up new
|
||||
No change No change none (or refresh)
|
||||
```
|
||||
|
||||
### cf refresh
|
||||
|
||||
Syncs state with reality by querying Docker on each host:
|
||||
|
||||
```bash
|
||||
docker ps --format '{{.Names}}'
|
||||
```
|
||||
|
||||
Updates state.yaml to match what's actually running.
|
||||
|
||||
## Compose File Discovery
|
||||
|
||||
For each stack, Compose Farm looks for compose files in:
|
||||
|
||||
```
|
||||
{compose_dir}/{stack}/
|
||||
├── compose.yaml # preferred
|
||||
├── compose.yml
|
||||
├── docker-compose.yml
|
||||
└── docker-compose.yaml
|
||||
```
|
||||
|
||||
First match wins.
|
||||
|
||||
## Traefik Integration
|
||||
|
||||
### Label Extraction
|
||||
|
||||
Compose Farm parses Traefik labels from compose files:
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
plex:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.plex.rule=Host(`plex.example.com`)
|
||||
- traefik.http.services.plex.loadbalancer.server.port=32400
|
||||
```
|
||||
|
||||
### File Provider Generation
|
||||
|
||||
Converts labels to Traefik file-provider YAML:
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
plex:
|
||||
rule: Host(`plex.example.com`)
|
||||
service: plex
|
||||
services:
|
||||
plex:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://192.168.1.10:32400
|
||||
```
|
||||
|
||||
### Variable Resolution
|
||||
|
||||
Supports `${VAR}` and `${VAR:-default}` from:
|
||||
1. Service's `.env` file
|
||||
2. Current environment
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Pre-flight Failures
|
||||
|
||||
Before any operation, Compose Farm checks:
|
||||
- SSH connectivity
|
||||
- Compose file existence
|
||||
- Required mounts
|
||||
- Required networks
|
||||
|
||||
If checks fail, operation aborts with clear error.
|
||||
|
||||
### Partial Failures
|
||||
|
||||
When operating on multiple stacks:
|
||||
- Each stack is independent
|
||||
- Failures are logged, but other stacks continue
|
||||
- Exit code reflects overall success/failure
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
Services are started/stopped in parallel:
|
||||
|
||||
```python
|
||||
await asyncio.gather(*[
|
||||
up_stack(stack) for stack in stacks
|
||||
])
|
||||
```
|
||||
|
||||
### SSH Multiplexing
|
||||
|
||||
For repeated connections to the same host, SSH reuses connections.
|
||||
|
||||
### Caching
|
||||
|
||||
- Config is parsed once per command
|
||||
- State is loaded once, written once
|
||||
- Host discovery results are cached during command
|
||||
|
||||
## Web UI Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Web UI │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ FastAPI │ │ Jinja │ │ HTMX │ │
|
||||
│ │ Backend │ │ Templates │ │ Dynamic Updates │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
│ │
|
||||
│ Pattern: Custom events, not hx-swap-oob │
|
||||
│ Elements trigger on: cf:refresh from:body │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Icons use [Lucide](https://lucide.dev/). Add new icons as macros in `web/templates/partials/icons.html`.
|
||||
3
docs/assets/apply.gif
Normal file
3
docs/assets/apply.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:01dabdd8f62773823ba2b8dc74f9931f1a1b88215117e6a080004096025491b0
|
||||
size 901456
|
||||
3
docs/assets/apply.webm
Normal file
3
docs/assets/apply.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:134c903a6b3acfb933617b33755b0cdb9bac2a59e5e35b64236e248a141d396d
|
||||
size 206883
|
||||
3
docs/assets/compose.gif
Normal file
3
docs/assets/compose.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d8b3cdb3486ec79b3ddb2f7571c13d54ac9aed182edfe708eff76a966a90cfc7
|
||||
size 1132310
|
||||
3
docs/assets/compose.webm
Normal file
3
docs/assets/compose.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a3c4d4a62f062f717df4e6752efced3caea29004dc90fe97fd7633e7f0ded9db
|
||||
size 341057
|
||||
3
docs/assets/install.gif
Normal file
3
docs/assets/install.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c1bb48cc2f364681515a4d8bd0c586d133f5a32789b7bb64524ad7d9ed0a8e9
|
||||
size 543135
|
||||
3
docs/assets/install.webm
Normal file
3
docs/assets/install.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5f82d96137f039f21964c15c1550aa1b1f0bb2d52c04d012d253dbfbd6fad096
|
||||
size 268086
|
||||
3
docs/assets/logs.gif
Normal file
3
docs/assets/logs.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2a4045b00d90928f42c7764b3c24751576cfb68a34c6e84d12b4e282d2e67378
|
||||
size 146467
|
||||
3
docs/assets/logs.webm
Normal file
3
docs/assets/logs.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f1b94416ed3740853f863e19bf45f26241a203fb0d7d187160a537f79aa544fa
|
||||
size 60353
|
||||
3
docs/assets/migration.gif
Normal file
3
docs/assets/migration.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:848d9c48fb7511da7996149277c038589fad1ee406ff2f30c28f777fc441d919
|
||||
size 1183641
|
||||
3
docs/assets/migration.webm
Normal file
3
docs/assets/migration.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e747ee71bb38b19946005d5a4def4d423dadeaaade452dec875c4cb2d24a5b77
|
||||
size 407373
|
||||
3
docs/assets/quickstart.gif
Normal file
3
docs/assets/quickstart.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d32c9a3eec06e57df085ad347e6bf61e323f8bd8322d0c540f0b9d4834196dfd
|
||||
size 3589776
|
||||
3
docs/assets/quickstart.webm
Normal file
3
docs/assets/quickstart.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6c54eda599389dac74c24c83527f95cd1399e653d7faf2972c2693d90e590597
|
||||
size 1085344
|
||||
3
docs/assets/update.gif
Normal file
3
docs/assets/update.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62f9b5ec71496197a3f1c3e3bca8967d603838804279ea7dbf00a70d3391ff6c
|
||||
size 127123
|
||||
3
docs/assets/update.webm
Normal file
3
docs/assets/update.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ac2b93d3630af87b44a135723c5d10e8287529bed17c28301b2802cd9593e9e8
|
||||
size 98748
|
||||
3
docs/assets/web-console.gif
Normal file
3
docs/assets/web-console.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7b50a7e9836c496c0989363d1440fa0a6ccdaa38ee16aae92b389b3cf3c3732f
|
||||
size 2385110
|
||||
3
docs/assets/web-console.webm
Normal file
3
docs/assets/web-console.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ccbb3d5366c7734377e12f98cca0b361028f5722124f1bb7efa231f6aeffc116
|
||||
size 2208044
|
||||
3
docs/assets/web-navigation.gif
Normal file
3
docs/assets/web-navigation.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:269993b52721ce70674d3aab2a4cd8c58aa621d4ba0739afedae661c90965b26
|
||||
size 3678371
|
||||
3
docs/assets/web-navigation.webm
Normal file
3
docs/assets/web-navigation.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0098b55bb6a52fa39f807a01fa352ce112bcb446e2a2acb963fb02d21b28c934
|
||||
size 3088813
|
||||
3
docs/assets/web-shell.gif
Normal file
3
docs/assets/web-shell.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4bf9d8c247d278799d1daea784fc662a22f12b1bd7883f808ef30f35025ebca6
|
||||
size 4166443
|
||||
3
docs/assets/web-shell.webm
Normal file
3
docs/assets/web-shell.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:02d5124217a94849bf2971d6d13d28da18c557195a81b9cca121fb7c07f0501b
|
||||
size 3523244
|
||||
3
docs/assets/web-stack.gif
Normal file
3
docs/assets/web-stack.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:412a0e68f8e52801cafbb9a703ca9577e7c14cc7c0e439160b9185961997f23c
|
||||
size 4435697
|
||||
3
docs/assets/web-stack.webm
Normal file
3
docs/assets/web-stack.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0e600a1d3216b44497a889f91eac94d62ef7207b4ed0471465dcb72408caa28e
|
||||
size 3764693
|
||||
3
docs/assets/web-themes.gif
Normal file
3
docs/assets/web-themes.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3c07a283f4f70c4ab205b0f0acb5d6f55e3ced4c12caa7a8d5914ffe3548233a
|
||||
size 5768166
|
||||
3
docs/assets/web-themes.webm
Normal file
3
docs/assets/web-themes.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:562228841de976d70ee80999b930eadf3866a13ff2867d900279993744c44671
|
||||
size 6667918
|
||||
3
docs/assets/web-workflow.gif
Normal file
3
docs/assets/web-workflow.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:845746ac1cb101c3077d420c4f3fda3ca372492582dc123ac8a031a68ae9b6b1
|
||||
size 12943150
|
||||
3
docs/assets/web-workflow.webm
Normal file
3
docs/assets/web-workflow.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:189259558b5760c02583885168d7b0b47cf476cba81c7c028ec770f9d6033129
|
||||
size 12415357
|
||||
372
docs/best-practices.md
Normal file
372
docs/best-practices.md
Normal file
@@ -0,0 +1,372 @@
|
||||
---
|
||||
icon: lucide/lightbulb
|
||||
---
|
||||
|
||||
# Best Practices
|
||||
|
||||
Tips, limitations, and recommendations for using Compose Farm effectively.
|
||||
|
||||
## Limitations
|
||||
|
||||
### No Cross-Host Networking
|
||||
|
||||
Compose Farm moves containers between hosts but **does not provide cross-host networking**. Docker's internal DNS and networks don't span hosts.
|
||||
|
||||
**What breaks when you move a stack:**
|
||||
|
||||
| Feature | Works? | Why |
|
||||
|---------|--------|-----|
|
||||
| `http://redis:6379` | No | Docker DNS doesn't cross hosts |
|
||||
| Docker network names | No | Networks are per-host |
|
||||
| `DATABASE_URL=postgres://db:5432` | No | Container name won't resolve |
|
||||
| Host IP addresses | Yes | Use `192.168.1.10:5432` |
|
||||
|
||||
### What Compose Farm Doesn't Do
|
||||
|
||||
- No overlay networking (use Swarm/Kubernetes)
|
||||
- No service discovery across hosts
|
||||
- No automatic dependency tracking between compose files
|
||||
- No health checks or restart policies beyond Docker's
|
||||
- No secrets management beyond Docker's
|
||||
|
||||
## Stack Organization
|
||||
|
||||
### Keep Dependencies Together
|
||||
|
||||
If services talk to each other, keep them in the same compose file on the same host:
|
||||
|
||||
```yaml
|
||||
# /opt/compose/myapp/docker-compose.yml
|
||||
services:
|
||||
app:
|
||||
image: myapp
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
db:
|
||||
image: postgres
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
```
|
||||
|
||||
```yaml
|
||||
# compose-farm.yaml
|
||||
stacks:
|
||||
myapp: nuc # All three containers stay together
|
||||
```
|
||||
|
||||
### Separate Standalone Stacks
|
||||
|
||||
Stacks whose services don't talk to other containers can be anywhere:
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
# These can run on any host
|
||||
plex: nuc
|
||||
jellyfin: hp
|
||||
homeassistant: nas
|
||||
|
||||
# These should stay together
|
||||
myapp: nuc # includes app + db + redis
|
||||
```
|
||||
|
||||
### Cross-Host Communication
|
||||
|
||||
If services MUST communicate across hosts, publish ports:
|
||||
|
||||
```yaml
|
||||
# Instead of
|
||||
DATABASE_URL=postgres://db:5432
|
||||
|
||||
# Use
|
||||
DATABASE_URL=postgres://192.168.1.10:5432
|
||||
```
|
||||
|
||||
```yaml
|
||||
# And publish the port
|
||||
services:
|
||||
db:
|
||||
ports:
|
||||
- "5432:5432"
|
||||
```
|
||||
|
||||
## Multi-Host Stacks
|
||||
|
||||
### When to Use `all`
|
||||
|
||||
Use `all` for stacks that need local access to each host:
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
# Need Docker socket
|
||||
dozzle: all # Log viewer
|
||||
portainer-agent: all # Portainer agents
|
||||
autokuma: all # Auto-creates monitors
|
||||
|
||||
# Need host metrics
|
||||
node-exporter: all # Prometheus metrics
|
||||
promtail: all # Log shipping
|
||||
```
|
||||
|
||||
### Host-Specific Lists
|
||||
|
||||
For stacks on specific hosts only:
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
# Only on compute nodes
|
||||
gitlab-runner: [nuc, hp]
|
||||
|
||||
# Only on storage nodes
|
||||
minio: [nas-1, nas-2]
|
||||
```
|
||||
|
||||
## Migration Safety
|
||||
|
||||
### Pre-flight Checks
|
||||
|
||||
Before migrating, Compose Farm verifies:
|
||||
- Compose file is accessible on new host
|
||||
- Required mounts exist on new host
|
||||
- Required networks exist on new host
|
||||
|
||||
### Data Considerations
|
||||
|
||||
**Compose Farm doesn't move data.** Ensure:
|
||||
|
||||
1. **Shared storage**: Data volumes on NFS/shared storage
|
||||
2. **External databases**: Data in external DB, not container
|
||||
3. **Backup first**: Always backup before migration
|
||||
|
||||
### Safe Migration Pattern
|
||||
|
||||
```bash
|
||||
# 1. Preview changes
|
||||
cf apply --dry-run
|
||||
|
||||
# 2. Verify target host can run the stack
|
||||
cf check myservice
|
||||
|
||||
# 3. Apply changes
|
||||
cf apply
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### When to Refresh
|
||||
|
||||
Run `cf refresh` after:
|
||||
- Manual `docker compose` commands
|
||||
- Container restarts
|
||||
- Host reboots
|
||||
- Any changes outside Compose Farm
|
||||
|
||||
```bash
|
||||
cf refresh --dry-run # Preview
|
||||
cf refresh # Sync
|
||||
```
|
||||
|
||||
### State Conflicts
|
||||
|
||||
If state doesn't match reality:
|
||||
|
||||
```bash
|
||||
# See what's actually running
|
||||
cf refresh --dry-run
|
||||
|
||||
# Sync state
|
||||
cf refresh
|
||||
|
||||
# Then apply config
|
||||
cf apply
|
||||
```
|
||||
|
||||
## Shared Storage
|
||||
|
||||
### NFS Best Practices
|
||||
|
||||
```bash
|
||||
# Mount options for Docker compatibility
|
||||
nas:/compose /opt/compose nfs rw,hard,intr,rsize=8192,wsize=8192 0 0
|
||||
```
|
||||
|
||||
### Directory Ownership
|
||||
|
||||
Ensure consistent UID/GID across hosts:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myapp:
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
```
|
||||
|
||||
### Config vs Data
|
||||
|
||||
Keep config and data separate:
|
||||
|
||||
```
|
||||
/opt/compose/ # Shared: compose files + config
|
||||
├── plex/
|
||||
│ ├── docker-compose.yml
|
||||
│ └── config/ # Small config files OK
|
||||
|
||||
/mnt/data/ # Shared: large media files
|
||||
├── movies/
|
||||
├── tv/
|
||||
└── music/
|
||||
|
||||
/opt/appdata/ # Local: per-host app data
|
||||
├── plex/
|
||||
└── grafana/
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
### Parallel Operations
|
||||
|
||||
Compose Farm runs operations in parallel. For large deployments:
|
||||
|
||||
```bash
|
||||
# Good: parallel by default
|
||||
cf up --all
|
||||
|
||||
# Avoid: sequential updates when possible
|
||||
for svc in plex grafana nextcloud; do
|
||||
cf update $svc
|
||||
done
|
||||
```
|
||||
|
||||
### SSH Connection Reuse
|
||||
|
||||
SSH connections are reused within a command. For many operations:
|
||||
|
||||
```bash
|
||||
# One command, one connection per host
|
||||
cf update --all
|
||||
|
||||
# Multiple commands, multiple connections (slower)
|
||||
cf update plex && cf update grafana && cf update nextcloud
|
||||
```
|
||||
|
||||
## Traefik Setup
|
||||
|
||||
### Stack Placement
|
||||
|
||||
Put Traefik on a reliable host:
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
traefik: nuc # Primary host with good uptime
|
||||
```
|
||||
|
||||
### Same-Host Stacks
|
||||
|
||||
Stacks on the same host as Traefik use Docker provider:
|
||||
|
||||
```yaml
|
||||
traefik_stack: traefik
|
||||
|
||||
stacks:
|
||||
traefik: nuc
|
||||
portainer: nuc # Docker provider handles this
|
||||
plex: hp # File provider handles this
|
||||
```
|
||||
|
||||
### Middleware in Separate File
|
||||
|
||||
Define middlewares outside Compose Farm's generated file:
|
||||
|
||||
```yaml
|
||||
# /opt/traefik/dynamic.d/middlewares.yml
|
||||
http:
|
||||
middlewares:
|
||||
redirect-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### What to Backup
|
||||
|
||||
| Item | Location | Method |
|
||||
|------|----------|--------|
|
||||
| Compose Farm config | `~/.config/compose-farm/` | Git or copy |
|
||||
| Compose files | `/opt/compose/` | Git |
|
||||
| State file | `~/.config/compose-farm/compose-farm-state.yaml` | Optional (can refresh) |
|
||||
| App data | `/opt/appdata/` | Backup solution |
|
||||
|
||||
### Disaster Recovery
|
||||
|
||||
```bash
|
||||
# Restore config
|
||||
cp backup/compose-farm.yaml ~/.config/compose-farm/
|
||||
|
||||
# Refresh state from running containers
|
||||
cf refresh
|
||||
|
||||
# Or start fresh
|
||||
cf apply
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Stack won't start:**
|
||||
```bash
|
||||
cf check myservice # Verify mounts/networks
|
||||
cf logs myservice # Check container logs
|
||||
```
|
||||
|
||||
**Migration fails:**
|
||||
```bash
|
||||
cf check myservice # Verify new host is ready
|
||||
cf init-network newhost # Create network if missing
|
||||
```
|
||||
|
||||
**State out of sync:**
|
||||
```bash
|
||||
cf refresh --dry-run # See differences
|
||||
cf refresh # Sync state
|
||||
```
|
||||
|
||||
**SSH issues:**
|
||||
```bash
|
||||
cf ssh status # Check key status
|
||||
cf ssh setup # Re-setup keys
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### SSH Keys
|
||||
|
||||
- Use dedicated SSH key for Compose Farm
|
||||
- Limit key to specific hosts if possible
|
||||
- Don't store keys in Docker images
|
||||
|
||||
### Network Exposure
|
||||
|
||||
- Published ports are accessible from network
|
||||
- Use firewalls for sensitive services
|
||||
- Consider VPN for cross-host communication
|
||||
|
||||
### Secrets
|
||||
|
||||
- Don't commit `.env` files with secrets
|
||||
- Use Docker secrets or external secret management
|
||||
- Avoid secrets in compose file labels
|
||||
|
||||
## Comparison: When to Use Alternatives
|
||||
|
||||
| Scenario | Solution |
|
||||
|----------|----------|
|
||||
| 2-10 hosts, static stacks | **Compose Farm** |
|
||||
| Cross-host container networking | Docker Swarm |
|
||||
| Auto-scaling, self-healing | Kubernetes |
|
||||
| Infrastructure as code | Ansible + Compose Farm |
|
||||
| High availability requirements | Kubernetes or Swarm |
|
||||
777
docs/commands.md
Normal file
777
docs/commands.md
Normal file
@@ -0,0 +1,777 @@
|
||||
---
|
||||
icon: lucide/terminal
|
||||
---
|
||||
|
||||
# Commands Reference
|
||||
|
||||
The Compose Farm CLI is available as both `compose-farm` and the shorter alias `cf`.
|
||||
|
||||
## Command Overview
|
||||
|
||||
| Category | Command | Description |
|
||||
|----------|---------|-------------|
|
||||
| **Lifecycle** | `apply` | Make reality match config |
|
||||
| | `up` | Start stacks |
|
||||
| | `down` | Stop stacks |
|
||||
| | `stop` | Stop services without removing containers |
|
||||
| | `restart` | Restart stacks (down + up) |
|
||||
| | `update` | Update stacks (pull + build + down + up) |
|
||||
| | `pull` | Pull latest images |
|
||||
| | `compose` | Run any docker compose command |
|
||||
| **Monitoring** | `ps` | Show stack status |
|
||||
| | `logs` | Show stack logs |
|
||||
| | `stats` | Show overview statistics |
|
||||
| **Configuration** | `check` | Validate config and mounts |
|
||||
| | `refresh` | Sync state from reality |
|
||||
| | `init-network` | Create Docker network |
|
||||
| | `traefik-file` | Generate Traefik config |
|
||||
| | `config` | Manage config files |
|
||||
| | `ssh` | Manage SSH keys |
|
||||
| **Server** | `web` | Start web UI |
|
||||
|
||||
## Global Options
|
||||
|
||||
```bash
|
||||
cf --version, -v # Show version
|
||||
cf --help, -h # Show help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle Commands
|
||||
|
||||
### cf apply
|
||||
|
||||
Make reality match your configuration. The primary reconciliation command.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/apply.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
```bash
|
||||
cf apply [OPTIONS]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--dry-run, -n` | Preview changes without executing |
|
||||
| `--no-orphans` | Skip stopping orphaned stacks |
|
||||
| `--full, -f` | Also refresh running stacks |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**What it does:**
|
||||
|
||||
1. Stops orphaned stacks (in state but removed from config)
|
||||
2. Migrates stacks on wrong host
|
||||
3. Starts missing stacks (in config but not running)
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Preview what would change
|
||||
cf apply --dry-run
|
||||
|
||||
# Apply all changes
|
||||
cf apply
|
||||
|
||||
# Only start/migrate, don't stop orphans
|
||||
cf apply --no-orphans
|
||||
|
||||
# Also refresh all running stacks
|
||||
cf apply --full
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf up
|
||||
|
||||
Start stacks. Auto-migrates if host assignment changed.
|
||||
|
||||
```bash
|
||||
cf up [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Start all stacks |
|
||||
| `--host, -H TEXT` | Filter to stacks on this host |
|
||||
| `--service, -s TEXT` | Target a specific service within the stack |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Start specific stacks
|
||||
cf up plex grafana
|
||||
|
||||
# Start all stacks
|
||||
cf up --all
|
||||
|
||||
# Start all stacks on a specific host
|
||||
cf up --all --host nuc
|
||||
|
||||
# Start a specific service within a stack
|
||||
cf up immich --service database
|
||||
```
|
||||
|
||||
**Auto-migration:**
|
||||
|
||||
If you change a stack's host in config and run `cf up`:
|
||||
|
||||
1. Verifies mounts/networks exist on new host
|
||||
2. Runs `down` on old host
|
||||
3. Runs `up -d` on new host
|
||||
4. Updates state
|
||||
|
||||
---
|
||||
|
||||
### cf down
|
||||
|
||||
Stop stacks.
|
||||
|
||||
```bash
|
||||
cf down [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Stop all stacks |
|
||||
| `--orphaned` | Stop orphaned stacks only |
|
||||
| `--host, -H TEXT` | Filter to stacks on this host |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Stop specific stacks
|
||||
cf down plex
|
||||
|
||||
# Stop all stacks
|
||||
cf down --all
|
||||
|
||||
# Stop stacks removed from config
|
||||
cf down --orphaned
|
||||
|
||||
# Stop all stacks on a host
|
||||
cf down --all --host nuc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf stop
|
||||
|
||||
Stop services without removing containers.
|
||||
|
||||
```bash
|
||||
cf stop [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Stop all stacks |
|
||||
| `--service, -s TEXT` | Target a specific service within the stack |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Stop specific stacks
|
||||
cf stop plex
|
||||
|
||||
# Stop all stacks
|
||||
cf stop --all
|
||||
|
||||
# Stop a specific service within a stack
|
||||
cf stop immich --service database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf restart
|
||||
|
||||
Restart stacks (down + up). With `--service`, restarts just that service.
|
||||
|
||||
```bash
|
||||
cf restart [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Restart all stacks |
|
||||
| `--service, -s TEXT` | Target a specific service within the stack |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
cf restart plex
|
||||
cf restart --all
|
||||
|
||||
# Restart a specific service
|
||||
cf restart immich --service database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf update
|
||||
|
||||
Update stacks (pull + build + down + up). With `--service`, updates just that service.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/update.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
```bash
|
||||
cf update [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Update all stacks |
|
||||
| `--service, -s TEXT` | Target a specific service within the stack |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Update specific stack
|
||||
cf update plex
|
||||
|
||||
# Update all stacks
|
||||
cf update --all
|
||||
|
||||
# Update a specific service
|
||||
cf update immich --service database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf pull
|
||||
|
||||
Pull latest images.
|
||||
|
||||
```bash
|
||||
cf pull [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Pull for all stacks |
|
||||
| `--service, -s TEXT` | Target a specific service within the stack |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
cf pull plex
|
||||
cf pull --all
|
||||
|
||||
# Pull a specific service
|
||||
cf pull immich --service database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf compose
|
||||
|
||||
Run any docker compose command on a stack. This is a passthrough to docker compose for commands not wrapped by cf.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/compose.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
```bash
|
||||
cf compose [OPTIONS] STACK COMMAND [ARGS]...
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `STACK` | Stack to operate on (use `.` for current dir) |
|
||||
| `COMMAND` | Docker compose command to run |
|
||||
| `ARGS` | Additional arguments passed to docker compose |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--host, -H TEXT` | Filter to stacks on this host (required for multi-host stacks) |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Show docker compose help
|
||||
cf compose mystack --help
|
||||
|
||||
# View running processes
|
||||
cf compose mystack top
|
||||
|
||||
# List images
|
||||
cf compose mystack images
|
||||
|
||||
# Interactive shell
|
||||
cf compose mystack exec web bash
|
||||
|
||||
# View parsed config
|
||||
cf compose mystack config
|
||||
|
||||
# Use current directory as stack
|
||||
cf compose . ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Commands
|
||||
|
||||
### cf ps
|
||||
|
||||
Show status of stacks.
|
||||
|
||||
```bash
|
||||
cf ps [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Show all stacks (default) |
|
||||
| `--host, -H TEXT` | Filter to stacks on this host |
|
||||
| `--service, -s TEXT` | Target a specific service within the stack |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Show all stacks
|
||||
cf ps
|
||||
|
||||
# Show specific stacks
|
||||
cf ps plex grafana
|
||||
|
||||
# Filter by host
|
||||
cf ps --host nuc
|
||||
|
||||
# Show status of a specific service
|
||||
cf ps immich --service database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf logs
|
||||
|
||||
Show stack logs.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/logs.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
```bash
|
||||
cf logs [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Show logs for all stacks |
|
||||
| `--host, -H TEXT` | Filter to stacks on this host |
|
||||
| `--service, -s TEXT` | Target a specific service within the stack |
|
||||
| `--follow, -f` | Follow logs (live stream) |
|
||||
| `--tail, -n INTEGER` | Number of lines (default: 20 for --all, 100 otherwise) |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Show last 100 lines
|
||||
cf logs plex
|
||||
|
||||
# Follow logs
|
||||
cf logs -f plex
|
||||
|
||||
# Show last 50 lines of multiple stacks
|
||||
cf logs -n 50 plex grafana
|
||||
|
||||
# Show last 20 lines of all stacks
|
||||
cf logs --all
|
||||
|
||||
# Show logs for a specific service
|
||||
cf logs immich --service database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf stats
|
||||
|
||||
Show overview statistics.
|
||||
|
||||
```bash
|
||||
cf stats [OPTIONS]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--live, -l` | Query Docker for live container counts |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Config/state overview
|
||||
cf stats
|
||||
|
||||
# Include live container counts
|
||||
cf stats --live
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Commands
|
||||
|
||||
### cf check
|
||||
|
||||
Validate configuration, mounts, and networks.
|
||||
|
||||
```bash
|
||||
cf check [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--local` | Skip SSH-based checks (faster) |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Full validation with SSH
|
||||
cf check
|
||||
|
||||
# Fast local-only validation
|
||||
cf check --local
|
||||
|
||||
# Check specific stack and show host compatibility
|
||||
cf check jellyfin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf refresh
|
||||
|
||||
Update local state from running stacks.
|
||||
|
||||
```bash
|
||||
cf refresh [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Refresh all stacks |
|
||||
| `--dry-run, -n` | Show what would change |
|
||||
| `--log-path, -l PATH` | Path to Dockerfarm TOML log |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
Without arguments, refreshes all stacks (same as `--all`). With stack names, refreshes only those stacks.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Sync state with reality (all stacks)
|
||||
cf refresh
|
||||
|
||||
# Preview changes
|
||||
cf refresh --dry-run
|
||||
|
||||
# Refresh specific stacks only
|
||||
cf refresh plex sonarr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf init-network
|
||||
|
||||
Create Docker network on hosts with consistent settings.
|
||||
|
||||
```bash
|
||||
cf init-network [OPTIONS] [HOSTS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--network, -n TEXT` | Network name (default: mynetwork) |
|
||||
| `--subnet, -s TEXT` | Network subnet (default: 172.20.0.0/16) |
|
||||
| `--gateway, -g TEXT` | Network gateway (default: 172.20.0.1) |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Create on all hosts
|
||||
cf init-network
|
||||
|
||||
# Create on specific hosts
|
||||
cf init-network nuc hp
|
||||
|
||||
# Custom network settings
|
||||
cf init-network -n production -s 10.0.0.0/16 -g 10.0.0.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf traefik-file
|
||||
|
||||
Generate Traefik file-provider config from compose labels.
|
||||
|
||||
```bash
|
||||
cf traefik-file [OPTIONS] [STACKS]...
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all, -a` | Generate for all stacks |
|
||||
| `--output, -o PATH` | Output file (stdout if omitted) |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Preview to stdout
|
||||
cf traefik-file --all
|
||||
|
||||
# Write to file
|
||||
cf traefik-file --all -o /opt/traefik/dynamic.d/cf.yml
|
||||
|
||||
# Specific stacks
|
||||
cf traefik-file plex jellyfin -o /opt/traefik/cf.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf config
|
||||
|
||||
Manage configuration files.
|
||||
|
||||
```bash
|
||||
cf config COMMAND
|
||||
```
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `init` | Create new config with examples |
|
||||
| `show` | Display config with highlighting |
|
||||
| `path` | Print config file path |
|
||||
| `validate` | Validate syntax and schema |
|
||||
| `edit` | Open in $EDITOR |
|
||||
| `symlink` | Create symlink from default location |
|
||||
|
||||
**Options by subcommand:**
|
||||
|
||||
| Subcommand | Options |
|
||||
|------------|---------|
|
||||
| `init` | `--path/-p PATH`, `--force/-f` |
|
||||
| `show` | `--path/-p PATH`, `--raw/-r` |
|
||||
| `edit` | `--path/-p PATH` |
|
||||
| `path` | `--path/-p PATH` |
|
||||
| `validate` | `--path/-p PATH` |
|
||||
| `symlink` | `--force/-f` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Create config at default location
|
||||
cf config init
|
||||
|
||||
# Create config at custom path
|
||||
cf config init --path /opt/compose-farm/config.yaml
|
||||
|
||||
# Show config with syntax highlighting
|
||||
cf config show
|
||||
|
||||
# Show raw config (for copy-paste)
|
||||
cf config show --raw
|
||||
|
||||
# Validate config
|
||||
cf config validate
|
||||
|
||||
# Edit config in $EDITOR
|
||||
cf config edit
|
||||
|
||||
# Print config path
|
||||
cf config path
|
||||
|
||||
# Create symlink to local config
|
||||
cf config symlink
|
||||
|
||||
# Create symlink to specific file
|
||||
cf config symlink /opt/compose-farm/config.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf ssh
|
||||
|
||||
Manage SSH keys for passwordless authentication.
|
||||
|
||||
```bash
|
||||
cf ssh COMMAND
|
||||
```
|
||||
|
||||
**Subcommands:**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `setup` | Generate key and copy to all hosts |
|
||||
| `status` | Show SSH key status and host connectivity |
|
||||
| `keygen` | Generate key without distributing |
|
||||
|
||||
**Options for `cf ssh setup`:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
| `--force, -f` | Regenerate key even if it exists |
|
||||
|
||||
**Options for `cf ssh status`:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Options for `cf ssh keygen`:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--force, -f` | Regenerate key even if it exists |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Set up SSH keys (generates and distributes)
|
||||
cf ssh setup
|
||||
|
||||
# Check status and connectivity
|
||||
cf ssh status
|
||||
|
||||
# Generate key only (don't distribute)
|
||||
cf ssh keygen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Commands
|
||||
|
||||
### cf web
|
||||
|
||||
Start the web UI server.
|
||||
|
||||
```bash
|
||||
cf web [OPTIONS]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--host, -H TEXT` | Host to bind to (default: 0.0.0.0) |
|
||||
| `--port, -p INTEGER` | Port to listen on (default: 8000) |
|
||||
| `--reload, -r` | Enable auto-reload for development |
|
||||
|
||||
**Note:** Requires web dependencies: `pip install compose-farm[web]`
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Start on default port
|
||||
cf web
|
||||
|
||||
# Start on custom port
|
||||
cf web --port 3000
|
||||
|
||||
# Development mode with auto-reload
|
||||
cf web --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Daily Operations
|
||||
|
||||
```bash
|
||||
# Morning: check status
|
||||
cf ps
|
||||
cf stats --live
|
||||
|
||||
# Update a specific stack
|
||||
cf update plex
|
||||
|
||||
# View logs
|
||||
cf logs -f plex
|
||||
```
|
||||
|
||||
### Maintenance
|
||||
|
||||
```bash
|
||||
# Update all stacks
|
||||
cf update --all
|
||||
|
||||
# Refresh state after manual changes
|
||||
cf refresh
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
```bash
|
||||
# Preview what would change
|
||||
cf apply --dry-run
|
||||
|
||||
# Move a stack: edit config, then
|
||||
cf up plex # auto-migrates
|
||||
|
||||
# Or reconcile everything
|
||||
cf apply
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
```bash
|
||||
# Validate config
|
||||
cf check --local
|
||||
cf check
|
||||
|
||||
# Check specific stack
|
||||
cf check jellyfin
|
||||
|
||||
# Sync state
|
||||
cf refresh --dry-run
|
||||
cf refresh
|
||||
```
|
||||
418
docs/configuration.md
Normal file
418
docs/configuration.md
Normal file
@@ -0,0 +1,418 @@
|
||||
---
|
||||
icon: lucide/settings
|
||||
---
|
||||
|
||||
# Configuration Reference
|
||||
|
||||
Compose Farm uses a YAML configuration file to define hosts and stack assignments.
|
||||
|
||||
## Config File Location
|
||||
|
||||
Compose Farm looks for configuration in this order:
|
||||
|
||||
1. `-c` / `--config` flag (if provided)
|
||||
2. `CF_CONFIG` environment variable
|
||||
3. `./compose-farm.yaml` (current directory)
|
||||
4. `$XDG_CONFIG_HOME/compose-farm/compose-farm.yaml` (defaults to `~/.config`)
|
||||
|
||||
Use `-c` / `--config` to specify a custom path:
|
||||
|
||||
```bash
|
||||
cf ps -c /path/to/config.yaml
|
||||
```
|
||||
|
||||
Or set the environment variable:
|
||||
|
||||
```bash
|
||||
export CF_CONFIG=/path/to/config.yaml
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Single host (local-only)
|
||||
|
||||
```yaml
|
||||
# Required: directory containing compose files
|
||||
compose_dir: /opt/stacks
|
||||
|
||||
# Define local host
|
||||
hosts:
|
||||
local: localhost
|
||||
|
||||
# Map stacks to the local host
|
||||
stacks:
|
||||
plex: local
|
||||
grafana: local
|
||||
nextcloud: local
|
||||
```
|
||||
|
||||
### Multi-host (full example)
|
||||
|
||||
```yaml
|
||||
# Required: directory containing compose files (same path on all hosts)
|
||||
compose_dir: /opt/compose
|
||||
|
||||
# Optional: auto-regenerate Traefik config
|
||||
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_stack: traefik
|
||||
|
||||
# Define Docker hosts
|
||||
hosts:
|
||||
nuc:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
hp:
|
||||
address: 192.168.1.11
|
||||
user: admin
|
||||
|
||||
# Map stacks to hosts
|
||||
stacks:
|
||||
# Single-host stacks
|
||||
plex: nuc
|
||||
grafana: nuc
|
||||
nextcloud: hp
|
||||
|
||||
# Multi-host stacks
|
||||
dozzle: all # Run on ALL hosts
|
||||
node-exporter: [nuc, hp] # Run on specific hosts
|
||||
```
|
||||
|
||||
## Settings Reference
|
||||
|
||||
### compose_dir (required)
|
||||
|
||||
Directory containing your compose stack folders. Must be the same path on all hosts.
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
```
|
||||
|
||||
**Directory structure:**
|
||||
|
||||
```
|
||||
/opt/compose/
|
||||
├── plex/
|
||||
│ ├── docker-compose.yml # or compose.yaml
|
||||
│ └── .env # optional environment file
|
||||
├── grafana/
|
||||
│ └── docker-compose.yml
|
||||
└── ...
|
||||
```
|
||||
|
||||
Supported compose file names (checked in order):
|
||||
- `compose.yaml`
|
||||
- `compose.yml`
|
||||
- `docker-compose.yml`
|
||||
- `docker-compose.yaml`
|
||||
|
||||
### traefik_file
|
||||
|
||||
Path to auto-generated Traefik file-provider config. When set, Compose Farm regenerates this file after `up`, `down`, `restart`, and `update` commands.
|
||||
|
||||
```yaml
|
||||
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
|
||||
```
|
||||
|
||||
### traefik_stack
|
||||
|
||||
Stack name running Traefik. Stacks on the same host are skipped in file-provider config (Traefik's docker provider handles them).
|
||||
|
||||
```yaml
|
||||
traefik_stack: traefik
|
||||
```
|
||||
|
||||
## Hosts Configuration
|
||||
|
||||
### Basic Host
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
myserver:
|
||||
address: 192.168.1.10
|
||||
```
|
||||
|
||||
### With SSH User
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
myserver:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
```
|
||||
|
||||
If `user` is omitted, the current user is used.
|
||||
|
||||
### With Custom SSH Port
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
myserver:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
port: 2222 # SSH port (default: 22)
|
||||
```
|
||||
|
||||
### Localhost
|
||||
|
||||
For stacks running on the same machine where you invoke Compose Farm:
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
local: localhost
|
||||
```
|
||||
|
||||
No SSH is used for localhost stacks.
|
||||
|
||||
### Multiple Hosts
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
nuc:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
hp:
|
||||
address: 192.168.1.11
|
||||
user: admin
|
||||
truenas:
|
||||
address: 192.168.1.100
|
||||
local: localhost
|
||||
```
|
||||
|
||||
## Stacks Configuration
|
||||
|
||||
### Single-Host Stack
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
plex: nuc
|
||||
grafana: nuc
|
||||
nextcloud: hp
|
||||
```
|
||||
|
||||
### Multi-Host Stack
|
||||
|
||||
For stacks that need to run on every host (e.g., log shippers, monitoring agents):
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
# Run on ALL configured hosts
|
||||
dozzle: all
|
||||
promtail: all
|
||||
|
||||
# Run on specific hosts
|
||||
node-exporter: [nuc, hp, truenas]
|
||||
```
|
||||
|
||||
**Common multi-host stacks:**
|
||||
- **Dozzle** - Docker log viewer (needs local socket)
|
||||
- **Promtail/Alloy** - Log shipping (needs local socket)
|
||||
- **node-exporter** - Host metrics (needs /proc, /sys)
|
||||
- **AutoKuma** - Uptime Kuma monitors (needs local socket)
|
||||
|
||||
### Stack Names
|
||||
|
||||
Stack names must match directory names in `compose_dir`:
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
stacks:
|
||||
plex: nuc # expects /opt/compose/plex/docker-compose.yml
|
||||
my-app: hp # expects /opt/compose/my-app/docker-compose.yml
|
||||
```
|
||||
|
||||
## State File
|
||||
|
||||
Compose Farm tracks deployment state in `compose-farm-state.yaml`, stored alongside the config file.
|
||||
|
||||
For example, if your config is at `~/.config/compose-farm/compose-farm.yaml`, the state file will be at `~/.config/compose-farm/compose-farm-state.yaml`.
|
||||
|
||||
```yaml
|
||||
deployed:
|
||||
plex: nuc
|
||||
grafana: nuc
|
||||
```
|
||||
|
||||
This file records which stacks are deployed and on which host.
|
||||
|
||||
**Don't edit manually.** Use `cf refresh` to sync state with reality.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### In Compose Files
|
||||
|
||||
Your compose files can use `.env` files as usual:
|
||||
|
||||
```
|
||||
/opt/compose/plex/
|
||||
├── docker-compose.yml
|
||||
└── .env
|
||||
```
|
||||
|
||||
Compose Farm runs `docker compose` which handles `.env` automatically.
|
||||
|
||||
### In Traefik Labels
|
||||
|
||||
When generating Traefik config, Compose Farm resolves `${VAR}` and `${VAR:-default}` from:
|
||||
|
||||
1. The stack's `.env` file
|
||||
2. Current environment
|
||||
|
||||
## Config Commands
|
||||
|
||||
### Initialize Config
|
||||
|
||||
```bash
|
||||
cf config init
|
||||
```
|
||||
|
||||
Creates a new config file with documented examples.
|
||||
|
||||
### Validate Config
|
||||
|
||||
```bash
|
||||
cf config validate
|
||||
```
|
||||
|
||||
Checks syntax and schema.
|
||||
|
||||
### Show Config
|
||||
|
||||
```bash
|
||||
cf config show
|
||||
```
|
||||
|
||||
Displays current config with syntax highlighting.
|
||||
|
||||
### Edit Config
|
||||
|
||||
```bash
|
||||
cf config edit
|
||||
```
|
||||
|
||||
Opens config in `$EDITOR`.
|
||||
|
||||
### Show Config Path
|
||||
|
||||
```bash
|
||||
cf config path
|
||||
```
|
||||
|
||||
Prints the config file location (useful for scripting).
|
||||
|
||||
### Create Symlink
|
||||
|
||||
```bash
|
||||
cf config symlink # Link to ./compose-farm.yaml
|
||||
cf config symlink /path/to/my-config.yaml # Link to specific file
|
||||
```
|
||||
|
||||
Creates a symlink from the default location (`~/.config/compose-farm/compose-farm.yaml`) to your config file. Use `--force` to overwrite an existing symlink.
|
||||
|
||||
## Validation
|
||||
|
||||
### Local Validation
|
||||
|
||||
Fast validation without SSH:
|
||||
|
||||
```bash
|
||||
cf check --local
|
||||
```
|
||||
|
||||
Checks:
|
||||
- Config syntax
|
||||
- Stack-to-host mappings
|
||||
- Compose file existence
|
||||
|
||||
### Full Validation
|
||||
|
||||
```bash
|
||||
cf check
|
||||
```
|
||||
|
||||
Additional SSH-based checks:
|
||||
- Host connectivity
|
||||
- Mount point existence
|
||||
- Docker network existence
|
||||
- Traefik label validation
|
||||
|
||||
### Stack-Specific Check
|
||||
|
||||
```bash
|
||||
cf check jellyfin
|
||||
```
|
||||
|
||||
Shows which hosts can run the stack (have required mounts/networks).
|
||||
|
||||
## Example Configurations
|
||||
|
||||
### Minimal
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
|
||||
hosts:
|
||||
server: 192.168.1.10
|
||||
|
||||
stacks:
|
||||
myapp: server
|
||||
```
|
||||
|
||||
### Home Lab
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
|
||||
hosts:
|
||||
nuc:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
nas:
|
||||
address: 192.168.1.100
|
||||
user: admin
|
||||
|
||||
stacks:
|
||||
# Media
|
||||
plex: nuc
|
||||
jellyfin: nuc
|
||||
immich: nuc
|
||||
|
||||
# Infrastructure
|
||||
traefik: nuc
|
||||
portainer: nuc
|
||||
|
||||
# Monitoring (on all hosts)
|
||||
dozzle: all
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
traefik_file: /opt/traefik/dynamic.d/cf.yml
|
||||
traefik_stack: traefik
|
||||
|
||||
hosts:
|
||||
web-1:
|
||||
address: 10.0.1.10
|
||||
user: deploy
|
||||
web-2:
|
||||
address: 10.0.1.11
|
||||
user: deploy
|
||||
db:
|
||||
address: 10.0.1.20
|
||||
user: deploy
|
||||
|
||||
stacks:
|
||||
# Load balanced
|
||||
api: [web-1, web-2]
|
||||
|
||||
# Single instance
|
||||
postgres: db
|
||||
redis: db
|
||||
|
||||
# Infrastructure
|
||||
traefik: web-1
|
||||
|
||||
# Monitoring
|
||||
promtail: all
|
||||
```
|
||||
17
docs/demos/README.md
Normal file
17
docs/demos/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Demo Recordings
|
||||
|
||||
Demo recording infrastructure for Compose Farm documentation.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
docs/demos/
|
||||
├── cli/ # VHS-based CLI terminal recordings
|
||||
└── web/ # Playwright-based web UI recordings
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
All recordings output to `docs/assets/` as WebM (primary) and GIF (fallback).
|
||||
|
||||
See subdirectory READMEs for usage.
|
||||
33
docs/demos/cli/README.md
Normal file
33
docs/demos/cli/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# CLI Demo Recordings
|
||||
|
||||
VHS-based terminal demo recordings for Compose Farm CLI.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [VHS](https://github.com/charmbracelet/vhs): `go install github.com/charmbracelet/vhs@latest`
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Record all demos
|
||||
python docs/demos/cli/record.py
|
||||
|
||||
# Record specific demos
|
||||
python docs/demos/cli/record.py quickstart migration
|
||||
```
|
||||
|
||||
## Demos
|
||||
|
||||
| Tape | Description |
|
||||
|------|-------------|
|
||||
| `install.tape` | Installing with `uv tool install` |
|
||||
| `quickstart.tape` | `cf ps`, `cf up`, `cf logs` |
|
||||
| `logs.tape` | Viewing logs |
|
||||
| `compose.tape` | `cf compose` passthrough (--help, images, exec) |
|
||||
| `update.tape` | `cf update` |
|
||||
| `migration.tape` | Service migration |
|
||||
| `apply.tape` | `cf apply` |
|
||||
|
||||
## Output
|
||||
|
||||
GIF and WebM files saved to `docs/assets/`.
|
||||
39
docs/demos/cli/apply.tape
Normal file
39
docs/demos/cli/apply.tape
Normal file
@@ -0,0 +1,39 @@
|
||||
# Apply Demo
|
||||
# Shows cf apply previewing and reconciling state
|
||||
|
||||
Output docs/assets/apply.gif
|
||||
Output docs/assets/apply.webm
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 900
|
||||
Set Height 600
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set TypingSpeed 50ms
|
||||
|
||||
Type "# Preview what would change"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf apply --dry-run"
|
||||
Enter
|
||||
Wait
|
||||
|
||||
Type "# Check current status"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf stats"
|
||||
Enter
|
||||
Wait+Screen /Summary/
|
||||
Sleep 2s
|
||||
|
||||
Type "# Apply the changes"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf apply"
|
||||
Enter
|
||||
# Wait for shell prompt (command complete)
|
||||
Wait
|
||||
Sleep 4s
|
||||
50
docs/demos/cli/compose.tape
Normal file
50
docs/demos/cli/compose.tape
Normal file
@@ -0,0 +1,50 @@
|
||||
# Compose Demo
|
||||
# Shows that cf compose passes through ANY docker compose command
|
||||
|
||||
Output docs/assets/compose.gif
|
||||
Output docs/assets/compose.webm
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 900
|
||||
Set Height 550
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set TypingSpeed 50ms
|
||||
|
||||
Type "# cf compose runs ANY docker compose command on the right host"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "# See ALL available compose commands"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf compose immich --help"
|
||||
Enter
|
||||
Sleep 4s
|
||||
|
||||
Type "# Show images"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf compose immich images"
|
||||
Enter
|
||||
Wait+Screen /immich/
|
||||
Sleep 2s
|
||||
|
||||
Type "# Open shell in a container"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf compose immich exec immich-machine-learning sh"
|
||||
Enter
|
||||
Wait+Screen /#/
|
||||
Sleep 1s
|
||||
|
||||
Type "python3 --version"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
Type "exit"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
42
docs/demos/cli/install.tape
Normal file
42
docs/demos/cli/install.tape
Normal file
@@ -0,0 +1,42 @@
|
||||
# Installation Demo
|
||||
# Shows installing compose-farm with uv
|
||||
|
||||
Output docs/assets/install.gif
|
||||
Output docs/assets/install.webm
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 900
|
||||
Set Height 600
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set TypingSpeed 50ms
|
||||
Env FORCE_COLOR "1"
|
||||
|
||||
Hide
|
||||
Type "export PATH=$HOME/.local/bin:$PATH && uv tool uninstall compose-farm 2>/dev/null; clear"
|
||||
Enter
|
||||
Show
|
||||
Type "# Install with uv (recommended)"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "uv tool install compose-farm"
|
||||
Enter
|
||||
Wait+Screen /Installed|already installed/
|
||||
|
||||
Type "# Verify installation"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf --version"
|
||||
Enter
|
||||
Wait+Screen /compose-farm/
|
||||
Sleep 1s
|
||||
|
||||
Type "cf --help | less"
|
||||
Enter
|
||||
Sleep 2s
|
||||
PageDown
|
||||
Sleep 2s
|
||||
Type "q"
|
||||
Sleep 2s
|
||||
21
docs/demos/cli/logs.tape
Normal file
21
docs/demos/cli/logs.tape
Normal file
@@ -0,0 +1,21 @@
|
||||
# Logs Demo
|
||||
# Shows viewing stack logs
|
||||
|
||||
Output docs/assets/logs.gif
|
||||
Output docs/assets/logs.webm
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 900
|
||||
Set Height 550
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set TypingSpeed 50ms
|
||||
|
||||
Type "# View recent logs"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf logs immich --tail 20"
|
||||
Enter
|
||||
Wait+Screen /immich/
|
||||
Sleep 2s
|
||||
71
docs/demos/cli/migration.tape
Normal file
71
docs/demos/cli/migration.tape
Normal file
@@ -0,0 +1,71 @@
|
||||
# Migration Demo
|
||||
# Shows automatic stack migration when host changes
|
||||
|
||||
Output docs/assets/migration.gif
|
||||
Output docs/assets/migration.webm
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 1000
|
||||
Set Height 600
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set TypingSpeed 50ms
|
||||
|
||||
Type "# Current status: audiobookshelf on 'nas'"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf ps audiobookshelf"
|
||||
Enter
|
||||
Wait+Screen /PORTS/
|
||||
|
||||
Type "# Edit config to move it to 'anton'"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
Type "nvim /opt/stacks/compose-farm.yaml"
|
||||
Enter
|
||||
Wait+Screen /stacks:/
|
||||
|
||||
# Search for audiobookshelf
|
||||
Type "/audiobookshelf"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
# Move to the host value (nas) and change it
|
||||
Type "f:"
|
||||
Sleep 500ms
|
||||
Type "w"
|
||||
Sleep 500ms
|
||||
Type "ciw"
|
||||
Sleep 500ms
|
||||
Type "anton"
|
||||
Escape
|
||||
Sleep 1s
|
||||
|
||||
# Save and quit
|
||||
Type ":wq"
|
||||
Enter
|
||||
Sleep 1s
|
||||
|
||||
Type "# Run up - automatically migrates!"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf up audiobookshelf"
|
||||
Enter
|
||||
# Wait for migration phases: first the stop on old host
|
||||
Wait+Screen /Migrating|down/
|
||||
# Then wait for start on new host
|
||||
Wait+Screen /Starting|up/
|
||||
# Finally wait for completion
|
||||
Wait
|
||||
|
||||
Type "# Verify: audiobookshelf now on 'anton'"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf ps audiobookshelf"
|
||||
Enter
|
||||
Wait+Screen /PORTS/
|
||||
Sleep 3s
|
||||
91
docs/demos/cli/quickstart.tape
Normal file
91
docs/demos/cli/quickstart.tape
Normal file
@@ -0,0 +1,91 @@
|
||||
# Quick Start Demo
|
||||
# Shows basic cf commands
|
||||
|
||||
Output docs/assets/quickstart.gif
|
||||
Output docs/assets/quickstart.webm
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 900
|
||||
Set Height 600
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set FontFamily "FiraCode Nerd Font"
|
||||
Set TypingSpeed 50ms
|
||||
Env BAT_PAGING "always"
|
||||
|
||||
Type "# Config is just: stack host"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "# First, define your hosts..."
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "bat -r 1:16 compose-farm.yaml"
|
||||
Enter
|
||||
Sleep 3s
|
||||
Type "q"
|
||||
Sleep 500ms
|
||||
|
||||
Type "# Then map each stack to a host"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "bat -r 17:35 compose-farm.yaml"
|
||||
Enter
|
||||
Sleep 3s
|
||||
Type "q"
|
||||
Sleep 500ms
|
||||
|
||||
Type "# Check stack status"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf ps immich"
|
||||
Enter
|
||||
Wait+Screen /PORTS/
|
||||
|
||||
Type "# Start a stack"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf up immich"
|
||||
Enter
|
||||
Wait
|
||||
|
||||
Type "# View logs"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf logs immich --tail 5"
|
||||
Enter
|
||||
Wait+Screen /immich/
|
||||
Sleep 2s
|
||||
|
||||
Type "# The magic: move between hosts (nas anton)"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "# Change host in config (using sed)"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "sed -i 's/audiobookshelf: nas/audiobookshelf: anton/' compose-farm.yaml"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "# Apply changes - auto-migrates!"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf apply"
|
||||
Enter
|
||||
Sleep 15s
|
||||
|
||||
Type "# Verify: now on anton"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf ps audiobookshelf"
|
||||
Enter
|
||||
Sleep 5s
|
||||
134
docs/demos/cli/record.py
Executable file
134
docs/demos/cli/record.py
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record CLI demos using VHS."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from compose_farm.config import load_config
|
||||
from compose_farm.state import load_state
|
||||
|
||||
console = Console()
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
STACKS_DIR = Path("/opt/stacks")
|
||||
CONFIG_FILE = STACKS_DIR / "compose-farm.yaml"
|
||||
OUTPUT_DIR = SCRIPT_DIR.parent.parent / "assets"
|
||||
|
||||
DEMOS = ["install", "quickstart", "logs", "compose", "update", "migration", "apply"]
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kw) -> bool:
|
||||
return subprocess.run(cmd, check=False, **kw).returncode == 0
|
||||
|
||||
|
||||
def _set_config(host: str) -> None:
|
||||
"""Set audiobookshelf host in config file."""
|
||||
_run(["sed", "-i", f"s/audiobookshelf: .*/audiobookshelf: {host}/", str(CONFIG_FILE)])
|
||||
|
||||
|
||||
def _get_hosts() -> tuple[str | None, str | None]:
|
||||
"""Return (config_host, state_host) for audiobookshelf."""
|
||||
config = load_config()
|
||||
state = load_state(config)
|
||||
return config.stacks.get("audiobookshelf"), state.get("audiobookshelf")
|
||||
|
||||
|
||||
def _setup_state(demo: str) -> bool:
|
||||
"""Set up required state for demo. Returns False on failure."""
|
||||
if demo not in ("migration", "apply"):
|
||||
return True
|
||||
|
||||
config_host, state_host = _get_hosts()
|
||||
|
||||
if demo == "migration":
|
||||
# Migration needs audiobookshelf on nas in BOTH config and state
|
||||
if config_host != "nas":
|
||||
console.print("[yellow]Setting up: config → nas[/yellow]")
|
||||
_set_config("nas")
|
||||
if state_host != "nas":
|
||||
console.print("[yellow]Setting up: state → nas[/yellow]")
|
||||
if not _run(["cf", "apply"], cwd=STACKS_DIR):
|
||||
return False
|
||||
|
||||
elif demo == "apply":
|
||||
# Apply needs config=nas, state=anton (so there's something to apply)
|
||||
if config_host != "nas":
|
||||
console.print("[yellow]Setting up: config → nas[/yellow]")
|
||||
_set_config("nas")
|
||||
if state_host == "nas":
|
||||
console.print("[yellow]Setting up: state → anton[/yellow]")
|
||||
_set_config("anton")
|
||||
if not _run(["cf", "apply"], cwd=STACKS_DIR):
|
||||
return False
|
||||
_set_config("nas")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _record(name: str, index: int, total: int) -> bool:
|
||||
"""Record a single demo."""
|
||||
console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] {name}")
|
||||
if _run(["vhs", str(SCRIPT_DIR / f"{name}.tape")], cwd=STACKS_DIR):
|
||||
console.print("[green] ✓ Done[/green]")
|
||||
return True
|
||||
console.print("[red] ✗ Failed[/red]")
|
||||
return False
|
||||
|
||||
|
||||
def _reset_after(demo: str, next_demo: str | None) -> None:
|
||||
"""Reset state after demos that modify audiobookshelf."""
|
||||
if demo not in ("quickstart", "migration"):
|
||||
return
|
||||
_set_config("nas")
|
||||
if next_demo != "apply": # Let apply demo show the migration
|
||||
_run(["cf", "apply"], cwd=STACKS_DIR)
|
||||
|
||||
|
||||
def _restore_config(original: str) -> None:
|
||||
"""Restore original config and sync state."""
|
||||
console.print("[yellow]Restoring original config...[/yellow]")
|
||||
CONFIG_FILE.write_text(original)
|
||||
_run(["cf", "apply"], cwd=STACKS_DIR)
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
if not shutil.which("vhs"):
|
||||
console.print("[red]VHS not found. Install: brew install vhs[/red]")
|
||||
return 1
|
||||
|
||||
if not _run(["git", "-C", str(STACKS_DIR), "diff", "--quiet", "compose-farm.yaml"]):
|
||||
console.print("[red]compose-farm.yaml has uncommitted changes[/red]")
|
||||
return 1
|
||||
|
||||
demos = [d for d in sys.argv[1:] if d in DEMOS] or DEMOS
|
||||
if sys.argv[1:] and not demos:
|
||||
console.print(f"[red]Unknown demo. Available: {', '.join(DEMOS)}[/red]")
|
||||
return 1
|
||||
|
||||
# Save original config to restore after recording
|
||||
original_config = CONFIG_FILE.read_text()
|
||||
|
||||
try:
|
||||
for i, demo in enumerate(demos, 1):
|
||||
if not _setup_state(demo):
|
||||
return 1
|
||||
if not _record(demo, i, len(demos)):
|
||||
return 1
|
||||
_reset_after(demo, demos[i] if i < len(demos) else None)
|
||||
finally:
|
||||
_restore_config(original_config)
|
||||
|
||||
# Move outputs
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
for f in (STACKS_DIR / "docs/assets").glob("*.[gw]*"):
|
||||
shutil.move(str(f), str(OUTPUT_DIR / f.name))
|
||||
|
||||
console.print(f"\n[green]Done![/green] Saved to {OUTPUT_DIR}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(_main())
|
||||
32
docs/demos/cli/update.tape
Normal file
32
docs/demos/cli/update.tape
Normal file
@@ -0,0 +1,32 @@
|
||||
# Update Demo
|
||||
# Shows updating stacks (pull + build + down + up)
|
||||
|
||||
Output docs/assets/update.gif
|
||||
Output docs/assets/update.webm
|
||||
|
||||
Set Shell "bash"
|
||||
Set FontSize 14
|
||||
Set Width 900
|
||||
Set Height 500
|
||||
Set Theme "Catppuccin Mocha"
|
||||
Set TypingSpeed 50ms
|
||||
|
||||
Type "# Update a single stack"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf update grocy"
|
||||
Enter
|
||||
# Wait for command to complete (chain waits for longer timeout)
|
||||
Wait+Screen /pull/
|
||||
Wait+Screen /grocy/
|
||||
Wait@60s
|
||||
|
||||
Type "# Check current status"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf ps grocy"
|
||||
Enter
|
||||
Wait+Screen /PORTS/
|
||||
Sleep 1s
|
||||
45
docs/demos/web/README.md
Normal file
45
docs/demos/web/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Web UI Demo Recordings
|
||||
|
||||
Playwright-based demo recording for Compose Farm web UI.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Chromium: `playwright install chromium`
|
||||
- ffmpeg: `apt install ffmpeg` or `brew install ffmpeg`
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Record all demos
|
||||
python docs/demos/web/record.py
|
||||
|
||||
# Record specific demo
|
||||
python docs/demos/web/record.py navigation
|
||||
```
|
||||
|
||||
## Demos
|
||||
|
||||
| Demo | Description |
|
||||
|------|-------------|
|
||||
| `navigation` | Command palette fuzzy search and navigation |
|
||||
| `stack` | Stack restart/logs via command palette |
|
||||
| `themes` | Theme switching with arrow key preview |
|
||||
| `workflow` | Full workflow: filter, navigate, logs, themes |
|
||||
| `console` | Console terminal running cf commands |
|
||||
| `shell` | Container shell exec with top |
|
||||
|
||||
## Output
|
||||
|
||||
WebM and GIF files saved to `docs/assets/web-{demo}.{webm,gif}`.
|
||||
|
||||
## Files
|
||||
|
||||
- `record.py` - Orchestration script
|
||||
- `conftest.py` - Playwright fixtures, helper functions
|
||||
- `demo_*.py` - Individual demo scripts
|
||||
|
||||
## Notes
|
||||
|
||||
- Uses real config at `/opt/stacks/compose-farm.yaml`
|
||||
- Adjust `pause(page, ms)` calls to control timing
|
||||
- Viewport: 1280x720
|
||||
1
docs/demos/web/__init__.py
Normal file
1
docs/demos/web/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Web UI demo recording scripts."""
|
||||
224
docs/demos/web/conftest.py
Normal file
224
docs/demos/web/conftest.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Shared fixtures for web UI demo recordings.
|
||||
|
||||
Based on tests/web/test_htmx_browser.py patterns for consistency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
|
||||
from compose_farm.config import Config as CFConfig
|
||||
from compose_farm.config import load_config
|
||||
from compose_farm.state import load_state as _original_load_state
|
||||
from compose_farm.web.app import create_app
|
||||
from compose_farm.web.cdn import CDN_ASSETS, ensure_vendor_cache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from playwright.sync_api import BrowserContext, Page, Route
|
||||
|
||||
# Stacks to exclude from demo recordings (exact match)
|
||||
DEMO_EXCLUDE_STACKS = {"arr"}
|
||||
|
||||
|
||||
def _get_filtered_config() -> CFConfig:
|
||||
"""Load config but filter out excluded stacks."""
|
||||
config = load_config()
|
||||
filtered_stacks = {
|
||||
name: host for name, host in config.stacks.items() if name not in DEMO_EXCLUDE_STACKS
|
||||
}
|
||||
return CFConfig(
|
||||
compose_dir=config.compose_dir,
|
||||
hosts=config.hosts,
|
||||
stacks=filtered_stacks,
|
||||
traefik_file=config.traefik_file,
|
||||
traefik_stack=config.traefik_stack,
|
||||
config_path=config.config_path,
|
||||
)
|
||||
|
||||
|
||||
def _get_filtered_state(config: CFConfig) -> dict[str, str | list[str]]:
|
||||
"""Load state but filter out excluded stacks."""
|
||||
state = _original_load_state(config)
|
||||
return {name: host for name, host in state.items() if name not in DEMO_EXCLUDE_STACKS}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def vendor_cache(request: pytest.FixtureRequest) -> Path:
|
||||
"""Download CDN assets once and cache to disk for faster recordings."""
|
||||
cache_dir = Path(str(request.config.rootdir)) / ".pytest_cache" / "vendor"
|
||||
return ensure_vendor_cache(cache_dir)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args() -> dict[str, str]:
|
||||
"""Configure Playwright to use system Chromium if available."""
|
||||
for name in ["chromium", "chromium-browser", "google-chrome", "chrome"]:
|
||||
path = shutil.which(name)
|
||||
if path:
|
||||
return {"executable_path": path}
|
||||
return {}
|
||||
|
||||
|
||||
# Path to real compose-farm config
|
||||
REAL_CONFIG_PATH = Path("/opt/stacks/compose-farm.yaml")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def server_url() -> Generator[str, None, None]:
|
||||
"""Start demo server using real config (with filtered stacks) and return URL."""
|
||||
os.environ["CF_CONFIG"] = str(REAL_CONFIG_PATH)
|
||||
|
||||
# Patch at source module level so all callers get filtered versions
|
||||
patches = [
|
||||
# Patch load_state at source - all functions calling it get filtered state
|
||||
patch("compose_farm.state.load_state", _get_filtered_state),
|
||||
# Patch get_config where imported
|
||||
patch("compose_farm.web.routes.pages.get_config", _get_filtered_config),
|
||||
patch("compose_farm.web.routes.api.get_config", _get_filtered_config),
|
||||
patch("compose_farm.web.routes.actions.get_config", _get_filtered_config),
|
||||
patch("compose_farm.web.app.get_config", _get_filtered_config),
|
||||
patch("compose_farm.web.ws.get_config", _get_filtered_config),
|
||||
]
|
||||
|
||||
for p in patches:
|
||||
p.start()
|
||||
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
port = s.getsockname()[1]
|
||||
|
||||
app = create_app()
|
||||
uvicorn_config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
server = uvicorn.Server(uvicorn_config)
|
||||
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
url = f"http://127.0.0.1:{port}"
|
||||
server_ready = False
|
||||
for _ in range(50):
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=0.5) # noqa: S310
|
||||
server_ready = True
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
|
||||
if not server_ready:
|
||||
msg = f"Demo server failed to start on {url}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
yield url
|
||||
|
||||
server.should_exit = True
|
||||
thread.join(timeout=2)
|
||||
os.environ.pop("CF_CONFIG", None)
|
||||
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def recording_output_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Directory for video recordings."""
|
||||
return Path(tmp_path_factory.mktemp("recordings"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recording_context(
|
||||
browser: Any, # pytest-playwright's browser fixture
|
||||
vendor_cache: Path,
|
||||
recording_output_dir: Path,
|
||||
) -> Generator[BrowserContext, None, None]:
|
||||
"""Browser context with video recording enabled."""
|
||||
context = browser.new_context(
|
||||
viewport={"width": 1280, "height": 720},
|
||||
record_video_dir=str(recording_output_dir),
|
||||
record_video_size={"width": 1280, "height": 720},
|
||||
)
|
||||
|
||||
# Set up CDN interception
|
||||
cache = {url: (vendor_cache / f, ct) for url, (f, ct) in CDN_ASSETS.items()}
|
||||
|
||||
def handle_cdn(route: Route) -> None:
|
||||
url = route.request.url
|
||||
for url_prefix, (filepath, content_type) in cache.items():
|
||||
if url.startswith(url_prefix):
|
||||
route.fulfill(status=200, content_type=content_type, body=filepath.read_bytes())
|
||||
return
|
||||
route.abort("failed")
|
||||
|
||||
context.route(re.compile(r"https://(cdn\.jsdelivr\.net|unpkg\.com)/.*"), handle_cdn)
|
||||
|
||||
yield context
|
||||
context.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recording_page(recording_context: BrowserContext) -> Generator[Page, None, None]:
|
||||
"""Page with recording and slow motion enabled."""
|
||||
page = recording_context.new_page()
|
||||
yield page
|
||||
page.close()
|
||||
|
||||
|
||||
# Demo helper functions
|
||||
|
||||
|
||||
def pause(page: Page, ms: int = 500) -> None:
|
||||
"""Pause for visibility in recording."""
|
||||
page.wait_for_timeout(ms)
|
||||
|
||||
|
||||
def slow_type(page: Page, selector: str, text: str, delay: int = 100) -> None:
|
||||
"""Type with visible delay between keystrokes."""
|
||||
page.type(selector, text, delay=delay)
|
||||
|
||||
|
||||
def open_command_palette(page: Page) -> None:
|
||||
"""Open command palette with Ctrl+K."""
|
||||
page.keyboard.press("Control+k")
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
pause(page, 300)
|
||||
|
||||
|
||||
def close_command_palette(page: Page) -> None:
|
||||
"""Close command palette with Escape."""
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_selector("#cmd-palette:not([open])", timeout=2000)
|
||||
pause(page, 200)
|
||||
|
||||
|
||||
def wait_for_sidebar(page: Page) -> None:
|
||||
"""Wait for sidebar to load with stacks."""
|
||||
page.wait_for_selector("#sidebar-stacks", timeout=5000)
|
||||
pause(page, 300)
|
||||
|
||||
|
||||
def navigate_to_stack(page: Page, stack: str) -> None:
|
||||
"""Navigate to a stack page via sidebar click."""
|
||||
page.locator("#sidebar-stacks a", has_text=stack).click()
|
||||
page.wait_for_url(f"**/stack/{stack}", timeout=5000)
|
||||
pause(page, 500)
|
||||
|
||||
|
||||
def select_command(page: Page, command: str) -> None:
|
||||
"""Filter and select a command from the palette."""
|
||||
page.locator("#cmd-input").fill(command)
|
||||
pause(page, 300)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 200)
|
||||
77
docs/demos/web/demo_console.py
Normal file
77
docs/demos/web/demo_console.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Demo: Console terminal.
|
||||
|
||||
Records a ~30 second demo showing:
|
||||
- Navigating to Console page
|
||||
- Running cf commands in the terminal
|
||||
- Showing the Compose Farm config in Monaco editor
|
||||
|
||||
Run: pytest docs/demos/web/demo_console.py -v --no-cov
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
pause,
|
||||
slow_type,
|
||||
wait_for_sidebar,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
@pytest.mark.browser # type: ignore[misc]
|
||||
def test_demo_console(recording_page: Page, server_url: str) -> None:
|
||||
"""Record console terminal demo."""
|
||||
page = recording_page
|
||||
|
||||
# Start on dashboard
|
||||
page.goto(server_url)
|
||||
wait_for_sidebar(page)
|
||||
pause(page, 800)
|
||||
|
||||
# Navigate to Console page via sidebar menu
|
||||
page.locator(".menu a", has_text="Console").click()
|
||||
page.wait_for_url("**/console", timeout=5000)
|
||||
pause(page, 1000)
|
||||
|
||||
# Wait for terminal to be ready (auto-connects)
|
||||
page.wait_for_selector("#console-terminal .xterm", timeout=10000)
|
||||
pause(page, 1500)
|
||||
|
||||
# Run fastfetch first
|
||||
slow_type(page, "#console-terminal .xterm-helper-textarea", "fastfetch", delay=80)
|
||||
pause(page, 300)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 2500) # Wait for output
|
||||
|
||||
# Type cf stats command
|
||||
slow_type(page, "#console-terminal .xterm-helper-textarea", "cf stats", delay=80)
|
||||
pause(page, 300)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 3000) # Wait for output
|
||||
|
||||
# Type cf ps command
|
||||
slow_type(page, "#console-terminal .xterm-helper-textarea", "cf ps grocy", delay=80)
|
||||
pause(page, 300)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 2500) # Wait for output
|
||||
|
||||
# Smoothly scroll down to show the Editor section with Compose Farm config
|
||||
page.evaluate("""
|
||||
const editor = document.getElementById('console-editor');
|
||||
if (editor) {
|
||||
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
""")
|
||||
pause(page, 1200) # Wait for smooth scroll animation
|
||||
|
||||
# Wait for Monaco editor to load with config content
|
||||
page.wait_for_selector("#console-editor .monaco-editor", timeout=10000)
|
||||
pause(page, 2500) # Let viewer see the Compose Farm config file
|
||||
|
||||
# Final pause
|
||||
pause(page, 800)
|
||||
74
docs/demos/web/demo_navigation.py
Normal file
74
docs/demos/web/demo_navigation.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Demo: Command palette navigation.
|
||||
|
||||
Records a ~15 second demo showing:
|
||||
- Opening command palette with Ctrl+K
|
||||
- Fuzzy search filtering
|
||||
- Arrow key navigation
|
||||
- Stack and page navigation
|
||||
|
||||
Run: pytest docs/demos/web/demo_navigation.py -v --no-cov
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
open_command_palette,
|
||||
pause,
|
||||
slow_type,
|
||||
wait_for_sidebar,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
@pytest.mark.browser # type: ignore[misc]
|
||||
def test_demo_navigation(recording_page: Page, server_url: str) -> None:
|
||||
"""Record command palette navigation demo."""
|
||||
page = recording_page
|
||||
|
||||
# Start on dashboard
|
||||
page.goto(server_url)
|
||||
wait_for_sidebar(page)
|
||||
pause(page, 1000) # Let viewer see dashboard
|
||||
|
||||
# Open command palette with keyboard shortcut
|
||||
open_command_palette(page)
|
||||
pause(page, 500)
|
||||
|
||||
# Type partial stack name for fuzzy search
|
||||
slow_type(page, "#cmd-input", "grocy", delay=120)
|
||||
pause(page, 800)
|
||||
|
||||
# Arrow down to show selection movement
|
||||
page.keyboard.press("ArrowDown")
|
||||
pause(page, 400)
|
||||
page.keyboard.press("ArrowUp")
|
||||
pause(page, 400)
|
||||
|
||||
# Press Enter to navigate to stack
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url("**/stack/grocy", timeout=5000)
|
||||
pause(page, 1500) # Show stack page
|
||||
|
||||
# Open palette again to navigate elsewhere
|
||||
open_command_palette(page)
|
||||
pause(page, 400)
|
||||
|
||||
# Navigate to another stack (immich) to show more navigation
|
||||
slow_type(page, "#cmd-input", "imm", delay=120)
|
||||
pause(page, 600)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url("**/stack/immich", timeout=5000)
|
||||
pause(page, 1200) # Show immich stack page
|
||||
|
||||
# Open palette one more time, navigate back to dashboard
|
||||
open_command_palette(page)
|
||||
slow_type(page, "#cmd-input", "dashb", delay=120)
|
||||
pause(page, 500)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url(server_url, timeout=5000)
|
||||
pause(page, 1000) # Final dashboard view
|
||||
106
docs/demos/web/demo_shell.py
Normal file
106
docs/demos/web/demo_shell.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Demo: Container shell exec via command palette.
|
||||
|
||||
Records a ~35 second demo showing:
|
||||
- Navigating to immich stack (multiple containers)
|
||||
- Using command palette with fuzzy matching ("sh mach") to open shell
|
||||
- Running a command
|
||||
- Using command palette to switch to server container shell
|
||||
- Running another command
|
||||
|
||||
Run: pytest docs/demos/web/demo_shell.py -v --no-cov
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
open_command_palette,
|
||||
pause,
|
||||
slow_type,
|
||||
wait_for_sidebar,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
@pytest.mark.browser # type: ignore[misc]
|
||||
def test_demo_shell(recording_page: Page, server_url: str) -> None:
|
||||
"""Record container shell demo."""
|
||||
page = recording_page
|
||||
|
||||
# Start on dashboard
|
||||
page.goto(server_url)
|
||||
wait_for_sidebar(page)
|
||||
pause(page, 800)
|
||||
|
||||
# Navigate to immich via command palette (has multiple containers)
|
||||
open_command_palette(page)
|
||||
pause(page, 400)
|
||||
slow_type(page, "#cmd-input", "immich", delay=100)
|
||||
pause(page, 600)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url("**/stack/immich", timeout=5000)
|
||||
pause(page, 1500)
|
||||
|
||||
# Wait for containers list to load (so shell commands are available)
|
||||
page.wait_for_selector("#containers-list button", timeout=10000)
|
||||
pause(page, 800)
|
||||
|
||||
# Use command palette with fuzzy matching: "sh mach" -> "Shell: immich-machine-learning"
|
||||
open_command_palette(page)
|
||||
pause(page, 400)
|
||||
slow_type(page, "#cmd-input", "sh mach", delay=100)
|
||||
pause(page, 600)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 1000)
|
||||
|
||||
# Wait for exec terminal to appear
|
||||
page.wait_for_selector("#exec-terminal .xterm", timeout=10000)
|
||||
|
||||
# Smoothly scroll down to make the terminal visible
|
||||
page.evaluate("""
|
||||
const terminal = document.getElementById('exec-terminal');
|
||||
if (terminal) {
|
||||
terminal.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
""")
|
||||
pause(page, 1200)
|
||||
|
||||
# Run python version command
|
||||
slow_type(page, "#exec-terminal .xterm-helper-textarea", "python3 --version", delay=60)
|
||||
pause(page, 300)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 1500)
|
||||
|
||||
# Blur the terminal to release focus (won't scroll)
|
||||
page.evaluate("document.activeElement?.blur()")
|
||||
pause(page, 500)
|
||||
|
||||
# Use command palette to switch to server container: "sh serv" -> "Shell: immich-server"
|
||||
open_command_palette(page)
|
||||
pause(page, 400)
|
||||
slow_type(page, "#cmd-input", "sh serv", delay=100)
|
||||
pause(page, 600)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 1000)
|
||||
|
||||
# Wait for new terminal
|
||||
page.wait_for_selector("#exec-terminal .xterm", timeout=10000)
|
||||
|
||||
# Scroll to terminal
|
||||
page.evaluate("""
|
||||
const terminal = document.getElementById('exec-terminal');
|
||||
if (terminal) {
|
||||
terminal.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
""")
|
||||
pause(page, 1200)
|
||||
|
||||
# Run ls command
|
||||
slow_type(page, "#exec-terminal .xterm-helper-textarea", "ls /usr/src/app", delay=60)
|
||||
pause(page, 300)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 2000)
|
||||
101
docs/demos/web/demo_stack.py
Normal file
101
docs/demos/web/demo_stack.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Demo: Stack actions.
|
||||
|
||||
Records a ~30 second demo showing:
|
||||
- Navigating to a stack page
|
||||
- Viewing compose file in Monaco editor
|
||||
- Triggering Restart action via command palette
|
||||
- Watching terminal output stream
|
||||
- Triggering Logs action
|
||||
|
||||
Run: pytest docs/demos/web/demo_stack.py -v --no-cov
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
open_command_palette,
|
||||
pause,
|
||||
slow_type,
|
||||
wait_for_sidebar,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
@pytest.mark.browser # type: ignore[misc]
|
||||
def test_demo_stack(recording_page: Page, server_url: str) -> None:
|
||||
"""Record stack actions demo."""
|
||||
page = recording_page
|
||||
|
||||
# Start on dashboard
|
||||
page.goto(server_url)
|
||||
wait_for_sidebar(page)
|
||||
pause(page, 800)
|
||||
|
||||
# Navigate to grocy via command palette
|
||||
open_command_palette(page)
|
||||
pause(page, 400)
|
||||
slow_type(page, "#cmd-input", "grocy", delay=100)
|
||||
pause(page, 500)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url("**/stack/grocy", timeout=5000)
|
||||
pause(page, 1000) # Show stack page
|
||||
|
||||
# Click on Compose File collapse to show the Monaco editor
|
||||
# The collapse uses a checkbox input, click it via the parent collapse div
|
||||
compose_collapse = page.locator(".collapse", has_text="Compose File").first
|
||||
compose_collapse.locator("input[type=checkbox]").click(force=True)
|
||||
pause(page, 500)
|
||||
|
||||
# Wait for Monaco editor to load and show content
|
||||
page.wait_for_selector("#compose-editor .monaco-editor", timeout=10000)
|
||||
pause(page, 2000) # Let viewer see the compose file
|
||||
|
||||
# Smoothly scroll down to show more of the editor
|
||||
page.evaluate("""
|
||||
const editor = document.getElementById('compose-editor');
|
||||
if (editor) {
|
||||
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
""")
|
||||
pause(page, 1200) # Wait for smooth scroll animation
|
||||
|
||||
# Close the compose file section
|
||||
compose_collapse.locator("input[type=checkbox]").click(force=True)
|
||||
pause(page, 500)
|
||||
|
||||
# Open command palette for stack actions
|
||||
open_command_palette(page)
|
||||
pause(page, 400)
|
||||
|
||||
# Filter to Restart action
|
||||
slow_type(page, "#cmd-input", "restart", delay=120)
|
||||
pause(page, 600)
|
||||
|
||||
# Execute Restart
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 300)
|
||||
|
||||
# Wait for terminal to expand and show output
|
||||
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
|
||||
pause(page, 2500) # Let viewer see terminal streaming
|
||||
|
||||
# Open palette again for Logs
|
||||
open_command_palette(page)
|
||||
pause(page, 400)
|
||||
|
||||
# Filter to Logs action
|
||||
slow_type(page, "#cmd-input", "logs", delay=120)
|
||||
pause(page, 600)
|
||||
|
||||
# Execute Logs
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 300)
|
||||
|
||||
# Show log output
|
||||
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
|
||||
pause(page, 2500) # Final view of logs
|
||||
81
docs/demos/web/demo_themes.py
Normal file
81
docs/demos/web/demo_themes.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Demo: Theme switching.
|
||||
|
||||
Records a ~15 second demo showing:
|
||||
- Opening theme picker via theme button
|
||||
- Live theme preview on arrow navigation
|
||||
- Selecting different themes
|
||||
- Theme persistence
|
||||
|
||||
Run: pytest docs/demos/web/demo_themes.py -v --no-cov
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from conftest import (
|
||||
pause,
|
||||
slow_type,
|
||||
wait_for_sidebar,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
@pytest.mark.browser # type: ignore[misc]
|
||||
def test_demo_themes(recording_page: Page, server_url: str) -> None:
|
||||
"""Record theme switching demo."""
|
||||
page = recording_page
|
||||
|
||||
# Start on dashboard
|
||||
page.goto(server_url)
|
||||
wait_for_sidebar(page)
|
||||
pause(page, 1000) # Show initial theme
|
||||
|
||||
# Click theme button to open theme picker
|
||||
page.locator("#theme-btn").click()
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
pause(page, 600)
|
||||
|
||||
# Arrow through many themes to show live preview effect
|
||||
for _ in range(12):
|
||||
page.keyboard.press("ArrowDown")
|
||||
pause(page, 350) # Show each preview
|
||||
|
||||
# Go back up through a few (land on valentine, not cyberpunk)
|
||||
for _ in range(4):
|
||||
page.keyboard.press("ArrowUp")
|
||||
pause(page, 350)
|
||||
|
||||
# Select current theme with Enter
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 1000)
|
||||
|
||||
# Close palette with Escape
|
||||
page.keyboard.press("Escape")
|
||||
pause(page, 800)
|
||||
|
||||
# Open again and use search to find specific theme
|
||||
page.locator("#theme-btn").click()
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
pause(page, 400)
|
||||
|
||||
# Type to filter to a light theme (theme button pre-populates "theme:")
|
||||
slow_type(page, "#cmd-input", "cup", delay=100)
|
||||
pause(page, 500)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 1000)
|
||||
|
||||
# Close and return to dark
|
||||
page.keyboard.press("Escape")
|
||||
pause(page, 500)
|
||||
page.locator("#theme-btn").click()
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
pause(page, 300)
|
||||
|
||||
slow_type(page, "#cmd-input", "dark", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 800)
|
||||
189
docs/demos/web/demo_workflow.py
Normal file
189
docs/demos/web/demo_workflow.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""Demo: Full workflow.
|
||||
|
||||
Records a comprehensive demo (~60 seconds) combining all major features:
|
||||
1. Console page: terminal with fastfetch, cf pull command
|
||||
2. Editor showing Compose Farm YAML config
|
||||
3. Command palette navigation to grocy stack
|
||||
4. Stack actions: up, logs
|
||||
5. Switch to dozzle stack via command palette, run update
|
||||
6. Dashboard overview
|
||||
7. Theme cycling via command palette
|
||||
|
||||
This demo is used on the homepage and Web UI page as the main showcase.
|
||||
|
||||
Run: pytest docs/demos/web/demo_workflow.py -v --no-cov
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from conftest import open_command_palette, pause, slow_type, wait_for_sidebar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from playwright.sync_api import Page
|
||||
|
||||
|
||||
def _demo_console_terminal(page: Page, server_url: str) -> None:
|
||||
"""Demo part 1: Console page with terminal and editor."""
|
||||
# Start on dashboard briefly
|
||||
page.goto(server_url)
|
||||
wait_for_sidebar(page)
|
||||
pause(page, 800)
|
||||
|
||||
# Navigate to Console page via command palette
|
||||
open_command_palette(page)
|
||||
pause(page, 300)
|
||||
slow_type(page, "#cmd-input", "cons", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url("**/console", timeout=5000)
|
||||
pause(page, 800)
|
||||
|
||||
# Wait for terminal to be ready
|
||||
page.wait_for_selector("#console-terminal .xterm", timeout=10000)
|
||||
pause(page, 1000)
|
||||
|
||||
# Run fastfetch first
|
||||
slow_type(page, "#console-terminal .xterm-helper-textarea", "fastfetch", delay=60)
|
||||
pause(page, 200)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 2000) # Wait for output
|
||||
|
||||
# Run cf pull on a stack to show Compose Farm in action
|
||||
slow_type(page, "#console-terminal .xterm-helper-textarea", "cf pull grocy", delay=60)
|
||||
pause(page, 200)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 3000) # Wait for pull output
|
||||
|
||||
|
||||
def _demo_config_editor(page: Page) -> None:
|
||||
"""Demo part 2: Show the Compose Farm config in editor."""
|
||||
# Smoothly scroll down to show the Editor section
|
||||
# Use JavaScript for smooth scrolling animation
|
||||
page.evaluate("""
|
||||
const editor = document.getElementById('console-editor');
|
||||
if (editor) {
|
||||
editor.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
""")
|
||||
pause(page, 1200) # Wait for smooth scroll animation
|
||||
|
||||
# Wait for Monaco editor to load with config content
|
||||
page.wait_for_selector("#console-editor .monaco-editor", timeout=10000)
|
||||
pause(page, 2000) # Let viewer see the Compose Farm config file
|
||||
|
||||
|
||||
def _demo_stack_actions(page: Page) -> None:
|
||||
"""Demo part 3: Navigate to stack and run actions."""
|
||||
# Click on sidebar to take focus away from terminal, then use command palette
|
||||
page.locator("#sidebar-stacks").click()
|
||||
pause(page, 300)
|
||||
|
||||
# Navigate to grocy via command palette
|
||||
open_command_palette(page)
|
||||
pause(page, 300)
|
||||
slow_type(page, "#cmd-input", "grocy", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url("**/stack/grocy", timeout=5000)
|
||||
pause(page, 1000)
|
||||
|
||||
# Open Compose File editor to show the compose.yaml
|
||||
compose_collapse = page.locator(".collapse", has_text="Compose File").first
|
||||
compose_collapse.locator("input[type=checkbox]").click(force=True)
|
||||
pause(page, 500)
|
||||
|
||||
# Wait for Monaco editor to load and show content
|
||||
page.wait_for_selector("#compose-editor .monaco-editor", timeout=10000)
|
||||
pause(page, 2000) # Let viewer see the compose file
|
||||
|
||||
# Close the compose file section
|
||||
compose_collapse.locator("input[type=checkbox]").click(force=True)
|
||||
pause(page, 500)
|
||||
|
||||
# Run Up action via command palette
|
||||
open_command_palette(page)
|
||||
pause(page, 300)
|
||||
slow_type(page, "#cmd-input", "up", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 200)
|
||||
|
||||
# Wait for terminal output
|
||||
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
|
||||
pause(page, 2500)
|
||||
|
||||
# Show logs
|
||||
open_command_palette(page)
|
||||
pause(page, 300)
|
||||
slow_type(page, "#cmd-input", "logs", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 200)
|
||||
|
||||
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
|
||||
pause(page, 2500)
|
||||
|
||||
# Switch to dozzle via command palette (on nas for lower latency)
|
||||
open_command_palette(page)
|
||||
pause(page, 300)
|
||||
slow_type(page, "#cmd-input", "dozzle", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url("**/stack/dozzle", timeout=5000)
|
||||
pause(page, 1000)
|
||||
|
||||
# Run update action
|
||||
open_command_palette(page)
|
||||
pause(page, 300)
|
||||
slow_type(page, "#cmd-input", "upda", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 200)
|
||||
|
||||
page.wait_for_selector("#terminal-output .xterm", timeout=5000)
|
||||
pause(page, 2500)
|
||||
|
||||
|
||||
def _demo_dashboard_and_themes(page: Page, server_url: str) -> None:
|
||||
"""Demo part 4: Dashboard and theme cycling."""
|
||||
# Navigate to dashboard via command palette
|
||||
open_command_palette(page)
|
||||
pause(page, 300)
|
||||
slow_type(page, "#cmd-input", "dash", delay=100)
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url(server_url, timeout=5000)
|
||||
pause(page, 800)
|
||||
|
||||
# Scroll to top of page to ensure dashboard is fully visible
|
||||
page.evaluate("window.scrollTo(0, 0)")
|
||||
pause(page, 600)
|
||||
|
||||
# Open theme picker and arrow down to Dracula (shows live preview)
|
||||
page.locator("#theme-btn").click()
|
||||
page.wait_for_selector("#cmd-palette[open]", timeout=2000)
|
||||
pause(page, 400)
|
||||
|
||||
# Arrow down through themes with live preview until we reach Dracula
|
||||
for _ in range(19):
|
||||
page.keyboard.press("ArrowDown")
|
||||
pause(page, 180)
|
||||
|
||||
# Select Dracula theme and end on it
|
||||
pause(page, 400)
|
||||
page.keyboard.press("Enter")
|
||||
pause(page, 1500)
|
||||
|
||||
|
||||
@pytest.mark.browser # type: ignore[misc]
|
||||
def test_demo_workflow(recording_page: Page, server_url: str) -> None:
|
||||
"""Record full workflow demo."""
|
||||
page = recording_page
|
||||
|
||||
_demo_console_terminal(page, server_url)
|
||||
_demo_config_editor(page)
|
||||
_demo_stack_actions(page)
|
||||
_demo_dashboard_and_themes(page, server_url)
|
||||
258
docs/demos/web/record.py
Executable file
258
docs/demos/web/record.py
Executable file
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Record all web UI demos.
|
||||
|
||||
This script orchestrates recording of web UI demos using Playwright,
|
||||
then converts the WebM recordings to GIF format.
|
||||
|
||||
Usage:
|
||||
python docs/demos/web/record.py # Record all demos
|
||||
python docs/demos/web/record.py navigation # Record specific demo
|
||||
|
||||
Requirements:
|
||||
- Playwright with Chromium: playwright install chromium
|
||||
- ffmpeg for GIF conversion: apt install ffmpeg / brew install ffmpeg
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
REPO_DIR = SCRIPT_DIR.parent.parent.parent
|
||||
OUTPUT_DIR = REPO_DIR / "docs" / "assets"
|
||||
|
||||
DEMOS = [
|
||||
"navigation",
|
||||
"stack",
|
||||
"themes",
|
||||
"workflow",
|
||||
"console",
|
||||
"shell",
|
||||
]
|
||||
|
||||
# High-quality ffmpeg settings for VP8 encoding
|
||||
# See: https://github.com/microsoft/playwright/issues/10855
|
||||
# See: https://github.com/microsoft/playwright/issues/31424
|
||||
#
|
||||
# MAX_QUALITY: Lossless-like, largest files
|
||||
# BALANCED_QUALITY: ~43% file size, nearly indistinguishable quality
|
||||
MAX_QUALITY_ARGS = "-c:v vp8 -qmin 0 -qmax 0 -crf 0 -deadline best -speed 0 -b:v 0 -threads 0"
|
||||
BALANCED_QUALITY_ARGS = "-c:v vp8 -qmin 0 -qmax 10 -crf 4 -deadline best -speed 0 -b:v 0 -threads 0"
|
||||
|
||||
# Choose which quality to use
|
||||
VIDEO_QUALITY_ARGS = MAX_QUALITY_ARGS
|
||||
|
||||
|
||||
def patch_playwright_video_quality() -> None:
|
||||
"""Patch Playwright's videoRecorder.js to use high-quality encoding settings."""
|
||||
from playwright._impl._driver import compute_driver_executable # noqa: PLC0415
|
||||
|
||||
# compute_driver_executable returns (node_path, cli_path)
|
||||
result = compute_driver_executable()
|
||||
node_path = result[0] if isinstance(result, tuple) else result
|
||||
driver_path = Path(node_path).parent
|
||||
|
||||
video_recorder = driver_path / "package" / "lib" / "server" / "chromium" / "videoRecorder.js"
|
||||
|
||||
if not video_recorder.exists():
|
||||
msg = f"videoRecorder.js not found at {video_recorder}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
content = video_recorder.read_text()
|
||||
|
||||
# Check if already patched
|
||||
if "deadline best" in content:
|
||||
return # Already patched
|
||||
|
||||
# Pattern to match the ffmpeg args line
|
||||
pattern = (
|
||||
r"-c:v vp8 -qmin \d+ -qmax \d+ -crf \d+ -deadline \w+ -speed \d+ -b:v \w+ -threads \d+"
|
||||
)
|
||||
|
||||
if not re.search(pattern, content):
|
||||
msg = "Could not find ffmpeg args pattern in videoRecorder.js"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Replace with high-quality settings
|
||||
new_content = re.sub(pattern, VIDEO_QUALITY_ARGS, content)
|
||||
video_recorder.write_text(new_content)
|
||||
console.print("[green]Patched Playwright for high-quality video recording[/green]")
|
||||
|
||||
|
||||
def record_demo(name: str, index: int, total: int) -> Path | None:
|
||||
"""Run a single demo and return the video path."""
|
||||
console.print(f"[cyan][{index}/{total}][/cyan] [green]Recording:[/green] web-{name}")
|
||||
|
||||
demo_file = SCRIPT_DIR / f"demo_{name}.py"
|
||||
if not demo_file.exists():
|
||||
console.print(f"[red] Demo file not found: {demo_file}[/red]")
|
||||
return None
|
||||
|
||||
# Create temp output dir for this recording
|
||||
temp_dir = SCRIPT_DIR / ".recordings"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Run pytest with video recording
|
||||
# Set PYTHONPATH so conftest.py imports work
|
||||
env = {**os.environ, "PYTHONPATH": str(SCRIPT_DIR)}
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
str(demo_file),
|
||||
"-v",
|
||||
"--no-cov",
|
||||
"-x", # Stop on first failure
|
||||
f"--basetemp={temp_dir}",
|
||||
],
|
||||
check=False,
|
||||
cwd=REPO_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
console.print(f"[red] Failed to record {name}[/red]")
|
||||
console.print(result.stdout)
|
||||
console.print(result.stderr)
|
||||
return None
|
||||
|
||||
# Find the recorded video
|
||||
videos = list(temp_dir.rglob("*.webm"))
|
||||
if not videos:
|
||||
console.print(f"[red] No video found for {name}[/red]")
|
||||
return None
|
||||
|
||||
# Use the most recent video
|
||||
video = max(videos, key=lambda p: p.stat().st_mtime)
|
||||
console.print(f"[green] Recorded: {video.name}[/green]")
|
||||
return video
|
||||
|
||||
|
||||
def convert_to_gif(webm_path: Path, output_name: str) -> Path:
|
||||
"""Convert WebM to GIF using ffmpeg with palette optimization."""
|
||||
gif_path = OUTPUT_DIR / f"{output_name}.gif"
|
||||
palette_path = webm_path.parent / "palette.png"
|
||||
|
||||
# Two-pass approach for better quality
|
||||
# Pass 1: Generate palette
|
||||
subprocess.run(
|
||||
[ # noqa: S607
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(webm_path),
|
||||
"-vf",
|
||||
"fps=10,scale=1280:-1:flags=lanczos,palettegen=stats_mode=diff",
|
||||
str(palette_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Pass 2: Generate GIF with palette
|
||||
subprocess.run(
|
||||
[ # noqa: S607
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(webm_path),
|
||||
"-i",
|
||||
str(palette_path),
|
||||
"-lavfi",
|
||||
"fps=10,scale=1280:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
|
||||
str(gif_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
palette_path.unlink(missing_ok=True)
|
||||
return gif_path
|
||||
|
||||
|
||||
def move_recording(video_path: Path, name: str) -> tuple[Path, Path]:
|
||||
"""Move WebM and convert to GIF, returning both paths."""
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_name = f"web-{name}"
|
||||
webm_dest = OUTPUT_DIR / f"{output_name}.webm"
|
||||
|
||||
shutil.copy2(video_path, webm_dest)
|
||||
console.print(f"[blue] WebM: {webm_dest.relative_to(REPO_DIR)}[/blue]")
|
||||
|
||||
gif_path = convert_to_gif(video_path, output_name)
|
||||
console.print(f"[blue] GIF: {gif_path.relative_to(REPO_DIR)}[/blue]")
|
||||
|
||||
return webm_dest, gif_path
|
||||
|
||||
|
||||
def cleanup() -> None:
|
||||
"""Clean up temporary recording files."""
|
||||
temp_dir = SCRIPT_DIR / ".recordings"
|
||||
if temp_dir.exists():
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Record all web UI demos."""
|
||||
console.print("[blue]Recording web UI demos...[/blue]")
|
||||
console.print(f"Output directory: {OUTPUT_DIR}")
|
||||
console.print()
|
||||
|
||||
# Patch Playwright for high-quality video recording
|
||||
patch_playwright_video_quality()
|
||||
|
||||
# Determine which demos to record
|
||||
if len(sys.argv) > 1:
|
||||
demos_to_record = [d for d in sys.argv[1:] if d in DEMOS]
|
||||
if not demos_to_record:
|
||||
console.print(f"[red]Unknown demo(s). Available: {', '.join(DEMOS)}[/red]")
|
||||
return 1
|
||||
else:
|
||||
demos_to_record = DEMOS
|
||||
|
||||
results: dict[str, tuple[Path | None, Path | None]] = {}
|
||||
|
||||
try:
|
||||
for i, demo in enumerate(demos_to_record, 1):
|
||||
video_path = record_demo(demo, i, len(demos_to_record))
|
||||
if video_path:
|
||||
webm, gif = move_recording(video_path, demo)
|
||||
results[demo] = (webm, gif)
|
||||
else:
|
||||
results[demo] = (None, None)
|
||||
console.print()
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
# Summary
|
||||
console.print("[blue]=== Summary ===[/blue]")
|
||||
success_count = sum(1 for w, _ in results.values() if w is not None)
|
||||
console.print(f"Recorded: {success_count}/{len(demos_to_record)} demos")
|
||||
console.print()
|
||||
|
||||
for demo, (webm, gif) in results.items(): # type: ignore[assignment]
|
||||
status = "[green]OK[/green]" if webm else "[red]FAILED[/red]"
|
||||
console.print(f" {demo}: {status}")
|
||||
if webm:
|
||||
console.print(f" {webm.relative_to(REPO_DIR)}")
|
||||
if gif:
|
||||
console.print(f" {gif.relative_to(REPO_DIR)}")
|
||||
|
||||
return 0 if success_count == len(demos_to_record) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,90 +0,0 @@
|
||||
# Docker Swarm Overlay Networks with Compose Farm
|
||||
|
||||
Notes from testing Docker Swarm's attachable overlay networks as a way to get cross-host container networking while still using `docker compose`.
|
||||
|
||||
## The Idea
|
||||
|
||||
Docker Swarm overlay networks can be made "attachable", allowing regular `docker compose` containers (not just swarm services) to join them. This would give us:
|
||||
|
||||
- Cross-host Docker DNS (containers find each other by name)
|
||||
- No need to publish ports for inter-container communication
|
||||
- Keep using `docker compose up` instead of `docker stack deploy`
|
||||
|
||||
## Setup Steps
|
||||
|
||||
```bash
|
||||
# On manager node
|
||||
docker swarm init --advertise-addr <manager-ip>
|
||||
|
||||
# On worker nodes (use token from init output)
|
||||
docker swarm join --token <token> <manager-ip>:2377
|
||||
|
||||
# Create attachable overlay network (on manager)
|
||||
docker network create --driver overlay --attachable my-network
|
||||
|
||||
# In compose files, add the network
|
||||
networks:
|
||||
my-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
## Required Ports
|
||||
|
||||
Docker Swarm requires these ports open **bidirectionally** between all nodes:
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
|------|----------|---------|
|
||||
| 2377 | TCP | Cluster management |
|
||||
| 7946 | TCP + UDP | Node communication |
|
||||
| 4789 | UDP | Overlay network traffic (VXLAN) |
|
||||
|
||||
## Test Results (2024-12-13)
|
||||
|
||||
- docker-debian (192.168.1.66) as manager
|
||||
- dev-lxc (192.168.1.167) as worker
|
||||
|
||||
### What worked
|
||||
|
||||
- Swarm init and join
|
||||
- Overlay network creation
|
||||
- Nodes showed as Ready
|
||||
|
||||
### What failed
|
||||
|
||||
- Container on dev-lxc couldn't attach to overlay network
|
||||
- Error: `attaching to network failed... context deadline exceeded`
|
||||
- Cause: Port 7946 blocked from docker-debian → dev-lxc
|
||||
|
||||
### Root cause
|
||||
|
||||
Firewall on dev-lxc wasn't configured to allow swarm ports. Opening these ports requires sudo access on each node.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Docker Swarm overlay networks are **not plug-and-play**. Requirements:
|
||||
|
||||
1. Swarm init/join on all nodes
|
||||
2. Firewall rules on all nodes (needs sudo/root)
|
||||
3. All nodes must have bidirectional connectivity on 3 ports
|
||||
|
||||
For a simpler alternative, consider:
|
||||
|
||||
- **Tailscale**: VPN mesh, containers use host's Tailscale IP
|
||||
- **Host networking + published ports**: What compose-farm does today
|
||||
- **Keep dependent services together**: Avoid cross-host networking entirely
|
||||
|
||||
## Future Work
|
||||
|
||||
If we decide to support overlay networks:
|
||||
|
||||
1. Add a `compose-farm network create` command that:
|
||||
- Initializes swarm if needed
|
||||
- Creates attachable overlay network
|
||||
- Documents required firewall rules
|
||||
|
||||
2. Add network config to compose-farm.yaml:
|
||||
```yaml
|
||||
overlay_network: compose-farm-net
|
||||
```
|
||||
|
||||
3. Auto-inject network into compose files (or document manual setup)
|
||||
@@ -1,128 +0,0 @@
|
||||
# Future Improvements
|
||||
|
||||
Low-priority improvements identified during code review. These are not currently causing issues but could be addressed if they become pain points.
|
||||
|
||||
## 1. State Module Efficiency (LOW)
|
||||
|
||||
**Current:** Every state operation reads and writes the entire file.
|
||||
|
||||
```python
|
||||
def set_service_host(config, service, host):
|
||||
state = load_state(config) # Read file
|
||||
state[service] = host
|
||||
save_state(config, state) # Write file
|
||||
```
|
||||
|
||||
**Impact:** With 87 services, this is fine. With 1000+, it would be slow.
|
||||
|
||||
**Potential fix:** Add batch operations:
|
||||
```python
|
||||
def update_state(config, updates: dict[str, str | None]) -> None:
|
||||
"""Batch update: set services to hosts, None means remove."""
|
||||
state = load_state(config)
|
||||
for service, host in updates.items():
|
||||
if host is None:
|
||||
state.pop(service, None)
|
||||
else:
|
||||
state[service] = host
|
||||
save_state(config, state)
|
||||
```
|
||||
|
||||
**When to do:** Only if state operations become noticeably slow.
|
||||
|
||||
---
|
||||
|
||||
## 2. Remote-Aware Compose Path Resolution (LOW)
|
||||
|
||||
**Current:** `config.get_compose_path()` checks if files exist on the local filesystem:
|
||||
|
||||
```python
|
||||
def get_compose_path(self, service: str) -> Path:
|
||||
for filename in ("compose.yaml", "compose.yml", ...):
|
||||
candidate = service_dir / filename
|
||||
if candidate.exists(): # Local check!
|
||||
return candidate
|
||||
```
|
||||
|
||||
**Why this works:** NFS/shared storage means local = remote.
|
||||
|
||||
**Why it could break:** If running compose-farm from a machine without the NFS mount, it returns `compose.yaml` (the default) even if `docker-compose.yml` exists on the remote host.
|
||||
|
||||
**Potential fix:** Query the remote host for file existence, or accept this limitation and document it.
|
||||
|
||||
**When to do:** Only if users need to run compose-farm from non-NFS machines.
|
||||
|
||||
---
|
||||
|
||||
## 3. Add Integration Tests for CLI Commands (MEDIUM)
|
||||
|
||||
**Current:** No integration tests for the actual CLI commands. Tests cover the underlying functions but not the Typer commands themselves.
|
||||
|
||||
**Potential fix:** Add integration tests using `CliRunner` from Typer:
|
||||
|
||||
```python
|
||||
from typer.testing import CliRunner
|
||||
from compose_farm.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
def test_check_command_validates_config():
|
||||
result = runner.invoke(app, ["check", "--local"])
|
||||
assert result.exit_code == 0
|
||||
```
|
||||
|
||||
**When to do:** When CLI behavior becomes complex enough to warrant dedicated testing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Add Tests for operations.py (MEDIUM)
|
||||
|
||||
**Current:** Operations module has 30% coverage. Most logic is tested indirectly through test_sync.py.
|
||||
|
||||
**Potential fix:** Add dedicated tests for:
|
||||
- `up_services()` with migration scenarios
|
||||
- `preflight_check()`
|
||||
- `check_host_compatibility()`
|
||||
|
||||
**When to do:** When adding new operations or modifying migration logic.
|
||||
|
||||
---
|
||||
|
||||
## 5. Consider Structured Logging (LOW)
|
||||
|
||||
**Current:** Operations print directly to console using Rich. This couples the operations module to the Rich library.
|
||||
|
||||
**Potential fix:** Use Python's logging module with a custom Rich handler:
|
||||
|
||||
```python
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# In operations:
|
||||
logger.info("Migrating %s from %s to %s", service, old_host, new_host)
|
||||
|
||||
# In cli.py - configure Rich handler:
|
||||
from rich.logging import RichHandler
|
||||
logging.basicConfig(handlers=[RichHandler()])
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Operations become testable without capturing stdout
|
||||
- Logs can be redirected to files
|
||||
- Log levels provide filtering
|
||||
|
||||
**When to do:** Only if console output coupling becomes a problem for testing or extensibility.
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions to Keep
|
||||
|
||||
These patterns are working well and should be preserved:
|
||||
|
||||
1. **asyncio + asyncssh** - Solid async foundation
|
||||
2. **Pydantic models** - Clean validation
|
||||
3. **Rich for output** - Good UX
|
||||
4. **Test structure** - Good coverage
|
||||
5. **Module separation** - cli/operations/executor/compose pattern
|
||||
6. **KISS principle** - Don't over-engineer
|
||||
340
docs/getting-started.md
Normal file
340
docs/getting-started.md
Normal file
@@ -0,0 +1,340 @@
|
||||
---
|
||||
icon: lucide/rocket
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
This guide walks you through installing Compose Farm and setting up your first multi-host deployment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have:
|
||||
|
||||
- **[uv](https://docs.astral.sh/uv/)** (recommended) or Python 3.11+
|
||||
- **SSH key-based authentication** to your Docker hosts
|
||||
- **Docker and Docker Compose** installed on all target hosts
|
||||
- **Shared storage** for compose files (NFS, Syncthing, etc.)
|
||||
|
||||
## Installation
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/install.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
### One-liner (recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://compose-farm.nijho.lt/install | sh
|
||||
```
|
||||
|
||||
This installs [uv](https://docs.astral.sh/uv/) if needed, then installs compose-farm.
|
||||
|
||||
### Using uv
|
||||
|
||||
If you already have [uv](https://docs.astral.sh/uv/) installed:
|
||||
|
||||
```bash
|
||||
uv tool install compose-farm
|
||||
```
|
||||
|
||||
### Using pip
|
||||
|
||||
If you already have Python 3.11+ installed:
|
||||
|
||||
```bash
|
||||
pip install compose-farm
|
||||
```
|
||||
|
||||
### Using Docker
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v $SSH_AUTH_SOCK:/ssh-agent -e SSH_AUTH_SOCK=/ssh-agent \
|
||||
-v ./compose-farm.yaml:/root/.config/compose-farm/compose-farm.yaml:ro \
|
||||
ghcr.io/basnijholt/compose-farm up --all
|
||||
```
|
||||
|
||||
**Running as non-root user** (recommended for NFS mounts):
|
||||
|
||||
By default, containers run as root. To preserve file ownership on mounted volumes, set these environment variables in your `.env` file:
|
||||
|
||||
```bash
|
||||
# Add to .env file (one-time setup)
|
||||
echo "CF_UID=$(id -u)" >> .env
|
||||
echo "CF_GID=$(id -g)" >> .env
|
||||
echo "CF_HOME=$HOME" >> .env
|
||||
echo "CF_USER=$USER" >> .env
|
||||
```
|
||||
|
||||
Or use [direnv](https://direnv.net/) to auto-set these variables when entering the directory:
|
||||
```bash
|
||||
cp .envrc.example .envrc && direnv allow
|
||||
```
|
||||
|
||||
This ensures files like `compose-farm-state.yaml` and web UI edits are owned by your user instead of root. The `CF_USER` variable is required for SSH to work when running as a non-root user.
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
cf --version
|
||||
cf --help
|
||||
```
|
||||
|
||||
## SSH Setup
|
||||
|
||||
Compose Farm uses SSH to run commands on remote hosts. You need passwordless SSH access.
|
||||
|
||||
### Option 1: SSH Agent (default)
|
||||
|
||||
If you already have SSH keys loaded in your agent:
|
||||
|
||||
```bash
|
||||
# Verify keys are loaded
|
||||
ssh-add -l
|
||||
|
||||
# Test connection
|
||||
ssh user@192.168.1.10 "docker --version"
|
||||
```
|
||||
|
||||
### Option 2: Dedicated Key (recommended for Docker)
|
||||
|
||||
For persistent access when running in Docker:
|
||||
|
||||
```bash
|
||||
# Generate and distribute key to all hosts
|
||||
cf ssh setup
|
||||
|
||||
# Check status
|
||||
cf ssh status
|
||||
```
|
||||
|
||||
This creates `~/.ssh/compose-farm/id_ed25519` and copies the public key to each host.
|
||||
|
||||
## Shared Storage Setup
|
||||
|
||||
Compose files must be accessible at the **same path** on all hosts. Common approaches:
|
||||
|
||||
### NFS Mount
|
||||
|
||||
```bash
|
||||
# On each Docker host
|
||||
sudo mount nas:/volume1/compose /opt/compose
|
||||
|
||||
# Or add to /etc/fstab
|
||||
nas:/volume1/compose /opt/compose nfs defaults 0 0
|
||||
```
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
/opt/compose/ # compose_dir in config
|
||||
├── plex/
|
||||
│ └── docker-compose.yml
|
||||
├── grafana/
|
||||
│ └── docker-compose.yml
|
||||
├── nextcloud/
|
||||
│ └── docker-compose.yml
|
||||
└── jellyfin/
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Create Config File
|
||||
|
||||
Create `compose-farm.yaml` in the directory where you'll run commands. For example, if your stacks are in `/opt/stacks`, place the config there too:
|
||||
|
||||
```bash
|
||||
cd /opt/stacks
|
||||
cf config init
|
||||
```
|
||||
|
||||
Alternatively, use `~/.config/compose-farm/compose-farm.yaml` for a global config. You can also symlink a working directory config to the global location:
|
||||
|
||||
```bash
|
||||
# Create config in your stacks directory, symlink to ~/.config
|
||||
cf config symlink /opt/stacks/compose-farm.yaml
|
||||
```
|
||||
|
||||
This way, `cf` commands work from anywhere while the config lives with your stacks.
|
||||
|
||||
#### Single host example
|
||||
|
||||
```yaml
|
||||
# Where compose files are located (one folder per stack)
|
||||
compose_dir: /opt/stacks
|
||||
|
||||
hosts:
|
||||
local: localhost
|
||||
|
||||
stacks:
|
||||
plex: local
|
||||
grafana: local
|
||||
nextcloud: local
|
||||
```
|
||||
|
||||
#### Multi-host example
|
||||
```yaml
|
||||
# Where compose files are located (same path on all hosts)
|
||||
compose_dir: /opt/compose
|
||||
|
||||
# Define your Docker hosts
|
||||
hosts:
|
||||
nuc:
|
||||
address: 192.168.1.10
|
||||
user: docker # SSH user
|
||||
hp:
|
||||
address: 192.168.1.11
|
||||
# user defaults to current user
|
||||
|
||||
# Map stacks to hosts
|
||||
stacks:
|
||||
plex: nuc
|
||||
grafana: nuc
|
||||
nextcloud: hp
|
||||
```
|
||||
|
||||
Each entry in `stacks:` maps to a folder under `compose_dir` that contains a compose file.
|
||||
|
||||
For cross-host HTTP routing, add Traefik labels and configure `traefik_file` (see [Traefik Integration](traefik.md)).
|
||||
### Validate Configuration
|
||||
|
||||
```bash
|
||||
cf check --local
|
||||
```
|
||||
|
||||
This validates syntax without SSH connections. For full validation:
|
||||
|
||||
```bash
|
||||
cf check
|
||||
```
|
||||
|
||||
## First Commands
|
||||
|
||||
### Check Status
|
||||
|
||||
```bash
|
||||
cf ps
|
||||
```
|
||||
|
||||
Shows all configured stacks and their status.
|
||||
|
||||
### Start All Stacks
|
||||
|
||||
```bash
|
||||
cf up --all
|
||||
```
|
||||
|
||||
Starts all stacks on their assigned hosts.
|
||||
|
||||
### Start Specific Stacks
|
||||
|
||||
```bash
|
||||
cf up plex grafana
|
||||
```
|
||||
|
||||
### Apply Configuration
|
||||
|
||||
The most powerful command - reconciles reality with your config:
|
||||
|
||||
```bash
|
||||
cf apply --dry-run # Preview changes
|
||||
cf apply # Execute changes
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Start stacks in config but not running
|
||||
2. Migrate stacks on wrong host
|
||||
3. Stop stacks removed from config
|
||||
|
||||
## Docker Network Setup
|
||||
|
||||
If your stacks use an external Docker network:
|
||||
|
||||
```bash
|
||||
# Create network on all hosts
|
||||
cf init-network
|
||||
|
||||
# Or specific hosts
|
||||
cf init-network nuc hp
|
||||
```
|
||||
|
||||
Default network: `mynetwork` with subnet `172.20.0.0/16`
|
||||
|
||||
## Example Workflow
|
||||
|
||||
### 1. Add a New Stack
|
||||
|
||||
Create the compose file:
|
||||
|
||||
```bash
|
||||
# On any host (shared storage)
|
||||
mkdir -p /opt/compose/gitea
|
||||
cat > /opt/compose/gitea/docker-compose.yml << 'EOF'
|
||||
services:
|
||||
gitea:
|
||||
image: docker.gitea.com/gitea:latest
|
||||
container_name: gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
volumes:
|
||||
- /opt/config/gitea:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "2222:22"
|
||||
restart: unless-stopped
|
||||
EOF
|
||||
```
|
||||
|
||||
Add to config:
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
# ... existing stacks
|
||||
gitea: nuc
|
||||
```
|
||||
|
||||
Start the stack:
|
||||
|
||||
```bash
|
||||
cf up gitea
|
||||
```
|
||||
|
||||
### 2. Move a Stack to Another Host
|
||||
|
||||
Edit `compose-farm.yaml`:
|
||||
|
||||
```yaml
|
||||
stacks:
|
||||
plex: hp # Changed from nuc
|
||||
```
|
||||
|
||||
Apply the change:
|
||||
|
||||
```bash
|
||||
cf up plex
|
||||
# Automatically: down on nuc, up on hp
|
||||
```
|
||||
|
||||
Or use apply to reconcile everything:
|
||||
|
||||
```bash
|
||||
cf apply
|
||||
```
|
||||
|
||||
### 3. Update All Stacks
|
||||
|
||||
```bash
|
||||
cf update --all
|
||||
# Runs: pull + build + down + up for each stack
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration Reference](configuration.md) - All config options
|
||||
- [Commands Reference](commands.md) - Full CLI documentation
|
||||
- [Traefik Integration](traefik.md) - Multi-host routing
|
||||
- [Best Practices](best-practices.md) - Tips and limitations
|
||||
167
docs/index.md
Normal file
167
docs/index.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
icon: lucide/server
|
||||
---
|
||||
|
||||
# Compose Farm
|
||||
|
||||
A minimal CLI tool to run Docker Compose commands across multiple hosts via SSH.
|
||||
|
||||
## What is Compose Farm?
|
||||
|
||||
Compose Farm lets you manage Docker Compose stacks across multiple machines from a single command line. Think [Dockge](https://dockge.kuma.pet/) but with a CLI and web interface, designed for multi-host deployments.
|
||||
|
||||
Define which stacks run where in one YAML file, then use `cf apply` to make reality match your configuration.
|
||||
It also works great on a single host with one folder per stack; just map stacks to `localhost`.
|
||||
|
||||
## Quick Demo
|
||||
|
||||
**CLI:**
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/quickstart.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
**[Web UI](web-ui.md):**
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-workflow.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
## Why Compose Farm?
|
||||
|
||||
| Problem | Compose Farm Solution |
|
||||
|---------|----------------------|
|
||||
| 100+ containers on one machine | Distribute across multiple hosts |
|
||||
| Kubernetes too complex | Just SSH + docker compose |
|
||||
| Swarm in maintenance mode | Zero infrastructure changes |
|
||||
| Manual SSH for each host | Single command for all |
|
||||
|
||||
**It's a convenience wrapper, not a new paradigm.** Your existing `docker-compose.yml` files work unchanged.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Single host
|
||||
|
||||
No SSH, shared storage, or Traefik file-provider required.
|
||||
|
||||
```yaml
|
||||
# compose-farm.yaml
|
||||
compose_dir: /opt/stacks
|
||||
|
||||
hosts:
|
||||
local: localhost
|
||||
|
||||
stacks:
|
||||
plex: local
|
||||
jellyfin: local
|
||||
traefik: local
|
||||
```
|
||||
|
||||
```bash
|
||||
cf apply # Start/stop stacks to match config
|
||||
```
|
||||
|
||||
### Multi-host
|
||||
|
||||
Requires SSH plus a shared `compose_dir` path on all hosts (NFS or sync).
|
||||
|
||||
```yaml
|
||||
# compose-farm.yaml
|
||||
compose_dir: /opt/compose
|
||||
|
||||
hosts:
|
||||
server-1:
|
||||
address: 192.168.1.10
|
||||
server-2:
|
||||
address: 192.168.1.11
|
||||
|
||||
stacks:
|
||||
plex: server-1
|
||||
jellyfin: server-2
|
||||
grafana: server-1
|
||||
```
|
||||
|
||||
```bash
|
||||
cf apply # Stacks start, migrate, or stop as needed
|
||||
```
|
||||
|
||||
Each entry in `stacks:` maps to a folder under `compose_dir` that contains a compose file.
|
||||
|
||||
For cross-host HTTP routing, add Traefik labels and configure `traefik_file` to generate file-provider config.
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
uv tool install compose-farm
|
||||
# or
|
||||
pip install compose-farm
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create `compose-farm.yaml` in the directory where you'll run commands (e.g., `/opt/stacks`), or in `~/.config/compose-farm/`:
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
|
||||
hosts:
|
||||
nuc:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
hp:
|
||||
address: 192.168.1.11
|
||||
|
||||
stacks:
|
||||
plex: nuc
|
||||
grafana: nuc
|
||||
nextcloud: hp
|
||||
```
|
||||
|
||||
See [Configuration](configuration.md) for all options and the full search order.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Make reality match config
|
||||
cf apply
|
||||
|
||||
# Start specific stacks
|
||||
cf up plex grafana
|
||||
|
||||
# Check status
|
||||
cf ps
|
||||
|
||||
# View logs
|
||||
cf logs -f plex
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Declarative configuration**: One YAML defines where everything runs
|
||||
- **Auto-migration**: Change a host assignment, run `cf up`, stack moves automatically
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/migration.webm" type="video/webm">
|
||||
</video>
|
||||
- **Parallel execution**: Multiple stacks start/stop concurrently
|
||||
- **State tracking**: Knows which stacks are running where
|
||||
- **Traefik integration**: Generate file-provider config for cross-host routing
|
||||
- **Zero changes**: Your compose files work as-is
|
||||
|
||||
## Requirements
|
||||
|
||||
- [uv](https://docs.astral.sh/uv/) (recommended) or Python 3.11+
|
||||
- SSH key-based authentication to your Docker hosts
|
||||
- Docker and Docker Compose on all target hosts
|
||||
- Shared storage (compose files at same path on all hosts)
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Getting Started](getting-started.md) - Installation and first steps
|
||||
- [Configuration](configuration.md) - All configuration options
|
||||
- [Commands](commands.md) - CLI reference
|
||||
- [Web UI](web-ui.md) - Browser-based management interface
|
||||
- [Architecture](architecture.md) - How it works under the hood
|
||||
- [Traefik Integration](traefik.md) - Multi-host routing setup
|
||||
- [Best Practices](best-practices.md) - Tips and limitations
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
29
docs/install
Normal file
29
docs/install
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# Compose Farm bootstrap script
|
||||
# Usage: curl -fsSL https://compose-farm.nijho.lt/install | sh
|
||||
#
|
||||
# This script installs uv (if needed) and then installs compose-farm as a uv tool.
|
||||
|
||||
set -e
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "uv is not installed. Installing..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "uv installation complete!"
|
||||
echo ""
|
||||
|
||||
if [ -x ~/.local/bin/uv ]; then
|
||||
~/.local/bin/uv tool install compose-farm
|
||||
else
|
||||
echo "Please restart your shell and run this script again"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
uv tool install compose-farm
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "compose-farm is installed!"
|
||||
echo "Run 'cf --help' to get started."
|
||||
echo "If 'cf' is not found, restart your shell or run: source ~/.bashrc"
|
||||
21
docs/javascripts/video-fix.js
Normal file
21
docs/javascripts/video-fix.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Fix Safari video autoplay issues
|
||||
(function() {
|
||||
function initVideos() {
|
||||
document.querySelectorAll('video[autoplay]').forEach(function(video) {
|
||||
video.load();
|
||||
video.play().catch(function() {});
|
||||
});
|
||||
}
|
||||
|
||||
// For initial page load (needed for Chrome)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initVideos);
|
||||
} else {
|
||||
initVideos();
|
||||
}
|
||||
|
||||
// For MkDocs instant navigation (needed for Safari)
|
||||
if (typeof document$ !== 'undefined') {
|
||||
document$.subscribe(initVideos);
|
||||
}
|
||||
})();
|
||||
@@ -5,7 +5,7 @@
|
||||
- I made a CLI to run Docker Compose across multiple hosts without Kubernetes or Swarm
|
||||
---
|
||||
|
||||
I've been running 100+ Docker Compose stacks on a single machine, and it kept running out of memory. I needed to spread services across multiple hosts, but:
|
||||
I've been running 100+ Docker Compose stacks on a single machine, and it kept running out of memory. I needed to spread stacks across multiple hosts, but:
|
||||
|
||||
- **Kubernetes** felt like overkill. I don't need pods, ingress controllers, or 10x more YAML.
|
||||
- **Docker Swarm** is basically in maintenance mode.
|
||||
@@ -15,7 +15,7 @@ So I built **Compose Farm**, a simple CLI that runs `docker compose` commands ov
|
||||
|
||||
## How it works
|
||||
|
||||
One YAML file maps services to hosts:
|
||||
One YAML file maps stacks to hosts:
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/stacks
|
||||
@@ -24,11 +24,11 @@ hosts:
|
||||
nuc: 192.168.1.10
|
||||
hp: 192.168.1.11
|
||||
|
||||
services:
|
||||
stacks:
|
||||
plex: nuc
|
||||
jellyfin: hp
|
||||
sonarr: nuc
|
||||
radarr: nuc
|
||||
grafana: nuc
|
||||
nextcloud: nuc
|
||||
```
|
||||
|
||||
Then just:
|
||||
@@ -43,7 +43,7 @@ cf ps # shows status across all hosts
|
||||
|
||||
## Auto-migration
|
||||
|
||||
Change a service's host in the config and run `cf up`. It stops the service on the old host and starts it on the new one. No manual SSH needed.
|
||||
Change a stack's host in the config and run `cf up`. It stops the stack on the old host and starts it on the new one. No manual SSH needed.
|
||||
|
||||
```yaml
|
||||
# Before
|
||||
@@ -65,7 +65,7 @@ cf up plex # migrates automatically
|
||||
|
||||
## What it doesn't do
|
||||
|
||||
- No high availability (if a host goes down, services don't auto-migrate)
|
||||
- No high availability (if a host goes down, stacks don't auto-migrate)
|
||||
- No overlay networking (containers on different hosts can't talk via Docker DNS)
|
||||
- No health checks or automatic restarts
|
||||
|
||||
|
||||
385
docs/traefik.md
Normal file
385
docs/traefik.md
Normal file
@@ -0,0 +1,385 @@
|
||||
---
|
||||
icon: lucide/globe
|
||||
---
|
||||
|
||||
# Traefik Integration
|
||||
|
||||
Compose Farm can generate Traefik file-provider configuration for routing traffic across multiple hosts.
|
||||
|
||||
## The Problem
|
||||
|
||||
When you run Traefik on one host but stacks on others, Traefik's docker provider can't see remote containers. The file provider bridges this gap.
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Host: nuc │
|
||||
│ │
|
||||
│ ┌─────────┐ │
|
||||
│ │ Traefik │◄─── Docker provider sees local containers │
|
||||
│ │ │ │
|
||||
│ │ │◄─── File provider sees remote stacks │
|
||||
│ └────┬────┘ (from compose-farm.yml) │
|
||||
│ │ │
|
||||
└───────┼─────────────────────────────────────────────────────┘
|
||||
│
|
||||
├────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐
|
||||
│ Host: hp │ │ Host: nas │
|
||||
│ │ │ │
|
||||
│ plex:32400 │ │ jellyfin:8096 │
|
||||
└───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Your compose files have standard Traefik labels
|
||||
2. Compose Farm reads labels and generates file-provider config
|
||||
3. Traefik watches the generated file
|
||||
4. Traffic routes to remote stacks via host IP + published port
|
||||
|
||||
## Setup
|
||||
|
||||
### Step 1: Configure Traefik File Provider
|
||||
|
||||
Add directory watching to your Traefik config:
|
||||
|
||||
```yaml
|
||||
# traefik.yml or docker-compose.yml command
|
||||
providers:
|
||||
file:
|
||||
directory: /opt/traefik/dynamic.d
|
||||
watch: true
|
||||
```
|
||||
|
||||
Or via command line:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
traefik:
|
||||
command:
|
||||
- --providers.file.directory=/dynamic.d
|
||||
- --providers.file.watch=true
|
||||
volumes:
|
||||
- /opt/traefik/dynamic.d:/dynamic.d:ro
|
||||
```
|
||||
|
||||
### Step 2: Add Traefik Labels to Services
|
||||
|
||||
Your compose files use standard Traefik labels:
|
||||
|
||||
```yaml
|
||||
# /opt/compose/plex/docker-compose.yml
|
||||
services:
|
||||
plex:
|
||||
image: lscr.io/linuxserver/plex
|
||||
ports:
|
||||
- "32400:32400" # IMPORTANT: Must publish port!
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.plex.rule=Host(`plex.example.com`)
|
||||
- traefik.http.routers.plex.entrypoints=websecure
|
||||
- traefik.http.routers.plex.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.plex.loadbalancer.server.port=32400
|
||||
```
|
||||
|
||||
**Important:** Services must publish ports for cross-host routing. Traefik connects via `host_ip:published_port`.
|
||||
|
||||
### Step 3: Generate File Provider Config
|
||||
|
||||
```bash
|
||||
cf traefik-file --all -o /opt/traefik/dynamic.d/compose-farm.yml
|
||||
```
|
||||
|
||||
This generates:
|
||||
|
||||
```yaml
|
||||
# /opt/traefik/dynamic.d/compose-farm.yml
|
||||
http:
|
||||
routers:
|
||||
plex:
|
||||
rule: Host(`plex.example.com`)
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
service: plex
|
||||
services:
|
||||
plex:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://192.168.1.11:32400
|
||||
```
|
||||
|
||||
## Auto-Regeneration
|
||||
|
||||
Configure automatic regeneration in `compose-farm.yaml`:
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_stack: traefik
|
||||
|
||||
hosts:
|
||||
nuc:
|
||||
address: 192.168.1.10
|
||||
hp:
|
||||
address: 192.168.1.11
|
||||
|
||||
stacks:
|
||||
traefik: nuc # Traefik runs here
|
||||
plex: hp # Routed via file-provider
|
||||
grafana: hp
|
||||
```
|
||||
|
||||
With `traefik_file` set, these commands auto-regenerate the config:
|
||||
- `cf up`
|
||||
- `cf down`
|
||||
- `cf restart`
|
||||
- `cf update`
|
||||
- `cf apply`
|
||||
|
||||
### traefik_stack Option
|
||||
|
||||
When set, stacks on the **same host as Traefik** are skipped in file-provider output. Traefik's docker provider handles them directly.
|
||||
|
||||
```yaml
|
||||
traefik_stack: traefik # traefik runs on nuc
|
||||
stacks:
|
||||
traefik: nuc # NOT in file-provider (docker provider)
|
||||
portainer: nuc # NOT in file-provider (docker provider)
|
||||
plex: hp # IN file-provider (cross-host)
|
||||
```
|
||||
|
||||
## Label Syntax
|
||||
|
||||
### Routers
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
# Basic router
|
||||
- traefik.http.routers.myapp.rule=Host(`app.example.com`)
|
||||
- traefik.http.routers.myapp.entrypoints=websecure
|
||||
|
||||
# With TLS
|
||||
- traefik.http.routers.myapp.tls=true
|
||||
- traefik.http.routers.myapp.tls.certresolver=letsencrypt
|
||||
|
||||
# With middleware
|
||||
- traefik.http.routers.myapp.middlewares=auth@file
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
# Load balancer port
|
||||
- traefik.http.services.myapp.loadbalancer.server.port=8080
|
||||
|
||||
# Health check
|
||||
- traefik.http.services.myapp.loadbalancer.healthcheck.path=/health
|
||||
```
|
||||
|
||||
### Middlewares
|
||||
|
||||
Middlewares should be defined in a separate file (not generated by Compose Farm):
|
||||
|
||||
```yaml
|
||||
# /opt/traefik/dynamic.d/middlewares.yml
|
||||
http:
|
||||
middlewares:
|
||||
auth:
|
||||
basicAuth:
|
||||
users:
|
||||
- "user:$apr1$..."
|
||||
```
|
||||
|
||||
Reference in labels:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- traefik.http.routers.myapp.middlewares=auth@file
|
||||
```
|
||||
|
||||
## Variable Substitution
|
||||
|
||||
Labels can use environment variables:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- traefik.http.routers.myapp.rule=Host(`${DOMAIN}`)
|
||||
```
|
||||
|
||||
Compose Farm resolves variables from:
|
||||
1. Stack's `.env` file
|
||||
2. Current environment
|
||||
|
||||
```bash
|
||||
# /opt/compose/myapp/.env
|
||||
DOMAIN=app.example.com
|
||||
```
|
||||
|
||||
## Port Resolution
|
||||
|
||||
Compose Farm determines the target URL from published ports:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:80" # Uses 8080
|
||||
- "192.168.1.11:8080:80" # Uses 8080 on specific IP
|
||||
```
|
||||
|
||||
If no suitable port is found, a warning is shown.
|
||||
|
||||
## Complete Example
|
||||
|
||||
### compose-farm.yaml
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/compose
|
||||
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_stack: traefik
|
||||
|
||||
hosts:
|
||||
nuc:
|
||||
address: 192.168.1.10
|
||||
hp:
|
||||
address: 192.168.1.11
|
||||
nas:
|
||||
address: 192.168.1.100
|
||||
|
||||
stacks:
|
||||
traefik: nuc
|
||||
plex: hp
|
||||
jellyfin: nas
|
||||
grafana: nuc
|
||||
nextcloud: nuc
|
||||
```
|
||||
|
||||
### /opt/compose/plex/docker-compose.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
plex:
|
||||
image: lscr.io/linuxserver/plex
|
||||
container_name: plex
|
||||
ports:
|
||||
- "32400:32400"
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.plex.rule=Host(`plex.example.com`)
|
||||
- traefik.http.routers.plex.entrypoints=websecure
|
||||
- traefik.http.routers.plex.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.plex.loadbalancer.server.port=32400
|
||||
# ... other config
|
||||
```
|
||||
|
||||
### Generated compose-farm.yml
|
||||
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
plex:
|
||||
rule: Host(`plex.example.com`)
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
service: plex
|
||||
jellyfin:
|
||||
rule: Host(`jellyfin.example.com`)
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
service: jellyfin
|
||||
|
||||
services:
|
||||
plex:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://192.168.1.11:32400
|
||||
jellyfin:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: http://192.168.1.100:8096
|
||||
```
|
||||
|
||||
Note: `grafana` and `nextcloud` are NOT in the file because they're on the same host as Traefik (`nuc`).
|
||||
|
||||
## Combining with Existing Config
|
||||
|
||||
If you have existing Traefik dynamic config:
|
||||
|
||||
```bash
|
||||
# Move existing config to directory
|
||||
mkdir -p /opt/traefik/dynamic.d
|
||||
mv /opt/traefik/dynamic.yml /opt/traefik/dynamic.d/manual.yml
|
||||
|
||||
# Generate Compose Farm config
|
||||
cf traefik-file --all -o /opt/traefik/dynamic.d/compose-farm.yml
|
||||
|
||||
# Update Traefik to watch directory
|
||||
# --providers.file.directory=/dynamic.d
|
||||
```
|
||||
|
||||
Traefik merges all YAML files in the directory.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Stack Not Accessible
|
||||
|
||||
1. **Check port is published:**
|
||||
```yaml
|
||||
ports:
|
||||
- "8080:80" # Must be published, not just exposed
|
||||
```
|
||||
|
||||
2. **Check label syntax:**
|
||||
```bash
|
||||
cf check mystack
|
||||
```
|
||||
|
||||
3. **Verify generated config:**
|
||||
```bash
|
||||
cf traefik-file mystack
|
||||
```
|
||||
|
||||
4. **Check Traefik logs:**
|
||||
```bash
|
||||
docker logs traefik
|
||||
```
|
||||
|
||||
### Config Not Regenerating
|
||||
|
||||
1. **Verify traefik_file is set:**
|
||||
```bash
|
||||
cf config show | grep traefik
|
||||
```
|
||||
|
||||
2. **Check file permissions:**
|
||||
```bash
|
||||
ls -la /opt/traefik/dynamic.d/
|
||||
```
|
||||
|
||||
3. **Manually regenerate:**
|
||||
```bash
|
||||
cf traefik-file --all -o /opt/traefik/dynamic.d/compose-farm.yml
|
||||
```
|
||||
|
||||
### Variable Not Resolved
|
||||
|
||||
1. **Check .env file exists:**
|
||||
```bash
|
||||
cat /opt/compose/myservice/.env
|
||||
```
|
||||
|
||||
2. **Test variable resolution:**
|
||||
```bash
|
||||
cd /opt/compose/myservice
|
||||
docker compose config
|
||||
```
|
||||
132
docs/web-ui.md
Normal file
132
docs/web-ui.md
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
icon: lucide/layout-dashboard
|
||||
---
|
||||
|
||||
# Web UI
|
||||
|
||||
Compose Farm includes a web interface for managing stacks from your browser. Start it with:
|
||||
|
||||
```bash
|
||||
cf web
|
||||
```
|
||||
|
||||
Then open [http://localhost:8000](http://localhost:8000).
|
||||
|
||||
## Features
|
||||
|
||||
### Full Workflow
|
||||
|
||||
Console terminal, config editor, stack navigation, actions (up, logs, update), dashboard overview, and theme switching - all in one flow.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-workflow.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
### Stack Actions
|
||||
|
||||
Navigate to any stack and use the command palette to trigger actions like restart, pull, update, or view logs. Output streams in real-time via WebSocket.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-stack.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
### Theme Switching
|
||||
|
||||
35 themes available via the command palette. Type `theme:` to filter, then use arrow keys to preview themes live before selecting.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-themes.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
### Command Palette
|
||||
|
||||
Press `Ctrl+K` (or `Cmd+K` on macOS) to open the command palette. Use fuzzy search to quickly navigate, trigger actions, or change themes.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-navigation.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
## Pages
|
||||
|
||||
### Dashboard (`/`)
|
||||
|
||||
- Stack overview with status indicators
|
||||
- Host statistics
|
||||
- Pending operations (migrations, orphaned stacks)
|
||||
- Quick actions via command palette
|
||||
|
||||
### Stack Detail (`/stack/{name}`)
|
||||
|
||||
- Compose file editor (Monaco)
|
||||
- Environment file editor
|
||||
- Action buttons: Up, Down, Restart, Update, Pull, Logs
|
||||
- Container shell access (exec into running containers)
|
||||
- Terminal output for running commands
|
||||
|
||||
Files are automatically backed up before saving to `~/.config/compose-farm/backups/`.
|
||||
|
||||
### Console (`/console`)
|
||||
|
||||
- Full shell access to any host
|
||||
- File editor for remote files
|
||||
- Monaco editor with syntax highlighting
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-console.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
### Container Shell
|
||||
|
||||
Click the Shell button on any running container to exec into it directly from the browser.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-shell.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+K` / `Cmd+K` | Open command palette |
|
||||
| `Ctrl+S` / `Cmd+S` | Save editors |
|
||||
| `Escape` | Close command palette |
|
||||
| `Arrow keys` | Navigate command list |
|
||||
| `Enter` | Execute selected command |
|
||||
|
||||
## Starting the Server
|
||||
|
||||
```bash
|
||||
# Default: http://0.0.0.0:8000
|
||||
cf web
|
||||
|
||||
# Custom port
|
||||
cf web --port 3000
|
||||
|
||||
# Development mode with auto-reload
|
||||
cf web --reload
|
||||
|
||||
# Bind to specific interface
|
||||
cf web --host 127.0.0.1
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
The web UI requires additional dependencies:
|
||||
|
||||
```bash
|
||||
# If installed via pip
|
||||
pip install compose-farm[web]
|
||||
|
||||
# If installed via uv
|
||||
uv tool install 'compose-farm[web]'
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The web UI uses:
|
||||
|
||||
- **FastAPI** - Backend API and WebSocket handling
|
||||
- **HTMX** - Dynamic page updates without full reloads
|
||||
- **DaisyUI + Tailwind** - Theming and styling
|
||||
- **Monaco Editor** - Code editing for compose/env files
|
||||
- **xterm.js** - Terminal emulation for logs and shell access
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Real-world examples demonstrating compose-farm patterns for multi-host Docker deployments.
|
||||
|
||||
## Services
|
||||
## Stacks
|
||||
|
||||
| Service | Type | Demonstrates |
|
||||
| Stack | Type | Demonstrates |
|
||||
|---------|------|--------------|
|
||||
| [traefik](traefik/) | Infrastructure | Reverse proxy, Let's Encrypt, file-provider |
|
||||
| [mealie](mealie/) | Single container | Traefik labels, resource limits, environment vars |
|
||||
@@ -16,7 +16,7 @@ Real-world examples demonstrating compose-farm patterns for multi-host Docker de
|
||||
|
||||
### External Network
|
||||
|
||||
All services connect to a shared external network for inter-service communication:
|
||||
All stacks connect to a shared external network for inter-service communication:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
@@ -32,12 +32,12 @@ compose-farm init-network --network mynetwork --subnet 172.20.0.0/16
|
||||
|
||||
### Traefik Labels (Dual Routes)
|
||||
|
||||
Services expose two routes for different access patterns:
|
||||
Stacks expose two routes for different access patterns:
|
||||
|
||||
1. **HTTPS route** (`websecure` entrypoint): For your custom domain with Let's Encrypt TLS
|
||||
2. **HTTP route** (`web` entrypoint): For `.local` domains on your LAN (no TLS needed)
|
||||
|
||||
This pattern allows accessing services via:
|
||||
This pattern allows accessing stacks via:
|
||||
- `https://mealie.example.com` - from anywhere, with TLS
|
||||
- `http://mealie.local` - from your local network, no TLS overhead
|
||||
|
||||
@@ -57,7 +57,7 @@ labels:
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Each service has a `.env` file for secrets and domain configuration.
|
||||
Each stack has a `.env` file for secrets and domain configuration.
|
||||
Edit these files to set your domain and credentials:
|
||||
|
||||
```bash
|
||||
@@ -76,15 +76,15 @@ volumes:
|
||||
- /mnt/data/myapp:/app/data
|
||||
```
|
||||
|
||||
This allows services to migrate between hosts without data loss.
|
||||
This allows stacks to migrate between hosts without data loss.
|
||||
|
||||
### Multi-Host Services
|
||||
### Multi-Host Stacks
|
||||
|
||||
Services that need to run on every host (e.g., monitoring agents):
|
||||
Stacks that need to run on every host (e.g., monitoring agents):
|
||||
|
||||
```yaml
|
||||
# In compose-farm.yaml
|
||||
services:
|
||||
stacks:
|
||||
autokuma: all # Runs on every configured host
|
||||
```
|
||||
|
||||
@@ -107,7 +107,7 @@ services:
|
||||
|
||||
### AutoKuma Labels (Optional)
|
||||
|
||||
The autokuma example demonstrates compose-farm's **multi-host feature** - running the same service on all hosts using the `all` keyword. AutoKuma itself is not part of compose-farm; it's just a good example because it needs to run on every host to monitor local Docker containers.
|
||||
The autokuma example demonstrates compose-farm's **multi-host feature** - running the same stack on all hosts using the `all` keyword. AutoKuma itself is not part of compose-farm; it's just a good example because it needs to run on every host to monitor local Docker containers.
|
||||
|
||||
[AutoKuma](https://github.com/BigBoot/AutoKuma) automatically creates Uptime Kuma monitors from Docker labels:
|
||||
|
||||
@@ -128,7 +128,7 @@ compose-farm init-network
|
||||
# 2. Start Traefik first (the reverse proxy)
|
||||
compose-farm up traefik
|
||||
|
||||
# 3. Start other services
|
||||
# 3. Start other stacks
|
||||
compose-farm up mealie uptime-kuma
|
||||
|
||||
# 4. Check status
|
||||
@@ -148,24 +148,24 @@ compose-farm down --all
|
||||
|
||||
The `compose-farm.yaml` shows a multi-host setup:
|
||||
|
||||
- **primary** (192.168.1.10): Runs Traefik and heavy services
|
||||
- **secondary** (192.168.1.11): Runs lighter services
|
||||
- **primary** (192.168.1.10): Runs Traefik and heavy stacks
|
||||
- **secondary** (192.168.1.11): Runs lighter stacks
|
||||
- **autokuma**: Runs on ALL hosts to monitor local containers
|
||||
|
||||
When Traefik runs on `primary` and a service runs on `secondary`, compose-farm
|
||||
When Traefik runs on `primary` and a stack runs on `secondary`, compose-farm
|
||||
automatically generates file-provider config so Traefik can route to it.
|
||||
|
||||
## Traefik File-Provider
|
||||
|
||||
When services run on different hosts than Traefik, use `traefik-file` to generate routing config:
|
||||
When stacks run on different hosts than Traefik, use `traefik-file` to generate routing config:
|
||||
|
||||
```bash
|
||||
# Generate config for all services
|
||||
# Generate config for all stacks
|
||||
compose-farm traefik-file --all -o traefik/dynamic.d/compose-farm.yml
|
||||
|
||||
# Or configure auto-generation in compose-farm.yaml:
|
||||
traefik_file: /opt/stacks/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_service: traefik
|
||||
traefik_stack: traefik
|
||||
```
|
||||
|
||||
With `traefik_file` configured, compose-farm automatically regenerates the config after `up`, `down`, `restart`, and `update` commands.
|
||||
|
||||
@@ -7,34 +7,34 @@ compose_dir: /opt/stacks/compose-farm/examples
|
||||
|
||||
# Auto-regenerate Traefik file-provider config after up/down/restart/update
|
||||
traefik_file: /opt/stacks/compose-farm/examples/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_service: traefik # Skip Traefik's host in file-provider (docker provider handles it)
|
||||
traefik_stack: traefik # Skip Traefik's host in file-provider (docker provider handles it)
|
||||
|
||||
hosts:
|
||||
# Primary server - runs Traefik and most services
|
||||
# Primary server - runs Traefik and most stacks
|
||||
# Full form with all options
|
||||
primary:
|
||||
address: 192.168.1.10
|
||||
user: deploy
|
||||
port: 22
|
||||
|
||||
# Secondary server - runs some services for load distribution
|
||||
# Secondary server - runs some stacks for load distribution
|
||||
# Short form (user defaults to current user, port defaults to 22)
|
||||
secondary: 192.168.1.11
|
||||
|
||||
# Local execution (no SSH) - for testing or when running on the host itself
|
||||
local: localhost
|
||||
|
||||
services:
|
||||
stacks:
|
||||
# Infrastructure (runs on primary where Traefik is)
|
||||
traefik: primary
|
||||
|
||||
# Multi-host services (runs on ALL hosts)
|
||||
# Multi-host stacks (runs on ALL hosts)
|
||||
# AutoKuma monitors Docker containers on each host
|
||||
autokuma: all
|
||||
|
||||
# Primary server services
|
||||
# Primary server stacks
|
||||
paperless-ngx: primary
|
||||
|
||||
# Secondary server services (distributed for performance)
|
||||
# Secondary server stacks (distributed for performance)
|
||||
mealie: secondary
|
||||
uptime-kuma: secondary
|
||||
|
||||
60
justfile
Normal file
60
justfile
Normal file
@@ -0,0 +1,60 @@
|
||||
# Compose Farm Development Commands
|
||||
# Run `just` to see available commands
|
||||
|
||||
# Default: list available commands
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Install development dependencies
|
||||
install:
|
||||
uv sync --all-extras --dev
|
||||
|
||||
# Run all tests (parallel)
|
||||
test:
|
||||
uv run pytest -n auto
|
||||
|
||||
# Run CLI tests only (parallel, with coverage)
|
||||
test-cli:
|
||||
uv run pytest -m "not browser" -n auto
|
||||
|
||||
# Run web UI tests only (parallel)
|
||||
test-web:
|
||||
uv run pytest -m browser -n auto
|
||||
|
||||
# Lint, format, and type check
|
||||
lint:
|
||||
uv run ruff check --fix .
|
||||
uv run ruff format .
|
||||
uv run mypy src
|
||||
uv run ty check src
|
||||
|
||||
# Start web UI in development mode with auto-reload
|
||||
web:
|
||||
uv run cf web --reload --port 9001
|
||||
|
||||
# Kill the web server
|
||||
kill-web:
|
||||
lsof -ti :9001 | xargs kill -9 2>/dev/null || true
|
||||
|
||||
# Build docs and serve locally
|
||||
doc:
|
||||
uvx zensical build
|
||||
python -m http.server -d site 9002
|
||||
|
||||
# Kill the docs server
|
||||
kill-doc:
|
||||
lsof -ti :9002 | xargs kill -9 2>/dev/null || true
|
||||
|
||||
# Record CLI demos (all or specific: just record-cli quickstart)
|
||||
record-cli *demos:
|
||||
python docs/demos/cli/record.py {{demos}}
|
||||
|
||||
# Record web UI demos (all or specific: just record-web navigation)
|
||||
record-web *demos:
|
||||
python docs/demos/web/record.py {{demos}}
|
||||
|
||||
# Clean up build artifacts and caches
|
||||
clean:
|
||||
rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
|
||||
@@ -133,6 +133,10 @@ disallow_untyped_decorators = false
|
||||
module = "compose_farm.web.*"
|
||||
disallow_untyped_decorators = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = "docs.demos.web.*"
|
||||
disallow_untyped_decorators = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
@@ -145,6 +149,9 @@ addopts = [
|
||||
"--no-cov-on-fail",
|
||||
"-v",
|
||||
]
|
||||
markers = [
|
||||
"browser: marks tests as browser tests (deselect with '-m \"not browser\"')",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = []
|
||||
@@ -157,9 +164,19 @@ exclude_lines = [
|
||||
'if __name__ == "__main__":',
|
||||
]
|
||||
|
||||
[tool.ty.environment]
|
||||
python-version = "3.11"
|
||||
|
||||
[tool.ty.src]
|
||||
exclude = [
|
||||
"hatch_build.py", # Build-time only, hatchling not in dev deps
|
||||
"docs/demos/**", # Demo scripts with local conftest imports
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy>=1.19.0",
|
||||
"ty>=0.0.1a13",
|
||||
"pre-commit>=4.5.0",
|
||||
"pytest>=9.0.2",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
@@ -174,4 +191,8 @@ dev = [
|
||||
"websockets>=12.0",
|
||||
# For FastAPI TestClient
|
||||
"httpx>=0.28.0",
|
||||
# For browser tests (use system chromium via nix-shell -p chromium)
|
||||
"pytest-playwright>=0.7.0",
|
||||
# For parallel test execution
|
||||
"pytest-xdist>=3.0.0",
|
||||
]
|
||||
|
||||
@@ -23,6 +23,7 @@ app = typer.Typer(
|
||||
help="Compose Farm - run docker compose commands across multiple hosts",
|
||||
no_args_is_help=True,
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
rich_markup_mode="rich",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from rich.progress import (
|
||||
|
||||
from compose_farm.console import (
|
||||
MSG_HOST_NOT_FOUND,
|
||||
MSG_SERVICE_NOT_FOUND,
|
||||
MSG_STACK_NOT_FOUND,
|
||||
console,
|
||||
print_error,
|
||||
print_hint,
|
||||
@@ -39,13 +39,13 @@ _R = TypeVar("_R")
|
||||
|
||||
|
||||
# --- Shared CLI Options ---
|
||||
ServicesArg = Annotated[
|
||||
StacksArg = Annotated[
|
||||
list[str] | None,
|
||||
typer.Argument(help="Services to operate on"),
|
||||
typer.Argument(help="Stacks to operate on"),
|
||||
]
|
||||
AllOption = Annotated[
|
||||
bool,
|
||||
typer.Option("--all", "-a", help="Run on all services"),
|
||||
typer.Option("--all", "-a", help="Run on all stacks"),
|
||||
]
|
||||
ConfigOption = Annotated[
|
||||
Path | None,
|
||||
@@ -57,7 +57,11 @@ LogPathOption = Annotated[
|
||||
]
|
||||
HostOption = Annotated[
|
||||
str | None,
|
||||
typer.Option("--host", "-H", help="Filter to services on this host"),
|
||||
typer.Option("--host", "-H", help="Filter to stacks on this host"),
|
||||
]
|
||||
ServiceOption = Annotated[
|
||||
str | None,
|
||||
typer.Option("--service", "-s", help="Target a specific service within the stack"),
|
||||
]
|
||||
|
||||
# --- Constants (internal) ---
|
||||
@@ -140,29 +144,57 @@ def load_config_or_exit(config_path: Path | None) -> Config:
|
||||
raise typer.Exit(1) from e
|
||||
|
||||
|
||||
def get_services(
|
||||
services: list[str],
|
||||
all_services: bool,
|
||||
def get_stacks(
|
||||
stacks: list[str],
|
||||
all_stacks: bool,
|
||||
config_path: Path | None,
|
||||
*,
|
||||
host: str | None = None,
|
||||
default_all: bool = False,
|
||||
) -> tuple[list[str], Config]:
|
||||
"""Resolve service list and load config.
|
||||
"""Resolve stack list and load config.
|
||||
|
||||
Handles three mutually exclusive selection methods:
|
||||
- Explicit stack names
|
||||
- --all flag
|
||||
- --host filter
|
||||
|
||||
Args:
|
||||
stacks: Explicit stack names
|
||||
all_stacks: Whether --all was specified
|
||||
config_path: Path to config file
|
||||
host: Filter to stacks on this host
|
||||
default_all: If True, default to all stacks when nothing specified (for ps)
|
||||
|
||||
Supports "." as shorthand for the current directory name.
|
||||
|
||||
"""
|
||||
validate_stack_selection(stacks, all_stacks, host)
|
||||
config = load_config_or_exit(config_path)
|
||||
|
||||
if all_services:
|
||||
return list(config.services.keys()), config
|
||||
if not services:
|
||||
print_error("Specify services or use [bold]--all[/]")
|
||||
if host is not None:
|
||||
validate_hosts(config, host)
|
||||
stack_list = [s for s in config.stacks if host in config.get_hosts(s)]
|
||||
if not stack_list:
|
||||
print_warning(f"No stacks configured for host [magenta]{host}[/]")
|
||||
raise typer.Exit(0)
|
||||
return stack_list, config
|
||||
|
||||
if all_stacks:
|
||||
return list(config.stacks.keys()), config
|
||||
|
||||
if not stacks:
|
||||
if default_all:
|
||||
return list(config.stacks.keys()), config
|
||||
print_error("Specify stacks or use [bold]--all[/] / [bold]--host[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Resolve "." to current directory name
|
||||
resolved = [Path.cwd().name if svc == "." else svc for svc in services]
|
||||
resolved = [Path.cwd().name if stack == "." else stack for stack in stacks]
|
||||
|
||||
# Validate all services exist in config
|
||||
validate_services(
|
||||
config, resolved, hint="Add the service to compose-farm.yaml or use [bold]--all[/]"
|
||||
# Validate all stacks exist in config
|
||||
validate_stacks(
|
||||
config, resolved, hint="Add the stack to compose-farm.yaml or use [bold]--all[/]"
|
||||
)
|
||||
|
||||
return resolved, config
|
||||
@@ -187,19 +219,19 @@ def report_results(results: list[CommandResult]) -> None:
|
||||
console.print() # Blank line before summary
|
||||
if failed:
|
||||
for r in failed:
|
||||
print_error(f"[cyan]{r.service}[/] failed with exit code {r.exit_code}")
|
||||
print_error(f"[cyan]{r.stack}[/] failed with exit code {r.exit_code}")
|
||||
console.print()
|
||||
console.print(
|
||||
f"[green]✓[/] {len(succeeded)}/{len(results)} services succeeded, "
|
||||
f"[green]✓[/] {len(succeeded)}/{len(results)} stacks succeeded, "
|
||||
f"[red]✗[/] {len(failed)} failed"
|
||||
)
|
||||
else:
|
||||
print_success(f"All {len(results)} services succeeded")
|
||||
print_success(f"All {len(results)} stacks succeeded")
|
||||
|
||||
elif failed:
|
||||
# Single service failed
|
||||
# Single stack failed
|
||||
r = failed[0]
|
||||
print_error(f"[cyan]{r.service}[/] failed with exit code {r.exit_code}")
|
||||
print_error(f"[cyan]{r.stack}[/] failed with exit code {r.exit_code}")
|
||||
|
||||
if failed:
|
||||
raise typer.Exit(1)
|
||||
@@ -211,12 +243,12 @@ def maybe_regenerate_traefik(
|
||||
) -> None:
|
||||
"""Regenerate traefik config if traefik_file is configured.
|
||||
|
||||
If results are provided, skips regeneration if all services failed.
|
||||
If results are provided, skips regeneration if all stacks failed.
|
||||
"""
|
||||
if cfg.traefik_file is None:
|
||||
return
|
||||
|
||||
# Skip if all services failed
|
||||
# Skip if all stacks failed
|
||||
if results and not any(r.success for r in results):
|
||||
return
|
||||
|
||||
@@ -227,7 +259,7 @@ def maybe_regenerate_traefik(
|
||||
)
|
||||
|
||||
try:
|
||||
dynamic, warnings = generate_traefik_config(cfg, list(cfg.services.keys()))
|
||||
dynamic, warnings = generate_traefik_config(cfg, list(cfg.stacks.keys()))
|
||||
new_content = render_traefik_config(dynamic)
|
||||
|
||||
# Check if content changed
|
||||
@@ -247,64 +279,50 @@ def maybe_regenerate_traefik(
|
||||
print_warning(f"Failed to update traefik config: {exc}")
|
||||
|
||||
|
||||
def validate_services(cfg: Config, services: list[str], *, hint: str | None = None) -> None:
|
||||
"""Validate that all services exist in config. Exits with error if any not found."""
|
||||
invalid = [s for s in services if s not in cfg.services]
|
||||
def validate_stacks(cfg: Config, stacks: list[str], *, hint: str | None = None) -> None:
|
||||
"""Validate that all stacks exist in config. Exits with error if any not found."""
|
||||
invalid = [s for s in stacks if s not in cfg.stacks]
|
||||
if invalid:
|
||||
for svc in invalid:
|
||||
print_error(MSG_SERVICE_NOT_FOUND.format(name=svc))
|
||||
print_error(MSG_STACK_NOT_FOUND.format(name=svc))
|
||||
if hint:
|
||||
print_hint(hint)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def validate_host(cfg: Config, host: str) -> None:
|
||||
"""Validate that a host exists in config. Exits with error if not found."""
|
||||
if host not in cfg.hosts:
|
||||
print_error(MSG_HOST_NOT_FOUND.format(name=host))
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def validate_hosts(cfg: Config, hosts: list[str]) -> None:
|
||||
"""Validate that all hosts exist in config. Exits with error if any not found."""
|
||||
invalid = [h for h in hosts if h not in cfg.hosts]
|
||||
def validate_hosts(cfg: Config, hosts: str | list[str]) -> None:
|
||||
"""Validate that host(s) exist in config. Exits with error if any not found."""
|
||||
host_list = [hosts] if isinstance(hosts, str) else hosts
|
||||
invalid = [h for h in host_list if h not in cfg.hosts]
|
||||
if invalid:
|
||||
for h in invalid:
|
||||
print_error(MSG_HOST_NOT_FOUND.format(name=h))
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def validate_host_for_service(cfg: Config, service: str, host: str) -> None:
|
||||
"""Validate that a host is valid for a service."""
|
||||
validate_host(cfg, host)
|
||||
allowed_hosts = cfg.get_hosts(service)
|
||||
def validate_host_for_stack(cfg: Config, stack: str, host: str) -> None:
|
||||
"""Validate that a host is valid for a stack."""
|
||||
validate_hosts(cfg, host)
|
||||
allowed_hosts = cfg.get_hosts(stack)
|
||||
if host not in allowed_hosts:
|
||||
print_error(
|
||||
f"Service [cyan]{service}[/] is not configured for host [magenta]{host}[/] "
|
||||
f"Stack [cyan]{stack}[/] is not configured for host [magenta]{host}[/] "
|
||||
f"(configured: {', '.join(allowed_hosts)})"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def run_host_operation(
|
||||
cfg: Config,
|
||||
svc_list: list[str],
|
||||
host: str,
|
||||
command: str,
|
||||
action_verb: str,
|
||||
state_callback: Callable[[Config, str, str], None],
|
||||
def validate_stack_selection(
|
||||
stacks: list[str] | None,
|
||||
all_stacks: bool,
|
||||
host: str | None,
|
||||
) -> None:
|
||||
"""Run an operation on a specific host for multiple services."""
|
||||
from compose_farm.executor import run_compose_on_host # noqa: PLC0415
|
||||
"""Validate that only one stack selection method is used.
|
||||
|
||||
results: list[CommandResult] = []
|
||||
for service in svc_list:
|
||||
validate_host_for_service(cfg, service, host)
|
||||
console.print(f"[cyan]\\[{service}][/] {action_verb} on [magenta]{host}[/]...")
|
||||
result = run_async(run_compose_on_host(cfg, service, host, command, raw=True))
|
||||
print() # Newline after raw output
|
||||
results.append(result)
|
||||
if result.success:
|
||||
state_callback(cfg, service, host)
|
||||
maybe_regenerate_traefik(cfg, results)
|
||||
report_results(results)
|
||||
The three selection methods (explicit stacks, --all, --host) are mutually
|
||||
exclusive. This ensures consistent behavior across all commands.
|
||||
"""
|
||||
methods = sum([bool(stacks), all_stacks, host is not None])
|
||||
if methods > 1:
|
||||
print_error("Use only one of: stack names, [bold]--all[/], or [bold]--host[/]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -40,24 +40,12 @@ _RawOption = Annotated[
|
||||
|
||||
|
||||
def _get_editor() -> str:
|
||||
"""Get the user's preferred editor.
|
||||
|
||||
Checks $EDITOR, then $VISUAL, then falls back to platform defaults.
|
||||
"""
|
||||
for env_var in ("EDITOR", "VISUAL"):
|
||||
editor = os.environ.get(env_var)
|
||||
if editor:
|
||||
return editor
|
||||
|
||||
"""Get the user's preferred editor ($EDITOR > $VISUAL > platform default)."""
|
||||
if editor := os.environ.get("EDITOR") or os.environ.get("VISUAL"):
|
||||
return editor
|
||||
if platform.system() == "Windows":
|
||||
return "notepad"
|
||||
|
||||
# Try common editors on Unix-like systems
|
||||
for editor in ("nano", "vim", "vi"):
|
||||
if shutil.which(editor):
|
||||
return editor
|
||||
|
||||
return "vi"
|
||||
return next((e for e in ("nano", "vim", "vi") if shutil.which(e)), "vi")
|
||||
|
||||
|
||||
def _generate_template() -> str:
|
||||
@@ -80,20 +68,16 @@ def _get_config_file(path: Path | None) -> Path | None:
|
||||
return config_path.resolve() if config_path else None
|
||||
|
||||
|
||||
def _report_no_config_found() -> None:
|
||||
"""Report that no config file was found in search paths."""
|
||||
console.print("[yellow]No config file found.[/yellow]")
|
||||
console.print("\nSearched locations:")
|
||||
for p in config_search_paths():
|
||||
status = "[green]exists[/green]" if p.exists() else "[dim]not found[/dim]"
|
||||
console.print(f" - {p} ({status})")
|
||||
console.print("\nRun [bold cyan]cf config init[/bold cyan] to create one.")
|
||||
|
||||
|
||||
def _report_config_path_not_exists(config_file: Path) -> None:
|
||||
"""Report that an explicit config path doesn't exist."""
|
||||
def _report_missing_config(explicit_path: Path | None = None) -> None:
|
||||
"""Report that a config file was not found."""
|
||||
console.print("[yellow]Config file not found.[/yellow]")
|
||||
console.print(f"\nProvided path does not exist: [cyan]{config_file}[/cyan]")
|
||||
if explicit_path:
|
||||
console.print(f"\nProvided path does not exist: [cyan]{explicit_path}[/cyan]")
|
||||
else:
|
||||
console.print("\nSearched locations:")
|
||||
for p in config_search_paths():
|
||||
status = "[green]exists[/green]" if p.exists() else "[dim]not found[/dim]"
|
||||
console.print(f" - {p} ({status})")
|
||||
console.print("\nRun [bold cyan]cf config init[/bold cyan] to create one.")
|
||||
|
||||
|
||||
@@ -140,11 +124,11 @@ def config_edit(
|
||||
config_file = _get_config_file(path)
|
||||
|
||||
if config_file is None:
|
||||
_report_no_config_found()
|
||||
_report_missing_config()
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not config_file.exists():
|
||||
_report_config_path_not_exists(config_file)
|
||||
_report_missing_config(config_file)
|
||||
raise typer.Exit(1)
|
||||
|
||||
editor = _get_editor()
|
||||
@@ -180,11 +164,11 @@ def config_show(
|
||||
config_file = _get_config_file(path)
|
||||
|
||||
if config_file is None:
|
||||
_report_no_config_found()
|
||||
_report_missing_config()
|
||||
raise typer.Exit(0)
|
||||
|
||||
if not config_file.exists():
|
||||
_report_config_path_not_exists(config_file)
|
||||
_report_missing_config(config_file)
|
||||
raise typer.Exit(1)
|
||||
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
@@ -211,7 +195,7 @@ def config_path(
|
||||
config_file = _get_config_file(path)
|
||||
|
||||
if config_file is None:
|
||||
_report_no_config_found()
|
||||
_report_missing_config()
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Just print the path for easy piping
|
||||
@@ -243,7 +227,7 @@ def config_validate(
|
||||
|
||||
print_success(f"Valid config: {config_file}")
|
||||
console.print(f" Hosts: {len(cfg.hosts)}")
|
||||
console.print(f" Services: {len(cfg.services)}")
|
||||
console.print(f" Stacks: {len(cfg.stacks)}")
|
||||
|
||||
|
||||
@config_app.command("symlink")
|
||||
|
||||
@@ -2,238 +2,249 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
|
||||
from compose_farm.cli.app import app
|
||||
from compose_farm.cli.common import (
|
||||
AllOption,
|
||||
ConfigOption,
|
||||
HostOption,
|
||||
ServicesArg,
|
||||
ServiceOption,
|
||||
StacksArg,
|
||||
format_host,
|
||||
get_services,
|
||||
get_stacks,
|
||||
load_config_or_exit,
|
||||
maybe_regenerate_traefik,
|
||||
report_results,
|
||||
run_async,
|
||||
run_host_operation,
|
||||
validate_host_for_stack,
|
||||
validate_stacks,
|
||||
)
|
||||
from compose_farm.console import MSG_DRY_RUN, console, print_error, print_success
|
||||
from compose_farm.executor import run_on_services, run_sequential_on_services
|
||||
from compose_farm.operations import stop_orphaned_services, up_services
|
||||
from compose_farm.executor import run_compose_on_host, run_on_stacks, run_sequential_on_stacks
|
||||
from compose_farm.operations import stop_orphaned_stacks, up_stacks
|
||||
from compose_farm.state import (
|
||||
add_service_to_host,
|
||||
get_orphaned_services,
|
||||
get_service_host,
|
||||
get_services_needing_migration,
|
||||
get_services_not_in_state,
|
||||
remove_service,
|
||||
remove_service_from_host,
|
||||
get_orphaned_stacks,
|
||||
get_stack_host,
|
||||
get_stacks_needing_migration,
|
||||
get_stacks_not_in_state,
|
||||
remove_stack,
|
||||
)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def up(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
host: HostOption = None,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Start services (docker compose up -d). Auto-migrates if host changed."""
|
||||
svc_list, cfg = get_services(services or [], all_services, config)
|
||||
|
||||
# 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))
|
||||
"""Start stacks (docker compose up -d). Auto-migrates if host changed."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
|
||||
if service:
|
||||
if len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
# For service-level up, use run_on_stacks directly (no migration logic)
|
||||
results = run_async(run_on_stacks(cfg, stack_list, f"up -d {service}", raw=True))
|
||||
else:
|
||||
results = run_async(up_stacks(cfg, stack_list, raw=True))
|
||||
maybe_regenerate_traefik(cfg, results)
|
||||
report_results(results)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def down(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
orphaned: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--orphaned", help="Stop orphaned services (in state but removed from config)"
|
||||
),
|
||||
typer.Option("--orphaned", help="Stop orphaned stacks (in state but removed from config)"),
|
||||
] = False,
|
||||
host: HostOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Stop services (docker compose down)."""
|
||||
# Handle --orphaned flag
|
||||
"""Stop stacks (docker compose down)."""
|
||||
# Handle --orphaned flag (mutually exclusive with other selection methods)
|
||||
if orphaned:
|
||||
if services or all_services or host:
|
||||
if stacks or all_stacks or host:
|
||||
print_error(
|
||||
"Cannot combine [bold]--orphaned[/] with services, [bold]--all[/], or [bold]--host[/]"
|
||||
"Cannot combine [bold]--orphaned[/] with stacks, [bold]--all[/], or [bold]--host[/]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
cfg = load_config_or_exit(config)
|
||||
orphaned_services = get_orphaned_services(cfg)
|
||||
orphaned_stacks = get_orphaned_stacks(cfg)
|
||||
|
||||
if not orphaned_services:
|
||||
print_success("No orphaned services to stop")
|
||||
if not orphaned_stacks:
|
||||
print_success("No orphaned stacks to stop")
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[yellow]Stopping {len(orphaned_services)} orphaned service(s):[/] "
|
||||
f"{', '.join(orphaned_services.keys())}"
|
||||
f"[yellow]Stopping {len(orphaned_stacks)} orphaned stack(s):[/] "
|
||||
f"{', '.join(orphaned_stacks.keys())}"
|
||||
)
|
||||
results = run_async(stop_orphaned_services(cfg))
|
||||
results = run_async(stop_orphaned_stacks(cfg))
|
||||
report_results(results)
|
||||
return
|
||||
|
||||
svc_list, cfg = get_services(services or [], all_services, config)
|
||||
|
||||
# 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))
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_on_stacks(cfg, stack_list, "down", raw=raw))
|
||||
|
||||
# Remove from state on success
|
||||
# For multi-host services, result.service is "svc@host", extract base name
|
||||
removed_services: set[str] = set()
|
||||
# For multi-host stacks, result.stack is "stack@host", extract base name
|
||||
removed_stacks: set[str] = set()
|
||||
for result in results:
|
||||
if result.success:
|
||||
base_service = result.service.split("@")[0]
|
||||
if base_service not in removed_services:
|
||||
remove_service(cfg, base_service)
|
||||
removed_services.add(base_service)
|
||||
base_stack = result.stack.split("@")[0]
|
||||
if base_stack not in removed_stacks:
|
||||
remove_stack(cfg, base_stack)
|
||||
removed_stacks.add(base_stack)
|
||||
|
||||
maybe_regenerate_traefik(cfg, results)
|
||||
report_results(results)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def stop(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Stop services without removing containers (docker compose stop)."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
cmd = f"stop {service}" if service else "stop"
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
|
||||
report_results(results)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def pull(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Pull latest images (docker compose pull)."""
|
||||
svc_list, cfg = get_services(services or [], all_services, config)
|
||||
raw = len(svc_list) == 1
|
||||
results = run_async(run_on_services(cfg, svc_list, "pull", raw=raw))
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
cmd = f"pull --ignore-buildable {service}" if service else "pull --ignore-buildable"
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd, raw=raw))
|
||||
report_results(results)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def restart(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Restart services (down + up)."""
|
||||
svc_list, cfg = get_services(services or [], all_services, config)
|
||||
raw = len(svc_list) == 1
|
||||
results = run_async(run_sequential_on_services(cfg, svc_list, ["down", "up -d"], raw=raw))
|
||||
"""Restart stacks (down + up). With --service, restarts just that service."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
if service:
|
||||
if len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
# For service-level restart, use docker compose restart (more efficient)
|
||||
raw = True
|
||||
results = run_async(run_on_stacks(cfg, stack_list, f"restart {service}", raw=raw))
|
||||
else:
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(run_sequential_on_stacks(cfg, stack_list, ["down", "up -d"], raw=raw))
|
||||
maybe_regenerate_traefik(cfg, results)
|
||||
report_results(results)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def update(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
service: ServiceOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Update services (pull + build + down + up)."""
|
||||
svc_list, cfg = get_services(services or [], all_services, config)
|
||||
raw = len(svc_list) == 1
|
||||
results = run_async(
|
||||
run_sequential_on_services(
|
||||
cfg, svc_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
|
||||
"""Update stacks (pull + build + down + up). With --service, updates just that service."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
if service:
|
||||
if len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
# For service-level update: pull + build + stop + up (stop instead of down)
|
||||
raw = True
|
||||
results = run_async(
|
||||
run_sequential_on_stacks(
|
||||
cfg,
|
||||
stack_list,
|
||||
[
|
||||
f"pull --ignore-buildable {service}",
|
||||
f"build {service}",
|
||||
f"stop {service}",
|
||||
f"up -d {service}",
|
||||
],
|
||||
raw=raw,
|
||||
)
|
||||
)
|
||||
else:
|
||||
raw = len(stack_list) == 1
|
||||
results = run_async(
|
||||
run_sequential_on_stacks(
|
||||
cfg, stack_list, ["pull --ignore-buildable", "build", "down", "up -d"], raw=raw
|
||||
)
|
||||
)
|
||||
)
|
||||
maybe_regenerate_traefik(cfg, results)
|
||||
report_results(results)
|
||||
|
||||
|
||||
def _report_pending_migrations(cfg: Config, migrations: list[str]) -> None:
|
||||
"""Report services that need migration."""
|
||||
console.print(f"[cyan]Services to migrate ({len(migrations)}):[/]")
|
||||
for svc in migrations:
|
||||
current = get_service_host(cfg, svc)
|
||||
target = cfg.get_hosts(svc)[0]
|
||||
console.print(f" [cyan]{svc}[/]: [magenta]{current}[/] → [magenta]{target}[/]")
|
||||
|
||||
|
||||
def _report_pending_orphans(orphaned: dict[str, str | list[str]]) -> None:
|
||||
"""Report orphaned services that will be stopped."""
|
||||
console.print(f"[yellow]Orphaned services to stop ({len(orphaned)}):[/]")
|
||||
for svc, hosts in orphaned.items():
|
||||
console.print(f" [cyan]{svc}[/] on [magenta]{format_host(hosts)}[/]")
|
||||
|
||||
|
||||
def _report_pending_starts(cfg: Config, missing: list[str]) -> None:
|
||||
"""Report services that will be started."""
|
||||
console.print(f"[green]Services to start ({len(missing)}):[/]")
|
||||
for svc in missing:
|
||||
target = format_host(cfg.get_hosts(svc))
|
||||
console.print(f" [cyan]{svc}[/] on [magenta]{target}[/]")
|
||||
|
||||
|
||||
def _report_pending_refresh(cfg: Config, to_refresh: list[str]) -> None:
|
||||
"""Report services that will be refreshed."""
|
||||
console.print(f"[blue]Services to refresh ({len(to_refresh)}):[/]")
|
||||
for svc in to_refresh:
|
||||
target = format_host(cfg.get_hosts(svc))
|
||||
console.print(f" [cyan]{svc}[/] on [magenta]{target}[/]")
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def apply(
|
||||
def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
|
||||
dry_run: Annotated[
|
||||
bool,
|
||||
typer.Option("--dry-run", "-n", help="Show what would change without executing"),
|
||||
] = False,
|
||||
no_orphans: Annotated[
|
||||
bool,
|
||||
typer.Option("--no-orphans", help="Only migrate, don't stop orphaned services"),
|
||||
typer.Option("--no-orphans", help="Only migrate, don't stop orphaned stacks"),
|
||||
] = False,
|
||||
full: Annotated[
|
||||
bool,
|
||||
typer.Option("--full", "-f", help="Also run up on all services to apply config changes"),
|
||||
typer.Option("--full", "-f", help="Also run up on all stacks to apply config changes"),
|
||||
] = False,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Make reality match config (start, migrate, stop as needed).
|
||||
|
||||
This is the "reconcile" command that ensures running services match your
|
||||
This is the "reconcile" command that ensures running stacks match your
|
||||
config file. It will:
|
||||
|
||||
1. Stop orphaned services (in state but removed from config)
|
||||
2. Migrate services on wrong host (host in state ≠ host in config)
|
||||
3. Start missing services (in config but not in state)
|
||||
1. Stop orphaned stacks (in state but removed from config)
|
||||
2. Migrate stacks on wrong host (host in state ≠ host in config)
|
||||
3. Start missing stacks (in config but not in state)
|
||||
|
||||
Use --dry-run to preview changes before applying.
|
||||
Use --no-orphans to only migrate/start without stopping orphaned services.
|
||||
Use --full to also run 'up' on all services (picks up compose/env changes).
|
||||
Use --no-orphans to only migrate/start without stopping orphaned stacks.
|
||||
Use --full to also run 'up' on all stacks (picks up compose/env changes).
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
orphaned = get_orphaned_services(cfg)
|
||||
migrations = get_services_needing_migration(cfg)
|
||||
missing = get_services_not_in_state(cfg)
|
||||
orphaned = get_orphaned_stacks(cfg)
|
||||
migrations = get_stacks_needing_migration(cfg)
|
||||
missing = get_stacks_not_in_state(cfg)
|
||||
|
||||
# For --full: refresh all services not already being started/migrated
|
||||
# For --full: refresh all stacks not already being started/migrated
|
||||
handled = set(migrations) | set(missing)
|
||||
to_refresh = [svc for svc in cfg.services if svc not in handled] if full else []
|
||||
to_refresh = [stack for stack in cfg.stacks if stack not in handled] if full else []
|
||||
|
||||
has_orphans = bool(orphaned) and not no_orphans
|
||||
has_migrations = bool(migrations)
|
||||
@@ -246,13 +257,23 @@ def apply(
|
||||
|
||||
# Report what will be done
|
||||
if has_orphans:
|
||||
_report_pending_orphans(orphaned)
|
||||
console.print(f"[yellow]Orphaned stacks to stop ({len(orphaned)}):[/]")
|
||||
for svc, hosts in orphaned.items():
|
||||
console.print(f" [cyan]{svc}[/] on [magenta]{format_host(hosts)}[/]")
|
||||
if has_migrations:
|
||||
_report_pending_migrations(cfg, migrations)
|
||||
console.print(f"[cyan]Stacks to migrate ({len(migrations)}):[/]")
|
||||
for stack in migrations:
|
||||
current = get_stack_host(cfg, stack)
|
||||
target = cfg.get_hosts(stack)[0]
|
||||
console.print(f" [cyan]{stack}[/]: [magenta]{current}[/] → [magenta]{target}[/]")
|
||||
if has_missing:
|
||||
_report_pending_starts(cfg, missing)
|
||||
console.print(f"[green]Stacks to start ({len(missing)}):[/]")
|
||||
for stack in missing:
|
||||
console.print(f" [cyan]{stack}[/] on [magenta]{format_host(cfg.get_hosts(stack))}[/]")
|
||||
if has_refresh:
|
||||
_report_pending_refresh(cfg, to_refresh)
|
||||
console.print(f"[blue]Stacks to refresh ({len(to_refresh)}):[/]")
|
||||
for stack in to_refresh:
|
||||
console.print(f" [cyan]{stack}[/] on [magenta]{format_host(cfg.get_hosts(stack))}[/]")
|
||||
|
||||
if dry_run:
|
||||
console.print(f"\n{MSG_DRY_RUN}")
|
||||
@@ -262,34 +283,91 @@ def apply(
|
||||
console.print()
|
||||
all_results = []
|
||||
|
||||
# 1. Stop orphaned services first
|
||||
# 1. Stop orphaned stacks first
|
||||
if has_orphans:
|
||||
console.print("[yellow]Stopping orphaned services...[/]")
|
||||
all_results.extend(run_async(stop_orphaned_services(cfg)))
|
||||
console.print("[yellow]Stopping orphaned stacks...[/]")
|
||||
all_results.extend(run_async(stop_orphaned_stacks(cfg)))
|
||||
|
||||
# 2. Migrate services on wrong host
|
||||
# 2. Migrate stacks on wrong host
|
||||
if has_migrations:
|
||||
console.print("[cyan]Migrating services...[/]")
|
||||
migrate_results = run_async(up_services(cfg, migrations, raw=True))
|
||||
console.print("[cyan]Migrating stacks...[/]")
|
||||
migrate_results = run_async(up_stacks(cfg, migrations, raw=True))
|
||||
all_results.extend(migrate_results)
|
||||
maybe_regenerate_traefik(cfg, migrate_results)
|
||||
|
||||
# 3. Start missing services (reuse up_services which handles state updates)
|
||||
# 3. Start missing stacks (reuse up_stacks which handles state updates)
|
||||
if has_missing:
|
||||
console.print("[green]Starting missing services...[/]")
|
||||
start_results = run_async(up_services(cfg, missing, raw=True))
|
||||
console.print("[green]Starting missing stacks...[/]")
|
||||
start_results = run_async(up_stacks(cfg, missing, raw=True))
|
||||
all_results.extend(start_results)
|
||||
maybe_regenerate_traefik(cfg, start_results)
|
||||
|
||||
# 4. Refresh remaining services (--full: run up to apply config changes)
|
||||
# 4. Refresh remaining stacks (--full: run up to apply config changes)
|
||||
if has_refresh:
|
||||
console.print("[blue]Refreshing services...[/]")
|
||||
refresh_results = run_async(up_services(cfg, to_refresh, raw=True))
|
||||
console.print("[blue]Refreshing stacks...[/]")
|
||||
refresh_results = run_async(up_stacks(cfg, to_refresh, raw=True))
|
||||
all_results.extend(refresh_results)
|
||||
maybe_regenerate_traefik(cfg, refresh_results)
|
||||
|
||||
report_results(all_results)
|
||||
|
||||
|
||||
@app.command(
|
||||
rich_help_panel="Lifecycle",
|
||||
context_settings={"allow_interspersed_args": False},
|
||||
)
|
||||
def compose(
|
||||
stack: Annotated[str, typer.Argument(help="Stack to operate on (use '.' for current dir)")],
|
||||
command: Annotated[str, typer.Argument(help="Docker compose command")],
|
||||
args: Annotated[list[str] | None, typer.Argument(help="Additional arguments")] = None,
|
||||
host: HostOption = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Run any docker compose command on a stack.
|
||||
|
||||
Passthrough to docker compose for commands not wrapped by cf.
|
||||
Options after COMMAND are passed to docker compose, not cf.
|
||||
|
||||
Examples:
|
||||
cf compose mystack --help - show docker compose help
|
||||
cf compose mystack top - view running processes
|
||||
cf compose mystack images - list images
|
||||
cf compose mystack exec web bash - interactive shell
|
||||
cf compose mystack config - view parsed config
|
||||
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
|
||||
# Resolve "." to current directory name
|
||||
resolved_stack = Path.cwd().name if stack == "." else stack
|
||||
validate_stacks(cfg, [resolved_stack])
|
||||
|
||||
# Handle multi-host stacks
|
||||
hosts = cfg.get_hosts(resolved_stack)
|
||||
if len(hosts) > 1:
|
||||
if host is None:
|
||||
print_error(
|
||||
f"Stack [cyan]{resolved_stack}[/] runs on multiple hosts: {', '.join(hosts)}\n"
|
||||
f"Use [bold]--host[/] to specify which host"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
validate_host_for_stack(cfg, resolved_stack, host)
|
||||
target_host = host
|
||||
else:
|
||||
target_host = hosts[0]
|
||||
|
||||
# Build the full compose command
|
||||
full_cmd = command
|
||||
if args:
|
||||
full_cmd += " " + " ".join(args)
|
||||
|
||||
# Run with raw=True for proper TTY handling (progress bars, interactive)
|
||||
result = run_async(run_compose_on_host(cfg, resolved_stack, target_host, full_cmd, raw=True))
|
||||
print() # Ensure newline after raw output
|
||||
|
||||
if not result.success:
|
||||
raise typer.Exit(result.exit_code)
|
||||
|
||||
|
||||
# Alias: cf a = cf apply
|
||||
app.command("a", hidden=True)(apply)
|
||||
|
||||
@@ -15,14 +15,14 @@ from compose_farm.cli.common import (
|
||||
AllOption,
|
||||
ConfigOption,
|
||||
LogPathOption,
|
||||
ServicesArg,
|
||||
StacksArg,
|
||||
format_host,
|
||||
get_services,
|
||||
get_stacks,
|
||||
load_config_or_exit,
|
||||
run_async,
|
||||
run_parallel_with_progress,
|
||||
validate_hosts,
|
||||
validate_services,
|
||||
validate_stacks,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,7 +43,7 @@ from compose_farm.executor import (
|
||||
from compose_farm.logs import (
|
||||
DEFAULT_LOG_PATH,
|
||||
SnapshotEntry,
|
||||
collect_service_entries,
|
||||
collect_stack_entries,
|
||||
isoformat,
|
||||
load_existing_entries,
|
||||
merge_entries,
|
||||
@@ -51,28 +51,29 @@ from compose_farm.logs import (
|
||||
)
|
||||
from compose_farm.operations import (
|
||||
check_host_compatibility,
|
||||
check_service_requirements,
|
||||
discover_service_host,
|
||||
check_stack_requirements,
|
||||
discover_stack_host,
|
||||
)
|
||||
from compose_farm.state import get_orphaned_services, load_state, save_state
|
||||
from compose_farm.state import get_orphaned_stacks, load_state, save_state
|
||||
from compose_farm.traefik import generate_traefik_config, render_traefik_config
|
||||
|
||||
# --- Sync helpers ---
|
||||
|
||||
|
||||
def _discover_services(cfg: Config) -> dict[str, str | list[str]]:
|
||||
"""Discover running services with a progress bar."""
|
||||
def _discover_stacks(cfg: Config, stacks: list[str] | None = None) -> dict[str, str | list[str]]:
|
||||
"""Discover running stacks with a progress bar."""
|
||||
stack_list = stacks if stacks is not None else list(cfg.stacks)
|
||||
results = run_parallel_with_progress(
|
||||
"Discovering",
|
||||
list(cfg.services),
|
||||
lambda s: discover_service_host(cfg, s),
|
||||
stack_list,
|
||||
lambda s: discover_stack_host(cfg, s),
|
||||
)
|
||||
return {svc: host for svc, host in results if host is not None}
|
||||
|
||||
|
||||
def _snapshot_services(
|
||||
def _snapshot_stacks(
|
||||
cfg: Config,
|
||||
services: list[str],
|
||||
stacks: list[str],
|
||||
log_path: Path | None,
|
||||
) -> Path:
|
||||
"""Capture image digests with a progress bar."""
|
||||
@@ -80,16 +81,16 @@ def _snapshot_services(
|
||||
now_dt = datetime.now(UTC)
|
||||
now_iso = isoformat(now_dt)
|
||||
|
||||
async def collect_service(service: str) -> tuple[str, list[SnapshotEntry]]:
|
||||
async def collect_stack(stack: str) -> tuple[str, list[SnapshotEntry]]:
|
||||
try:
|
||||
return service, await collect_service_entries(cfg, service, now=now_dt)
|
||||
return stack, await collect_stack_entries(cfg, stack, now=now_dt)
|
||||
except RuntimeError:
|
||||
return service, []
|
||||
return stack, []
|
||||
|
||||
results = run_parallel_with_progress(
|
||||
"Capturing",
|
||||
services,
|
||||
collect_service,
|
||||
stacks,
|
||||
collect_stack,
|
||||
)
|
||||
snapshot_entries = [entry for _, entries in results for entry in entries]
|
||||
|
||||
@@ -104,6 +105,18 @@ def _snapshot_services(
|
||||
return effective_log_path
|
||||
|
||||
|
||||
def _merge_state(
|
||||
current_state: dict[str, str | list[str]],
|
||||
discovered: dict[str, str | list[str]],
|
||||
removed: list[str],
|
||||
) -> dict[str, str | list[str]]:
|
||||
"""Merge discovered stacks into existing state for partial refresh."""
|
||||
new_state = {**current_state, **discovered}
|
||||
for svc in removed:
|
||||
new_state.pop(svc, None)
|
||||
return new_state
|
||||
|
||||
|
||||
def _report_sync_changes(
|
||||
added: list[str],
|
||||
removed: list[str],
|
||||
@@ -113,25 +126,25 @@ def _report_sync_changes(
|
||||
) -> None:
|
||||
"""Report sync changes to the user."""
|
||||
if added:
|
||||
console.print(f"\nNew services found ({len(added)}):")
|
||||
for service in sorted(added):
|
||||
host_str = format_host(discovered[service])
|
||||
console.print(f" [green]+[/] [cyan]{service}[/] on [magenta]{host_str}[/]")
|
||||
console.print(f"\nNew stacks found ({len(added)}):")
|
||||
for stack in sorted(added):
|
||||
host_str = format_host(discovered[stack])
|
||||
console.print(f" [green]+[/] [cyan]{stack}[/] on [magenta]{host_str}[/]")
|
||||
|
||||
if changed:
|
||||
console.print(f"\nServices on different hosts ({len(changed)}):")
|
||||
for service, old_host, new_host in sorted(changed):
|
||||
console.print(f"\nStacks on different hosts ({len(changed)}):")
|
||||
for stack, old_host, new_host in sorted(changed):
|
||||
old_str = format_host(old_host)
|
||||
new_str = format_host(new_host)
|
||||
console.print(
|
||||
f" [yellow]~[/] [cyan]{service}[/]: [magenta]{old_str}[/] → [magenta]{new_str}[/]"
|
||||
f" [yellow]~[/] [cyan]{stack}[/]: [magenta]{old_str}[/] → [magenta]{new_str}[/]"
|
||||
)
|
||||
|
||||
if removed:
|
||||
console.print(f"\nServices no longer running ({len(removed)}):")
|
||||
for service in sorted(removed):
|
||||
host_str = format_host(current_state[service])
|
||||
console.print(f" [red]-[/] [cyan]{service}[/] (was on [magenta]{host_str}[/])")
|
||||
console.print(f"\nStacks no longer running ({len(removed)}):")
|
||||
for stack in sorted(removed):
|
||||
host_str = format_host(current_state[stack])
|
||||
console.print(f" [red]-[/] [cyan]{stack}[/] (was on [magenta]{host_str}[/])")
|
||||
|
||||
|
||||
# --- Check helpers ---
|
||||
@@ -166,44 +179,44 @@ def _check_ssh_connectivity(cfg: Config) -> list[str]:
|
||||
return [host for host, success in results if not success]
|
||||
|
||||
|
||||
def _check_service_requirements(
|
||||
def _check_stack_requirements(
|
||||
cfg: Config,
|
||||
services: list[str],
|
||||
stacks: list[str],
|
||||
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]], list[tuple[str, str, str]]]:
|
||||
"""Check mounts, networks, and devices for all services with a progress bar.
|
||||
"""Check mounts, networks, and devices for all stacks with a progress bar.
|
||||
|
||||
Returns (mount_errors, network_errors, device_errors) where each is a list of
|
||||
(service, host, missing_item) tuples.
|
||||
(stack, host, missing_item) tuples.
|
||||
"""
|
||||
|
||||
async def check_service(
|
||||
service: str,
|
||||
async def check_stack(
|
||||
stack: str,
|
||||
) -> tuple[
|
||||
str,
|
||||
list[tuple[str, str, str]],
|
||||
list[tuple[str, str, str]],
|
||||
list[tuple[str, str, str]],
|
||||
]:
|
||||
"""Check requirements for a single service on all its hosts."""
|
||||
host_names = cfg.get_hosts(service)
|
||||
"""Check requirements for a single stack on all its hosts."""
|
||||
host_names = cfg.get_hosts(stack)
|
||||
mount_errors: list[tuple[str, str, str]] = []
|
||||
network_errors: list[tuple[str, str, str]] = []
|
||||
device_errors: list[tuple[str, str, str]] = []
|
||||
|
||||
for host_name in host_names:
|
||||
missing_paths, missing_nets, missing_devs = await check_service_requirements(
|
||||
cfg, service, host_name
|
||||
missing_paths, missing_nets, missing_devs = await check_stack_requirements(
|
||||
cfg, stack, host_name
|
||||
)
|
||||
mount_errors.extend((service, host_name, p) for p in missing_paths)
|
||||
network_errors.extend((service, host_name, n) for n in missing_nets)
|
||||
device_errors.extend((service, host_name, d) for d in missing_devs)
|
||||
mount_errors.extend((stack, host_name, p) for p in missing_paths)
|
||||
network_errors.extend((stack, host_name, n) for n in missing_nets)
|
||||
device_errors.extend((stack, host_name, d) for d in missing_devs)
|
||||
|
||||
return service, mount_errors, network_errors, device_errors
|
||||
return stack, mount_errors, network_errors, device_errors
|
||||
|
||||
results = run_parallel_with_progress(
|
||||
"Checking requirements",
|
||||
services,
|
||||
check_service,
|
||||
stacks,
|
||||
check_stack,
|
||||
)
|
||||
|
||||
all_mount_errors: list[tuple[str, str, str]] = []
|
||||
@@ -219,7 +232,7 @@ def _check_service_requirements(
|
||||
|
||||
def _report_config_status(cfg: Config) -> bool:
|
||||
"""Check and report config vs disk status. Returns True if errors found."""
|
||||
configured = set(cfg.services.keys())
|
||||
configured = set(cfg.stacks.keys())
|
||||
on_disk = cfg.discover_compose_dirs()
|
||||
unmanaged = sorted(on_disk - configured)
|
||||
missing_from_disk = sorted(configured - on_disk)
|
||||
@@ -240,12 +253,12 @@ def _report_config_status(cfg: Config) -> bool:
|
||||
return bool(missing_from_disk)
|
||||
|
||||
|
||||
def _report_orphaned_services(cfg: Config) -> bool:
|
||||
"""Check for services in state but not in config. Returns True if orphans found."""
|
||||
orphaned = get_orphaned_services(cfg)
|
||||
def _report_orphaned_stacks(cfg: Config) -> bool:
|
||||
"""Check for stacks in state but not in config. Returns True if orphans found."""
|
||||
orphaned = get_orphaned_stacks(cfg)
|
||||
|
||||
if orphaned:
|
||||
console.print("\n[yellow]Orphaned services[/] (in state but not in config):")
|
||||
console.print("\n[yellow]Orphaned stacks[/] (in state but not in config):")
|
||||
console.print(
|
||||
"[dim]Run [bold]cf apply[/bold] to stop them, or [bold]cf down --orphaned[/bold] for just orphans.[/]"
|
||||
)
|
||||
@@ -256,10 +269,10 @@ def _report_orphaned_services(cfg: Config) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _report_traefik_status(cfg: Config, services: list[str]) -> None:
|
||||
def _report_traefik_status(cfg: Config, stacks: list[str]) -> None:
|
||||
"""Check and report traefik label status."""
|
||||
try:
|
||||
_, warnings = generate_traefik_config(cfg, services, check_all=True)
|
||||
_, warnings = generate_traefik_config(cfg, stacks, check_all=True)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return
|
||||
|
||||
@@ -272,16 +285,16 @@ def _report_traefik_status(cfg: Config, services: list[str]) -> None:
|
||||
|
||||
|
||||
def _report_requirement_errors(errors: list[tuple[str, str, str]], category: str) -> None:
|
||||
"""Report requirement errors (mounts, networks, devices) grouped by service."""
|
||||
by_service: dict[str, list[tuple[str, str]]] = {}
|
||||
for svc, host, item in errors:
|
||||
by_service.setdefault(svc, []).append((host, item))
|
||||
"""Report requirement errors (mounts, networks, devices) grouped by stack."""
|
||||
by_stack: dict[str, list[tuple[str, str]]] = {}
|
||||
for stack, host, item in errors:
|
||||
by_stack.setdefault(stack, []).append((host, item))
|
||||
|
||||
console.print(f"[red]Missing {category}[/] ({len(errors)}):")
|
||||
for svc, items in sorted(by_service.items()):
|
||||
for stack, items in sorted(by_stack.items()):
|
||||
host = items[0][0]
|
||||
missing = [i for _, i in items]
|
||||
console.print(f" [cyan]{svc}[/] on [magenta]{host}[/]:")
|
||||
console.print(f" [cyan]{stack}[/] on [magenta]{host}[/]:")
|
||||
for item in missing:
|
||||
console.print(f" [red]✗[/] {item}")
|
||||
|
||||
@@ -301,7 +314,7 @@ def _report_host_compatibility(
|
||||
compat: dict[str, tuple[int, int, list[str]]],
|
||||
assigned_hosts: list[str],
|
||||
) -> None:
|
||||
"""Report host compatibility for a service."""
|
||||
"""Report host compatibility for a stack."""
|
||||
for host_name, (found, total, missing) in sorted(compat.items()):
|
||||
is_assigned = host_name in assigned_hosts
|
||||
marker = " [dim](assigned)[/]" if is_assigned else ""
|
||||
@@ -332,7 +345,7 @@ def _run_remote_checks(cfg: Config, svc_list: list[str], *, show_host_compat: bo
|
||||
console.print() # Spacing before mounts/networks check
|
||||
|
||||
# Check mounts, networks, and devices
|
||||
mount_errors, network_errors, device_errors = _check_service_requirements(cfg, svc_list)
|
||||
mount_errors, network_errors, device_errors = _check_stack_requirements(cfg, svc_list)
|
||||
|
||||
if mount_errors:
|
||||
_report_requirement_errors(mount_errors, "mounts")
|
||||
@@ -347,10 +360,10 @@ def _run_remote_checks(cfg: Config, svc_list: list[str], *, show_host_compat: bo
|
||||
print_success("All mounts, networks, and devices exist")
|
||||
|
||||
if show_host_compat:
|
||||
for service in svc_list:
|
||||
console.print(f"\n[bold]Host compatibility for[/] [cyan]{service}[/]:")
|
||||
compat = run_async(check_host_compatibility(cfg, service))
|
||||
assigned_hosts = cfg.get_hosts(service)
|
||||
for stack in svc_list:
|
||||
console.print(f"\n[bold]Host compatibility for[/] [cyan]{stack}[/]:")
|
||||
compat = run_async(check_host_compatibility(cfg, stack))
|
||||
assigned_hosts = cfg.get_hosts(stack)
|
||||
_report_host_compatibility(compat, assigned_hosts)
|
||||
|
||||
return has_errors
|
||||
@@ -364,8 +377,8 @@ _DEFAULT_NETWORK_GATEWAY = "172.20.0.1"
|
||||
|
||||
@app.command("traefik-file", rich_help_panel="Configuration")
|
||||
def traefik_file(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
output: Annotated[
|
||||
Path | None,
|
||||
typer.Option(
|
||||
@@ -377,9 +390,9 @@ def traefik_file(
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Generate a Traefik file-provider fragment from compose Traefik labels."""
|
||||
svc_list, cfg = get_services(services or [], all_services, config)
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config)
|
||||
try:
|
||||
dynamic, warnings = generate_traefik_config(cfg, svc_list)
|
||||
dynamic, warnings = generate_traefik_config(cfg, stack_list)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
print_error(str(exc))
|
||||
raise typer.Exit(1) from exc
|
||||
@@ -399,6 +412,8 @@ def traefik_file(
|
||||
|
||||
@app.command(rich_help_panel="Configuration")
|
||||
def refresh(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
config: ConfigOption = None,
|
||||
log_path: LogPathOption = None,
|
||||
dry_run: Annotated[
|
||||
@@ -406,22 +421,35 @@ def refresh(
|
||||
typer.Option("--dry-run", "-n", help="Show what would change without writing"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Update local state from running services.
|
||||
"""Update local state from running stacks.
|
||||
|
||||
Discovers which services are running on which hosts, updates the state
|
||||
Discovers which stacks are running on which hosts, updates the state
|
||||
file, and captures image digests. This is a read operation - it updates
|
||||
your local state to match reality, not the other way around.
|
||||
|
||||
Without arguments: refreshes all stacks (same as --all).
|
||||
With stack names: refreshes only those stacks.
|
||||
|
||||
Use 'cf apply' to make reality match your config (stop orphans, migrate).
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, default_all=True)
|
||||
|
||||
# Partial refresh merges with existing state; full refresh replaces it
|
||||
# Partial = specific stacks provided (not --all, not default)
|
||||
partial_refresh = bool(stacks) and not all_stacks
|
||||
|
||||
current_state = load_state(cfg)
|
||||
|
||||
discovered = _discover_services(cfg)
|
||||
discovered = _discover_stacks(cfg, stack_list)
|
||||
|
||||
# Calculate changes
|
||||
# Calculate changes (only for the stacks we're refreshing)
|
||||
added = [s for s in discovered if s not in current_state]
|
||||
removed = [s for s in current_state if s not in discovered]
|
||||
# Only mark as "removed" if we're doing a full refresh
|
||||
if partial_refresh:
|
||||
# In partial refresh, a stack not running is just "not found"
|
||||
removed = [s for s in stack_list if s in current_state and s not in discovered]
|
||||
else:
|
||||
removed = [s for s in current_state if s not in discovered]
|
||||
changed = [
|
||||
(s, current_state[s], discovered[s])
|
||||
for s in discovered
|
||||
@@ -441,13 +469,16 @@ def refresh(
|
||||
|
||||
# Update state file
|
||||
if state_changed:
|
||||
save_state(cfg, discovered)
|
||||
print_success(f"State updated: {len(discovered)} services tracked.")
|
||||
new_state = (
|
||||
_merge_state(current_state, discovered, removed) if partial_refresh else discovered
|
||||
)
|
||||
save_state(cfg, new_state)
|
||||
print_success(f"State updated: {len(new_state)} stacks tracked.")
|
||||
|
||||
# Capture image digests for running services
|
||||
# Capture image digests for running stacks
|
||||
if discovered:
|
||||
try:
|
||||
path = _snapshot_services(cfg, list(discovered.keys()), log_path)
|
||||
path = _snapshot_stacks(cfg, list(discovered.keys()), log_path)
|
||||
print_success(f"Digests written to {path}")
|
||||
except RuntimeError as exc:
|
||||
print_warning(str(exc))
|
||||
@@ -455,7 +486,7 @@ def refresh(
|
||||
|
||||
@app.command(rich_help_panel="Configuration")
|
||||
def check(
|
||||
services: ServicesArg = None,
|
||||
stacks: StacksArg = None,
|
||||
local: Annotated[
|
||||
bool,
|
||||
typer.Option("--local", help="Skip SSH-based checks (faster)"),
|
||||
@@ -464,31 +495,31 @@ def check(
|
||||
) -> None:
|
||||
"""Validate configuration, traefik labels, mounts, and networks.
|
||||
|
||||
Without arguments: validates all services against configured hosts.
|
||||
With service arguments: validates specific services and shows host compatibility.
|
||||
Without arguments: validates all stacks against configured hosts.
|
||||
With stack arguments: validates specific stacks and shows host compatibility.
|
||||
|
||||
Use --local to skip SSH-based checks for faster validation.
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
|
||||
# Determine which services to check and whether to show host compatibility
|
||||
if services:
|
||||
svc_list = list(services)
|
||||
validate_services(cfg, svc_list)
|
||||
# Determine which stacks to check and whether to show host compatibility
|
||||
if stacks:
|
||||
stack_list = list(stacks)
|
||||
validate_stacks(cfg, stack_list)
|
||||
show_host_compat = True
|
||||
else:
|
||||
svc_list = list(cfg.services.keys())
|
||||
stack_list = list(cfg.stacks.keys())
|
||||
show_host_compat = False
|
||||
|
||||
# Run checks
|
||||
has_errors = _report_config_status(cfg)
|
||||
_report_traefik_status(cfg, svc_list)
|
||||
_report_traefik_status(cfg, stack_list)
|
||||
|
||||
if not local and _run_remote_checks(cfg, svc_list, show_host_compat=show_host_compat):
|
||||
if not local and _run_remote_checks(cfg, stack_list, show_host_compat=show_host_compat):
|
||||
has_errors = True
|
||||
|
||||
# Check for orphaned services (in state but removed from config)
|
||||
if _report_orphaned_services(cfg):
|
||||
# Check for orphaned stacks (in state but removed from config)
|
||||
if _report_orphaned_stacks(cfg):
|
||||
has_errors = True
|
||||
|
||||
if has_errors:
|
||||
@@ -517,7 +548,7 @@ def init_network(
|
||||
) -> None:
|
||||
"""Create Docker network on hosts with consistent settings.
|
||||
|
||||
Creates an external Docker network that services can use for cross-host
|
||||
Creates an external Docker network that stacks can use for cross-host
|
||||
communication. Uses the same subnet/gateway on all hosts to ensure
|
||||
consistent networking.
|
||||
"""
|
||||
@@ -534,7 +565,7 @@ def init_network(
|
||||
|
||||
if check_result.success:
|
||||
console.print(f"[cyan]\\[{host_name}][/] Network '{network}' already exists")
|
||||
return CommandResult(service=host_name, exit_code=0, success=True)
|
||||
return CommandResult(stack=host_name, exit_code=0, success=True)
|
||||
|
||||
# Create the network
|
||||
create_cmd = (
|
||||
|
||||
@@ -14,17 +14,17 @@ from compose_farm.cli.common import (
|
||||
AllOption,
|
||||
ConfigOption,
|
||||
HostOption,
|
||||
ServicesArg,
|
||||
get_services,
|
||||
ServiceOption,
|
||||
StacksArg,
|
||||
get_stacks,
|
||||
load_config_or_exit,
|
||||
report_results,
|
||||
run_async,
|
||||
run_parallel_with_progress,
|
||||
validate_host,
|
||||
)
|
||||
from compose_farm.console import console, print_error, print_warning
|
||||
from compose_farm.executor import run_command, run_on_services
|
||||
from compose_farm.state import get_services_needing_migration, group_services_by_host, load_state
|
||||
from compose_farm.console import console, print_error
|
||||
from compose_farm.executor import run_command, run_on_stacks
|
||||
from compose_farm.state import get_stacks_needing_migration, group_stacks_by_host, load_state
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
@@ -52,7 +52,7 @@ def _get_container_counts(cfg: Config) -> dict[str, int]:
|
||||
|
||||
def _build_host_table(
|
||||
cfg: Config,
|
||||
services_by_host: dict[str, list[str]],
|
||||
stacks_by_host: dict[str, list[str]],
|
||||
running_by_host: dict[str, list[str]],
|
||||
container_counts: dict[str, int],
|
||||
*,
|
||||
@@ -69,7 +69,7 @@ def _build_host_table(
|
||||
|
||||
for host_name in sorted(cfg.hosts.keys()):
|
||||
host = cfg.hosts[host_name]
|
||||
configured = len(services_by_host[host_name])
|
||||
configured = len(stacks_by_host[host_name])
|
||||
running = len(running_by_host[host_name])
|
||||
|
||||
row = [
|
||||
@@ -97,8 +97,8 @@ def _build_summary_table(
|
||||
table.add_column("Value", style="bold")
|
||||
|
||||
table.add_row("Total hosts", str(len(cfg.hosts)))
|
||||
table.add_row("Services (configured)", str(len(cfg.services)))
|
||||
table.add_row("Services (tracked)", str(len(state)))
|
||||
table.add_row("Stacks (configured)", str(len(cfg.stacks)))
|
||||
table.add_row("Stacks (tracked)", str(len(state)))
|
||||
table.add_row("Compose files on disk", str(len(on_disk)))
|
||||
|
||||
if pending:
|
||||
@@ -116,9 +116,10 @@ def _build_summary_table(
|
||||
|
||||
@app.command(rich_help_panel="Monitoring")
|
||||
def logs(
|
||||
services: ServicesArg = None,
|
||||
all_services: AllOption = False,
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
host: HostOption = None,
|
||||
service: ServiceOption = None,
|
||||
follow: Annotated[bool, typer.Option("--follow", "-f", help="Follow logs")] = False,
|
||||
tail: Annotated[
|
||||
int | None,
|
||||
@@ -126,41 +127,45 @@ def logs(
|
||||
] = None,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Show service logs."""
|
||||
if all_services and host is not None:
|
||||
print_error("Cannot combine [bold]--all[/] and [bold]--host[/]")
|
||||
"""Show stack logs. With --service, shows logs for just that service."""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host)
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
|
||||
cfg = load_config_or_exit(config)
|
||||
|
||||
# Determine service list based on options
|
||||
if host is not None:
|
||||
validate_host(cfg, host)
|
||||
# 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:
|
||||
print_warning(f"No services configured for host [magenta]{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)
|
||||
# Default to fewer lines when showing multiple stacks
|
||||
many_stacks = all_stacks or host is not None or len(stack_list) > 1
|
||||
effective_tail = tail if tail is not None else (20 if many_stacks else 100)
|
||||
cmd = f"logs --tail {effective_tail}"
|
||||
if follow:
|
||||
cmd += " -f"
|
||||
results = run_async(run_on_services(cfg, svc_list, cmd))
|
||||
if service:
|
||||
cmd += f" {service}"
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd))
|
||||
report_results(results)
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Monitoring")
|
||||
def ps(
|
||||
stacks: StacksArg = None,
|
||||
all_stacks: AllOption = False,
|
||||
host: HostOption = None,
|
||||
service: ServiceOption = None,
|
||||
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"))
|
||||
"""Show status of stacks.
|
||||
|
||||
Without arguments: shows all stacks (same as --all).
|
||||
With stack names: shows only those stacks.
|
||||
With --host: shows stacks on that host.
|
||||
With --service: filters to a specific service within the stack.
|
||||
"""
|
||||
stack_list, cfg = get_stacks(stacks or [], all_stacks, config, host=host, default_all=True)
|
||||
if service and len(stack_list) != 1:
|
||||
print_error("--service requires exactly one stack")
|
||||
raise typer.Exit(1)
|
||||
cmd = f"ps {service}" if service else "ps"
|
||||
results = run_async(run_on_stacks(cfg, stack_list, cmd))
|
||||
report_results(results)
|
||||
|
||||
|
||||
@@ -172,25 +177,25 @@ def stats(
|
||||
] = False,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Show overview statistics for hosts and services.
|
||||
"""Show overview statistics for hosts and stacks.
|
||||
|
||||
Without --live: Shows config/state info (hosts, services, pending migrations).
|
||||
Without --live: Shows config/state info (hosts, stacks, pending migrations).
|
||||
With --live: Also queries Docker on each host for container counts.
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
state = load_state(cfg)
|
||||
pending = get_services_needing_migration(cfg)
|
||||
pending = get_stacks_needing_migration(cfg)
|
||||
|
||||
all_hosts = list(cfg.hosts.keys())
|
||||
services_by_host = group_services_by_host(cfg.services, cfg.hosts, all_hosts)
|
||||
running_by_host = group_services_by_host(state, cfg.hosts, all_hosts)
|
||||
stacks_by_host = group_stacks_by_host(cfg.stacks, cfg.hosts, all_hosts)
|
||||
running_by_host = group_stacks_by_host(state, cfg.hosts, all_hosts)
|
||||
|
||||
container_counts: dict[str, int] = {}
|
||||
if live:
|
||||
container_counts = _get_container_counts(cfg)
|
||||
|
||||
host_table = _build_host_table(
|
||||
cfg, services_by_host, running_by_host, container_counts, show_containers=live
|
||||
cfg, stacks_by_host, running_by_host, container_counts, show_containers=live
|
||||
)
|
||||
console.print(host_table)
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
|
||||
# Port parsing constants
|
||||
@@ -141,32 +141,51 @@ def _resolve_host_path(host_path: str, compose_dir: Path) -> str | None:
|
||||
return None # Named volume
|
||||
|
||||
|
||||
def _is_socket(path: str) -> bool:
|
||||
"""Check if a path is a socket (e.g., SSH agent socket)."""
|
||||
try:
|
||||
return stat.S_ISSOCK(Path(path).stat().st_mode)
|
||||
except (FileNotFoundError, PermissionError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
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."""
|
||||
"""Parse a single volume item and return host path if it's a bind mount.
|
||||
|
||||
Skips socket paths (e.g., SSH_AUTH_SOCK) since they're machine-local
|
||||
and shouldn't be validated on remote hosts.
|
||||
"""
|
||||
host_path: str | None = None
|
||||
|
||||
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)
|
||||
host_path = _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
|
||||
host_path = _resolve_host_path(interpolated, compose_dir)
|
||||
|
||||
# Skip sockets - they're machine-local (e.g., SSH agent)
|
||||
if host_path and _is_socket(host_path):
|
||||
return None
|
||||
|
||||
return host_path
|
||||
|
||||
|
||||
def parse_host_volumes(config: Config, service: str) -> list[str]:
|
||||
"""Extract host bind mount paths from a service's compose file.
|
||||
def parse_host_volumes(config: Config, stack: str) -> list[str]:
|
||||
"""Extract host bind mount paths from a stack's compose file.
|
||||
|
||||
Returns a list of absolute host paths used as volume mounts.
|
||||
Skips named volumes and resolves relative paths.
|
||||
"""
|
||||
compose_path = config.get_compose_path(service)
|
||||
compose_path = config.get_compose_path(stack)
|
||||
if not compose_path.exists():
|
||||
return []
|
||||
|
||||
@@ -194,21 +213,15 @@ def parse_host_volumes(config: Config, service: str) -> list[str]:
|
||||
paths.append(host_path)
|
||||
|
||||
# Return unique paths, preserving order
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for p in paths:
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
unique.append(p)
|
||||
return unique
|
||||
return list(dict.fromkeys(paths))
|
||||
|
||||
|
||||
def parse_devices(config: Config, service: str) -> list[str]:
|
||||
"""Extract host device paths from a service's compose file.
|
||||
def parse_devices(config: Config, stack: str) -> list[str]:
|
||||
"""Extract host device paths from a stack's compose file.
|
||||
|
||||
Returns a list of host device paths (e.g., /dev/dri, /dev/dri/renderD128).
|
||||
"""
|
||||
compose_path = config.get_compose_path(service)
|
||||
compose_path = config.get_compose_path(stack)
|
||||
if not compose_path.exists():
|
||||
return []
|
||||
|
||||
@@ -239,21 +252,15 @@ def parse_devices(config: Config, service: str) -> list[str]:
|
||||
devices.append(host_path)
|
||||
|
||||
# Return unique devices, preserving order
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for d in devices:
|
||||
if d not in seen:
|
||||
seen.add(d)
|
||||
unique.append(d)
|
||||
return unique
|
||||
return list(dict.fromkeys(devices))
|
||||
|
||||
|
||||
def parse_external_networks(config: Config, service: str) -> list[str]:
|
||||
"""Extract external network names from a service's compose file.
|
||||
def parse_external_networks(config: Config, stack: str) -> list[str]:
|
||||
"""Extract external network names from a stack's compose file.
|
||||
|
||||
Returns a list of network names marked as external: true.
|
||||
"""
|
||||
compose_path = config.get_compose_path(service)
|
||||
compose_path = config.get_compose_path(stack)
|
||||
if not compose_path.exists():
|
||||
return []
|
||||
|
||||
@@ -329,3 +336,18 @@ def get_ports_for_service(
|
||||
if isinstance(ref_def, dict):
|
||||
return _parse_ports(ref_def.get("ports"), env)
|
||||
return _parse_ports(definition.get("ports"), env)
|
||||
|
||||
|
||||
def get_container_name(
|
||||
service_name: str,
|
||||
service_def: dict[str, Any] | None,
|
||||
project_name: str,
|
||||
) -> str:
|
||||
"""Get the container name for a service.
|
||||
|
||||
Uses container_name from compose if set, otherwise defaults to {project}-{service}-1.
|
||||
This matches Docker Compose's default naming convention.
|
||||
"""
|
||||
if isinstance(service_def, dict) and service_def.get("container_name"):
|
||||
return str(service_def["container_name"])
|
||||
return f"{project_name}-{service_name}-1"
|
||||
|
||||
@@ -4,14 +4,18 @@ from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from .paths import config_search_paths, find_config_path
|
||||
|
||||
# Supported compose filenames, in priority order
|
||||
COMPOSE_FILENAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
|
||||
|
||||
class Host(BaseModel):
|
||||
|
||||
class Host(BaseModel, extra="forbid"):
|
||||
"""SSH host configuration."""
|
||||
|
||||
address: str
|
||||
@@ -19,14 +23,14 @@ class Host(BaseModel):
|
||||
port: int = 22
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
class Config(BaseModel, extra="forbid"):
|
||||
"""Main configuration."""
|
||||
|
||||
compose_dir: Path = Path("/opt/compose")
|
||||
hosts: dict[str, Host]
|
||||
services: dict[str, str | list[str]] # service_name -> host_name or list of hosts
|
||||
stacks: dict[str, str | list[str]] # stack_name -> host_name or list of hosts
|
||||
traefik_file: Path | None = None # Auto-regenerate traefik config after up/down
|
||||
traefik_service: str | None = None # Service name for Traefik (skip its host in file-provider)
|
||||
traefik_stack: str | None = None # Stack name for Traefik (skip its host in file-provider)
|
||||
config_path: Path = Path() # Set by load_config()
|
||||
|
||||
def get_state_path(self) -> Path:
|
||||
@@ -34,100 +38,83 @@ class Config(BaseModel):
|
||||
return self.config_path.parent / "compose-farm-state.yaml"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_hosts_and_services(self) -> Config:
|
||||
"""Validate host names and service configurations."""
|
||||
def validate_hosts_and_stacks(self) -> Config:
|
||||
"""Validate host names and stack configurations."""
|
||||
# "all" is reserved keyword, cannot be used as host name
|
||||
if "all" in self.hosts:
|
||||
msg = "'all' is a reserved keyword and cannot be used as a host name"
|
||||
raise ValueError(msg)
|
||||
|
||||
for service, host_value in self.services.items():
|
||||
for stack, host_value in self.stacks.items():
|
||||
# Validate list configurations
|
||||
if isinstance(host_value, list):
|
||||
if not host_value:
|
||||
msg = f"Service '{service}' has empty host list"
|
||||
msg = f"Stack '{stack}' has empty host list"
|
||||
raise ValueError(msg)
|
||||
if len(host_value) != len(set(host_value)):
|
||||
msg = f"Service '{service}' has duplicate hosts in list"
|
||||
msg = f"Stack '{stack}' has duplicate hosts in list"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Validate all referenced hosts exist
|
||||
host_names = self.get_hosts(service)
|
||||
host_names = self.get_hosts(stack)
|
||||
for host_name in host_names:
|
||||
if host_name not in self.hosts:
|
||||
msg = f"Service '{service}' references unknown host '{host_name}'"
|
||||
msg = f"Stack '{stack}' references unknown host '{host_name}'"
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
def get_hosts(self, service: str) -> list[str]:
|
||||
"""Get list of host names for a service.
|
||||
def get_hosts(self, stack: str) -> list[str]:
|
||||
"""Get list of host names for a stack.
|
||||
|
||||
Supports:
|
||||
- Single host: "truenas-debian" -> ["truenas-debian"]
|
||||
- All hosts: "all" -> list of all configured hosts
|
||||
- Explicit list: ["host1", "host2"] -> ["host1", "host2"]
|
||||
"""
|
||||
if service not in self.services:
|
||||
msg = f"Unknown service: {service}"
|
||||
if stack not in self.stacks:
|
||||
msg = f"Unknown stack: {stack}"
|
||||
raise ValueError(msg)
|
||||
host_value = self.services[service]
|
||||
host_value = self.stacks[stack]
|
||||
if isinstance(host_value, list):
|
||||
return host_value
|
||||
if host_value == "all":
|
||||
return list(self.hosts.keys())
|
||||
return [host_value]
|
||||
|
||||
def is_multi_host(self, service: str) -> bool:
|
||||
"""Check if a service runs on multiple hosts."""
|
||||
return len(self.get_hosts(service)) > 1
|
||||
def is_multi_host(self, stack: str) -> bool:
|
||||
"""Check if a stack runs on multiple hosts."""
|
||||
return len(self.get_hosts(stack)) > 1
|
||||
|
||||
def get_host(self, service: str) -> Host:
|
||||
"""Get host config for a service (first host if multi-host)."""
|
||||
if service not in self.services:
|
||||
msg = f"Unknown service: {service}"
|
||||
def get_host(self, stack: str) -> Host:
|
||||
"""Get host config for a stack (first host if multi-host)."""
|
||||
if stack not in self.stacks:
|
||||
msg = f"Unknown stack: {stack}"
|
||||
raise ValueError(msg)
|
||||
host_names = self.get_hosts(service)
|
||||
host_names = self.get_hosts(stack)
|
||||
return self.hosts[host_names[0]]
|
||||
|
||||
def get_compose_path(self, service: str) -> Path:
|
||||
"""Get compose file path for a service.
|
||||
|
||||
Tries compose.yaml first, then docker-compose.yml.
|
||||
"""
|
||||
service_dir = self.compose_dir / service
|
||||
for filename in (
|
||||
"compose.yaml",
|
||||
"compose.yml",
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
):
|
||||
candidate = service_dir / filename
|
||||
def get_compose_path(self, stack: str) -> Path:
|
||||
"""Get compose file path for a stack (tries compose.yaml first)."""
|
||||
stack_dir = self.compose_dir / stack
|
||||
for filename in COMPOSE_FILENAMES:
|
||||
candidate = stack_dir / filename
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
# Default to compose.yaml if none exist (will error later)
|
||||
return service_dir / "compose.yaml"
|
||||
return stack_dir / "compose.yaml"
|
||||
|
||||
def discover_compose_dirs(self) -> set[str]:
|
||||
"""Find all directories in compose_dir that contain a compose file."""
|
||||
compose_filenames = {
|
||||
"compose.yaml",
|
||||
"compose.yml",
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
}
|
||||
found: set[str] = set()
|
||||
if not self.compose_dir.exists():
|
||||
return found
|
||||
for subdir in self.compose_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
for filename in compose_filenames:
|
||||
if (subdir / filename).exists():
|
||||
found.add(subdir.name)
|
||||
break
|
||||
if subdir.is_dir() and any((subdir / f).exists() for f in COMPOSE_FILENAMES):
|
||||
found.add(subdir.name)
|
||||
return found
|
||||
|
||||
|
||||
def _parse_hosts(raw_hosts: dict[str, str | dict[str, str | int]]) -> dict[str, Host]:
|
||||
def _parse_hosts(raw_hosts: dict[str, Any]) -> dict[str, Host]:
|
||||
"""Parse hosts from config, handling both simple and full forms."""
|
||||
hosts = {}
|
||||
for name, value in raw_hosts.items():
|
||||
|
||||
@@ -9,7 +9,7 @@ err_console = Console(stderr=True, highlight=False)
|
||||
# --- Message Constants ---
|
||||
# Standardized message templates for consistent user-facing output
|
||||
|
||||
MSG_SERVICE_NOT_FOUND = "Service [cyan]{name}[/] not found in config"
|
||||
MSG_STACK_NOT_FOUND = "Stack [cyan]{name}[/] not found in config"
|
||||
MSG_HOST_NOT_FOUND = "Host [magenta]{name}[/] not found in config"
|
||||
MSG_CONFIG_NOT_FOUND = "Config file not found"
|
||||
MSG_DRY_RUN = "[dim](dry-run: no changes made)[/]"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Compose Farm configuration
|
||||
# Documentation: https://github.com/basnijholt/compose-farm
|
||||
#
|
||||
# This file configures compose-farm to manage Docker Compose services
|
||||
# This file configures compose-farm to manage Docker Compose stacks
|
||||
# across multiple hosts via SSH.
|
||||
#
|
||||
# Place this file at:
|
||||
@@ -11,7 +11,7 @@
|
||||
# - Or set CF_CONFIG environment variable
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# compose_dir: Directory containing service subdirectories with compose files
|
||||
# compose_dir: Directory containing stack subdirectories with compose files
|
||||
# ------------------------------------------------------------------------------
|
||||
# Each subdirectory should contain a compose.yaml (or docker-compose.yml).
|
||||
# This path must be the same on all hosts (NFS mount recommended).
|
||||
@@ -48,28 +48,28 @@ hosts:
|
||||
port: 2222
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# services: Map service names to their target host(s)
|
||||
# stacks: Map stack names to their target host(s)
|
||||
# ------------------------------------------------------------------------------
|
||||
# Each service name must match a subdirectory in compose_dir.
|
||||
# Each stack name must match a subdirectory in compose_dir.
|
||||
#
|
||||
# Single host:
|
||||
# service-name: hostname
|
||||
# stack-name: hostname
|
||||
#
|
||||
# Multiple hosts (explicit list):
|
||||
# service-name: [host1, host2]
|
||||
# stack-name: [host1, host2]
|
||||
#
|
||||
# All hosts:
|
||||
# service-name: all
|
||||
# stack-name: all
|
||||
#
|
||||
services:
|
||||
# Example: service runs on a single host
|
||||
stacks:
|
||||
# Example: stack runs on a single host
|
||||
nginx: server1
|
||||
postgres: server2
|
||||
|
||||
# Example: service runs on multiple specific hosts
|
||||
# Example: stack runs on multiple specific hosts
|
||||
# prometheus: [server1, server2]
|
||||
|
||||
# Example: service runs on ALL hosts (e.g., monitoring agents)
|
||||
# Example: stack runs on ALL hosts (e.g., monitoring agents)
|
||||
# node-exporter: all
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
@@ -81,9 +81,9 @@ services:
|
||||
# traefik_file: /opt/compose/traefik/dynamic.d/compose-farm.yml
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# traefik_service: (optional) Service name running Traefik
|
||||
# traefik_stack: (optional) Stack name running Traefik
|
||||
# ------------------------------------------------------------------------------
|
||||
# When generating traefik_file, services on the same host as Traefik are
|
||||
# When generating traefik_file, stacks on the same host as Traefik are
|
||||
# skipped (they're handled by Traefik's Docker provider directly).
|
||||
#
|
||||
# traefik_service: traefik
|
||||
# traefik_stack: traefik
|
||||
|
||||
@@ -23,6 +23,48 @@ LOCAL_ADDRESSES = frozenset({"local", "localhost", "127.0.0.1", "::1"})
|
||||
_DEFAULT_SSH_PORT = 22
|
||||
|
||||
|
||||
def _print_compose_command(
|
||||
host_name: str,
|
||||
compose_dir: str,
|
||||
compose_path: str,
|
||||
compose_cmd: str,
|
||||
) -> None:
|
||||
"""Print the docker compose command being executed.
|
||||
|
||||
Shows the host and a simplified command with relative path from compose_dir.
|
||||
"""
|
||||
# Show relative path from compose_dir for cleaner output
|
||||
if compose_path.startswith(compose_dir):
|
||||
rel_path = compose_path[len(compose_dir) :].lstrip("/")
|
||||
else:
|
||||
rel_path = compose_path
|
||||
|
||||
console.print(
|
||||
f"[dim][magenta]{host_name}[/magenta]: docker compose -f {rel_path} {compose_cmd}[/dim]"
|
||||
)
|
||||
|
||||
|
||||
async def _stream_output_lines(
|
||||
reader: Any,
|
||||
prefix: str,
|
||||
*,
|
||||
is_stderr: bool = False,
|
||||
) -> None:
|
||||
"""Stream lines from a reader to console with a stack prefix.
|
||||
|
||||
Works with both asyncio.StreamReader (bytes) and asyncssh readers (str).
|
||||
If prefix is empty, output is printed without a prefix.
|
||||
"""
|
||||
out = err_console if is_stderr else console
|
||||
async for line in reader:
|
||||
text = line.decode() if isinstance(line, bytes) else line
|
||||
if text.strip():
|
||||
if prefix:
|
||||
out.print(f"[cyan]\\[{prefix}][/] {escape(text)}", end="")
|
||||
else:
|
||||
out.print(escape(text), end="")
|
||||
|
||||
|
||||
def build_ssh_command(host: Host, command: str, *, tty: bool = False) -> list[str]:
|
||||
"""Build SSH command args for executing a command on a remote host.
|
||||
|
||||
@@ -84,7 +126,7 @@ def _get_local_ips() -> frozenset[str]:
|
||||
class CommandResult:
|
||||
"""Result of a command execution."""
|
||||
|
||||
service: str
|
||||
stack: str
|
||||
exit_code: int
|
||||
success: bool
|
||||
stdout: str = ""
|
||||
@@ -130,10 +172,11 @@ def ssh_connect_kwargs(host: Host) -> dict[str, Any]:
|
||||
|
||||
async def _run_local_command(
|
||||
command: str,
|
||||
service: str,
|
||||
stack: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
prefix: str = "",
|
||||
) -> CommandResult:
|
||||
"""Run a command locally with streaming output."""
|
||||
try:
|
||||
@@ -146,7 +189,7 @@ async def _run_local_command(
|
||||
)
|
||||
await proc.wait()
|
||||
return CommandResult(
|
||||
service=service,
|
||||
stack=stack,
|
||||
exit_code=proc.returncode or 0,
|
||||
success=proc.returncode == 0,
|
||||
)
|
||||
@@ -158,25 +201,9 @@ async def _run_local_command(
|
||||
)
|
||||
|
||||
if stream and proc.stdout and proc.stderr:
|
||||
|
||||
async def read_stream(
|
||||
reader: asyncio.StreamReader,
|
||||
prefix: str,
|
||||
*,
|
||||
is_stderr: bool = False,
|
||||
) -> None:
|
||||
out = err_console if is_stderr else console
|
||||
while True:
|
||||
line = await reader.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode()
|
||||
if text.strip(): # Skip empty lines
|
||||
out.print(f"[cyan]\\[{prefix}][/] {escape(text)}", end="")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(proc.stdout, service),
|
||||
read_stream(proc.stderr, service, is_stderr=True),
|
||||
_stream_output_lines(proc.stdout, prefix),
|
||||
_stream_output_lines(proc.stderr, prefix, is_stderr=True),
|
||||
)
|
||||
|
||||
stdout_data = b""
|
||||
@@ -187,34 +214,39 @@ async def _run_local_command(
|
||||
await proc.wait()
|
||||
|
||||
return CommandResult(
|
||||
service=service,
|
||||
stack=stack,
|
||||
exit_code=proc.returncode or 0,
|
||||
success=proc.returncode == 0,
|
||||
stdout=stdout_data.decode() if stdout_data else "",
|
||||
stderr=stderr_data.decode() if stderr_data else "",
|
||||
)
|
||||
except OSError as e:
|
||||
err_console.print(f"[cyan]\\[{service}][/] [red]Local error:[/] {e}")
|
||||
return CommandResult(service=service, exit_code=1, success=False)
|
||||
err_console.print(f"[cyan]\\[{stack}][/] [red]Local error:[/] {e}")
|
||||
return CommandResult(stack=stack, exit_code=1, success=False)
|
||||
|
||||
|
||||
async def _run_ssh_command(
|
||||
host: Host,
|
||||
command: str,
|
||||
service: str,
|
||||
stack: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
prefix: str = "",
|
||||
) -> CommandResult:
|
||||
"""Run a command on a remote host via SSH with streaming output."""
|
||||
if raw:
|
||||
# Use native ssh with TTY for proper progress bar rendering
|
||||
ssh_args = build_ssh_command(host, command, tty=True)
|
||||
|
||||
def run_ssh() -> subprocess.CompletedProcess[bytes]:
|
||||
return subprocess.run(ssh_args, check=False, env=get_ssh_env())
|
||||
|
||||
# Run in thread to avoid blocking the event loop
|
||||
# Use get_ssh_env() to auto-detect SSH agent socket
|
||||
result = await asyncio.to_thread(subprocess.run, ssh_args, check=False, env=get_ssh_env())
|
||||
result = await asyncio.to_thread(run_ssh)
|
||||
return CommandResult(
|
||||
service=service,
|
||||
stack=stack,
|
||||
exit_code=result.returncode,
|
||||
success=result.returncode == 0,
|
||||
)
|
||||
@@ -226,21 +258,9 @@ async def _run_ssh_command(
|
||||
async with asyncssh.connect(**ssh_connect_kwargs(host)) as conn: # noqa: SIM117
|
||||
async with conn.create_process(command) as proc:
|
||||
if stream:
|
||||
|
||||
async def read_stream(
|
||||
reader: Any,
|
||||
prefix: str,
|
||||
*,
|
||||
is_stderr: bool = False,
|
||||
) -> None:
|
||||
out = err_console if is_stderr else console
|
||||
async for line in reader:
|
||||
if line.strip(): # Skip empty lines
|
||||
out.print(f"[cyan]\\[{prefix}][/] {escape(line)}", end="")
|
||||
|
||||
await asyncio.gather(
|
||||
read_stream(proc.stdout, service),
|
||||
read_stream(proc.stderr, service, is_stderr=True),
|
||||
_stream_output_lines(proc.stdout, prefix),
|
||||
_stream_output_lines(proc.stderr, prefix, is_stderr=True),
|
||||
)
|
||||
|
||||
stdout_data = ""
|
||||
@@ -251,131 +271,155 @@ async def _run_ssh_command(
|
||||
|
||||
await proc.wait()
|
||||
return CommandResult(
|
||||
service=service,
|
||||
stack=stack,
|
||||
exit_code=proc.exit_status or 0,
|
||||
success=proc.exit_status == 0,
|
||||
stdout=stdout_data,
|
||||
stderr=stderr_data,
|
||||
)
|
||||
except (OSError, asyncssh.Error) as e:
|
||||
err_console.print(f"[cyan]\\[{service}][/] [red]SSH error:[/] {e}")
|
||||
return CommandResult(service=service, exit_code=1, success=False)
|
||||
err_console.print(f"[cyan]\\[{stack}][/] [red]SSH error:[/] {e}")
|
||||
return CommandResult(stack=stack, exit_code=1, success=False)
|
||||
|
||||
|
||||
async def run_command(
|
||||
host: Host,
|
||||
command: str,
|
||||
service: str,
|
||||
stack: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
prefix: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Run a command on a host (locally or via SSH).
|
||||
|
||||
Args:
|
||||
host: Host configuration
|
||||
command: Command to run
|
||||
service: Service name (used as prefix in output)
|
||||
stack: Stack name (stored in result)
|
||||
stream: Whether to stream output (default True)
|
||||
raw: Whether to use raw mode with TTY (default False)
|
||||
prefix: Output prefix. None=use stack name, ""=no prefix.
|
||||
|
||||
"""
|
||||
output_prefix = stack if prefix is None else prefix
|
||||
if is_local(host):
|
||||
return await _run_local_command(command, service, stream=stream, raw=raw)
|
||||
return await _run_ssh_command(host, command, service, stream=stream, raw=raw)
|
||||
return await _run_local_command(
|
||||
command, stack, stream=stream, raw=raw, prefix=output_prefix
|
||||
)
|
||||
return await _run_ssh_command(
|
||||
host, command, stack, stream=stream, raw=raw, prefix=output_prefix
|
||||
)
|
||||
|
||||
|
||||
async def run_compose(
|
||||
config: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
prefix: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Run a docker compose command for a service."""
|
||||
host = config.get_host(service)
|
||||
compose_path = config.get_compose_path(service)
|
||||
"""Run a docker compose command for a stack."""
|
||||
host_name = config.get_hosts(stack)[0]
|
||||
host = config.hosts[host_name]
|
||||
compose_path = config.get_compose_path(stack)
|
||||
|
||||
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), compose_cmd)
|
||||
|
||||
command = f"docker compose -f {compose_path} {compose_cmd}"
|
||||
return await run_command(host, command, service, stream=stream, raw=raw)
|
||||
return await run_command(host, command, stack, stream=stream, raw=raw, prefix=prefix)
|
||||
|
||||
|
||||
async def run_compose_on_host(
|
||||
config: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
host_name: str,
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
prefix: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Run a docker compose command for a service on a specific host.
|
||||
"""Run a docker compose command for a stack on a specific host.
|
||||
|
||||
Used for migration - running 'down' on the old host before 'up' on new host.
|
||||
"""
|
||||
host = config.hosts[host_name]
|
||||
compose_path = config.get_compose_path(service)
|
||||
compose_path = config.get_compose_path(stack)
|
||||
|
||||
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), compose_cmd)
|
||||
|
||||
command = f"docker compose -f {compose_path} {compose_cmd}"
|
||||
return await run_command(host, command, service, stream=stream, raw=raw)
|
||||
return await run_command(host, command, stack, stream=stream, raw=raw, prefix=prefix)
|
||||
|
||||
|
||||
async def run_on_services(
|
||||
async def run_on_stacks(
|
||||
config: Config,
|
||||
services: list[str],
|
||||
stacks: list[str],
|
||||
compose_cmd: str,
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> list[CommandResult]:
|
||||
"""Run a docker compose command on multiple services in parallel.
|
||||
"""Run a docker compose command on multiple stacks in parallel.
|
||||
|
||||
For multi-host services, runs on all configured hosts.
|
||||
Note: raw=True only makes sense for single-service operations.
|
||||
For multi-host stacks, runs on all configured hosts.
|
||||
Note: raw=True only makes sense for single-stack operations.
|
||||
"""
|
||||
return await run_sequential_on_services(config, services, [compose_cmd], stream=stream, raw=raw)
|
||||
return await run_sequential_on_stacks(config, stacks, [compose_cmd], stream=stream, raw=raw)
|
||||
|
||||
|
||||
async def _run_sequential_commands(
|
||||
async def _run_sequential_stack_commands(
|
||||
config: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
commands: list[str],
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
prefix: str | None = None,
|
||||
) -> CommandResult:
|
||||
"""Run multiple compose commands sequentially for a service."""
|
||||
"""Run multiple compose commands sequentially for a stack."""
|
||||
for cmd in commands:
|
||||
result = await run_compose(config, service, cmd, stream=stream, raw=raw)
|
||||
result = await run_compose(config, stack, cmd, stream=stream, raw=raw, prefix=prefix)
|
||||
if not result.success:
|
||||
return result
|
||||
return CommandResult(service=service, exit_code=0, success=True)
|
||||
return CommandResult(stack=stack, exit_code=0, success=True)
|
||||
|
||||
|
||||
async def _run_sequential_commands_multi_host(
|
||||
async def _run_sequential_stack_commands_multi_host(
|
||||
config: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
commands: list[str],
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
prefix: str | None = None,
|
||||
) -> list[CommandResult]:
|
||||
"""Run multiple compose commands sequentially for a multi-host service.
|
||||
"""Run multiple compose commands sequentially for a multi-host stack.
|
||||
|
||||
Commands are run sequentially, but each command runs on all hosts in parallel.
|
||||
For multi-host stacks, prefix defaults to stack@host format.
|
||||
"""
|
||||
host_names = config.get_hosts(service)
|
||||
compose_path = config.get_compose_path(service)
|
||||
host_names = config.get_hosts(stack)
|
||||
compose_path = config.get_compose_path(stack)
|
||||
final_results: list[CommandResult] = []
|
||||
|
||||
for cmd in commands:
|
||||
command = f"docker compose -f {compose_path} {cmd}"
|
||||
tasks = []
|
||||
for host_name in host_names:
|
||||
_print_compose_command(host_name, str(config.compose_dir), str(compose_path), cmd)
|
||||
host = config.hosts[host_name]
|
||||
label = f"{service}@{host_name}" if len(host_names) > 1 else service
|
||||
tasks.append(run_command(host, command, label, stream=stream, raw=raw))
|
||||
# For multi-host stacks, always use stack@host prefix to distinguish output
|
||||
label = f"{stack}@{host_name}" if len(host_names) > 1 else stack
|
||||
# Multi-host stacks always need prefixes to distinguish output from different hosts
|
||||
# (ignore empty prefix from single-stack batches - we still need to distinguish hosts)
|
||||
effective_prefix = label if len(host_names) > 1 else prefix
|
||||
tasks.append(
|
||||
run_command(host, command, label, stream=stream, raw=raw, prefix=effective_prefix)
|
||||
)
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
final_results = list(results)
|
||||
@@ -387,33 +431,38 @@ async def _run_sequential_commands_multi_host(
|
||||
return final_results
|
||||
|
||||
|
||||
async def run_sequential_on_services(
|
||||
async def run_sequential_on_stacks(
|
||||
config: Config,
|
||||
services: list[str],
|
||||
stacks: list[str],
|
||||
commands: list[str],
|
||||
*,
|
||||
stream: bool = True,
|
||||
raw: bool = False,
|
||||
) -> list[CommandResult]:
|
||||
"""Run sequential commands on multiple services in parallel.
|
||||
"""Run sequential commands on multiple stacks in parallel.
|
||||
|
||||
For multi-host services, runs on all configured hosts.
|
||||
Note: raw=True only makes sense for single-service operations.
|
||||
For multi-host stacks, runs on all configured hosts.
|
||||
Note: raw=True only makes sense for single-stack operations.
|
||||
"""
|
||||
# Separate multi-host and single-host services for type-safe gathering
|
||||
# Skip prefix for single-stack operations (command line already shows context)
|
||||
prefix: str | None = "" if len(stacks) == 1 else None
|
||||
|
||||
# Separate multi-host and single-host stacks for type-safe gathering
|
||||
multi_host_tasks = []
|
||||
single_host_tasks = []
|
||||
|
||||
for service in services:
|
||||
if config.is_multi_host(service):
|
||||
for stack in stacks:
|
||||
if config.is_multi_host(stack):
|
||||
multi_host_tasks.append(
|
||||
_run_sequential_commands_multi_host(
|
||||
config, service, commands, stream=stream, raw=raw
|
||||
_run_sequential_stack_commands_multi_host(
|
||||
config, stack, commands, stream=stream, raw=raw, prefix=prefix
|
||||
)
|
||||
)
|
||||
else:
|
||||
single_host_tasks.append(
|
||||
_run_sequential_commands(config, service, commands, stream=stream, raw=raw)
|
||||
_run_sequential_stack_commands(
|
||||
config, stack, commands, stream=stream, raw=raw, prefix=prefix
|
||||
)
|
||||
)
|
||||
|
||||
# Gather results separately to maintain type safety
|
||||
@@ -431,18 +480,18 @@ async def run_sequential_on_services(
|
||||
return flat_results
|
||||
|
||||
|
||||
async def check_service_running(
|
||||
async def check_stack_running(
|
||||
config: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
host_name: str,
|
||||
) -> bool:
|
||||
"""Check if a service has running containers on a specific host."""
|
||||
"""Check if a stack has running containers on a specific host."""
|
||||
host = config.hosts[host_name]
|
||||
compose_path = config.get_compose_path(service)
|
||||
compose_path = config.get_compose_path(stack)
|
||||
|
||||
# Use ps --status running to check for running containers
|
||||
command = f"docker compose -f {compose_path} ps --status running -q"
|
||||
result = await run_command(host, command, service, stream=False)
|
||||
result = await run_command(host, command, stack, stream=False)
|
||||
|
||||
# If command succeeded and has output, containers are running
|
||||
return result.success and bool(result.stdout.strip())
|
||||
|
||||
@@ -25,9 +25,9 @@ _DIGEST_HEX_LENGTH = 64
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SnapshotEntry:
|
||||
"""Normalized image snapshot for a single service."""
|
||||
"""Normalized image snapshot for a single stack."""
|
||||
|
||||
service: str
|
||||
stack: str
|
||||
host: str
|
||||
compose_file: Path
|
||||
image: str
|
||||
@@ -37,7 +37,7 @@ class SnapshotEntry:
|
||||
def as_dict(self, first_seen: str, last_seen: str) -> dict[str, str]:
|
||||
"""Render snapshot as a TOML-friendly dict."""
|
||||
return {
|
||||
"service": self.service,
|
||||
"stack": self.stack,
|
||||
"host": self.host,
|
||||
"compose_file": str(self.compose_file),
|
||||
"image": self.image,
|
||||
@@ -103,24 +103,24 @@ def _extract_image_fields(record: dict[str, Any]) -> tuple[str, str]:
|
||||
return image, digest
|
||||
|
||||
|
||||
async def collect_service_entries(
|
||||
async def collect_stack_entries(
|
||||
config: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
*,
|
||||
now: datetime,
|
||||
run_compose_fn: Callable[..., Awaitable[CommandResult]] = run_compose,
|
||||
) -> list[SnapshotEntry]:
|
||||
"""Run `docker compose images` for a service and normalize results."""
|
||||
result = await run_compose_fn(config, service, "images --format json", stream=False)
|
||||
"""Run `docker compose images` for a stack and normalize results."""
|
||||
result = await run_compose_fn(config, stack, "images --format json", stream=False)
|
||||
if not result.success:
|
||||
msg = result.stderr or f"compose images exited with {result.exit_code}"
|
||||
error = f"[{service}] Unable to read images: {msg}"
|
||||
error = f"[{stack}] Unable to read images: {msg}"
|
||||
raise RuntimeError(error)
|
||||
|
||||
records = _parse_images_output(result.stdout)
|
||||
# Use first host for snapshots (multi-host services use same images on all hosts)
|
||||
host_name = config.get_hosts(service)[0]
|
||||
compose_path = config.get_compose_path(service)
|
||||
# Use first host for snapshots (multi-host stacks use same images on all hosts)
|
||||
host_name = config.get_hosts(stack)[0]
|
||||
compose_path = config.get_compose_path(stack)
|
||||
|
||||
entries: list[SnapshotEntry] = []
|
||||
for record in records:
|
||||
@@ -129,7 +129,7 @@ async def collect_service_entries(
|
||||
continue
|
||||
entries.append(
|
||||
SnapshotEntry(
|
||||
service=service,
|
||||
stack=stack,
|
||||
host=host_name,
|
||||
compose_file=compose_path,
|
||||
image=image,
|
||||
@@ -145,7 +145,14 @@ def load_existing_entries(log_path: Path) -> list[dict[str, str]]:
|
||||
if not log_path.exists():
|
||||
return []
|
||||
data = tomllib.loads(log_path.read_text())
|
||||
return list(data.get("entries", []))
|
||||
entries = list(data.get("entries", []))
|
||||
normalized: list[dict[str, str]] = []
|
||||
for entry in entries:
|
||||
normalized_entry = dict(entry)
|
||||
if "stack" not in normalized_entry and "service" in normalized_entry:
|
||||
normalized_entry["stack"] = normalized_entry.pop("service")
|
||||
normalized.append(normalized_entry)
|
||||
return normalized
|
||||
|
||||
|
||||
def merge_entries(
|
||||
@@ -156,11 +163,11 @@ def merge_entries(
|
||||
) -> list[dict[str, str]]:
|
||||
"""Merge new snapshot entries with existing ones, preserving first_seen timestamps."""
|
||||
merged: dict[tuple[str, str, str], dict[str, str]] = {
|
||||
(e["service"], e["host"], e["digest"]): dict(e) for e in existing
|
||||
(e["stack"], e["host"], e["digest"]): dict(e) for e in existing
|
||||
}
|
||||
|
||||
for entry in new_entries:
|
||||
key = (entry.service, entry.host, entry.digest)
|
||||
key = (entry.stack, entry.host, entry.digest)
|
||||
first_seen = merged.get(key, {}).get("first_seen", now_iso)
|
||||
merged[key] = entry.as_dict(first_seen, now_iso)
|
||||
|
||||
@@ -175,10 +182,10 @@ def write_toml(log_path: Path, *, meta: dict[str, str], entries: list[dict[str,
|
||||
if entries:
|
||||
lines.append("")
|
||||
|
||||
for entry in sorted(entries, key=lambda e: (e["service"], e["host"], e["digest"])):
|
||||
for entry in sorted(entries, key=lambda e: (e["stack"], e["host"], e["digest"])):
|
||||
lines.append("[[entries]]")
|
||||
for field in [
|
||||
"service",
|
||||
"stack",
|
||||
"host",
|
||||
"compose_file",
|
||||
"image",
|
||||
|
||||
@@ -15,17 +15,17 @@ from .executor import (
|
||||
CommandResult,
|
||||
check_networks_exist,
|
||||
check_paths_exist,
|
||||
check_service_running,
|
||||
check_stack_running,
|
||||
run_command,
|
||||
run_compose,
|
||||
run_compose_on_host,
|
||||
)
|
||||
from .state import (
|
||||
get_orphaned_services,
|
||||
get_service_host,
|
||||
remove_service,
|
||||
set_multi_host_service,
|
||||
set_service_host,
|
||||
get_orphaned_stacks,
|
||||
get_stack_host,
|
||||
remove_stack,
|
||||
set_multi_host_stack,
|
||||
set_stack_host,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -37,7 +37,7 @@ class OperationInterruptedError(Exception):
|
||||
|
||||
|
||||
class PreflightResult(NamedTuple):
|
||||
"""Result of pre-flight checks for a service on a host."""
|
||||
"""Result of pre-flight checks for a stack on a host."""
|
||||
|
||||
missing_paths: list[str]
|
||||
missing_networks: list[str]
|
||||
@@ -51,7 +51,7 @@ class PreflightResult(NamedTuple):
|
||||
|
||||
async def _run_compose_step(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
command: str,
|
||||
*,
|
||||
raw: bool,
|
||||
@@ -59,9 +59,9 @@ async def _run_compose_step(
|
||||
) -> CommandResult:
|
||||
"""Run a compose command, handle raw output newline, and check for interrupts."""
|
||||
if host:
|
||||
result = await run_compose_on_host(cfg, service, host, command, raw=raw)
|
||||
result = await run_compose_on_host(cfg, stack, host, command, raw=raw)
|
||||
else:
|
||||
result = await run_compose(cfg, service, command, raw=raw)
|
||||
result = await run_compose(cfg, stack, command, raw=raw)
|
||||
if raw:
|
||||
print() # Ensure newline after raw output
|
||||
if result.interrupted:
|
||||
@@ -69,63 +69,61 @@ async def _run_compose_step(
|
||||
return result
|
||||
|
||||
|
||||
def get_service_paths(cfg: Config, service: str) -> list[str]:
|
||||
"""Get all required paths for a service (compose_dir + volumes)."""
|
||||
def get_stack_paths(cfg: Config, stack: str) -> list[str]:
|
||||
"""Get all required paths for a stack (compose_dir + volumes)."""
|
||||
paths = [str(cfg.compose_dir)]
|
||||
paths.extend(parse_host_volumes(cfg, service))
|
||||
paths.extend(parse_host_volumes(cfg, stack))
|
||||
return paths
|
||||
|
||||
|
||||
async def discover_service_host(cfg: Config, service: str) -> tuple[str, str | list[str] | None]:
|
||||
"""Discover where a service is running.
|
||||
async def discover_stack_host(cfg: Config, stack: str) -> tuple[str, str | list[str] | None]:
|
||||
"""Discover where a stack is running.
|
||||
|
||||
For multi-host services, checks all assigned hosts in parallel.
|
||||
For multi-host stacks, checks all assigned hosts in parallel.
|
||||
For single-host, checks assigned host first, then others.
|
||||
|
||||
Returns (service_name, host_or_hosts_or_none).
|
||||
Returns (stack_name, host_or_hosts_or_none).
|
||||
"""
|
||||
assigned_hosts = cfg.get_hosts(service)
|
||||
assigned_hosts = cfg.get_hosts(stack)
|
||||
|
||||
if cfg.is_multi_host(service):
|
||||
if cfg.is_multi_host(stack):
|
||||
# Check all assigned hosts in parallel
|
||||
checks = await asyncio.gather(
|
||||
*[check_service_running(cfg, service, h) for h in assigned_hosts]
|
||||
)
|
||||
checks = await asyncio.gather(*[check_stack_running(cfg, stack, h) for h in assigned_hosts])
|
||||
running = [h for h, is_running in zip(assigned_hosts, checks, strict=True) if is_running]
|
||||
return service, running if running else None
|
||||
return stack, running if running else None
|
||||
|
||||
# Single-host: check assigned host first, then others
|
||||
if await check_service_running(cfg, service, assigned_hosts[0]):
|
||||
return service, assigned_hosts[0]
|
||||
if await check_stack_running(cfg, stack, assigned_hosts[0]):
|
||||
return stack, assigned_hosts[0]
|
||||
for host in cfg.hosts:
|
||||
if host != assigned_hosts[0] and await check_service_running(cfg, service, host):
|
||||
return service, host
|
||||
return service, None
|
||||
if host != assigned_hosts[0] and await check_stack_running(cfg, stack, host):
|
||||
return stack, host
|
||||
return stack, None
|
||||
|
||||
|
||||
async def check_service_requirements(
|
||||
async def check_stack_requirements(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
host_name: str,
|
||||
) -> PreflightResult:
|
||||
"""Check if a service can run on a specific host.
|
||||
"""Check if a stack can run on a specific host.
|
||||
|
||||
Verifies that all required paths (volumes), networks, and devices exist.
|
||||
"""
|
||||
# Check mount paths
|
||||
paths = get_service_paths(cfg, service)
|
||||
paths = get_stack_paths(cfg, stack)
|
||||
path_exists = await check_paths_exist(cfg, host_name, paths)
|
||||
missing_paths = [p for p, found in path_exists.items() if not found]
|
||||
|
||||
# Check external networks
|
||||
networks = parse_external_networks(cfg, service)
|
||||
networks = parse_external_networks(cfg, stack)
|
||||
missing_networks: list[str] = []
|
||||
if networks:
|
||||
net_exists = await check_networks_exist(cfg, host_name, networks)
|
||||
missing_networks = [n for n, found in net_exists.items() if not found]
|
||||
|
||||
# Check devices
|
||||
devices = parse_devices(cfg, service)
|
||||
devices = parse_devices(cfg, stack)
|
||||
missing_devices: list[str] = []
|
||||
if devices:
|
||||
dev_exists = await check_paths_exist(cfg, host_name, devices)
|
||||
@@ -136,7 +134,7 @@ async def check_service_requirements(
|
||||
|
||||
async def _cleanup_and_rollback(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
target_host: str,
|
||||
current_host: str,
|
||||
prefix: str,
|
||||
@@ -146,29 +144,29 @@ async def _cleanup_and_rollback(
|
||||
) -> None:
|
||||
"""Clean up failed start and attempt rollback to old host if it was running."""
|
||||
print_warning(f"{prefix} Cleaning up failed start on [magenta]{target_host}[/]")
|
||||
await run_compose(cfg, service, "down", raw=raw)
|
||||
await run_compose(cfg, stack, "down", raw=raw)
|
||||
|
||||
if not was_running:
|
||||
err_console.print(
|
||||
f"{prefix} [dim]Service was not running on [magenta]{current_host}[/], skipping rollback[/]"
|
||||
f"{prefix} [dim]Stack was not running on [magenta]{current_host}[/], skipping rollback[/]"
|
||||
)
|
||||
return
|
||||
|
||||
print_warning(f"{prefix} Rolling back to [magenta]{current_host}[/]...")
|
||||
rollback_result = await run_compose_on_host(cfg, service, current_host, "up -d", raw=raw)
|
||||
rollback_result = await run_compose_on_host(cfg, stack, current_host, "up -d", raw=raw)
|
||||
if rollback_result.success:
|
||||
print_success(f"{prefix} Rollback succeeded on [magenta]{current_host}[/]")
|
||||
else:
|
||||
print_error(f"{prefix} Rollback failed - service is down")
|
||||
print_error(f"{prefix} Rollback failed - stack is down")
|
||||
|
||||
|
||||
def _report_preflight_failures(
|
||||
service: str,
|
||||
stack: str,
|
||||
target_host: str,
|
||||
preflight: PreflightResult,
|
||||
) -> None:
|
||||
"""Report pre-flight check failures."""
|
||||
print_error(f"[cyan]\\[{service}][/] Cannot start on [magenta]{target_host}[/]:")
|
||||
print_error(f"[cyan]\\[{stack}][/] Cannot start on [magenta]{target_host}[/]:")
|
||||
for path in preflight.missing_paths:
|
||||
print_error(f" missing path: {path}")
|
||||
for net in preflight.missing_networks:
|
||||
@@ -179,27 +177,25 @@ def _report_preflight_failures(
|
||||
print_error(f" missing device: {dev}")
|
||||
|
||||
|
||||
async def _up_multi_host_service(
|
||||
async def _up_multi_host_stack(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
prefix: str,
|
||||
*,
|
||||
raw: bool = False,
|
||||
) -> list[CommandResult]:
|
||||
"""Start a multi-host service on all configured hosts."""
|
||||
host_names = cfg.get_hosts(service)
|
||||
"""Start a multi-host stack on all configured hosts."""
|
||||
host_names = cfg.get_hosts(stack)
|
||||
results: list[CommandResult] = []
|
||||
compose_path = cfg.get_compose_path(service)
|
||||
compose_path = cfg.get_compose_path(stack)
|
||||
command = f"docker compose -f {compose_path} up -d"
|
||||
|
||||
# Pre-flight checks on all hosts
|
||||
for host_name in host_names:
|
||||
preflight = await check_service_requirements(cfg, service, host_name)
|
||||
preflight = await check_stack_requirements(cfg, stack, host_name)
|
||||
if not preflight.ok:
|
||||
_report_preflight_failures(service, host_name, preflight)
|
||||
results.append(
|
||||
CommandResult(service=f"{service}@{host_name}", exit_code=1, success=False)
|
||||
)
|
||||
_report_preflight_failures(stack, host_name, preflight)
|
||||
results.append(CommandResult(stack=f"{stack}@{host_name}", exit_code=1, success=False))
|
||||
return results
|
||||
|
||||
# Start on all hosts
|
||||
@@ -209,7 +205,7 @@ async def _up_multi_host_service(
|
||||
succeeded_hosts: list[str] = []
|
||||
for host_name in host_names:
|
||||
host = cfg.hosts[host_name]
|
||||
label = f"{service}@{host_name}"
|
||||
label = f"{stack}@{host_name}"
|
||||
result = await run_command(host, command, label, stream=not raw, raw=raw)
|
||||
if raw:
|
||||
print() # Ensure newline after raw output
|
||||
@@ -219,72 +215,70 @@ async def _up_multi_host_service(
|
||||
|
||||
# Update state with hosts that succeeded (partial success is tracked)
|
||||
if succeeded_hosts:
|
||||
set_multi_host_service(cfg, service, succeeded_hosts)
|
||||
set_multi_host_stack(cfg, stack, succeeded_hosts)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _migrate_service(
|
||||
async def _migrate_stack(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
current_host: str,
|
||||
target_host: str,
|
||||
prefix: str,
|
||||
*,
|
||||
raw: bool = False,
|
||||
) -> CommandResult | None:
|
||||
"""Migrate a service from current_host to target_host.
|
||||
"""Migrate a stack from current_host to target_host.
|
||||
|
||||
Pre-pulls/builds images on target, then stops service on current host.
|
||||
Pre-pulls/builds images on target, then stops stack on current host.
|
||||
Returns failure result if migration prep fails, None on success.
|
||||
"""
|
||||
console.print(
|
||||
f"{prefix} Migrating from [magenta]{current_host}[/] → [magenta]{target_host}[/]..."
|
||||
)
|
||||
|
||||
# Prepare images on target host before stopping old service to minimize downtime.
|
||||
# Pull handles image-based services; build handles Dockerfile-based services.
|
||||
# Prepare images on target host before stopping old stack to minimize downtime.
|
||||
# Pull handles image-based compose services; build handles Dockerfile-based ones.
|
||||
# --ignore-buildable makes pull skip images that have build: defined.
|
||||
for cmd, label in [("pull --ignore-buildable", "Pull"), ("build", "Build")]:
|
||||
result = await _run_compose_step(cfg, service, cmd, raw=raw)
|
||||
result = await _run_compose_step(cfg, stack, cmd, raw=raw)
|
||||
if not result.success:
|
||||
print_error(
|
||||
f"{prefix} {label} failed on [magenta]{target_host}[/], "
|
||||
"leaving service on current host"
|
||||
"leaving stack on current host"
|
||||
)
|
||||
return result
|
||||
|
||||
# Stop on current host
|
||||
down_result = await _run_compose_step(cfg, service, "down", raw=raw, host=current_host)
|
||||
down_result = await _run_compose_step(cfg, stack, "down", raw=raw, host=current_host)
|
||||
return down_result if not down_result.success else None
|
||||
|
||||
|
||||
async def _up_single_service(
|
||||
async def _up_single_stack(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
prefix: str,
|
||||
*,
|
||||
raw: bool,
|
||||
) -> CommandResult:
|
||||
"""Start a single-host service with migration support."""
|
||||
target_host = cfg.get_hosts(service)[0]
|
||||
current_host = get_service_host(cfg, service)
|
||||
"""Start a single-host stack with migration support."""
|
||||
target_host = cfg.get_hosts(stack)[0]
|
||||
current_host = get_stack_host(cfg, stack)
|
||||
|
||||
# Pre-flight check: verify paths, networks, and devices exist on target
|
||||
preflight = await check_service_requirements(cfg, service, target_host)
|
||||
preflight = await check_stack_requirements(cfg, stack, target_host)
|
||||
if not preflight.ok:
|
||||
_report_preflight_failures(service, target_host, preflight)
|
||||
return CommandResult(service=service, exit_code=1, success=False)
|
||||
_report_preflight_failures(stack, target_host, preflight)
|
||||
return CommandResult(stack=stack, exit_code=1, success=False)
|
||||
|
||||
# If service is deployed elsewhere, migrate it
|
||||
# If stack is deployed elsewhere, migrate it
|
||||
did_migration = False
|
||||
was_running = False
|
||||
if current_host and current_host != target_host:
|
||||
if current_host in cfg.hosts:
|
||||
was_running = await check_service_running(cfg, service, current_host)
|
||||
failure = await _migrate_service(
|
||||
cfg, service, current_host, target_host, prefix, raw=raw
|
||||
)
|
||||
was_running = await check_stack_running(cfg, stack, current_host)
|
||||
failure = await _migrate_stack(cfg, stack, current_host, target_host, prefix, raw=raw)
|
||||
if failure:
|
||||
return failure
|
||||
did_migration = True
|
||||
@@ -295,15 +289,15 @@ async def _up_single_service(
|
||||
|
||||
# Start on target host
|
||||
console.print(f"{prefix} Starting on [magenta]{target_host}[/]...")
|
||||
up_result = await _run_compose_step(cfg, service, "up -d", raw=raw)
|
||||
up_result = await _run_compose_step(cfg, stack, "up -d", raw=raw)
|
||||
|
||||
# Update state on success, or rollback on failure
|
||||
if up_result.success:
|
||||
set_service_host(cfg, service, target_host)
|
||||
set_stack_host(cfg, stack, target_host)
|
||||
elif did_migration and current_host:
|
||||
await _cleanup_and_rollback(
|
||||
cfg,
|
||||
service,
|
||||
stack,
|
||||
target_host,
|
||||
current_host,
|
||||
prefix,
|
||||
@@ -314,24 +308,24 @@ async def _up_single_service(
|
||||
return up_result
|
||||
|
||||
|
||||
async def up_services(
|
||||
async def up_stacks(
|
||||
cfg: Config,
|
||||
services: list[str],
|
||||
stacks: list[str],
|
||||
*,
|
||||
raw: bool = False,
|
||||
) -> list[CommandResult]:
|
||||
"""Start services with automatic migration if host changed."""
|
||||
"""Start stacks with automatic migration if host changed."""
|
||||
results: list[CommandResult] = []
|
||||
total = len(services)
|
||||
total = len(stacks)
|
||||
|
||||
try:
|
||||
for idx, service in enumerate(services, 1):
|
||||
prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{service}][/]"
|
||||
for idx, stack in enumerate(stacks, 1):
|
||||
prefix = f"[dim][{idx}/{total}][/] [cyan]\\[{stack}][/]"
|
||||
|
||||
if cfg.is_multi_host(service):
|
||||
results.extend(await _up_multi_host_service(cfg, service, prefix, raw=raw))
|
||||
if cfg.is_multi_host(stack):
|
||||
results.extend(await _up_multi_host_stack(cfg, stack, prefix, raw=raw))
|
||||
else:
|
||||
results.append(await _up_single_service(cfg, service, prefix, raw=raw))
|
||||
results.append(await _up_single_stack(cfg, stack, prefix, raw=raw))
|
||||
except OperationInterruptedError:
|
||||
raise KeyboardInterrupt from None
|
||||
|
||||
@@ -340,22 +334,22 @@ async def up_services(
|
||||
|
||||
async def check_host_compatibility(
|
||||
cfg: Config,
|
||||
service: str,
|
||||
stack: str,
|
||||
) -> dict[str, tuple[int, int, list[str]]]:
|
||||
"""Check which hosts can run a service based on paths, networks, and devices.
|
||||
"""Check which hosts can run a stack based on paths, networks, and devices.
|
||||
|
||||
Returns dict of host_name -> (found_count, total_count, missing_items).
|
||||
"""
|
||||
# Get total requirements count
|
||||
paths = get_service_paths(cfg, service)
|
||||
networks = parse_external_networks(cfg, service)
|
||||
devices = parse_devices(cfg, service)
|
||||
paths = get_stack_paths(cfg, stack)
|
||||
networks = parse_external_networks(cfg, stack)
|
||||
devices = parse_devices(cfg, stack)
|
||||
total = len(paths) + len(networks) + len(devices)
|
||||
|
||||
results: dict[str, tuple[int, int, list[str]]] = {}
|
||||
|
||||
for host_name in cfg.hosts:
|
||||
preflight = await check_service_requirements(cfg, service, host_name)
|
||||
preflight = await check_stack_requirements(cfg, stack, host_name)
|
||||
all_missing = (
|
||||
preflight.missing_paths + preflight.missing_networks + preflight.missing_devices
|
||||
)
|
||||
@@ -365,70 +359,68 @@ async def check_host_compatibility(
|
||||
return results
|
||||
|
||||
|
||||
async def stop_orphaned_services(cfg: Config) -> list[CommandResult]:
|
||||
"""Stop orphaned services (in state but not in config).
|
||||
async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
|
||||
"""Stop orphaned stacks (in state but not in config).
|
||||
|
||||
Runs docker compose down on each service on its tracked host(s).
|
||||
Runs docker compose down on each stack on its tracked host(s).
|
||||
Only removes from state on successful stop.
|
||||
|
||||
Returns list of CommandResults for each service@host.
|
||||
Returns list of CommandResults for each stack@host.
|
||||
"""
|
||||
orphaned = get_orphaned_services(cfg)
|
||||
orphaned = get_orphaned_stacks(cfg)
|
||||
if not orphaned:
|
||||
return []
|
||||
|
||||
results: list[CommandResult] = []
|
||||
tasks: list[tuple[str, str, asyncio.Task[CommandResult]]] = []
|
||||
|
||||
# Build list of (service, host, task) for all orphaned services
|
||||
for service, hosts in orphaned.items():
|
||||
# Build list of (stack, host, task) for all orphaned stacks
|
||||
for stack, hosts in orphaned.items():
|
||||
host_list = hosts if isinstance(hosts, list) else [hosts]
|
||||
for host in host_list:
|
||||
# Skip hosts no longer in config
|
||||
if host not in cfg.hosts:
|
||||
print_warning(f"{service}@{host}: host no longer in config, skipping")
|
||||
print_warning(f"{stack}@{host}: host no longer in config, skipping")
|
||||
results.append(
|
||||
CommandResult(
|
||||
service=f"{service}@{host}",
|
||||
stack=f"{stack}@{host}",
|
||||
exit_code=1,
|
||||
success=False,
|
||||
stderr="host no longer in config",
|
||||
)
|
||||
)
|
||||
continue
|
||||
coro = run_compose_on_host(cfg, service, host, "down")
|
||||
tasks.append((service, host, asyncio.create_task(coro)))
|
||||
coro = run_compose_on_host(cfg, stack, host, "down")
|
||||
tasks.append((stack, host, asyncio.create_task(coro)))
|
||||
|
||||
# Run all down commands in parallel
|
||||
if tasks:
|
||||
for service, host, task in tasks:
|
||||
for stack, host, task in tasks:
|
||||
try:
|
||||
result = await task
|
||||
results.append(result)
|
||||
if result.success:
|
||||
print_success(f"{service}@{host}: stopped")
|
||||
print_success(f"{stack}@{host}: stopped")
|
||||
else:
|
||||
print_error(f"{service}@{host}: {result.stderr or 'failed'}")
|
||||
print_error(f"{stack}@{host}: {result.stderr or 'failed'}")
|
||||
except Exception as e:
|
||||
print_error(f"{service}@{host}: {e}")
|
||||
print_error(f"{stack}@{host}: {e}")
|
||||
results.append(
|
||||
CommandResult(
|
||||
service=f"{service}@{host}",
|
||||
stack=f"{stack}@{host}",
|
||||
exit_code=1,
|
||||
success=False,
|
||||
stderr=str(e),
|
||||
)
|
||||
)
|
||||
|
||||
# Remove from state only for services where ALL hosts succeeded
|
||||
for service, hosts in orphaned.items():
|
||||
# Remove from state only for stacks where ALL hosts succeeded
|
||||
for stack, hosts in orphaned.items():
|
||||
host_list = hosts if isinstance(hosts, list) else [hosts]
|
||||
all_succeeded = all(
|
||||
r.success
|
||||
for r in results
|
||||
if r.service.startswith(f"{service}@") or r.service == service
|
||||
r.success for r in results if r.stack.startswith(f"{stack}@") or r.stack == stack
|
||||
)
|
||||
if all_succeeded:
|
||||
remove_service(cfg, service)
|
||||
remove_stack(cfg, stack)
|
||||
|
||||
return results
|
||||
|
||||
@@ -11,9 +11,19 @@ def xdg_config_home() -> Path:
|
||||
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
"""Get the compose-farm config directory."""
|
||||
return xdg_config_home() / "compose-farm"
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
"""Get the default user config path."""
|
||||
return xdg_config_home() / "compose-farm" / "compose-farm.yaml"
|
||||
return config_dir() / "compose-farm.yaml"
|
||||
|
||||
|
||||
def backup_dir() -> Path:
|
||||
"""Get the backup directory for file edits."""
|
||||
return config_dir() / "backups"
|
||||
|
||||
|
||||
def config_search_paths() -> list[Path]:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""State tracking for deployed services."""
|
||||
"""State tracking for deployed stacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,44 +13,44 @@ if TYPE_CHECKING:
|
||||
from .config import Config
|
||||
|
||||
|
||||
def group_services_by_host(
|
||||
services: dict[str, str | list[str]],
|
||||
def group_stacks_by_host(
|
||||
stacks: dict[str, str | list[str]],
|
||||
hosts: Mapping[str, object],
|
||||
all_hosts: list[str] | None = None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Group services by their assigned host(s).
|
||||
"""Group stacks by their assigned host(s).
|
||||
|
||||
For multi-host services (list or "all"), the service appears in multiple host lists.
|
||||
For multi-host stacks (list or "all"), the stack appears in multiple host lists.
|
||||
"""
|
||||
by_host: dict[str, list[str]] = {h: [] for h in hosts}
|
||||
for service, host_value in services.items():
|
||||
for stack, host_value in stacks.items():
|
||||
if isinstance(host_value, list):
|
||||
for host_name in host_value:
|
||||
if host_name in by_host:
|
||||
by_host[host_name].append(service)
|
||||
by_host[host_name].append(stack)
|
||||
elif host_value == "all" and all_hosts:
|
||||
for host_name in all_hosts:
|
||||
if host_name in by_host:
|
||||
by_host[host_name].append(service)
|
||||
by_host[host_name].append(stack)
|
||||
elif host_value in by_host:
|
||||
by_host[host_value].append(service)
|
||||
by_host[host_value].append(stack)
|
||||
return by_host
|
||||
|
||||
|
||||
def group_running_services_by_host(
|
||||
def group_running_stacks_by_host(
|
||||
state: dict[str, str | list[str]],
|
||||
hosts: Mapping[str, object],
|
||||
) -> dict[str, list[str]]:
|
||||
"""Group running services by host, filtering out hosts with no services."""
|
||||
by_host = group_services_by_host(state, hosts)
|
||||
"""Group running stacks by host, filtering out hosts with no stacks."""
|
||||
by_host = group_stacks_by_host(state, hosts)
|
||||
return {h: svcs for h, svcs in by_host.items() if svcs}
|
||||
|
||||
|
||||
def load_state(config: Config) -> dict[str, str | list[str]]:
|
||||
"""Load the current deployment state.
|
||||
|
||||
Returns a dict mapping service names to host name(s).
|
||||
Multi-host services store a list of hosts.
|
||||
Returns a dict mapping stack names to host name(s).
|
||||
Multi-host stacks store a list of hosts.
|
||||
"""
|
||||
state_path = config.get_state_path()
|
||||
if not state_path.exists():
|
||||
@@ -83,13 +83,13 @@ def _modify_state(config: Config) -> Generator[dict[str, str | list[str]], None,
|
||||
save_state(config, state)
|
||||
|
||||
|
||||
def get_service_host(config: Config, service: str) -> str | None:
|
||||
"""Get the host where a service is currently deployed.
|
||||
def get_stack_host(config: Config, stack: str) -> str | None:
|
||||
"""Get the host where a stack is currently deployed.
|
||||
|
||||
For multi-host services, returns the first host or None.
|
||||
For multi-host stacks, returns the first host or None.
|
||||
"""
|
||||
state = load_state(config)
|
||||
value = state.get(service)
|
||||
value = state.get(stack)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
@@ -97,103 +97,59 @@ def get_service_host(config: Config, service: str) -> str | None:
|
||||
return value
|
||||
|
||||
|
||||
def set_service_host(config: Config, service: str, host: str) -> None:
|
||||
"""Record that a service is deployed on a host."""
|
||||
def set_stack_host(config: Config, stack: str, host: str) -> None:
|
||||
"""Record that a stack is deployed on a host."""
|
||||
with _modify_state(config) as state:
|
||||
state[service] = host
|
||||
state[stack] = host
|
||||
|
||||
|
||||
def set_multi_host_service(config: Config, service: str, hosts: list[str]) -> None:
|
||||
"""Record that a multi-host service is deployed on multiple hosts."""
|
||||
def set_multi_host_stack(config: Config, stack: str, hosts: list[str]) -> None:
|
||||
"""Record that a multi-host stack is deployed on multiple hosts."""
|
||||
with _modify_state(config) as state:
|
||||
state[service] = hosts
|
||||
state[stack] = hosts
|
||||
|
||||
|
||||
def remove_service(config: Config, service: str) -> None:
|
||||
"""Remove a service from the state (after down)."""
|
||||
def remove_stack(config: Config, stack: str) -> None:
|
||||
"""Remove a stack from the state (after down)."""
|
||||
with _modify_state(config) as state:
|
||||
state.pop(service, None)
|
||||
state.pop(stack, None)
|
||||
|
||||
|
||||
def add_service_to_host(config: Config, service: str, host: str) -> None:
|
||||
"""Add a specific host to a service's state.
|
||||
def get_stacks_needing_migration(config: Config) -> list[str]:
|
||||
"""Get stacks where current host differs from configured host.
|
||||
|
||||
For multi-host services, adds the host to the list if not present.
|
||||
For single-host services, sets the host.
|
||||
"""
|
||||
with _modify_state(config) as state:
|
||||
current = state.get(service)
|
||||
|
||||
if config.is_multi_host(service):
|
||||
# Multi-host: add to list if not present
|
||||
if isinstance(current, list):
|
||||
if host not in current:
|
||||
state[service] = [*current, host]
|
||||
else:
|
||||
state[service] = [host]
|
||||
else:
|
||||
# Single-host: just set it
|
||||
state[service] = host
|
||||
|
||||
|
||||
def remove_service_from_host(config: Config, service: str, host: str) -> None:
|
||||
"""Remove a specific host from a service's state.
|
||||
|
||||
For multi-host services, removes just that host from the list.
|
||||
For single-host services, removes the service entirely if host matches.
|
||||
"""
|
||||
with _modify_state(config) as state:
|
||||
current = state.get(service)
|
||||
if current is None:
|
||||
return
|
||||
|
||||
if isinstance(current, list):
|
||||
# Multi-host: remove this host from list
|
||||
remaining = [h for h in current if h != host]
|
||||
if remaining:
|
||||
state[service] = remaining
|
||||
else:
|
||||
state.pop(service, None)
|
||||
elif current == host:
|
||||
# Single-host: remove if matches
|
||||
state.pop(service, None)
|
||||
|
||||
|
||||
def get_services_needing_migration(config: Config) -> list[str]:
|
||||
"""Get services where current host differs from configured host.
|
||||
|
||||
Multi-host services are never considered for migration.
|
||||
Multi-host stacks are never considered for migration.
|
||||
"""
|
||||
needs_migration = []
|
||||
for service in config.services:
|
||||
# Skip multi-host services
|
||||
if config.is_multi_host(service):
|
||||
for stack in config.stacks:
|
||||
# Skip multi-host stacks
|
||||
if config.is_multi_host(stack):
|
||||
continue
|
||||
|
||||
configured_host = config.get_hosts(service)[0]
|
||||
current_host = get_service_host(config, service)
|
||||
configured_host = config.get_hosts(stack)[0]
|
||||
current_host = get_stack_host(config, stack)
|
||||
if current_host and current_host != configured_host:
|
||||
needs_migration.append(service)
|
||||
needs_migration.append(stack)
|
||||
return needs_migration
|
||||
|
||||
|
||||
def get_orphaned_services(config: Config) -> dict[str, str | list[str]]:
|
||||
"""Get services that are in state but not in config.
|
||||
def get_orphaned_stacks(config: Config) -> dict[str, str | list[str]]:
|
||||
"""Get stacks that are in state but not in config.
|
||||
|
||||
These are services that were previously deployed but have been
|
||||
These are stacks that were previously deployed but have been
|
||||
removed from the config file (e.g., commented out).
|
||||
|
||||
Returns a dict mapping service name to host(s) where it's deployed.
|
||||
Returns a dict mapping stack name to host(s) where it's deployed.
|
||||
"""
|
||||
state = load_state(config)
|
||||
return {service: hosts for service, hosts in state.items() if service not in config.services}
|
||||
return {stack: hosts for stack, hosts in state.items() if stack not in config.stacks}
|
||||
|
||||
|
||||
def get_services_not_in_state(config: Config) -> list[str]:
|
||||
"""Get services that are in config but not in state.
|
||||
def get_stacks_not_in_state(config: Config) -> list[str]:
|
||||
"""Get stacks that are in config but not in state.
|
||||
|
||||
These are services that should be running but aren't tracked
|
||||
These are stacks that should be running but aren't tracked
|
||||
(e.g., newly added to config, or previously stopped as orphans).
|
||||
"""
|
||||
state = load_state(config)
|
||||
return [service for service in config.services if service not in state]
|
||||
return [stack for stack in config.stacks if stack not in state]
|
||||
|
||||
@@ -8,6 +8,7 @@ use host-published ports for cross-host reachability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -311,7 +312,7 @@ def _process_service_labels(
|
||||
|
||||
def generate_traefik_config(
|
||||
config: Config,
|
||||
services: list[str],
|
||||
stacks: list[str],
|
||||
*,
|
||||
check_all: bool = False,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
@@ -319,8 +320,8 @@ def generate_traefik_config(
|
||||
|
||||
Args:
|
||||
config: The compose-farm config.
|
||||
services: List of service names to process.
|
||||
check_all: If True, check all services for warnings (ignore host filtering).
|
||||
stacks: List of stack names to process.
|
||||
check_all: If True, check all stacks for warnings (ignore host filtering).
|
||||
Used by the check command to validate all traefik labels.
|
||||
|
||||
Returns (config_dict, warnings).
|
||||
@@ -332,14 +333,14 @@ def generate_traefik_config(
|
||||
|
||||
# Determine Traefik's host from service assignment
|
||||
traefik_host = None
|
||||
if config.traefik_service and not check_all:
|
||||
traefik_host = config.services.get(config.traefik_service)
|
||||
if config.traefik_stack and not check_all:
|
||||
traefik_host = config.stacks.get(config.traefik_stack)
|
||||
|
||||
for stack in services:
|
||||
for stack in stacks:
|
||||
raw_services, env, host_address = load_compose_services(config, stack)
|
||||
stack_host = config.services.get(stack)
|
||||
stack_host = config.stacks.get(stack)
|
||||
|
||||
# Skip services on Traefik's host - docker provider handles them directly
|
||||
# Skip stacks on Traefik's host - docker provider handles them directly
|
||||
# (unless check_all is True, for validation purposes)
|
||||
if not check_all:
|
||||
if host_address.lower() in LOCAL_ADDRESSES:
|
||||
@@ -370,7 +371,7 @@ _TRAEFIK_CONFIG_HEADER = """\
|
||||
# Auto-generated by compose-farm
|
||||
# https://github.com/basnijholt/compose-farm
|
||||
#
|
||||
# This file routes traffic to services running on hosts other than Traefik's host.
|
||||
# This file routes traffic to stacks running on hosts other than Traefik's host.
|
||||
# Services on Traefik's host use the Docker provider directly.
|
||||
#
|
||||
# Regenerate with: compose-farm traefik-file --all -o <this-file>
|
||||
@@ -383,3 +384,53 @@ def render_traefik_config(dynamic: dict[str, Any]) -> str:
|
||||
"""Render Traefik dynamic config as YAML with a header comment."""
|
||||
body = yaml.safe_dump(dynamic, sort_keys=False)
|
||||
return _TRAEFIK_CONFIG_HEADER + body
|
||||
|
||||
|
||||
_HOST_RULE_PATTERN = re.compile(r"Host\(`([^`]+)`\)")
|
||||
|
||||
|
||||
def extract_website_urls(config: Config, stack: str) -> list[str]:
|
||||
"""Extract website URLs from Traefik labels in a stack's compose file.
|
||||
|
||||
Reuses generate_traefik_config to parse labels, then extracts Host() rules
|
||||
from router configurations.
|
||||
|
||||
Returns a list of unique URLs, preferring HTTPS over HTTP.
|
||||
"""
|
||||
try:
|
||||
dynamic, _ = generate_traefik_config(config, [stack], check_all=True)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
routers = dynamic.get("http", {}).get("routers", {})
|
||||
if not routers:
|
||||
return []
|
||||
|
||||
# Track URLs with their scheme preference (https > http)
|
||||
urls: dict[str, str] = {} # host -> scheme
|
||||
|
||||
for router_info in routers.values():
|
||||
if not isinstance(router_info, dict):
|
||||
continue
|
||||
|
||||
rule = router_info.get("rule", "")
|
||||
entrypoints = router_info.get("entrypoints", [])
|
||||
|
||||
# entrypoints can be a list or string
|
||||
if isinstance(entrypoints, list):
|
||||
entrypoints_str = ",".join(entrypoints)
|
||||
else:
|
||||
entrypoints_str = str(entrypoints)
|
||||
|
||||
# Determine scheme from entrypoint
|
||||
scheme = "https" if "websecure" in entrypoints_str else "http"
|
||||
|
||||
# Extract host(s) from rule
|
||||
for match in _HOST_RULE_PATTERN.finditer(str(rule)):
|
||||
host = match.group(1)
|
||||
# Prefer https over http
|
||||
if host not in urls or scheme == "https":
|
||||
urls[host] = scheme
|
||||
|
||||
# Build URL list, sorted for consistency
|
||||
return sorted(f"{scheme}://{host}" for host, scheme in urls.items())
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -9,29 +11,56 @@ from typing import TYPE_CHECKING
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import ValidationError
|
||||
from rich.logging import RichHandler
|
||||
|
||||
from compose_farm.web.deps import STATIC_DIR, get_config
|
||||
from compose_farm.web.routes import actions, api, pages
|
||||
from compose_farm.web.streaming import TASK_TTL_SECONDS, cleanup_stale_tasks
|
||||
|
||||
# Configure logging with Rich handler for compose_farm.web modules
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
datefmt="[%X]",
|
||||
handlers=[RichHandler(rich_tracebacks=True, show_path=False)],
|
||||
)
|
||||
# Set our web modules to INFO level (uvicorn handles its own logging)
|
||||
logging.getLogger("compose_farm.web").setLevel(logging.INFO)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
|
||||
async def _task_cleanup_loop() -> None:
|
||||
"""Periodically clean up stale completed tasks."""
|
||||
while True:
|
||||
await asyncio.sleep(TASK_TTL_SECONDS // 2) # Run every 5 minutes
|
||||
cleanup_stale_tasks()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Application lifespan handler."""
|
||||
# Startup: pre-load config (ignore errors - handled per-request)
|
||||
with suppress(ValidationError, FileNotFoundError):
|
||||
get_config()
|
||||
|
||||
# Start background cleanup task
|
||||
cleanup_task = asyncio.create_task(_task_cleanup_loop())
|
||||
|
||||
yield
|
||||
# Shutdown: nothing to clean up
|
||||
|
||||
# Shutdown: cancel cleanup task
|
||||
cleanup_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await cleanup_task
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
app = FastAPI(
|
||||
title="Compose Farm",
|
||||
description="Web UI for managing Docker Compose services across multiple hosts",
|
||||
description="Web UI for managing Docker Compose stacks across multiple hosts",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
108
src/compose_farm/web/cdn.py
Normal file
108
src/compose_farm/web/cdn.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""CDN asset definitions and caching for tests and demo recordings.
|
||||
|
||||
This module provides a single source of truth for CDN asset URLs used in
|
||||
browser tests and demo recordings. Assets are intercepted and served from
|
||||
a local cache to eliminate network variability.
|
||||
|
||||
Note: The canonical list of CDN assets for production is in base.html
|
||||
(with data-vendor attributes). This module includes those plus dynamically
|
||||
loaded assets (like Monaco editor modules loaded by app.js).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# CDN assets to cache locally for tests/demos
|
||||
# Format: URL -> (local_filename, content_type)
|
||||
#
|
||||
# If tests fail with "Uncached CDN request", add the URL here.
|
||||
CDN_ASSETS: dict[str, tuple[str, str]] = {
|
||||
# From base.html (data-vendor attributes)
|
||||
"https://cdn.jsdelivr.net/npm/daisyui@5/themes.css": ("daisyui-themes.css", "text/css"),
|
||||
"https://cdn.jsdelivr.net/npm/daisyui@5": ("daisyui.css", "text/css"),
|
||||
"https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4": (
|
||||
"tailwind.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css": ("xterm.css", "text/css"),
|
||||
"https://unpkg.com/htmx.org@2.0.4": ("htmx.js", "application/javascript"),
|
||||
"https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js": (
|
||||
"xterm.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js": (
|
||||
"xterm-fit.js",
|
||||
"application/javascript",
|
||||
),
|
||||
# Monaco editor - dynamically loaded by app.js
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/loader.js": (
|
||||
"monaco-loader.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.js": (
|
||||
"monaco-editor-main.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/editor/editor.main.css": (
|
||||
"monaco-editor-main.css",
|
||||
"text/css",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/worker/workerMain.js": (
|
||||
"monaco-workerMain.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/basic-languages/yaml/yaml.js": (
|
||||
"monaco-yaml.js",
|
||||
"application/javascript",
|
||||
),
|
||||
"https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs/base/browser/ui/codicons/codicon/codicon.ttf": (
|
||||
"monaco-codicon.ttf",
|
||||
"font/ttf",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def download_url(url: str) -> bytes | None:
|
||||
"""Download URL content using curl."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["curl", "-fsSL", "--max-time", "30", url], # noqa: S607
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return bytes(result.stdout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ensure_vendor_cache(cache_dir: Path) -> Path:
|
||||
"""Download CDN assets to cache directory if not already present.
|
||||
|
||||
Args:
|
||||
cache_dir: Directory to store cached assets.
|
||||
|
||||
Returns:
|
||||
The cache directory path.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If any asset fails to download.
|
||||
|
||||
"""
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for url, (filename, _content_type) in CDN_ASSETS.items():
|
||||
filepath = cache_dir / filename
|
||||
if filepath.exists():
|
||||
continue
|
||||
content = download_url(url)
|
||||
if not content:
|
||||
msg = f"Failed to download {url} - check network/curl"
|
||||
raise RuntimeError(msg)
|
||||
filepath.write_bytes(content)
|
||||
|
||||
return cache_dir
|
||||
@@ -12,6 +12,8 @@ from typing import TYPE_CHECKING
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import ValidationError
|
||||
|
||||
from compose_farm.executor import is_local
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
|
||||
@@ -38,3 +40,11 @@ def extract_config_error(exc: Exception) -> str:
|
||||
if isinstance(exc, ValidationError):
|
||||
return "; ".join(err.get("msg", str(err)) for err in exc.errors())
|
||||
return str(exc)
|
||||
|
||||
|
||||
def get_local_host(config: Config) -> str | None:
|
||||
"""Find the local host name from config, if any."""
|
||||
for name, host in config.hosts.items():
|
||||
if is_local(host):
|
||||
return name
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Action routes for service operations."""
|
||||
"""Action routes for stack operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -32,56 +32,47 @@ def _start_task(coro_factory: Callable[[str], Coroutine[Any, Any, None]]) -> str
|
||||
return task_id
|
||||
|
||||
|
||||
async def _run_service_action(name: str, command: str) -> dict[str, Any]:
|
||||
"""Run a compose command for a service."""
|
||||
config = get_config()
|
||||
# Allowed stack commands
|
||||
ALLOWED_COMMANDS = {"up", "down", "restart", "pull", "update", "logs", "stop"}
|
||||
|
||||
if name not in config.services:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
|
||||
# Allowed service-level commands (no 'down' - use 'stop' for individual services)
|
||||
ALLOWED_SERVICE_COMMANDS = {"logs", "pull", "restart", "up", "stop"}
|
||||
|
||||
|
||||
@router.post("/stack/{name}/{command}")
|
||||
async def stack_action(name: str, command: str) -> dict[str, Any]:
|
||||
"""Run a compose command for a stack (up, down, restart, pull, update, logs, stop)."""
|
||||
if command not in ALLOWED_COMMANDS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown command '{command}'")
|
||||
|
||||
config = get_config()
|
||||
if name not in config.stacks:
|
||||
raise HTTPException(status_code=404, detail=f"Stack '{name}' not found")
|
||||
|
||||
task_id = _start_task(lambda tid: run_compose_streaming(config, name, command, tid))
|
||||
return {"task_id": task_id, "service": name, "command": command}
|
||||
return {"task_id": task_id, "stack": name, "command": command}
|
||||
|
||||
|
||||
@router.post("/service/{name}/up")
|
||||
async def up_service(name: str) -> dict[str, Any]:
|
||||
"""Start a service."""
|
||||
return await _run_service_action(name, "up")
|
||||
@router.post("/stack/{name}/service/{service}/{command}")
|
||||
async def service_action(name: str, service: str, command: str) -> dict[str, Any]:
|
||||
"""Run a compose command for a specific service within a stack."""
|
||||
if command not in ALLOWED_SERVICE_COMMANDS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown command '{command}'")
|
||||
|
||||
config = get_config()
|
||||
if name not in config.stacks:
|
||||
raise HTTPException(status_code=404, detail=f"Stack '{name}' not found")
|
||||
|
||||
@router.post("/service/{name}/down")
|
||||
async def down_service(name: str) -> dict[str, Any]:
|
||||
"""Stop a service."""
|
||||
return await _run_service_action(name, "down")
|
||||
|
||||
|
||||
@router.post("/service/{name}/restart")
|
||||
async def restart_service(name: str) -> dict[str, Any]:
|
||||
"""Restart a service (down + up)."""
|
||||
return await _run_service_action(name, "restart")
|
||||
|
||||
|
||||
@router.post("/service/{name}/pull")
|
||||
async def pull_service(name: str) -> dict[str, Any]:
|
||||
"""Pull latest images for a service."""
|
||||
return await _run_service_action(name, "pull")
|
||||
|
||||
|
||||
@router.post("/service/{name}/update")
|
||||
async def update_service(name: str) -> dict[str, Any]:
|
||||
"""Update a service (pull + build + down + up)."""
|
||||
return await _run_service_action(name, "update")
|
||||
|
||||
|
||||
@router.post("/service/{name}/logs")
|
||||
async def logs_service(name: str) -> dict[str, Any]:
|
||||
"""Show logs for a service."""
|
||||
return await _run_service_action(name, "logs")
|
||||
# Use --service flag to target specific service
|
||||
task_id = _start_task(
|
||||
lambda tid: run_compose_streaming(config, name, f"{command} --service {service}", tid)
|
||||
)
|
||||
return {"task_id": task_id, "stack": name, "service": service, "command": command}
|
||||
|
||||
|
||||
@router.post("/apply")
|
||||
async def apply_all() -> dict[str, Any]:
|
||||
"""Run cf apply to reconcile all services."""
|
||||
"""Run cf apply to reconcile all stacks."""
|
||||
config = get_config()
|
||||
task_id = _start_task(lambda tid: run_cli_streaming(config, ["apply"], tid))
|
||||
return {"task_id": task_id, "command": "apply"}
|
||||
@@ -89,7 +80,23 @@ async def apply_all() -> dict[str, Any]:
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_state() -> dict[str, Any]:
|
||||
"""Refresh state from running services."""
|
||||
"""Refresh state from running stacks."""
|
||||
config = get_config()
|
||||
task_id = _start_task(lambda tid: run_cli_streaming(config, ["refresh"], tid))
|
||||
return {"task_id": task_id, "command": "refresh"}
|
||||
|
||||
|
||||
@router.post("/pull-all")
|
||||
async def pull_all() -> dict[str, Any]:
|
||||
"""Pull latest images for all stacks."""
|
||||
config = get_config()
|
||||
task_id = _start_task(lambda tid: run_cli_streaming(config, ["pull", "--all"], tid))
|
||||
return {"task_id": task_id, "command": "pull --all"}
|
||||
|
||||
|
||||
@router.post("/update-all")
|
||||
async def update_all() -> dict[str, Any]:
|
||||
"""Update all stacks (pull + build + down + up)."""
|
||||
config = get_config()
|
||||
task_id = _start_task(lambda tid: run_cli_streaming(config, ["update", "--all"], tid))
|
||||
return {"task_id": task_id, "command": "update --all"}
|
||||
|
||||
@@ -5,21 +5,28 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
import asyncssh
|
||||
import yaml
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from compose_farm.compose import get_container_name
|
||||
from compose_farm.executor import is_local, run_compose_on_host, ssh_connect_kwargs
|
||||
from compose_farm.paths import find_config_path
|
||||
from compose_farm.paths import backup_dir, find_config_path
|
||||
from compose_farm.state import load_state
|
||||
from compose_farm.web.deps import get_config, get_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["api"])
|
||||
|
||||
|
||||
@@ -34,26 +41,30 @@ def _validate_yaml(content: str) -> None:
|
||||
def _backup_file(file_path: Path) -> Path | None:
|
||||
"""Create a timestamped backup of a file if it exists and content differs.
|
||||
|
||||
Backups are stored in a .backups directory alongside the file.
|
||||
Backups are stored in XDG config dir under compose-farm/backups/.
|
||||
The original file's absolute path is mirrored in the backup directory.
|
||||
Returns the backup path if created, None if no backup was needed.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
# Create backup directory
|
||||
backup_dir = file_path.parent / ".backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
# Create backup directory mirroring original path structure
|
||||
# e.g., /opt/stacks/plex/compose.yaml -> ~/.config/compose-farm/backups/opt/stacks/plex/
|
||||
# On Windows: C:\Users\foo\stacks -> backups/Users/foo/stacks
|
||||
resolved = file_path.resolve()
|
||||
file_backup_dir = backup_dir() / resolved.parent.relative_to(resolved.anchor)
|
||||
file_backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate timestamped backup filename
|
||||
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
|
||||
backup_name = f"{file_path.name}.{timestamp}"
|
||||
backup_path = backup_dir / backup_name
|
||||
backup_path = file_backup_dir / backup_name
|
||||
|
||||
# Copy current content to backup
|
||||
backup_path.write_text(file_path.read_text())
|
||||
|
||||
# Clean up old backups (keep last 200)
|
||||
backups = sorted(backup_dir.glob(f"{file_path.name}.*"), reverse=True)
|
||||
backups = sorted(file_backup_dir.glob(f"{file_path.name}.*"), reverse=True)
|
||||
for old_backup in backups[200:]:
|
||||
old_backup.unlink()
|
||||
|
||||
@@ -76,12 +87,12 @@ def _save_with_backup(file_path: Path, content: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _get_service_compose_path(name: str) -> Path:
|
||||
"""Get compose path for service, raising HTTPException if not found."""
|
||||
def _get_stack_compose_path(name: str) -> Path:
|
||||
"""Get compose path for stack, raising HTTPException if not found."""
|
||||
config = get_config()
|
||||
|
||||
if name not in config.services:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
|
||||
if name not in config.stacks:
|
||||
raise HTTPException(status_code=404, detail=f"Stack '{name}' not found")
|
||||
|
||||
compose_path = config.get_compose_path(name)
|
||||
if not compose_path:
|
||||
@@ -90,12 +101,12 @@ def _get_service_compose_path(name: str) -> Path:
|
||||
return compose_path
|
||||
|
||||
|
||||
def _get_compose_services(config: Any, service: str, hosts: list[str]) -> list[dict[str, Any]]:
|
||||
def _get_compose_services(config: Any, stack: str, hosts: list[str]) -> list[dict[str, Any]]:
|
||||
"""Get container info from compose file (fast, local read).
|
||||
|
||||
Returns one entry per container per host for multi-host services.
|
||||
Returns one entry per container per host for multi-host stacks.
|
||||
"""
|
||||
compose_path = config.get_compose_path(service)
|
||||
compose_path = config.get_compose_path(stack)
|
||||
if not compose_path or not compose_path.exists():
|
||||
return []
|
||||
|
||||
@@ -110,14 +121,9 @@ def _get_compose_services(config: Any, service: str, hosts: list[str]) -> list[d
|
||||
containers = []
|
||||
for host in hosts:
|
||||
for svc_name, svc_def in raw_services.items():
|
||||
# Use container_name if set, otherwise default to {project}-{service}-1
|
||||
if isinstance(svc_def, dict) and svc_def.get("container_name"):
|
||||
container_name = svc_def["container_name"]
|
||||
else:
|
||||
container_name = f"{project_name}-{svc_name}-1"
|
||||
containers.append(
|
||||
{
|
||||
"Name": container_name,
|
||||
"Name": get_container_name(svc_name, svc_def, project_name),
|
||||
"Service": svc_name,
|
||||
"Host": host,
|
||||
"State": "unknown", # Status requires Docker query
|
||||
@@ -127,7 +133,7 @@ def _get_compose_services(config: Any, service: str, hosts: list[str]) -> list[d
|
||||
|
||||
|
||||
async def _get_container_states(
|
||||
config: Any, service: str, containers: list[dict[str, Any]]
|
||||
config: Any, stack: str, containers: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Query Docker for actual container states on a single host."""
|
||||
if not containers:
|
||||
@@ -136,54 +142,73 @@ async def _get_container_states(
|
||||
# All containers should be on the same host
|
||||
host_name = containers[0]["Host"]
|
||||
|
||||
result = await run_compose_on_host(config, service, host_name, "ps --format json", stream=False)
|
||||
# Use -a to include stopped/exited containers
|
||||
result = await run_compose_on_host(
|
||||
config, stack, host_name, "ps -a --format json", stream=False
|
||||
)
|
||||
if not result.success:
|
||||
logger.warning(
|
||||
"Failed to get container states for %s on %s: %s",
|
||||
stack,
|
||||
host_name,
|
||||
result.stderr or result.stdout,
|
||||
)
|
||||
return containers
|
||||
|
||||
# Build state map
|
||||
state_map: dict[str, str] = {}
|
||||
# Build state map: name -> (state, exit_code)
|
||||
state_map: dict[str, tuple[str, int]] = {}
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
with contextlib.suppress(json.JSONDecodeError):
|
||||
data = json.loads(line)
|
||||
state_map[data.get("Name", "")] = data.get("State", "unknown")
|
||||
name = data.get("Name", "")
|
||||
state = data.get("State", "unknown")
|
||||
exit_code = data.get("ExitCode", 0)
|
||||
state_map[name] = (state, exit_code)
|
||||
|
||||
# Update container states
|
||||
for c in containers:
|
||||
if c["Name"] in state_map:
|
||||
c["State"] = state_map[c["Name"]]
|
||||
state, exit_code = state_map[c["Name"]]
|
||||
c["State"] = state
|
||||
c["ExitCode"] = exit_code
|
||||
else:
|
||||
# Container not in ps output means it was never started
|
||||
c["State"] = "created"
|
||||
c["ExitCode"] = None
|
||||
|
||||
return containers
|
||||
|
||||
|
||||
def _render_containers(
|
||||
service: str, host: str, containers: list[dict[str, Any]], *, show_header: bool = False
|
||||
stack: str, host: str, containers: list[dict[str, Any]], *, show_header: bool = False
|
||||
) -> str:
|
||||
"""Render containers HTML using Jinja template."""
|
||||
templates = get_templates()
|
||||
template = templates.env.get_template("partials/containers.html")
|
||||
module = template.make_module()
|
||||
result: str = module.host_containers(service, host, containers, show_header=show_header)
|
||||
return result
|
||||
# TemplateModule exports macros as attributes; getattr keeps type checkers happy
|
||||
host_containers: Callable[..., str] = getattr(module, "host_containers") # noqa: B009
|
||||
return host_containers(stack, host, containers, show_header=show_header)
|
||||
|
||||
|
||||
@router.get("/service/{name}/containers", response_class=HTMLResponse)
|
||||
@router.get("/stack/{name}/containers", response_class=HTMLResponse)
|
||||
async def get_containers(name: str, host: str | None = None) -> HTMLResponse:
|
||||
"""Get containers for a service as HTML buttons.
|
||||
"""Get containers for a stack as HTML buttons.
|
||||
|
||||
If host is specified, queries Docker for that host's status.
|
||||
Otherwise returns all hosts with loading spinners that auto-fetch.
|
||||
"""
|
||||
config = get_config()
|
||||
|
||||
if name not in config.services:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{name}' not found")
|
||||
if name not in config.stacks:
|
||||
raise HTTPException(status_code=404, detail=f"Stack '{name}' not found")
|
||||
|
||||
# Get hosts where service is running from state
|
||||
# Get hosts where stack is running from state
|
||||
state = load_state(config)
|
||||
current_hosts = state.get(name)
|
||||
if not current_hosts:
|
||||
return HTMLResponse('<span class="text-base-content/60">Service not running</span>')
|
||||
return HTMLResponse('<span class="text-base-content/60">Stack not running</span>')
|
||||
|
||||
all_hosts = current_hosts if isinstance(current_hosts, list) else [current_hosts]
|
||||
|
||||
@@ -210,7 +235,7 @@ async def get_containers(name: str, host: str | None = None) -> HTMLResponse:
|
||||
# Container for this host that auto-fetches its own status
|
||||
html_parts.append(f"""
|
||||
<div id="{host_id}"
|
||||
hx-get="/api/service/{name}/containers?host={h}"
|
||||
hx-get="/api/stack/{name}/containers?host={h}"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-select="unset"
|
||||
@@ -222,24 +247,24 @@ async def get_containers(name: str, host: str | None = None) -> HTMLResponse:
|
||||
return HTMLResponse("".join(html_parts))
|
||||
|
||||
|
||||
@router.put("/service/{name}/compose")
|
||||
@router.put("/stack/{name}/compose")
|
||||
async def save_compose(
|
||||
name: str, content: Annotated[str, Body(media_type="text/plain")]
|
||||
) -> dict[str, Any]:
|
||||
"""Save compose file content."""
|
||||
compose_path = _get_service_compose_path(name)
|
||||
compose_path = _get_stack_compose_path(name)
|
||||
_validate_yaml(content)
|
||||
saved = _save_with_backup(compose_path, content)
|
||||
msg = "Compose file saved" if saved else "No changes to save"
|
||||
return {"success": True, "message": msg}
|
||||
|
||||
|
||||
@router.put("/service/{name}/env")
|
||||
@router.put("/stack/{name}/env")
|
||||
async def save_env(
|
||||
name: str, content: Annotated[str, Body(media_type="text/plain")]
|
||||
) -> dict[str, Any]:
|
||||
"""Save .env file content."""
|
||||
env_path = _get_service_compose_path(name).parent / ".env"
|
||||
env_path = _get_stack_compose_path(name).parent / ".env"
|
||||
saved = _save_with_backup(env_path, content)
|
||||
msg = ".env file saved" if saved else "No changes to save"
|
||||
return {"success": True, "message": msg}
|
||||
@@ -290,9 +315,9 @@ async def _read_file_remote(host: Any, path: str) -> str:
|
||||
|
||||
async def _write_file_remote(host: Any, path: str, content: str) -> None:
|
||||
"""Write content to a file on a remote host via SSH."""
|
||||
# Expand ~ on remote by using shell
|
||||
target_path = f"~/{path[2:]}" if path.startswith("~/") else path
|
||||
cmd = f"cat > {shlex.quote(target_path)}"
|
||||
# Expand ~ on remote: keep ~ unquoted for shell expansion, quote the rest
|
||||
target = f"~/{shlex.quote(path[2:])}" if path.startswith("~/") else shlex.quote(path)
|
||||
cmd = f"cat > {target}"
|
||||
|
||||
async with asyncssh.connect(**ssh_connect_kwargs(host)) as conn:
|
||||
result = await conn.run(cmd, input=content, check=True)
|
||||
@@ -334,6 +359,7 @@ async def read_console_file(
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {path}") from None
|
||||
except Exception as e:
|
||||
logger.exception("Failed to read file %s from host %s", path, host)
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@@ -357,4 +383,5 @@ async def write_console_file(
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {path}") from None
|
||||
except Exception as e:
|
||||
logger.exception("Failed to write file %s to host %s", path, host)
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
@@ -7,19 +7,21 @@ from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
from compose_farm.executor import is_local
|
||||
from compose_farm.compose import get_container_name
|
||||
from compose_farm.paths import find_config_path
|
||||
from compose_farm.state import (
|
||||
get_orphaned_services,
|
||||
get_service_host,
|
||||
get_services_needing_migration,
|
||||
get_services_not_in_state,
|
||||
group_running_services_by_host,
|
||||
get_orphaned_stacks,
|
||||
get_stack_host,
|
||||
get_stacks_needing_migration,
|
||||
get_stacks_not_in_state,
|
||||
group_running_stacks_by_host,
|
||||
load_state,
|
||||
)
|
||||
from compose_farm.traefik import extract_website_urls
|
||||
from compose_farm.web.deps import (
|
||||
extract_config_error,
|
||||
get_config,
|
||||
get_local_host,
|
||||
get_templates,
|
||||
)
|
||||
|
||||
@@ -32,14 +34,8 @@ async def console(request: Request) -> HTMLResponse:
|
||||
config = get_config()
|
||||
templates = get_templates()
|
||||
|
||||
# Find local host and sort it first
|
||||
local_host = None
|
||||
for name, host in config.hosts.items():
|
||||
if is_local(host):
|
||||
local_host = name
|
||||
break
|
||||
|
||||
# Sort hosts with local first
|
||||
local_host = get_local_host(config)
|
||||
hosts = sorted(config.hosts.keys())
|
||||
if local_host:
|
||||
hosts = [local_host] + [h for h in hosts if h != local_host]
|
||||
@@ -80,7 +76,7 @@ async def index(request: Request) -> HTMLResponse:
|
||||
"request": request,
|
||||
"config_error": config_error,
|
||||
"hosts": {},
|
||||
"services": {},
|
||||
"stacks": {},
|
||||
"config_content": config_content,
|
||||
"state_content": "",
|
||||
"running_count": 0,
|
||||
@@ -88,24 +84,24 @@ async def index(request: Request) -> HTMLResponse:
|
||||
"orphaned": [],
|
||||
"migrations": [],
|
||||
"not_started": [],
|
||||
"services_by_host": {},
|
||||
"stacks_by_host": {},
|
||||
},
|
||||
)
|
||||
|
||||
# Get state
|
||||
deployed = load_state(config)
|
||||
|
||||
# Stats
|
||||
running_count = len(deployed)
|
||||
stopped_count = len(config.services) - running_count
|
||||
# Stats (only count stacks that are both in config AND deployed)
|
||||
running_count = sum(1 for stack in deployed if stack in config.stacks)
|
||||
stopped_count = len(config.stacks) - running_count
|
||||
|
||||
# Pending operations
|
||||
orphaned = get_orphaned_services(config)
|
||||
migrations = get_services_needing_migration(config)
|
||||
not_started = get_services_not_in_state(config)
|
||||
orphaned = get_orphaned_stacks(config)
|
||||
migrations = get_stacks_needing_migration(config)
|
||||
not_started = get_stacks_not_in_state(config)
|
||||
|
||||
# Group services by host (filter out hosts with no running services)
|
||||
services_by_host = group_running_services_by_host(deployed, config.hosts)
|
||||
# Group stacks by host (filter out hosts with no running stacks)
|
||||
stacks_by_host = group_running_stacks_by_host(deployed, config.hosts)
|
||||
|
||||
# Config file content
|
||||
config_content = ""
|
||||
@@ -122,7 +118,7 @@ async def index(request: Request) -> HTMLResponse:
|
||||
"config_error": None,
|
||||
# Config data
|
||||
"hosts": config.hosts,
|
||||
"services": config.services,
|
||||
"stacks": config.stacks,
|
||||
"config_content": config_content,
|
||||
# State data
|
||||
"state_content": state_content,
|
||||
@@ -133,15 +129,15 @@ async def index(request: Request) -> HTMLResponse:
|
||||
"orphaned": orphaned,
|
||||
"migrations": migrations,
|
||||
"not_started": not_started,
|
||||
# Services by host
|
||||
"services_by_host": services_by_host,
|
||||
# Stacks by host
|
||||
"stacks_by_host": stacks_by_host,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/service/{name}", response_class=HTMLResponse)
|
||||
async def service_detail(request: Request, name: str) -> HTMLResponse:
|
||||
"""Service detail page."""
|
||||
@router.get("/stack/{name}", response_class=HTMLResponse)
|
||||
async def stack_detail(request: Request, name: str) -> HTMLResponse:
|
||||
"""Stack detail page."""
|
||||
config = get_config()
|
||||
templates = get_templates()
|
||||
|
||||
@@ -163,10 +159,33 @@ async def service_detail(request: Request, name: str) -> HTMLResponse:
|
||||
hosts = config.get_hosts(name)
|
||||
|
||||
# Get state
|
||||
current_host = get_service_host(config, name)
|
||||
current_host = get_stack_host(config, name)
|
||||
|
||||
# Get service names and container info from compose file
|
||||
services: list[str] = []
|
||||
containers: dict[str, dict[str, str]] = {}
|
||||
shell_host = current_host[0] if isinstance(current_host, list) else current_host
|
||||
if compose_content:
|
||||
compose_data = yaml.safe_load(compose_content) or {}
|
||||
raw_services = compose_data.get("services", {})
|
||||
if isinstance(raw_services, dict):
|
||||
services = list(raw_services.keys())
|
||||
# Build container info for shell access (only if stack is running)
|
||||
if shell_host:
|
||||
project_name = compose_path.parent.name if compose_path else name
|
||||
containers = {
|
||||
svc: {
|
||||
"container": get_container_name(svc, svc_def, project_name),
|
||||
"host": shell_host,
|
||||
}
|
||||
for svc, svc_def in raw_services.items()
|
||||
}
|
||||
|
||||
# Extract website URLs from Traefik labels
|
||||
website_urls = extract_website_urls(config, name)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"service.html",
|
||||
"stack.html",
|
||||
{
|
||||
"request": request,
|
||||
"name": name,
|
||||
@@ -176,31 +195,35 @@ async def service_detail(request: Request, name: str) -> HTMLResponse:
|
||||
"compose_path": str(compose_path) if compose_path else None,
|
||||
"env_content": env_content,
|
||||
"env_path": str(env_path) if env_path else None,
|
||||
"services": services,
|
||||
"containers": containers,
|
||||
"website_urls": website_urls,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/partials/sidebar", response_class=HTMLResponse)
|
||||
async def sidebar_partial(request: Request) -> HTMLResponse:
|
||||
"""Sidebar service list partial."""
|
||||
"""Sidebar stack list partial."""
|
||||
config = get_config()
|
||||
templates = get_templates()
|
||||
|
||||
state = load_state(config)
|
||||
|
||||
# Build service -> host mapping (empty string for multi-host services)
|
||||
service_hosts = {
|
||||
# Build stack -> host mapping (empty string for multi-host stacks)
|
||||
stack_hosts = {
|
||||
svc: "" if host_val == "all" or isinstance(host_val, list) else host_val
|
||||
for svc, host_val in config.services.items()
|
||||
for svc, host_val in config.stacks.items()
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/sidebar.html",
|
||||
{
|
||||
"request": request,
|
||||
"services": sorted(config.services.keys()),
|
||||
"service_hosts": service_hosts,
|
||||
"stacks": sorted(config.stacks.keys()),
|
||||
"stack_hosts": stack_hosts,
|
||||
"hosts": sorted(config.hosts.keys()),
|
||||
"local_host": get_local_host(config),
|
||||
"state": state,
|
||||
},
|
||||
)
|
||||
@@ -227,15 +250,16 @@ async def stats_partial(request: Request) -> HTMLResponse:
|
||||
templates = get_templates()
|
||||
|
||||
deployed = load_state(config)
|
||||
running_count = len(deployed)
|
||||
stopped_count = len(config.services) - running_count
|
||||
# Only count stacks that are both in config AND deployed
|
||||
running_count = sum(1 for stack in deployed if stack in config.stacks)
|
||||
stopped_count = len(config.stacks) - running_count
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/stats.html",
|
||||
{
|
||||
"request": request,
|
||||
"hosts": config.hosts,
|
||||
"services": config.services,
|
||||
"stacks": config.stacks,
|
||||
"running_count": running_count,
|
||||
"stopped_count": stopped_count,
|
||||
},
|
||||
@@ -248,9 +272,9 @@ async def pending_partial(request: Request, expanded: bool = True) -> HTMLRespon
|
||||
config = get_config()
|
||||
templates = get_templates()
|
||||
|
||||
orphaned = get_orphaned_services(config)
|
||||
migrations = get_services_needing_migration(config)
|
||||
not_started = get_services_not_in_state(config)
|
||||
orphaned = get_orphaned_stacks(config)
|
||||
migrations = get_stacks_needing_migration(config)
|
||||
not_started = get_stacks_not_in_state(config)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/pending.html",
|
||||
@@ -264,21 +288,21 @@ async def pending_partial(request: Request, expanded: bool = True) -> HTMLRespon
|
||||
)
|
||||
|
||||
|
||||
@router.get("/partials/services-by-host", response_class=HTMLResponse)
|
||||
async def services_by_host_partial(request: Request, expanded: bool = True) -> HTMLResponse:
|
||||
"""Services by host partial."""
|
||||
@router.get("/partials/stacks-by-host", response_class=HTMLResponse)
|
||||
async def stacks_by_host_partial(request: Request, expanded: bool = True) -> HTMLResponse:
|
||||
"""Stacks by host partial."""
|
||||
config = get_config()
|
||||
templates = get_templates()
|
||||
|
||||
deployed = load_state(config)
|
||||
services_by_host = group_running_services_by_host(deployed, config.hosts)
|
||||
stacks_by_host = group_running_stacks_by_host(deployed, config.hosts)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/services_by_host.html",
|
||||
"partials/stacks_by_host.html",
|
||||
{
|
||||
"request": request,
|
||||
"hosts": config.hosts,
|
||||
"services_by_host": services_by_host,
|
||||
"stacks_by_host": stacks_by_host,
|
||||
"expanded": expanded,
|
||||
},
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user