mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-03 06:03:25 +00:00
Compare commits
361 Commits
v0.5.0
...
704f77f263
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704f77f263 | ||
|
|
4d65702868 | ||
|
|
596a05e39d | ||
|
|
e1a8ceb9e6 | ||
|
|
ed450c65e5 | ||
|
|
0f84864a06 | ||
|
|
9c72e0937a | ||
|
|
74cc2f3245 | ||
|
|
940bd9585a | ||
|
|
dd60af61a8 | ||
|
|
2f3720949b | ||
|
|
1e3b1d71ed | ||
|
|
c159549a9e | ||
|
|
d65f4cf7f4 | ||
|
|
7ce2067fcb | ||
|
|
f32057aa7b | ||
|
|
c3e3aeb538 | ||
|
|
009f3b1403 | ||
|
|
51f74eab42 | ||
|
|
4acf797128 | ||
|
|
d167da9d63 | ||
|
|
a5eac339db | ||
|
|
9f3813eb72 | ||
|
|
b9ae0ad4d5 | ||
|
|
ca2a4dd6d9 | ||
|
|
fafdce5736 | ||
|
|
6436becff9 | ||
|
|
3460d8a3ea | ||
|
|
8dabc27272 | ||
|
|
5e08f1d712 | ||
|
|
8302f1d97a | ||
|
|
eac9338352 | ||
|
|
667931dc80 | ||
|
|
5890221528 | ||
|
|
c8fc3c2496 | ||
|
|
ffb7a32402 | ||
|
|
beb1630fcf | ||
|
|
2af48b2642 | ||
|
|
f69993eac8 | ||
|
|
9bdcd143cf | ||
|
|
9230e12eb0 | ||
|
|
2a923e6e81 | ||
|
|
5f2e081298 | ||
|
|
6fbc7430cb | ||
|
|
6fdb43e1e9 | ||
|
|
620e797671 | ||
|
|
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 | ||
|
|
87849a8161 | ||
|
|
c8bf792a9a | ||
|
|
d37295fbee | ||
|
|
266f541d35 | ||
|
|
aabdd550ba | ||
|
|
8ff60a1e3e | ||
|
|
2497bd727a | ||
|
|
e37d9d87ba | ||
|
|
80a1906d90 | ||
|
|
282de12336 | ||
|
|
2c5308aea3 | ||
|
|
5057202938 | ||
|
|
5e1b9987dd | ||
|
|
d9c26f7f2c | ||
|
|
adfcd4bb31 | ||
|
|
95f7d9c3cf | ||
|
|
4c1674cfd8 | ||
|
|
f65ca8420e | ||
|
|
85aff2c271 | ||
|
|
61ca24bb8e | ||
|
|
ed36588358 | ||
|
|
80c8079a8c | ||
|
|
763bedf9f6 | ||
|
|
641f7e91a8 | ||
|
|
4e8e925d59 | ||
|
|
d84858dcfb | ||
|
|
3121ee04eb | ||
|
|
a795132a04 | ||
|
|
a6e491575a | ||
|
|
78bf90afd9 | ||
|
|
76b60bdd96 | ||
|
|
98bfb1bf6d | ||
|
|
3c1cc79684 | ||
|
|
12bbcee374 | ||
|
|
6e73ae0157 | ||
|
|
d90b951a8c | ||
|
|
14558131ed | ||
|
|
a422363337 | ||
|
|
1278d0b3af | ||
|
|
c8ab6271a8 | ||
|
|
957e828a5b | ||
|
|
5afda8cbb2 | ||
|
|
1bbf324f1e | ||
|
|
1be5b987a2 | ||
|
|
6b684b19f2 | ||
|
|
4a37982e30 | ||
|
|
55cb44e0e7 | ||
|
|
5c242d08bf | ||
|
|
5bf65d3849 | ||
|
|
21d5dfa175 | ||
|
|
e49ad29999 | ||
|
|
cdbe74ed89 | ||
|
|
129970379c | ||
|
|
c5c47d14dd | ||
|
|
95f19e7333 | ||
|
|
9c6edd3f18 | ||
|
|
bda9210354 | ||
|
|
f57951e8dc | ||
|
|
ba8c04caf8 | ||
|
|
ff0658117d | ||
|
|
920b593d5f | ||
|
|
27d9b08ce2 | ||
|
|
700cdacb4d | ||
|
|
3c7a532704 | ||
|
|
6048f37ad5 | ||
|
|
f18952633f | ||
|
|
437257e631 | ||
|
|
c720170f26 | ||
|
|
d9c03d6509 | ||
|
|
3b7066711f | ||
|
|
6a630c40a1 | ||
|
|
9f9c042b66 | ||
|
|
2a6d7d0b85 | ||
|
|
6d813ccd84 | ||
|
|
af9c760fb8 | ||
|
|
90656b05e3 | ||
|
|
d7a3d4e8c7 | ||
|
|
35f0b8bf99 | ||
|
|
be6b391121 | ||
|
|
7f56ba6a41 | ||
|
|
4b3d7a861e | ||
|
|
affed2edcf | ||
|
|
34642e8b8e | ||
|
|
4c8b6c5209 | ||
|
|
2b38ed28c0 | ||
|
|
26b57895ce | ||
|
|
367da13fae | ||
|
|
d6ecd42559 | ||
|
|
233c33fa52 | ||
|
|
43974c5743 | ||
|
|
cf94a62f37 | ||
|
|
81b4074827 | ||
|
|
455657c8df | ||
|
|
ee5a92788a | ||
|
|
2ba396a419 | ||
|
|
7144d58160 | ||
|
|
279fa2e5ef | ||
|
|
dbe0b8b597 | ||
|
|
b7315d255a | ||
|
|
f003d2931f | ||
|
|
6f7c557065 | ||
|
|
ecb6ee46b1 | ||
|
|
354967010f | ||
|
|
57122f31a3 | ||
|
|
cbbcec0d14 | ||
|
|
de38c35b8a | ||
|
|
def996ddf4 | ||
|
|
790e32e96b | ||
|
|
fd75c4d87f | ||
|
|
411a99cbc4 | ||
|
|
d2c6ab72b2 | ||
|
|
3656584eda | ||
|
|
8be370098d | ||
|
|
45057cb6df | ||
|
|
3f24484d60 | ||
|
|
b6d50a22b4 | ||
|
|
8a658210e1 | ||
|
|
583aaaa080 | ||
|
|
22ca4f64e8 | ||
|
|
32e798fcaa | ||
|
|
ced81c8b50 | ||
|
|
7ec4b71101 | ||
|
|
94aa58d380 | ||
|
|
f8d88e6f97 | ||
|
|
a95f6309b0 | ||
|
|
502de018af | ||
|
|
a3e8daad33 | ||
|
|
78a2f65c94 | ||
|
|
1689a6833a | ||
|
|
6d2f32eadf | ||
|
|
c549dd50c9 | ||
|
|
82312e9421 | ||
|
|
e13b367188 | ||
|
|
d73049cc1b | ||
|
|
4373b23cd3 | ||
|
|
73eb6ccf41 | ||
|
|
6ca48d0d56 | ||
|
|
b82599005e | ||
|
|
b044053674 | ||
|
|
e4f03bcd94 | ||
|
|
ac3797912f | ||
|
|
429a1f6e7e | ||
|
|
fab20e0796 | ||
|
|
1bc6baa0b0 | ||
|
|
996e0748f8 | ||
|
|
ca46fdfaa4 | ||
|
|
b480797e5b | ||
|
|
c47fdf847e | ||
|
|
3ca9562013 | ||
|
|
3104d5de28 | ||
|
|
fd141cbc8c | ||
|
|
aa0c15b6b3 | ||
|
|
4630a3e551 | ||
|
|
b70d5c52f1 | ||
|
|
5d8635ba7b | ||
|
|
27dad9d9d5 | ||
|
|
abb4417b15 | ||
|
|
388cca5591 | ||
|
|
8aa019e25f | ||
|
|
e4061cfbde | ||
|
|
9a1f20e2d4 | ||
|
|
3b45736729 | ||
|
|
1d88fa450a | ||
|
|
31ee6be163 | ||
|
|
096a2ca5f4 | ||
|
|
fb04f6f64d | ||
|
|
d8e54aa347 | ||
|
|
b2b6b421ba | ||
|
|
c6b35f02f0 | ||
|
|
7e43b0a6b8 | ||
|
|
2915b287ba | ||
|
|
ae561db0c9 | ||
|
|
2d132747c4 | ||
|
|
2848163a04 | ||
|
|
76aa6e11d2 | ||
|
|
d377df15b4 | ||
|
|
334c17cc28 | ||
|
|
f148b5bd3a | ||
|
|
54af649d76 | ||
|
|
f6e5a5fa56 | ||
|
|
01aa24d0db | ||
|
|
3e702ef72e | ||
|
|
a31218f7e5 | ||
|
|
5decb3ed95 | ||
|
|
da61436fbb | ||
|
|
b6025af0c8 | ||
|
|
ab914677c4 | ||
|
|
c0b421f812 | ||
|
|
2a446c800f | ||
|
|
dc541c0298 | ||
|
|
4d9b8b5ba4 | ||
|
|
566a07d3a4 | ||
|
|
921ce6f13a | ||
|
|
708e09a8cc | ||
|
|
04154b84f6 | ||
|
|
2bc9b09e58 | ||
|
|
16d517dcd0 | ||
|
|
5e8d09b010 | ||
|
|
6fc3535449 | ||
|
|
9158dba0ce | ||
|
|
7b2c431ca3 | ||
|
|
9deb460cfc | ||
|
|
2ce6f2473b | ||
|
|
04d8444168 | ||
|
|
b539c4ba76 | ||
|
|
473bc089c7 | ||
|
|
50f405eb77 | ||
|
|
fd0d3bcbcf | ||
|
|
f2e8ab0387 | ||
|
|
dfbf2748c7 | ||
|
|
57b0ba5916 | ||
|
|
e668fb0faf | ||
|
|
2702203cb5 | ||
|
|
27f17a2451 | ||
|
|
98c2492d21 | ||
|
|
04339cbb9a | ||
|
|
cdb3b1d257 | ||
|
|
0913769729 | ||
|
|
3a1d5b77b5 | ||
|
|
e12002ce86 | ||
|
|
676a6fe72d | ||
|
|
f29f8938fe | ||
|
|
4c0e147786 | ||
|
|
cba61118de | ||
|
|
32dc6b3665 | ||
|
|
7d98e664e9 | ||
|
|
6763403700 | ||
|
|
feb0e13bfd | ||
|
|
b86f6d190f |
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
|
||||
88
.github/check_readme_commands.py
vendored
Executable file
88
.github/check_readme_commands.py
vendored
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that all CLI commands are documented in the README."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import typer
|
||||
|
||||
from compose_farm.cli import app
|
||||
|
||||
|
||||
def get_all_commands(typer_app: typer.Typer, prefix: str = "cf") -> set[str]:
|
||||
"""Extract all command names from a Typer app, including nested subcommands."""
|
||||
commands = set()
|
||||
|
||||
# Get registered commands (skip hidden ones like aliases)
|
||||
for command in typer_app.registered_commands:
|
||||
if command.hidden:
|
||||
continue
|
||||
name = command.name
|
||||
if not name and command.callback:
|
||||
name = getattr(command.callback, "__name__", None)
|
||||
if name:
|
||||
commands.add(f"{prefix} {name}")
|
||||
|
||||
# Get registered sub-apps (like 'config')
|
||||
for group in typer_app.registered_groups:
|
||||
sub_app = group.typer_instance
|
||||
sub_name = group.name
|
||||
if sub_app and sub_name:
|
||||
commands.add(f"{prefix} {sub_name}")
|
||||
# Don't recurse into subcommands - we only document the top-level subcommand
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
def get_documented_commands(readme_path: Path) -> set[str]:
|
||||
"""Extract commands documented in README from help output sections."""
|
||||
content = readme_path.read_text()
|
||||
|
||||
# Match patterns like: <code>cf command --help</code>
|
||||
pattern = r"<code>(cf\s+[\w-]+)\s+--help</code>"
|
||||
matches = re.findall(pattern, content)
|
||||
|
||||
return set(matches)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Check that all CLI commands are documented in the README."""
|
||||
readme_path = Path(__file__).parent.parent / "README.md"
|
||||
|
||||
if not readme_path.exists():
|
||||
print(f"ERROR: README.md not found at {readme_path}")
|
||||
return 1
|
||||
|
||||
cli_commands = get_all_commands(app)
|
||||
documented_commands = get_documented_commands(readme_path)
|
||||
|
||||
# Also check for the main 'cf' help
|
||||
if "<code>cf --help</code>" in readme_path.read_text():
|
||||
documented_commands.add("cf")
|
||||
cli_commands.add("cf")
|
||||
|
||||
missing = cli_commands - documented_commands
|
||||
extra = documented_commands - cli_commands
|
||||
|
||||
if missing or extra:
|
||||
if missing:
|
||||
print("ERROR: Commands missing from README --help documentation:")
|
||||
for cmd in sorted(missing):
|
||||
print(f" - {cmd}")
|
||||
if extra:
|
||||
print("WARNING: Commands documented but not in CLI:")
|
||||
for cmd in sorted(extra):
|
||||
print(f" - {cmd}")
|
||||
return 1
|
||||
|
||||
print(f"✓ All {len(cli_commands)} commands documented in README")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
@@ -12,14 +12,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
python-version: ["3.11", "3.12", "3.13"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
@@ -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,13 +36,33 @@ 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:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Set up Python
|
||||
run: uv python install 3.12
|
||||
@@ -50,11 +70,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Run ruff check
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run ruff format check
|
||||
run: uv run ruff format --check .
|
||||
|
||||
- name: Run mypy
|
||||
run: uv run mypy src/compose_farm
|
||||
- name: Run pre-commit (via prek)
|
||||
uses: j178/prek-action@v1
|
||||
|
||||
111
.github/workflows/docker.yml
vendored
Normal file
111
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Upload Python Package"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (leave empty for latest)'
|
||||
required: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
# Only run if PyPI upload succeeded (or manual dispatch)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||
# Get version from the tag that triggered the release
|
||||
VERSION="${{ github.event.workflow_run.head_branch }}"
|
||||
# Strip 'v' prefix if present
|
||||
VERSION="${VERSION#v}"
|
||||
elif [ -n "${{ github.event.inputs.version }}" ]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
else
|
||||
VERSION=""
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Wait for PyPI
|
||||
if: steps.version.outputs.version != ''
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Waiting for compose-farm==$VERSION on PyPI..."
|
||||
for i in {1..30}; do
|
||||
if curl -sf "https://pypi.org/pypi/compose-farm/$VERSION/json" > /dev/null; then
|
||||
echo "✓ Version $VERSION available on PyPI"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i: not yet available, waiting 10s..."
|
||||
sleep 10
|
||||
done
|
||||
echo "✗ Timeout waiting for PyPI"
|
||||
exit 1
|
||||
|
||||
- name: Check if latest release
|
||||
id: latest
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
# Get latest release tag from GitHub (strip 'v' prefix)
|
||||
LATEST=$(gh release view --json tagName -q '.tagName' | sed 's/^v//')
|
||||
echo "Building version: $VERSION"
|
||||
echo "Latest release: $LATEST"
|
||||
if [ "$VERSION" = "$LATEST" ]; then
|
||||
echo "is_latest=true" >> $GITHUB_OUTPUT
|
||||
echo "✓ This is the latest release, will tag as :latest"
|
||||
else
|
||||
echo "is_latest=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠ This is NOT the latest release, skipping :latest tag"
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# Only tag as 'latest' if this is the latest release (prevents re-runs of old releases from overwriting)
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ steps.version.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ steps.version.outputs.version }}
|
||||
type=semver,pattern={{major}},value=v${{ steps.version.outputs.version }}
|
||||
type=raw,value=latest,enable=${{ steps.latest.outputs.is_latest }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
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
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -13,9 +13,9 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Build
|
||||
run: uv build
|
||||
- name: Publish package distributions to PyPI
|
||||
|
||||
10
.github/workflows/update-readme.yml
vendored
10
.github/workflows/update-readme.yml
vendored
@@ -11,22 +11,24 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Run markdown-code-runner
|
||||
env:
|
||||
TERM: dumb
|
||||
NO_COLOR: 1
|
||||
TERMINAL_WIDTH: 90
|
||||
COLUMNS: 90 # POSIX terminal width for Rich
|
||||
TERMINAL_WIDTH: 90 # Typer MAX_WIDTH for help panels
|
||||
_TYPER_FORCE_DISABLE_TERMINAL: 1 # Prevent Typer forcing terminal mode in CI
|
||||
run: |
|
||||
uvx --with . markdown-code-runner README.md
|
||||
sed -i 's/[[:space:]]*$//' README.md
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -37,8 +37,13 @@ ENV/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.code/
|
||||
|
||||
# Local config (don't commit real configs)
|
||||
compose-farm.yaml
|
||||
!examples/compose-farm.yaml
|
||||
coverage.xml
|
||||
.env
|
||||
homepage/
|
||||
site/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-readme-commands
|
||||
name: Check README documents all CLI commands
|
||||
entry: uv run python .github/check_readme_commands.py
|
||||
language: system
|
||||
files: ^(README\.md|src/compose_farm/cli/.*)$
|
||||
pass_filenames: false
|
||||
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
@@ -10,18 +19,24 @@ repos:
|
||||
- id: debug-statements
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.8.4
|
||||
rev: v0.14.9
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-check
|
||||
args: [--fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.14.0
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
- pydantic>=2.0.0
|
||||
- typer>=0.9.0
|
||||
- asyncssh>=2.14.0
|
||||
- types-PyYAML
|
||||
name: mypy (type checker)
|
||||
entry: uv run mypy src tests
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
- id: ty
|
||||
name: ty (type checker)
|
||||
entry: uv run ty check
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
|
||||
119
.prompts/docs-review.md
Normal file
119
.prompts/docs-review.md
Normal file
@@ -0,0 +1,119 @@
|
||||
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 and CLAUDE.md
|
||||
|
||||
```bash
|
||||
# What source files actually exist?
|
||||
git ls-files "src/**/*.py"
|
||||
```
|
||||
|
||||
Check **both** `docs/architecture.md` and `CLAUDE.md` (Architecture section):
|
||||
- Listed files exist
|
||||
- No files are missing from the list
|
||||
- Descriptions match what the code does
|
||||
|
||||
Both files have architecture listings that can drift independently.
|
||||
|
||||
### 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`
|
||||
```
|
||||
79
.prompts/duplication-audit.md
Normal file
79
.prompts/duplication-audit.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Duplication audit and generalization prompt
|
||||
|
||||
You are a coding agent working inside a repository. Your job is to find duplicated
|
||||
functionality (not just identical code) and propose a minimal, safe generalization.
|
||||
Keep it simple and avoid adding features.
|
||||
|
||||
## First steps
|
||||
|
||||
- Read project-specific instructions (AGENTS.md, CONTRIBUTING.md, or similar) and follow them.
|
||||
- If instructions mention tooling or style (e.g., preferred search tools), use those.
|
||||
- Ask a brief clarification if the request is ambiguous (for example: report only vs refactor).
|
||||
|
||||
## Objective
|
||||
|
||||
Identify and consolidate duplicated functionality across the codebase. Duplication includes:
|
||||
- Multiple functions that parse or validate the same data in slightly different ways
|
||||
- Repeated file reads or config parsing
|
||||
- Similar command building or subprocess execution paths
|
||||
- Near-identical error handling or logging patterns
|
||||
- Repeated data transforms that can become a shared helper
|
||||
|
||||
The goal is to propose a general, reusable abstraction that reduces duplication while
|
||||
preserving behavior. Keep changes minimal and easy to review.
|
||||
|
||||
## Search strategy
|
||||
|
||||
1) Map the hot paths
|
||||
- Scan entry points (CLI, web handlers, tasks, jobs) to see what they do repeatedly.
|
||||
- Look for cross-module patterns: same steps, different files.
|
||||
|
||||
2) Find duplicate operations
|
||||
- Use fast search tools (prefer `rg`) to find repeated keywords and patterns.
|
||||
- Check for repeated YAML/JSON parsing, env interpolation, file IO, command building,
|
||||
data validation, or response formatting.
|
||||
|
||||
3) Validate duplication is real
|
||||
- Confirm the functional intent matches (not just similar code).
|
||||
- Note any subtle differences that must be preserved.
|
||||
|
||||
4) Propose a minimal generalization
|
||||
- Suggest a shared helper, utility, or wrapper.
|
||||
- Avoid over-engineering. If only two call sites exist, keep the helper small.
|
||||
- Prefer pure functions and centralized IO if that already exists.
|
||||
|
||||
## Deliverables
|
||||
|
||||
Provide a concise report with:
|
||||
|
||||
1) Findings
|
||||
- List duplicated behaviors with file references and a short description of the
|
||||
shared functionality.
|
||||
- Explain why these are functionally the same (or nearly the same).
|
||||
|
||||
2) Proposed generalizations
|
||||
- For each duplication, propose a shared helper and where it should live.
|
||||
- Outline any behavior differences that need to be parameterized.
|
||||
|
||||
3) Impact and risk
|
||||
- Note any behavior risks, test needs, or migration steps.
|
||||
|
||||
If the user asked you to implement changes:
|
||||
- Make only the minimal edits needed to dedupe behavior.
|
||||
- Keep the public API stable unless explicitly requested.
|
||||
- Add small comments only when the logic is non-obvious.
|
||||
- Summarize what changed and why.
|
||||
|
||||
## Output format
|
||||
|
||||
- Start with a short summary of the top 1-3 duplications.
|
||||
- Then provide a list of findings, ordered by impact.
|
||||
- Include a small proposed refactor plan (step-by-step, no more than 5 steps).
|
||||
- End with any questions or assumptions.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not add new features or change behavior beyond deduplication.
|
||||
- Avoid deep refactors without explicit request.
|
||||
- Preserve existing style conventions and import rules.
|
||||
- If a duplication is better left alone (e.g., clarity, single usage), say so.
|
||||
16
.prompts/pr-review.md
Normal file
16
.prompts/pr-review.md
Normal file
@@ -0,0 +1,16 @@
|
||||
Review the pull request for:
|
||||
|
||||
- **Code cleanliness**: Is the implementation clean and well-structured?
|
||||
- **DRY principle**: Does it avoid duplication?
|
||||
- **Code reuse**: Are there parts that should be reused from other places?
|
||||
- **Organization**: Is everything in the right place?
|
||||
- **Consistency**: Is it in the same style as other parts of the codebase?
|
||||
- **Simplicity**: Is it not over-engineered? Remember KISS and YAGNI. No dead code paths and NO defensive programming.
|
||||
- **No pointless wrappers**: Identify functions/methods that just call another function and return its result. Callers should call the underlying function directly instead of going through unnecessary indirection.
|
||||
- **User experience**: Does it provide a good user experience?
|
||||
- **PR**: Is the PR description and title clear and informative?
|
||||
- **Tests**: Are there tests, and do they cover the changes adequately? Are they testing something meaningful or are they just trivial?
|
||||
- **Live tests**: Test the changes in a REAL live environment to ensure they work as expected, use the config in `/opt/stacks/compose-farm.yaml`.
|
||||
- **Rules**: Does the code follow the project's coding standards and guidelines as laid out in @CLAUDE.md?
|
||||
|
||||
Look at `git diff origin/main..HEAD` for the changes made in this pull request.
|
||||
51
.prompts/update-demos.md
Normal file
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
|
||||
```
|
||||
154
CLAUDE.md
154
CLAUDE.md
@@ -9,20 +9,88 @@
|
||||
## Architecture
|
||||
|
||||
```
|
||||
compose_farm/
|
||||
├── config.py # Pydantic models, YAML loading
|
||||
├── ssh.py # asyncssh execution, streaming
|
||||
└── cli.py # Typer commands
|
||||
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, 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, list commands
|
||||
│ ├── ssh.py # SSH key management (setup, status, keygen)
|
||||
│ └── web.py # Web UI server command
|
||||
├── compose.py # Compose file parsing (.env, ports, volumes, networks)
|
||||
├── config.py # Pydantic models, YAML loading
|
||||
├── console.py # Shared Rich console instances
|
||||
├── executor.py # SSH/local command execution, streaming output
|
||||
├── glances.py # Glances API integration for host resource stats
|
||||
├── logs.py # Image digest snapshots (dockerfarm-log.toml)
|
||||
├── operations.py # Business logic (up, migrate, discover, preflight checks)
|
||||
├── paths.py # Path utilities, config file discovery
|
||||
├── registry.py # Container registry client for update checking
|
||||
├── ssh_keys.py # SSH key path constants and utilities
|
||||
├── state.py # Deployment state tracking (which stack on which host)
|
||||
├── 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. **asyncssh over Paramiko/Fabric**: Native async support, built-in streaming
|
||||
2. **Parallel by default**: Multiple services run concurrently via `asyncio.gather`
|
||||
3. **Streaming output**: Real-time stdout/stderr with `[service]` prefix
|
||||
1. **Hybrid SSH approach**: asyncssh for parallel streaming with prefixes; native `ssh -t` for raw mode (progress bars)
|
||||
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 execution**: When host is `localhost`/`local`, skip SSH and run locally
|
||||
6. **Local IP auto-detection**: Skips SSH when target host matches local machine's IP
|
||||
7. **State tracking**: Tracks where 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
|
||||
|
||||
@@ -34,14 +102,66 @@ compose_farm/
|
||||
- **NEVER merge anything into main.** Always commit directly or use fast-forward/rebase.
|
||||
- Never force push.
|
||||
|
||||
## SSH Agent in Remote Sessions
|
||||
|
||||
When pushing to GitHub via SSH fails with "Permission denied (publickey)", fix the SSH agent socket:
|
||||
|
||||
```bash
|
||||
# Find and set the correct SSH agent socket
|
||||
SSH_AUTH_SOCK=$(ls -t ~/.ssh/agent/s.*.sshd.* 2>/dev/null | head -1) git push origin branch-name
|
||||
```
|
||||
|
||||
This is needed because the SSH agent socket path changes between sessions.
|
||||
|
||||
## 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.
|
||||
|
||||
```bash
|
||||
# IMPORTANT: Ensure you're on latest origin/main before releasing!
|
||||
git fetch origin
|
||||
git checkout origin/main
|
||||
|
||||
# Check current version
|
||||
git tag --sort=-v:refname | head -1
|
||||
|
||||
# Create release (minor version bump: v0.21.1 -> v0.22.0)
|
||||
gh release create v0.22.0 --title "v0.22.0" --notes "release notes here"
|
||||
```
|
||||
|
||||
Versioning:
|
||||
- **Patch** (v0.21.0 → v0.21.1): Bug fixes
|
||||
- **Minor** (v0.21.1 → v0.22.0): New features, non-breaking changes
|
||||
|
||||
Write release notes manually describing what changed. Group by features and bug fixes.
|
||||
|
||||
## Commands Quick Reference
|
||||
|
||||
| Command | Docker Compose Equivalent |
|
||||
|---------|--------------------------|
|
||||
| `up` | `docker compose up -d` |
|
||||
| `down` | `docker compose down` |
|
||||
| `pull` | `docker compose pull` |
|
||||
| `restart` | `down` + `up -d` |
|
||||
| `update` | `pull` + `down` + `up -d` |
|
||||
| `logs` | `docker compose logs` |
|
||||
| `ps` | `docker compose ps` |
|
||||
CLI available as `cf` or `compose-farm`.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `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` | Restart running containers (`docker compose restart`) |
|
||||
| `update` | Pull, build, recreate only if changed (`up -d --pull always --build`) |
|
||||
| `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) |
|
||||
| `list` | List stacks and hosts (`--simple` for scripting, `--host` to filter) |
|
||||
| `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, init-env, show, path, validate, edit, symlink) |
|
||||
| `ssh` | Manage SSH keys (setup, status, keygen) |
|
||||
| `web` | Start web UI server |
|
||||
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Build stage - install with uv
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-alpine AS builder
|
||||
|
||||
ARG VERSION
|
||||
RUN uv tool install --compile-bytecode "compose-farm[web]${VERSION:+==$VERSION}"
|
||||
|
||||
# Runtime stage - minimal image without uv
|
||||
FROM python:3.14-alpine
|
||||
|
||||
# Install only runtime requirements
|
||||
RUN apk add --no-cache openssh-client
|
||||
|
||||
# Copy installed tool virtualenv and bin symlinks from builder
|
||||
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
|
||||
|
||||
# Allow non-root users to add passwd entries (required for SSH)
|
||||
RUN chmod 666 /etc/passwd
|
||||
|
||||
# Entrypoint creates /etc/passwd entry for non-root UIDs (required for SSH)
|
||||
ENTRYPOINT ["sh", "-c", "[ $(id -u) != 0 ] && echo ${USER:-u}:x:$(id -u):$(id -g)::${HOME:-/}:/bin/sh >> /etc/passwd; exec cf \"$@\"", "--"]
|
||||
CMD ["--help"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Bas Nijholt
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
35
PLAN.md
35
PLAN.md
@@ -1,35 +0,0 @@
|
||||
# Compose Farm – Traefik Multihost Ingress Plan
|
||||
|
||||
## Goal
|
||||
Generate a Traefik file-provider fragment from existing docker-compose Traefik labels (no config duplication) so a single front-door Traefik on 192.168.1.66 with wildcard `*.lab.mydomain.org` can route to services running on other hosts. Keep the current simplicity (SSH + docker compose); no Swarm/K8s.
|
||||
|
||||
## Requirements
|
||||
- Traefik stays on main host; keep current `dynamic.yml` and Docker provider for local containers.
|
||||
- Add a watched directory provider (any path works) and load a generated fragment (e.g., `compose-farm.generated.yml`).
|
||||
- No edits to compose files: reuse existing `traefik.*` labels as the single source of truth; Compose Farm only reads them.
|
||||
- Generator infers routing from labels and reachability from `ports:` mappings; prefer host-published ports so Traefik can reach services across hosts. Upstreams point to `<host address>:<published host port>`; warn if no published port is found.
|
||||
- Only minimal data in `compose-farm.yaml`: hosts map and service→host mapping (already present).
|
||||
- No new orchestration/discovery layers; respect KISS/YAGNI/DRY.
|
||||
|
||||
## Non-Goals
|
||||
- No Swarm/Kubernetes adoption.
|
||||
- No global Docker provider across hosts.
|
||||
- No health checks/service discovery layer.
|
||||
|
||||
## Current State (Dec 2025)
|
||||
- Compose Farm: Typer CLI wrapping `docker compose` over SSH; config in `compose-farm.yaml`; parallel by default; snapshot/log tooling present.
|
||||
- Traefik: single instance on 192.168.1.66, wildcard `*.lab.mydomain.org`, Docker provider for local services, file provider via `dynamic.yml` already in use.
|
||||
|
||||
## Proposed Implementation Steps
|
||||
1) Add generator command: `compose-farm traefik-file --output <path>`.
|
||||
2) Resolve per-service host from `compose-farm.yaml`; read compose file at `{compose_dir}/{service}/docker-compose.yml`.
|
||||
3) Parse `traefik.*` labels to build routers/services/middlewares as in compose; map container port to published host port (from `ports:`) to form upstream URLs with host address.
|
||||
4) Emit file-provider YAML to the watched directory (recommended default: `/mnt/data/traefik/dynamic.d/compose-farm.generated.yml`, but user chooses via `--output`).
|
||||
5) Warnings: if no published port is found, warn that cross-host reachability requires L3 reachability to container IPs.
|
||||
6) Tests: label parsing, port mapping, YAML render; scenario with published port; scenario without published port.
|
||||
7) Docs: update README/CLAUDE to describe directory provider flags and the generator workflow; note that compose files remain unchanged.
|
||||
|
||||
## Open Questions
|
||||
- How to derive target host address: use `hosts.<name>.address` verbatim, or allow override per service? (Default: use host address.)
|
||||
- Should we support multiple hosts/backends per service for LB/HA? (Start with single server.)
|
||||
- Where to store generated file by default? (Default to user-specified `--output`; maybe fallback to `./compose-farm-traefik.yml`.)
|
||||
@@ -3,23 +3,28 @@
|
||||
|
||||
compose_dir: /opt/compose
|
||||
|
||||
# Optional: Auto-regenerate Traefik file-provider config after up/down/update
|
||||
traefik_file: /opt/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_stack: traefik # Skip stacks on same host (docker provider handles them)
|
||||
|
||||
hosts:
|
||||
# Full form with all options
|
||||
nas01:
|
||||
server-1:
|
||||
address: 192.168.1.10
|
||||
user: docker
|
||||
port: 22
|
||||
|
||||
# Short form (just address, user defaults to current user)
|
||||
nas02: 192.168.1.11
|
||||
server-2: 192.168.1.11
|
||||
|
||||
# Local execution (no SSH)
|
||||
local: localhost
|
||||
|
||||
services:
|
||||
# Map service names to hosts
|
||||
# Compose file expected at: {compose_dir}/{service}/docker-compose.yml
|
||||
plex: nas01
|
||||
jellyfin: nas02
|
||||
sonarr: nas01
|
||||
radarr: nas02
|
||||
stacks:
|
||||
# Map stack names to hosts
|
||||
# Compose file expected at: {compose_dir}/{stack}/compose.yaml
|
||||
traefik: server-1 # Traefik runs here
|
||||
plex: server-2 # Stacks on other hosts get file-provider entries
|
||||
jellyfin: server-2
|
||||
grafana: server-1
|
||||
nextcloud: local
|
||||
|
||||
70
docker-compose.yml
Normal file
70
docker-compose.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
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:
|
||||
# Compose directory (contains compose files AND compose-farm.yaml config)
|
||||
- ${CF_COMPOSE_DIR:-/opt/stacks}:${CF_COMPOSE_DIR:-/opt/stacks}
|
||||
# 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}:${CF_HOME:-/root}/.ssh/compose-farm
|
||||
# Option 2: Named volume - managed by Docker, shared between services
|
||||
# - cf-ssh:${CF_HOME:-/root}/.ssh
|
||||
# Option 3: SSH agent forwarding (uncomment if using ssh-agent)
|
||||
# - ${SSH_AUTH_SOCK}:/ssh-agent:ro
|
||||
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:
|
||||
- ${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}:${CF_HOME:-/root}/.ssh/compose-farm
|
||||
# Option 2: Named volume
|
||||
# - cf-ssh:${CF_HOME:-/root}/.ssh
|
||||
# Option 3: SSH agent forwarding (uncomment if using ssh-agent)
|
||||
# - ${SSH_AUTH_SOCK}:/ssh-agent:ro
|
||||
# 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_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}`)
|
||||
- traefik.http.routers.compose-farm.entrypoints=websecure
|
||||
- traefik.http.routers.compose-farm-local.rule=Host(`compose-farm.local`)
|
||||
- traefik.http.routers.compose-farm-local.entrypoints=web
|
||||
- traefik.http.services.compose-farm.loadbalancer.server.port=9000
|
||||
networks:
|
||||
- mynetwork
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
cf-ssh:
|
||||
# Only used if Option 2 is selected above
|
||||
1
docs/CNAME
Normal file
1
docs/CNAME
Normal file
@@ -0,0 +1 @@
|
||||
compose-farm.nijho.lt
|
||||
361
docs/architecture.md
Normal file
361
docs/architecture.md
Normal file
@@ -0,0 +1,361 @@
|
||||
---
|
||||
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, init-env, 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`.
|
||||
|
||||
### Host Resource Monitoring (`src/compose_farm/glances.py`)
|
||||
|
||||
Integration with [Glances](https://nicolargo.github.io/glances/) for real-time host stats:
|
||||
|
||||
- Fetches CPU, memory, and load from Glances REST API on each host
|
||||
- Used by web UI dashboard to display host resource usage
|
||||
- Requires `glances_stack` config option pointing to a Glances stack running on all hosts
|
||||
|
||||
### Container Registry Client (`src/compose_farm/registry.py`)
|
||||
|
||||
OCI Distribution API client for checking image updates:
|
||||
|
||||
- Parses image references (registry, namespace, name, tag, digest)
|
||||
- Fetches available tags from Docker Hub, GHCR, and other registries
|
||||
- Compares semantic versions to find newer releases
|
||||
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-live_stats.gif
Normal file
3
docs/assets/web-live_stats.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4135888689a10c5ae2904825d98f2a6d215c174a4bd823e25761f619590f04ff
|
||||
size 3990104
|
||||
3
docs/assets/web-live_stats.webm
Normal file
3
docs/assets/web-live_stats.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:87739cd6f6576a81100392d8d1e59d3e776fecc8f0721a31332df89e7fc8593d
|
||||
size 5814274
|
||||
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 |
|
||||
842
docs/commands.md
Normal file
842
docs/commands.md
Normal file
@@ -0,0 +1,842 @@
|
||||
---
|
||||
icon: lucide/terminal
|
||||
---
|
||||
|
||||
# Commands Reference
|
||||
|
||||
The Compose Farm CLI is available as both `compose-farm` and the shorter alias `cf`.
|
||||
|
||||
## Command Overview
|
||||
|
||||
Commands are either **Docker Compose wrappers** (`up`, `down`, `stop`, `restart`, `pull`, `logs`, `ps`, `compose`) with multi-host superpowers, or **Compose Farm originals** (`apply`, `update`, `refresh`, `check`) for orchestration Docker Compose can't do.
|
||||
|
||||
| Category | Command | Description |
|
||||
|----------|---------|-------------|
|
||||
| **Lifecycle** | `apply` | Make reality match config |
|
||||
| | `up` | Start stacks |
|
||||
| | `down` | Stop stacks |
|
||||
| | `stop` | Stop services without removing containers |
|
||||
| | `restart` | Restart running containers |
|
||||
| | `update` | Shorthand for `up --pull --build` |
|
||||
| | `pull` | Pull latest images |
|
||||
| | `compose` | Run any docker compose command |
|
||||
| **Monitoring** | `ps` | Show stack status |
|
||||
| | `logs` | Show stack logs |
|
||||
| | `stats` | Show overview statistics |
|
||||
| | `list` | List stacks and hosts |
|
||||
| **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
|
||||
```
|
||||
|
||||
## Command Aliases
|
||||
|
||||
Short aliases for frequently used commands:
|
||||
|
||||
| Alias | Command | Alias | Command |
|
||||
|-------|---------|-------|---------|
|
||||
| `cf a` | `apply` | `cf s` | `stats` |
|
||||
| `cf l` | `logs` | `cf ls` | `list` |
|
||||
| `cf r` | `restart` | `cf rf` | `refresh` |
|
||||
| `cf u` | `update` | `cf ck` | `check` |
|
||||
| `cf p` | `pull` | `cf tf` | `traefik-file` |
|
||||
| `cf c` | `compose` | | |
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| `--no-strays` | Skip stopping stray stacks (running on wrong host) |
|
||||
| `--full, -f` | Also run up on all stacks (applies compose/env changes, triggers migrations) |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**What it does:**
|
||||
|
||||
1. Stops orphaned stacks (in state but removed from config)
|
||||
2. Stops stray stacks (running on unauthorized hosts)
|
||||
3. Migrates stacks on wrong host
|
||||
4. 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
|
||||
|
||||
# Don't stop stray stacks
|
||||
cf apply --no-strays
|
||||
|
||||
# Also run up on all stacks (applies compose/env changes, triggers migrations)
|
||||
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 |
|
||||
| `--pull` | Pull images before starting (`--pull always`) |
|
||||
| `--build` | Build images before starting |
|
||||
| `--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 running containers (`docker compose restart`). 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 + up). Shorthand for `up --pull --build`. 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### cf list
|
||||
|
||||
List all stacks and their assigned hosts.
|
||||
|
||||
```bash
|
||||
cf list [OPTIONS]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--host, -H TEXT` | Filter to stacks on this host |
|
||||
| `--simple, -s` | Plain output for scripting (one stack per line) |
|
||||
| `--config, -c PATH` | Path to config file |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# List all stacks
|
||||
cf list
|
||||
|
||||
# Filter by host
|
||||
cf list --host nas
|
||||
|
||||
# Plain output for scripting
|
||||
cf list --simple
|
||||
|
||||
# Combine: list stack names on a specific host
|
||||
cf list --host nuc --simple
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| `init-env` | Generate .env file for Docker deployment |
|
||||
| `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` |
|
||||
| `init-env` | `--path/-p PATH`, `--output/-o 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
|
||||
|
||||
# Generate .env file in current directory
|
||||
cf config init-env
|
||||
|
||||
# Generate .env at specific path
|
||||
cf config init-env -o /opt/stacks/.env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
```
|
||||
447
docs/configuration.md
Normal file
447
docs/configuration.md
Normal file
@@ -0,0 +1,447 @@
|
||||
---
|
||||
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`, 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
|
||||
```
|
||||
|
||||
### glances_stack
|
||||
|
||||
Stack name running [Glances](https://nicolargo.github.io/glances/) for host resource monitoring. When set, the CLI (`cf stats --containers`) and web UI display CPU, memory, and container stats for all hosts.
|
||||
|
||||
```yaml
|
||||
glances_stack: glances
|
||||
```
|
||||
|
||||
The Glances stack should run on all hosts and expose port 61208. See the README for full setup instructions.
|
||||
|
||||
## 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
|
||||
|
||||
### Compose Farm Environment Variables
|
||||
|
||||
These environment variables configure Compose Farm itself:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CF_CONFIG` | Path to config file |
|
||||
| `CF_WEB_STACK` | Web UI stack name (Docker only, enables self-update detection and local host inference) |
|
||||
|
||||
**Docker deployment variables** (used in docker-compose.yml):
|
||||
|
||||
| Variable | Description | Generated by |
|
||||
|----------|-------------|--------------|
|
||||
| `CF_COMPOSE_DIR` | Compose files directory | `cf config init-env` |
|
||||
| `CF_UID` / `CF_GID` | User/group ID for containers | `cf config init-env` |
|
||||
| `CF_HOME` / `CF_USER` | Home directory and username | `cf config init-env` |
|
||||
| `CF_SSH_DIR` | SSH keys volume mount | Manual |
|
||||
| `CF_XDG_CONFIG` | Config backup volume mount | Manual |
|
||||
|
||||
## 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 (only recreates containers if images changed)
|
||||
|
||||
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."""
|
||||
302
docs/demos/web/conftest.py
Normal file
302
docs/demos/web/conftest.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""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.executor import (
|
||||
get_container_compose_labels as _original_get_compose_labels,
|
||||
)
|
||||
from compose_farm.glances import ContainerStats
|
||||
from compose_farm.glances import fetch_container_stats as _original_fetch_container_stats
|
||||
from compose_farm.state import load_state as _original_load_state
|
||||
from compose_farm.web.cdn import CDN_ASSETS, ensure_vendor_cache
|
||||
|
||||
# NOTE: Do NOT import create_app here - it must be imported AFTER patches are applied
|
||||
# to ensure the patched get_config is used by all route modules
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
from playwright.sync_api import BrowserContext, Page, Route
|
||||
|
||||
# Substrings to exclude from demo recordings (case-insensitive)
|
||||
DEMO_EXCLUDE_PATTERNS = {"arr", "vpn", "tash"}
|
||||
|
||||
|
||||
def _should_exclude(name: str) -> bool:
|
||||
"""Check if a stack/container name should be excluded from demo."""
|
||||
name_lower = name.lower()
|
||||
return any(pattern in name_lower for pattern in DEMO_EXCLUDE_PATTERNS)
|
||||
|
||||
|
||||
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 not _should_exclude(name)
|
||||
}
|
||||
return CFConfig(
|
||||
compose_dir=config.compose_dir,
|
||||
hosts=config.hosts,
|
||||
stacks=filtered_stacks,
|
||||
traefik_file=config.traefik_file,
|
||||
traefik_stack=config.traefik_stack,
|
||||
glances_stack=config.glances_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 not _should_exclude(name)}
|
||||
|
||||
|
||||
async def _filtered_fetch_container_stats(
|
||||
host_name: str,
|
||||
host_address: str,
|
||||
port: int = 61208,
|
||||
request_timeout: float = 10.0,
|
||||
) -> tuple[list[ContainerStats] | None, str | None]:
|
||||
"""Fetch container stats but filter out excluded containers."""
|
||||
containers, error = await _original_fetch_container_stats(
|
||||
host_name, host_address, port, request_timeout
|
||||
)
|
||||
if containers:
|
||||
# Filter by container name (stack is empty at this point)
|
||||
containers = [c for c in containers if not _should_exclude(c.name)]
|
||||
return containers, error
|
||||
|
||||
|
||||
async def _filtered_get_compose_labels(
|
||||
config: CFConfig,
|
||||
host_name: str,
|
||||
) -> dict[str, tuple[str, str]]:
|
||||
"""Get compose labels but filter out excluded stacks."""
|
||||
labels = await _original_get_compose_labels(config, host_name)
|
||||
# Filter out containers whose stack (project) name should be excluded
|
||||
return {
|
||||
name: (stack, service)
|
||||
for name, (stack, service) in labels.items()
|
||||
if not _should_exclude(stack)
|
||||
}
|
||||
|
||||
|
||||
@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_config at source - get_config() calls this internally
|
||||
patch("compose_farm.config.load_config", _get_filtered_config),
|
||||
# Patch load_state at source and where imported
|
||||
patch("compose_farm.state.load_state", _get_filtered_state),
|
||||
patch("compose_farm.web.routes.pages.load_state", _get_filtered_state),
|
||||
# Patch container fetch to filter out excluded containers (Live Stats page)
|
||||
patch("compose_farm.glances.fetch_container_stats", _filtered_fetch_container_stats),
|
||||
# Patch compose labels to filter out excluded stacks
|
||||
patch("compose_farm.executor.get_container_compose_labels", _filtered_get_compose_labels),
|
||||
]
|
||||
|
||||
for p in patches:
|
||||
p.start()
|
||||
|
||||
# Import create_app AFTER patches are started so route modules see patched get_config
|
||||
from compose_farm.web.app import create_app # noqa: PLC0415
|
||||
|
||||
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
|
||||
print(f"UNCACHED CDN request: {url}")
|
||||
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()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wide_recording_context(
|
||||
browser: Any, # pytest-playwright's browser fixture
|
||||
recording_output_dir: Path,
|
||||
) -> Generator[BrowserContext, None, None]:
|
||||
"""Browser context with wider viewport for demos needing more horizontal space.
|
||||
|
||||
NOTE: This fixture does NOT use CDN interception (unlike recording_context).
|
||||
CDN interception was causing inline scripts from containers.html to be
|
||||
removed from the DOM, likely due to Tailwind's browser plugin behavior.
|
||||
"""
|
||||
context = browser.new_context(
|
||||
viewport={"width": 1920, "height": 1080},
|
||||
record_video_dir=str(recording_output_dir),
|
||||
record_video_size={"width": 1920, "height": 1080},
|
||||
)
|
||||
|
||||
yield context
|
||||
context.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wide_recording_page(wide_recording_context: BrowserContext) -> Generator[Page, None, None]:
|
||||
"""Page with wider viewport for demos needing more horizontal space."""
|
||||
page = wide_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)
|
||||
85
docs/demos/web/demo_live_stats.py
Normal file
85
docs/demos/web/demo_live_stats.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Demo: Live Stats page.
|
||||
|
||||
Records a ~20 second demo showing:
|
||||
- Navigating to Live Stats via command palette
|
||||
- Container table with real-time stats
|
||||
- Filtering containers
|
||||
- Sorting by different columns
|
||||
- Auto-refresh countdown
|
||||
|
||||
Run: pytest docs/demos/web/demo_live_stats.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_live_stats(wide_recording_page: Page, server_url: str) -> None:
|
||||
"""Record Live Stats page demo."""
|
||||
page = wide_recording_page
|
||||
|
||||
# Start on dashboard
|
||||
page.goto(server_url)
|
||||
wait_for_sidebar(page)
|
||||
pause(page, 1000)
|
||||
|
||||
# Navigate to Live Stats via command palette
|
||||
open_command_palette(page)
|
||||
pause(page, 400)
|
||||
slow_type(page, "#cmd-input", "live", delay=100)
|
||||
pause(page, 500)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_url("**/live-stats", timeout=5000)
|
||||
|
||||
# Wait for containers to load (may take ~10s on first load due to SSH)
|
||||
page.wait_for_selector("#container-rows tr:not(:has(.loading))", timeout=30000)
|
||||
pause(page, 2000) # Let viewer see the full table with timer
|
||||
|
||||
# Demonstrate filtering
|
||||
slow_type(page, "#filter-input", "grocy", delay=100)
|
||||
pause(page, 1500) # Show filtered results
|
||||
|
||||
# Clear filter
|
||||
page.fill("#filter-input", "")
|
||||
pause(page, 1000)
|
||||
|
||||
# Sort by memory (click header)
|
||||
page.click("th:has-text('Mem')")
|
||||
pause(page, 1500)
|
||||
|
||||
# Sort by CPU
|
||||
page.click("th:has-text('CPU')")
|
||||
pause(page, 1500)
|
||||
|
||||
# Sort by host
|
||||
page.click("th:has-text('Host')")
|
||||
pause(page, 1500)
|
||||
|
||||
# Watch auto-refresh timer count down
|
||||
pause(page, 3500) # Wait for refresh to happen
|
||||
|
||||
# Hover on action menu to show pause behavior
|
||||
action_btn = page.locator('button[onclick^="openActionMenu"]').first
|
||||
action_btn.scroll_into_view_if_needed()
|
||||
action_btn.hover()
|
||||
pause(page, 2000) # Show paused state (timer shows ⏸) and action menu
|
||||
|
||||
# Move away to close menu and resume refresh
|
||||
page.locator("h2").first.hover() # Move to header
|
||||
pause(page, 3500) # Watch countdown resume and refresh happen
|
||||
|
||||
# Final pause
|
||||
pause(page, 1000)
|
||||
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)
|
||||
259
docs/demos/web/record.py
Executable file
259
docs/demos/web/record.py
Executable file
@@ -0,0 +1,259 @@
|
||||
#!/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",
|
||||
"live_stats",
|
||||
]
|
||||
|
||||
# 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)
|
||||
101
docs/docker-deployment.md
Normal file
101
docs/docker-deployment.md
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
icon: lucide/container
|
||||
---
|
||||
|
||||
# Docker Deployment
|
||||
|
||||
Run the Compose Farm web UI in Docker.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**1. Get the compose file:**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/basnijholt/compose-farm/main/docker-compose.yml
|
||||
```
|
||||
|
||||
**2. Generate `.env` file:**
|
||||
|
||||
```bash
|
||||
cf config init-env
|
||||
```
|
||||
|
||||
This auto-detects settings from your `compose-farm.yaml`:
|
||||
- `DOMAIN` from existing traefik labels
|
||||
- `CF_COMPOSE_DIR` from config
|
||||
- `CF_UID/GID/HOME/USER` from current user
|
||||
|
||||
Review the output and edit if needed.
|
||||
|
||||
**3. Set up SSH keys:**
|
||||
|
||||
```bash
|
||||
docker compose run --rm cf ssh setup
|
||||
```
|
||||
|
||||
**4. Start the web UI:**
|
||||
|
||||
```bash
|
||||
docker compose up -d web
|
||||
```
|
||||
|
||||
Open `http://localhost:9000` (or `https://compose-farm.example.com` if using Traefik).
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
The `cf config init-env` command auto-detects most settings. After running it, review the generated `.env` file and edit if needed:
|
||||
|
||||
```bash
|
||||
$EDITOR .env
|
||||
```
|
||||
|
||||
### What init-env detects
|
||||
|
||||
| Variable | How it's detected |
|
||||
|----------|-------------------|
|
||||
| `DOMAIN` | Extracted from traefik labels in your stacks |
|
||||
| `CF_COMPOSE_DIR` | From `compose_dir` in your config |
|
||||
| `CF_UID/GID/HOME/USER` | From current user (for NFS compatibility) |
|
||||
|
||||
If auto-detection fails for any value, edit the `.env` file manually.
|
||||
|
||||
### Glances Monitoring
|
||||
|
||||
To show host CPU/memory stats in the dashboard, deploy [Glances](https://nicolargo.github.io/glances/) on your hosts. When running the web UI container, Compose Farm infers the local host from `CF_WEB_STACK` and uses the Glances container name for that host.
|
||||
|
||||
See [Host Resource Monitoring](https://github.com/basnijholt/compose-farm#host-resource-monitoring-glances) in the README.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SSH "Permission denied" or "Host key verification failed"
|
||||
|
||||
Regenerate keys:
|
||||
|
||||
```bash
|
||||
docker compose run --rm cf ssh setup
|
||||
```
|
||||
|
||||
### Files created as root
|
||||
|
||||
Add the non-root variables above and restart.
|
||||
|
||||
---
|
||||
|
||||
## All Environment Variables
|
||||
|
||||
For advanced users, here's the complete reference:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DOMAIN` | Domain for Traefik labels | *(required)* |
|
||||
| `CF_COMPOSE_DIR` | Compose files directory | `/opt/stacks` |
|
||||
| `CF_UID` / `CF_GID` | User/group ID | `0` (root) |
|
||||
| `CF_HOME` | Home directory | `/root` |
|
||||
| `CF_USER` | Username for SSH | `root` |
|
||||
| `CF_WEB_STACK` | Web UI stack name (enables self-update, local host inference) | *(none)* |
|
||||
| `CF_SSH_DIR` | SSH keys directory | `~/.ssh/compose-farm` |
|
||||
| `CF_XDG_CONFIG` | Config/backup directory | `~/.config/compose-farm` |
|
||||
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
|
||||
# Only recreates containers if images changed
|
||||
```
|
||||
|
||||
## 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);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,6 @@
|
||||
<!-- Privacy-friendly analytics by Plausible -->
|
||||
<script async src="https://plausible.nijho.lt/js/pa-NRX7MolONWKTUREJpAjkB.js"></script>
|
||||
<script>
|
||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||
plausible.init()
|
||||
</script>
|
||||
79
docs/reddit-post.md
Normal file
79
docs/reddit-post.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Title options
|
||||
|
||||
- Multi-host Docker Compose without Kubernetes or file changes
|
||||
- I built a CLI to run Docker Compose across hosts. Zero changes to your files.
|
||||
- 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 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.
|
||||
- Both require rewriting my compose files.
|
||||
|
||||
So I built **Compose Farm**, a simple CLI that runs `docker compose` commands over SSH. No agents, no cluster setup, no changes to your existing compose files.
|
||||
|
||||
## How it works
|
||||
|
||||
One YAML file maps stacks to hosts:
|
||||
|
||||
```yaml
|
||||
compose_dir: /opt/stacks
|
||||
|
||||
hosts:
|
||||
nuc: 192.168.1.10
|
||||
hp: 192.168.1.11
|
||||
|
||||
stacks:
|
||||
plex: nuc
|
||||
jellyfin: hp
|
||||
grafana: nuc
|
||||
nextcloud: nuc
|
||||
```
|
||||
|
||||
Then just:
|
||||
|
||||
```bash
|
||||
cf up plex # runs on nuc via SSH
|
||||
cf apply # makes config state match desired state on all hosts (like Terraform apply)
|
||||
cf up --all # starts everything on their assigned hosts
|
||||
cf logs -f plex # streams logs
|
||||
cf ps # shows status across all hosts
|
||||
```
|
||||
|
||||
## Auto-migration
|
||||
|
||||
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
|
||||
plex: nuc
|
||||
|
||||
# After (just change this)
|
||||
plex: hp
|
||||
```
|
||||
|
||||
```bash
|
||||
cf up plex # migrates automatically
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- SSH key auth to your hosts
|
||||
- Same paths on all hosts (I use NFS from my NAS)
|
||||
- That's it. No agents, no daemons.
|
||||
|
||||
## What it doesn't do
|
||||
|
||||
- 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
|
||||
|
||||
It's a convenience wrapper around `docker compose` + SSH. If you need failover or cross-host container networking, you probably do need Swarm or Kubernetes.
|
||||
|
||||
## Links
|
||||
|
||||
- GitHub: https://github.com/basnijholt/compose-farm
|
||||
- Install: `uv tool install compose-farm` or `pip install compose-farm`
|
||||
|
||||
Happy to answer questions or take feedback!
|
||||
384
docs/traefik.md
Normal file
384
docs/traefik.md
Normal file
@@ -0,0 +1,384 @@
|
||||
---
|
||||
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 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
|
||||
```
|
||||
169
docs/truenas-nested-nfs.md
Normal file
169
docs/truenas-nested-nfs.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# TrueNAS NFS: Accessing Child ZFS Datasets
|
||||
|
||||
When NFS-exporting a parent ZFS dataset on TrueNAS, child datasets appear as **empty directories** to NFS clients. This document explains the problem and provides a workaround.
|
||||
|
||||
## The Problem
|
||||
|
||||
TrueNAS structures storage as ZFS datasets. A common pattern is:
|
||||
|
||||
```
|
||||
tank/data <- parent dataset (NFS exported)
|
||||
tank/data/app1 <- child dataset
|
||||
tank/data/app2 <- child dataset
|
||||
```
|
||||
|
||||
When you create an NFS share for `tank/data`, clients mount it and see the `app1/` and `app2/` directories—but they're empty. This happens because each ZFS dataset is a separate filesystem, and NFS doesn't traverse into child filesystems by default.
|
||||
|
||||
## The Solution: `crossmnt`
|
||||
|
||||
The NFS `crossmnt` export option tells the server to allow clients to traverse into child filesystems. However, TrueNAS doesn't expose this option in the UI.
|
||||
|
||||
### Workaround Script
|
||||
|
||||
This Python script injects `crossmnt` into `/etc/exports`:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add crossmnt to TrueNAS NFS exports for child dataset visibility.
|
||||
|
||||
Usage: fix-nfs-crossmnt.py /mnt/pool/dataset
|
||||
|
||||
Setup:
|
||||
1. scp fix-nfs-crossmnt.py root@truenas.local:/root/
|
||||
2. chmod +x /root/fix-nfs-crossmnt.py
|
||||
3. Test: /root/fix-nfs-crossmnt.py /mnt/pool/dataset
|
||||
4. Add cron job: TrueNAS UI > System > Advanced > Cron Jobs
|
||||
Command: /root/fix-nfs-crossmnt.py /mnt/pool/dataset
|
||||
Schedule: */5 * * * *
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
EXPORTS_FILE = Path("/etc/exports")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print(f"Usage: {sys.argv[0]} /mnt/pool/dataset", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
export_path = sys.argv[1]
|
||||
content = EXPORTS_FILE.read_text()
|
||||
|
||||
if f'"{export_path}"' not in content:
|
||||
print(f"ERROR: {export_path} not found in {EXPORTS_FILE}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
lines = content.splitlines()
|
||||
result = []
|
||||
in_block = False
|
||||
modified = False
|
||||
|
||||
for line in lines:
|
||||
if f'"{export_path}"' in line:
|
||||
in_block = True
|
||||
elif line.startswith('"'):
|
||||
in_block = False
|
||||
|
||||
if in_block and line[:1] in (" ", "\t") and "crossmnt" not in line:
|
||||
line = re.sub(r"\)(\\\s*)?$", r",crossmnt)\1", line)
|
||||
modified = True
|
||||
|
||||
result.append(line)
|
||||
|
||||
if not modified:
|
||||
return 0 # Already applied
|
||||
|
||||
EXPORTS_FILE.write_text("\n".join(result) + "\n")
|
||||
subprocess.run(["exportfs", "-ra"], check=True)
|
||||
print(f"Added crossmnt to {export_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Copy the script to TrueNAS
|
||||
|
||||
```bash
|
||||
scp fix-nfs-crossmnt.py root@truenas.local:/root/
|
||||
ssh root@truenas.local chmod +x /root/fix-nfs-crossmnt.py
|
||||
```
|
||||
|
||||
### 2. Test manually
|
||||
|
||||
```bash
|
||||
ssh root@truenas.local
|
||||
|
||||
# Run the script
|
||||
/root/fix-nfs-crossmnt.py /mnt/tank/data
|
||||
|
||||
# Verify crossmnt was added
|
||||
cat /etc/exports
|
||||
```
|
||||
|
||||
You should see `,crossmnt` added to the client options:
|
||||
|
||||
```
|
||||
"/mnt/tank/data"\
|
||||
192.168.1.10(sec=sys,rw,no_subtree_check,crossmnt)\
|
||||
192.168.1.11(sec=sys,rw,no_subtree_check,crossmnt)
|
||||
```
|
||||
|
||||
### 3. Verify on NFS client
|
||||
|
||||
```bash
|
||||
# Before: empty directory
|
||||
ls /mnt/data/app1/
|
||||
# (nothing)
|
||||
|
||||
# After: actual contents visible
|
||||
ls /mnt/data/app1/
|
||||
# config.yaml data/ logs/
|
||||
```
|
||||
|
||||
### 4. Make it persistent
|
||||
|
||||
TrueNAS regenerates `/etc/exports` when you modify NFS shares in the UI. To survive this, set up a cron job:
|
||||
|
||||
1. Go to **TrueNAS UI → System → Advanced → Cron Jobs → Add**
|
||||
2. Configure:
|
||||
- **Description:** Fix NFS crossmnt
|
||||
- **Command:** `/root/fix-nfs-crossmnt.py /mnt/tank/data`
|
||||
- **Run As User:** root
|
||||
- **Schedule:** `*/5 * * * *` (every 5 minutes)
|
||||
- **Enabled:** checked
|
||||
3. Save
|
||||
|
||||
The script is idempotent—it only modifies the file if `crossmnt` is missing, and skips the write entirely if already applied.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Parses `/etc/exports` to find the specified export block
|
||||
2. Adds `,crossmnt` before the closing `)` on each client line
|
||||
3. Writes the file only if changes were made
|
||||
4. Runs `exportfs -ra` to reload the NFS configuration
|
||||
|
||||
## Why Not Use SMB Instead?
|
||||
|
||||
SMB handles child datasets seamlessly, but:
|
||||
|
||||
- NFS is simpler for Linux-to-Linux with matching UIDs
|
||||
- SMB requires more complex permission mapping for Docker volumes
|
||||
- Many existing setups already use NFS
|
||||
|
||||
## Related Links
|
||||
|
||||
- [TrueNAS Forum: Add crossmnt option to NFS exports](https://forums.truenas.com/t/add-crossmnt-option-to-nfs-exports/10573)
|
||||
- [exports(5) man page](https://man7.org/linux/man-pages/man5/exports.5.html) - see `crossmnt` option
|
||||
|
||||
## Tested On
|
||||
|
||||
- TrueNAS SCALE 24.10
|
||||
65
docs/truenas-nfs-root-squash.md
Normal file
65
docs/truenas-nfs-root-squash.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# TrueNAS NFS: Disabling Root Squash
|
||||
|
||||
When running Docker containers on NFS-mounted storage, containers that run as root will fail to write files unless root squash is disabled. This document explains the problem and solution.
|
||||
|
||||
## The Problem
|
||||
|
||||
By default, NFS uses "root squash" which maps the root user (UID 0) on clients to `nobody` on the server. This is a security feature to prevent remote root users from having root access to the NFS server's files.
|
||||
|
||||
However, many Docker containers run as root internally. When these containers try to write to NFS-mounted volumes, the writes fail with "Permission denied" because the NFS server sees them as `nobody`, not `root`.
|
||||
|
||||
Example error in container logs:
|
||||
```
|
||||
System.UnauthorizedAccessException: Access to the path '/data' is denied.
|
||||
Error: EACCES: permission denied, mkdir '/app/data'
|
||||
```
|
||||
|
||||
## The Solution
|
||||
|
||||
In TrueNAS, configure the NFS share to map remote root to local root:
|
||||
|
||||
### TrueNAS SCALE UI
|
||||
|
||||
1. Go to **Shares → NFS**
|
||||
2. Edit your share
|
||||
3. Under **Advanced Options**:
|
||||
- **Maproot User**: `root`
|
||||
- **Maproot Group**: `wheel`
|
||||
4. Save
|
||||
|
||||
### Result in /etc/exports
|
||||
|
||||
```
|
||||
"/mnt/pool/data"\
|
||||
192.168.1.25(sec=sys,rw,no_root_squash,no_subtree_check)\
|
||||
192.168.1.26(sec=sys,rw,no_root_squash,no_subtree_check)
|
||||
```
|
||||
|
||||
The `no_root_squash` option means remote root is treated as root on the server.
|
||||
|
||||
## Why `wheel`?
|
||||
|
||||
On FreeBSD/TrueNAS, the root user's primary group is `wheel` (GID 0), not `root` like on Linux. So `root:wheel` = `0:0`.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Disabling root squash means any machine that can mount the NFS share has full root access to those files. This is acceptable when:
|
||||
|
||||
- The NFS clients are on a trusted private network
|
||||
- Only known hosts (by IP) are allowed to mount the share
|
||||
- The data isn't security-critical
|
||||
|
||||
For home lab setups with Docker containers, this is typically fine.
|
||||
|
||||
## Alternative: Run Containers as Non-Root
|
||||
|
||||
If you prefer to keep root squash enabled, you can run containers as a non-root user:
|
||||
|
||||
1. **LinuxServer.io images**: Set `PUID=1000` and `PGID=1000` environment variables
|
||||
2. **Other images**: Add `user: "1000:1000"` to the compose service
|
||||
|
||||
However, not all containers support running as non-root (they may need to bind to privileged ports, create system directories, etc.).
|
||||
|
||||
## Tested On
|
||||
|
||||
- TrueNAS SCALE 24.10
|
||||
154
docs/web-ui.md
Normal file
154
docs/web-ui.md
Normal file
@@ -0,0 +1,154 @@
|
||||
---
|
||||
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 (CPU, memory, disk, load via Glances)
|
||||
- Pending operations (migrations, orphaned stacks)
|
||||
- Quick actions via command palette
|
||||
|
||||
### Live Stats (`/live-stats`)
|
||||
|
||||
Real-time container monitoring across all hosts, powered by [Glances](https://nicolargo.github.io/glances/).
|
||||
|
||||
- **Live metrics**: CPU, memory, network I/O for every container
|
||||
- **Auto-refresh**: Updates every 3 seconds (pauses when dropdown menus are open)
|
||||
- **Filtering**: Type to filter containers by name, stack, host, or image
|
||||
- **Sorting**: Click column headers to sort by any metric
|
||||
- **Update detection**: Shows when container images have updates available
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/web-live_stats.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
#### Requirements
|
||||
|
||||
Live Stats requires Glances to be deployed on all hosts:
|
||||
|
||||
1. Add `glances_stack: glances` to your `compose-farm.yaml`
|
||||
2. Deploy a Glances stack that runs on all hosts (see [example](https://github.com/basnijholt/compose-farm/tree/main/examples/glances))
|
||||
3. Glances must expose its REST API on port 61208
|
||||
|
||||
### 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
|
||||
@@ -1,42 +1,156 @@
|
||||
# Compose Farm Examples
|
||||
|
||||
This folder contains example Docker Compose services for testing Compose Farm locally.
|
||||
Real-world examples demonstrating compose-farm patterns for multi-host Docker deployments.
|
||||
|
||||
## Stacks
|
||||
|
||||
| Stack | Type | Demonstrates |
|
||||
|---------|------|--------------|
|
||||
| [traefik](traefik/) | Infrastructure | Reverse proxy, Let's Encrypt, file-provider |
|
||||
| [coredns](coredns/) | Infrastructure | Wildcard DNS for `*.local` domains |
|
||||
| [mealie](mealie/) | Single container | Traefik labels, resource limits, environment vars |
|
||||
| [uptime-kuma](uptime-kuma/) | Single container | Docker socket, user mapping, custom DNS |
|
||||
| [paperless-ngx](paperless-ngx/) | Multi-container | Redis + PostgreSQL + App stack |
|
||||
| [autokuma](autokuma/) | Multi-host | Demonstrates `all` keyword (runs on every host) |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### External Network
|
||||
|
||||
All stacks connect to a shared external network for inter-service communication:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
```
|
||||
|
||||
Create it on each host with consistent settings:
|
||||
|
||||
```bash
|
||||
compose-farm init-network --network mynetwork --subnet 172.20.0.0/16
|
||||
```
|
||||
|
||||
### Traefik Labels (Dual Routes)
|
||||
|
||||
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 stacks via:
|
||||
- `https://mealie.example.com` - from anywhere, with TLS
|
||||
- `http://mealie.local` - from your local network, no TLS overhead
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
# HTTPS route for custom domain (e.g., mealie.example.com)
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.myapp.rule=Host(`myapp.${DOMAIN}`)
|
||||
- traefik.http.routers.myapp.entrypoints=websecure
|
||||
- traefik.http.services.myapp.loadbalancer.server.port=8080
|
||||
# HTTP route for .local domain (e.g., myapp.local)
|
||||
- traefik.http.routers.myapp-local.rule=Host(`myapp.local`)
|
||||
- traefik.http.routers.myapp-local.entrypoints=web
|
||||
```
|
||||
|
||||
> **Note:** `.local` domains require local DNS to resolve to your Traefik host.
|
||||
> The [coredns](coredns/) example provides this - edit `Corefile` to set your Traefik IP.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Each stack has a `.env` file for secrets and domain configuration.
|
||||
Edit these files to set your domain and credentials:
|
||||
|
||||
```bash
|
||||
# Example: set your domain
|
||||
echo "DOMAIN=example.com" > mealie/.env
|
||||
```
|
||||
|
||||
Variables like `${DOMAIN}` are substituted at runtime by Docker Compose.
|
||||
|
||||
### NFS Volume Mounts
|
||||
|
||||
All data is stored on shared NFS storage at `/mnt/data/`:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /mnt/data/myapp:/app/data
|
||||
```
|
||||
|
||||
This allows stacks to migrate between hosts without data loss.
|
||||
|
||||
### Multi-Host Stacks
|
||||
|
||||
Stacks that need to run on every host (e.g., monitoring agents):
|
||||
|
||||
```yaml
|
||||
# In compose-farm.yaml
|
||||
stacks:
|
||||
autokuma: all # Runs on every configured host
|
||||
```
|
||||
|
||||
### AutoKuma Labels (Optional)
|
||||
|
||||
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:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- kuma.myapp.http.name=My App
|
||||
- kuma.myapp.http.url=https://myapp.${DOMAIN}
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd examples
|
||||
|
||||
# Check status of all services
|
||||
# 1. Create the shared network on all hosts
|
||||
compose-farm init-network
|
||||
|
||||
# 2. Start infrastructure (reverse proxy + DNS)
|
||||
compose-farm up traefik coredns
|
||||
|
||||
# 3. Start other stacks
|
||||
compose-farm up mealie uptime-kuma
|
||||
|
||||
# 4. Check status
|
||||
compose-farm ps
|
||||
|
||||
# Pull images
|
||||
compose-farm pull --all
|
||||
# 5. Generate Traefik file-provider config for cross-host routing
|
||||
compose-farm traefik-file --all
|
||||
|
||||
# Start hello-world (runs and exits)
|
||||
compose-farm up hello
|
||||
# 6. View logs
|
||||
compose-farm logs mealie
|
||||
|
||||
# Start nginx (stays running)
|
||||
compose-farm up nginx
|
||||
|
||||
# Check nginx is running
|
||||
curl localhost:8080
|
||||
|
||||
# View logs
|
||||
compose-farm logs nginx
|
||||
|
||||
# Stop nginx
|
||||
compose-farm down nginx
|
||||
|
||||
# Update all (pull + restart)
|
||||
compose-farm update --all
|
||||
# 7. Stop everything
|
||||
compose-farm down --all
|
||||
```
|
||||
|
||||
## Services
|
||||
## Configuration
|
||||
|
||||
- **hello**: Simple hello-world container (exits immediately)
|
||||
- **nginx**: Nginx web server on port 8080
|
||||
The `compose-farm.yaml` shows a multi-host setup:
|
||||
|
||||
## Config
|
||||
- **primary** (192.168.1.10): Runs Traefik and heavy stacks
|
||||
- **secondary** (192.168.1.11): Runs lighter stacks
|
||||
- **autokuma**: Runs on ALL hosts to monitor local containers
|
||||
|
||||
The `compose-farm.yaml` in this directory configures both services to run locally (no SSH).
|
||||
When Traefik runs on `primary` and a stack runs on `secondary`, compose-farm
|
||||
automatically generates file-provider config so Traefik can route to it.
|
||||
|
||||
## Traefik File-Provider
|
||||
|
||||
When stacks run on different hosts than Traefik, use `traefik-file` to generate routing config:
|
||||
|
||||
```bash
|
||||
# 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_stack: traefik
|
||||
```
|
||||
|
||||
With `traefik_file` configured, compose-farm automatically regenerates the config after `up`, `down`, and `update` commands.
|
||||
|
||||
4
examples/autokuma/.env
Normal file
4
examples/autokuma/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copy to .env and fill in your values
|
||||
DOMAIN=example.com
|
||||
UPTIME_KUMA_USERNAME=admin
|
||||
UPTIME_KUMA_PASSWORD=your-uptime-kuma-password
|
||||
31
examples/autokuma/compose.yaml
Normal file
31
examples/autokuma/compose.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# AutoKuma - Automatic Uptime Kuma monitor creation from Docker labels
|
||||
# Demonstrates: Multi-host service (runs on ALL hosts)
|
||||
#
|
||||
# This service monitors Docker containers on each host and automatically
|
||||
# creates Uptime Kuma monitors based on container labels.
|
||||
#
|
||||
# In compose-farm.yaml, configure as:
|
||||
# autokuma: all
|
||||
#
|
||||
# This runs the same container on every host, so each host's local
|
||||
# Docker socket is monitored.
|
||||
name: autokuma
|
||||
services:
|
||||
autokuma:
|
||||
image: ghcr.io/bigboot/autokuma:latest
|
||||
container_name: autokuma
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Connect to your Uptime Kuma instance
|
||||
AUTOKUMA__KUMA__URL: https://uptime.${DOMAIN}
|
||||
AUTOKUMA__KUMA__USERNAME: ${UPTIME_KUMA_USERNAME}
|
||||
AUTOKUMA__KUMA__PASSWORD: ${UPTIME_KUMA_PASSWORD}
|
||||
# Tag for auto-created monitors
|
||||
AUTOKUMA__TAG__NAME: autokuma
|
||||
AUTOKUMA__TAG__COLOR: "#10B981"
|
||||
volumes:
|
||||
# Access local Docker socket to discover containers
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
# Custom DNS for resolving internal domains
|
||||
dns:
|
||||
- 192.168.1.1 # Your local DNS server
|
||||
10
examples/compose-farm-state.yaml
Normal file
10
examples/compose-farm-state.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
deployed:
|
||||
autokuma:
|
||||
- primary
|
||||
- secondary
|
||||
- local
|
||||
coredns: primary
|
||||
mealie: secondary
|
||||
paperless-ngx: primary
|
||||
traefik: primary
|
||||
uptime-kuma: secondary
|
||||
@@ -1,11 +1,41 @@
|
||||
# Example Compose Farm config for local testing
|
||||
# Run from the examples directory: cd examples && compose-farm ps
|
||||
# Example Compose Farm configuration
|
||||
# Demonstrates a multi-host setup with NFS shared storage
|
||||
#
|
||||
# To test locally: Update the host addresses and run from the examples directory
|
||||
|
||||
compose_dir: .
|
||||
compose_dir: /opt/stacks/compose-farm/examples
|
||||
|
||||
# Auto-regenerate Traefik file-provider config after up/down/update
|
||||
traefik_file: /opt/stacks/compose-farm/examples/traefik/dynamic.d/compose-farm.yml
|
||||
traefik_stack: traefik # Skip Traefik's host in file-provider (docker provider handles it)
|
||||
|
||||
hosts:
|
||||
# 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 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:
|
||||
hello: local
|
||||
nginx: local
|
||||
stacks:
|
||||
# Infrastructure (runs on primary where Traefik is)
|
||||
traefik: primary
|
||||
coredns: primary # DNS for *.local resolution
|
||||
|
||||
# Multi-host stacks (runs on ALL hosts)
|
||||
# AutoKuma monitors Docker containers on each host
|
||||
autokuma: all
|
||||
|
||||
# Primary server stacks
|
||||
paperless-ngx: primary
|
||||
|
||||
# Secondary server stacks (distributed for performance)
|
||||
mealie: secondary
|
||||
uptime-kuma: secondary
|
||||
|
||||
2
examples/coredns/.env
Normal file
2
examples/coredns/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# CoreDNS doesn't need environment variables
|
||||
# The Traefik IP is configured in the Corefile
|
||||
22
examples/coredns/Corefile
Normal file
22
examples/coredns/Corefile
Normal file
@@ -0,0 +1,22 @@
|
||||
# CoreDNS configuration for .local domain resolution
|
||||
#
|
||||
# Resolves *.local to the Traefik host IP (where your reverse proxy runs).
|
||||
# All other queries are forwarded to upstream DNS.
|
||||
|
||||
# Handle .local domains - resolve everything to Traefik's host
|
||||
local {
|
||||
template IN A {
|
||||
answer "{{ .Name }} 60 IN A 192.168.1.10"
|
||||
}
|
||||
template IN AAAA {
|
||||
# Return empty for AAAA to avoid delays on IPv4-only networks
|
||||
rcode NOERROR
|
||||
}
|
||||
}
|
||||
|
||||
# Forward everything else to upstream DNS
|
||||
. {
|
||||
forward . 1.1.1.1 8.8.8.8
|
||||
cache 300
|
||||
errors
|
||||
}
|
||||
27
examples/coredns/compose.yaml
Normal file
27
examples/coredns/compose.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
# CoreDNS - DNS server for .local domain resolution
|
||||
#
|
||||
# Demonstrates:
|
||||
# - Wildcard DNS for *.local domains
|
||||
# - Config file mounting from stack directory
|
||||
# - UDP/TCP port exposure
|
||||
#
|
||||
# This enables all the .local routes in the examples to work.
|
||||
# Point your devices/router DNS to this server's IP.
|
||||
name: coredns
|
||||
services:
|
||||
coredns:
|
||||
image: coredns/coredns:latest
|
||||
container_name: coredns
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mynetwork
|
||||
ports:
|
||||
- "53:53/udp"
|
||||
- "53:53/tcp"
|
||||
volumes:
|
||||
- ./Corefile:/root/Corefile:ro
|
||||
command: -conf /root/Corefile
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
@@ -1,4 +0,0 @@
|
||||
services:
|
||||
hello:
|
||||
image: hello-world
|
||||
container_name: sdc-hello
|
||||
2
examples/mealie/.env
Normal file
2
examples/mealie/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# Copy to .env and fill in your values
|
||||
DOMAIN=example.com
|
||||
47
examples/mealie/compose.yaml
Normal file
47
examples/mealie/compose.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# Mealie - Recipe manager
|
||||
# Simple single-container service with Traefik labels
|
||||
#
|
||||
# Demonstrates:
|
||||
# - HTTPS route: mealie.${DOMAIN} (e.g., mealie.example.com) with Let's Encrypt
|
||||
# - HTTP route: mealie.local for LAN access without TLS
|
||||
# - External network, resource limits, environment variables
|
||||
name: mealie
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:latest
|
||||
container_name: mealie
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mynetwork
|
||||
ports:
|
||||
- "9925:9000"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1000M
|
||||
volumes:
|
||||
- /mnt/data/mealie:/app/data
|
||||
environment:
|
||||
ALLOW_SIGNUP: "false"
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
TZ: America/Los_Angeles
|
||||
MAX_WORKERS: 1
|
||||
WEB_CONCURRENCY: 1
|
||||
BASE_URL: https://mealie.${DOMAIN}
|
||||
labels:
|
||||
# HTTPS route: mealie.example.com (requires DOMAIN in .env)
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.mealie.rule=Host(`mealie.${DOMAIN}`)
|
||||
- traefik.http.routers.mealie.entrypoints=websecure
|
||||
- traefik.http.services.mealie.loadbalancer.server.port=9000
|
||||
# HTTP route: mealie.local (for LAN access, no TLS)
|
||||
- traefik.http.routers.mealie-local.rule=Host(`mealie.local`)
|
||||
- traefik.http.routers.mealie-local.entrypoints=web
|
||||
# AutoKuma: automatically create Uptime Kuma monitor
|
||||
- kuma.mealie.http.name=Mealie
|
||||
- kuma.mealie.http.url=https://mealie.${DOMAIN}
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
@@ -1,6 +0,0 @@
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: sdc-nginx
|
||||
ports:
|
||||
- "8080:80"
|
||||
4
examples/paperless-ngx/.env
Normal file
4
examples/paperless-ngx/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copy to .env and fill in your values
|
||||
DOMAIN=example.com
|
||||
POSTGRES_PASSWORD=change-me-to-a-secure-password
|
||||
PAPERLESS_SECRET_KEY=change-me-to-a-long-random-string
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user