mirror of
https://github.com/basnijholt/compose-farm.git
synced 2026-02-05 06:52:17 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35413716d4 | ||
|
|
e9b7695b2f | ||
|
|
8ca0d8c989 | ||
|
|
6fdb43e1e9 | ||
|
|
620e797671 | ||
|
|
72db309100 | ||
|
|
78273e09dd | ||
|
|
d4dbeeef69 | ||
|
|
6e005d728e | ||
|
|
ec5115f793 | ||
|
|
0848cf8de7 | ||
|
|
6722136252 | ||
|
|
2b32bf8713 | ||
|
|
ae5844f0db |
15
.prompts/pr-review.md
Normal file
15
.prompts/pr-review.md
Normal file
@@ -0,0 +1,15 @@
|
||||
Review the pull request for:
|
||||
|
||||
- **Code cleanliness**: Is the implementation clean and well-structured?
|
||||
- **DRY principle**: Does it avoid duplication?
|
||||
- **Code reuse**: Are there parts that should be reused from other places?
|
||||
- **Organization**: Is everything in the right place?
|
||||
- **Consistency**: Is it in the same style as other parts of the codebase?
|
||||
- **Simplicity**: Is it not over-engineered? Remember KISS and YAGNI. No dead code paths and NO defensive programming.
|
||||
- **User experience**: Does it provide a good user experience?
|
||||
- **PR**: Is the PR description and title clear and informative?
|
||||
- **Tests**: Are there tests, and do they cover the changes adequately? Are they testing something meaningful or are they just trivial?
|
||||
- **Live tests**: Test the changes in a REAL live environment to ensure they work as expected, use the config in `/opt/stacks/compose-farm.yaml`.
|
||||
- **Rules**: Does the code follow the project's coding standards and guidelines as laid out in @CLAUDE.md?
|
||||
|
||||
Look at `git diff origin/main..HEAD` for the changes made in this pull request.
|
||||
@@ -20,5 +20,9 @@ COPY --from=builder /usr/local/bin/cf /usr/local/bin/compose-farm /usr/local/bin
|
||||
# (required when running with user: "${CF_UID:-0}:${CF_GID:-0}")
|
||||
RUN chmod 755 /root
|
||||
|
||||
ENTRYPOINT ["cf"]
|
||||
# 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"]
|
||||
|
||||
49
README.md
49
README.md
@@ -342,14 +342,18 @@ When you run `cf up autokuma`, it starts the stack on all hosts in parallel. Mul
|
||||
Compose Farm includes a `config` subcommand to help manage configuration files:
|
||||
|
||||
```bash
|
||||
cf config init # Create a new config file with documented example
|
||||
cf config show # Display current config with syntax highlighting
|
||||
cf config path # Print the config file path (useful for scripting)
|
||||
cf config validate # Validate config syntax and schema
|
||||
cf config edit # Open config in $EDITOR
|
||||
cf config init # Create a new config file with documented example
|
||||
cf config init --discover # Auto-detect compose files and interactively create config
|
||||
cf config show # Display current config with syntax highlighting
|
||||
cf config path # Print the config file path (useful for scripting)
|
||||
cf config validate # Validate config syntax and schema
|
||||
cf config edit # Open config in $EDITOR
|
||||
cf config example --list # List available example templates
|
||||
cf config example whoami # Generate sample stack files
|
||||
cf config example full # Generate complete Traefik + whoami setup
|
||||
```
|
||||
|
||||
Use `cf config init` to get started with a fully documented template.
|
||||
Use `cf config init` to get started with a template, or `cf config init --discover` if you already have compose files.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -449,6 +453,15 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ copy it or customize the installation. │
|
||||
│ --help -h Show this message and exit. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Configuration ──────────────────────────────────────────────────────────────╮
|
||||
│ traefik-file Generate a Traefik file-provider fragment from compose │
|
||||
│ Traefik labels. │
|
||||
│ refresh Update local state from running stacks. │
|
||||
│ check Validate configuration, traefik labels, mounts, and networks. │
|
||||
│ init-network Create Docker network on hosts with consistent settings. │
|
||||
│ config Manage compose-farm configuration files. │
|
||||
│ ssh Manage SSH keys for passwordless authentication. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Lifecycle ──────────────────────────────────────────────────────────────────╮
|
||||
│ up Start stacks (docker compose up -d). Auto-migrates if host │
|
||||
│ changed. │
|
||||
@@ -460,18 +473,10 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ that service. │
|
||||
│ update Update stacks (pull + build + down + up). With --service, │
|
||||
│ updates just that service. │
|
||||
│ apply Make reality match config (start, migrate, stop as needed). │
|
||||
│ apply Make reality match config (start, migrate, stop │
|
||||
│ strays/orphans as needed). │
|
||||
│ compose Run any docker compose command on a stack. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Configuration ──────────────────────────────────────────────────────────────╮
|
||||
│ traefik-file Generate a Traefik file-provider fragment from compose │
|
||||
│ Traefik labels. │
|
||||
│ refresh Update local state from running stacks. │
|
||||
│ check Validate configuration, traefik labels, mounts, and networks. │
|
||||
│ init-network Create Docker network on hosts with consistent settings. │
|
||||
│ config Manage compose-farm configuration files. │
|
||||
│ ssh Manage SSH keys for passwordless authentication. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭─ Monitoring ─────────────────────────────────────────────────────────────────╮
|
||||
│ logs Show stack logs. With --service, shows logs for just that │
|
||||
│ service. │
|
||||
@@ -721,22 +726,25 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
|
||||
Usage: cf apply [OPTIONS]
|
||||
|
||||
Make reality match config (start, migrate, stop as needed).
|
||||
Make reality match config (start, migrate, stop strays/orphans as needed).
|
||||
|
||||
This is the "reconcile" command that ensures running stacks match your
|
||||
config file. It will:
|
||||
|
||||
1. Stop orphaned stacks (in state but removed from config)
|
||||
2. Migrate stacks on wrong host (host in state ≠ host in config)
|
||||
3. Start missing stacks (in config but not in state)
|
||||
2. Stop stray stacks (running on unauthorized hosts)
|
||||
3. Migrate stacks on wrong host (host in state ≠ host in config)
|
||||
4. Start missing stacks (in config but not in state)
|
||||
|
||||
Use --dry-run to preview changes before applying.
|
||||
Use --no-orphans to only migrate/start without stopping orphaned stacks.
|
||||
Use --no-orphans to skip stopping orphaned stacks.
|
||||
Use --no-strays to skip stopping stray stacks.
|
||||
Use --full to also run 'up' on all stacks (picks up compose/env changes).
|
||||
|
||||
╭─ Options ────────────────────────────────────────────────────────────────────╮
|
||||
│ --dry-run -n Show what would change without executing │
|
||||
│ --no-orphans Only migrate, don't stop orphaned stacks │
|
||||
│ --no-strays Don't stop stray stacks (running on wrong host) │
|
||||
│ --full -f Also run up on all stacks to apply config │
|
||||
│ changes │
|
||||
│ --config -c PATH Path to config file │
|
||||
@@ -991,6 +999,7 @@ Full `--help` output for each command. See the [Usage](#usage) table above for a
|
||||
│ validate Validate the config file syntax and schema. │
|
||||
│ symlink Create a symlink from the default config location to a config │
|
||||
│ file. │
|
||||
│ example Generate example stack files from built-in templates. │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
```
|
||||
|
||||
3
docs/assets/config-example.gif
Normal file
3
docs/assets/config-example.gif
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:611d6fef767a8e0755367bf0c008dad016f38fa8b3be2362825ef7ef6ec2ec1a
|
||||
size 2444902
|
||||
3
docs/assets/config-example.webm
Normal file
3
docs/assets/config-example.webm
Normal file
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1e851879acc99234628abce0f8dadeeaf500effe4f78bebc63c4b17a0ae092f1
|
||||
size 900800
|
||||
@@ -578,6 +578,10 @@ cf traefik-file plex jellyfin -o /opt/traefik/cf.yml
|
||||
|
||||
Manage configuration files.
|
||||
|
||||
<video autoplay loop muted playsinline>
|
||||
<source src="/assets/config-example.webm" type="video/webm">
|
||||
</video>
|
||||
|
||||
```bash
|
||||
cf config COMMAND
|
||||
```
|
||||
@@ -592,17 +596,19 @@ cf config COMMAND
|
||||
| `validate` | Validate syntax and schema |
|
||||
| `edit` | Open in $EDITOR |
|
||||
| `symlink` | Create symlink from default location |
|
||||
| `example` | Generate example stack files |
|
||||
|
||||
**Options by subcommand:**
|
||||
|
||||
| Subcommand | Options |
|
||||
|------------|---------|
|
||||
| `init` | `--path/-p PATH`, `--force/-f` |
|
||||
| `init` | `--path/-p PATH`, `--force/-f`, `--discover/-d` |
|
||||
| `show` | `--path/-p PATH`, `--raw/-r` |
|
||||
| `edit` | `--path/-p PATH` |
|
||||
| `path` | `--path/-p PATH` |
|
||||
| `validate` | `--path/-p PATH` |
|
||||
| `symlink` | `--force/-f` |
|
||||
| `example` | `--list/-l`, `--output/-o PATH`, `--force/-f` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
@@ -610,6 +616,9 @@ cf config COMMAND
|
||||
# Create config at default location
|
||||
cf config init
|
||||
|
||||
# Auto-discover compose files and interactively create config
|
||||
cf config init --discover
|
||||
|
||||
# Create config at custom path
|
||||
cf config init --path /opt/compose-farm/config.yaml
|
||||
|
||||
@@ -633,6 +642,18 @@ cf config symlink
|
||||
|
||||
# Create symlink to specific file
|
||||
cf config symlink /opt/compose-farm/config.yaml
|
||||
|
||||
# List available example templates
|
||||
cf config example --list
|
||||
|
||||
# Generate a sample stack (whoami, nginx, postgres)
|
||||
cf config example whoami
|
||||
|
||||
# Generate complete Traefik + whoami setup
|
||||
cf config example full
|
||||
|
||||
# Generate example in specific directory
|
||||
cf config example nginx --output /opt/compose
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -27,6 +27,7 @@ python docs/demos/cli/record.py quickstart migration
|
||||
| `update.tape` | `cf update` |
|
||||
| `migration.tape` | Service migration |
|
||||
| `apply.tape` | `cf apply` |
|
||||
| `config-example.tape` | `cf config example` - generate example stacks |
|
||||
|
||||
## Output
|
||||
|
||||
|
||||
110
docs/demos/cli/config-example.tape
Normal file
110
docs/demos/cli/config-example.tape
Normal file
@@ -0,0 +1,110 @@
|
||||
# Config Example Demo
|
||||
# Shows cf config example command
|
||||
|
||||
Output docs/assets/config-example.gif
|
||||
Output docs/assets/config-example.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 "# Generate example stacks with cf config example"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "# List available templates"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf config example --list"
|
||||
Enter
|
||||
Wait+Screen /Usage:/
|
||||
Sleep 2s
|
||||
|
||||
Type "# Create a directory for our stacks"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "mkdir -p ~/compose && cd ~/compose"
|
||||
Enter
|
||||
Wait
|
||||
Sleep 500ms
|
||||
|
||||
Type "# Generate the full Traefik + whoami setup"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf config example full"
|
||||
Enter
|
||||
Wait
|
||||
Sleep 2s
|
||||
|
||||
Type "# See what was created"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "tree ."
|
||||
Enter
|
||||
Wait
|
||||
Sleep 2s
|
||||
|
||||
Type "# View the generated config"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "bat compose-farm.yaml"
|
||||
Enter
|
||||
Sleep 3s
|
||||
Type "q"
|
||||
Sleep 500ms
|
||||
|
||||
Type "# View the traefik compose file"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "bat traefik/compose.yaml"
|
||||
Enter
|
||||
Sleep 3s
|
||||
Type "q"
|
||||
Sleep 500ms
|
||||
|
||||
Type "# Validate the config"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf check --local"
|
||||
Enter
|
||||
Wait
|
||||
Sleep 2s
|
||||
|
||||
Type "# Create the Docker network"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf init-network"
|
||||
Enter
|
||||
Wait
|
||||
Sleep 1s
|
||||
|
||||
Type "# Deploy traefik and whoami"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf up traefik whoami"
|
||||
Enter
|
||||
Wait
|
||||
Sleep 3s
|
||||
|
||||
Type "# Verify it's running"
|
||||
Enter
|
||||
Sleep 500ms
|
||||
|
||||
Type "cf ps"
|
||||
Enter
|
||||
Wait
|
||||
Sleep 2s
|
||||
@@ -149,6 +149,24 @@ cd /opt/stacks
|
||||
cf config init
|
||||
```
|
||||
|
||||
**Already have compose files?** Use `--discover` to auto-detect them and interactively build your config:
|
||||
|
||||
```bash
|
||||
cf config init --discover
|
||||
```
|
||||
|
||||
This scans for directories containing compose files, lets you select which stacks to include, and generates a ready-to-use config.
|
||||
|
||||
**Starting fresh?** Generate example stacks to learn from:
|
||||
|
||||
```bash
|
||||
# List available examples
|
||||
cf config example --list
|
||||
|
||||
# Generate a complete Traefik + whoami setup
|
||||
cf config example full
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -37,6 +37,12 @@ _RawOption = Annotated[
|
||||
bool,
|
||||
typer.Option("--raw", "-r", help="Output raw file contents (for copy-paste)."),
|
||||
]
|
||||
_DiscoverOption = Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--discover", "-d", help="Auto-detect compose files and interactively select stacks."
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _get_editor() -> str:
|
||||
@@ -68,6 +74,117 @@ def _get_config_file(path: Path | None) -> Path | None:
|
||||
return config_path.resolve() if config_path else None
|
||||
|
||||
|
||||
def _generate_discovered_config(
|
||||
compose_dir: Path,
|
||||
hostname: str,
|
||||
host_address: str,
|
||||
selected_stacks: list[str],
|
||||
) -> str:
|
||||
"""Generate config YAML from discovered stacks."""
|
||||
import yaml # noqa: PLC0415
|
||||
|
||||
config_data = {
|
||||
"compose_dir": str(compose_dir),
|
||||
"hosts": {hostname: host_address},
|
||||
"stacks": dict.fromkeys(selected_stacks, hostname),
|
||||
}
|
||||
|
||||
header = """\
|
||||
# Compose Farm configuration
|
||||
# Documentation: https://github.com/basnijholt/compose-farm
|
||||
#
|
||||
# Generated by: cf config init --discover
|
||||
|
||||
"""
|
||||
return header + yaml.dump(config_data, default_flow_style=False, sort_keys=False)
|
||||
|
||||
|
||||
def _interactive_stack_selection(stacks: list[str]) -> list[str]:
|
||||
"""Interactively select stacks to include."""
|
||||
from rich.prompt import Confirm, Prompt # noqa: PLC0415
|
||||
|
||||
console.print("\n[bold]Found stacks:[/bold]")
|
||||
for stack in stacks:
|
||||
console.print(f" [cyan]{stack}[/cyan]")
|
||||
|
||||
console.print()
|
||||
|
||||
# Fast path: include all
|
||||
if Confirm.ask(f"Include all {len(stacks)} stacks?", default=True):
|
||||
return stacks
|
||||
|
||||
# Let user specify which to exclude
|
||||
console.print(
|
||||
"\n[dim]Enter stack names to exclude (comma-separated), or press Enter to select individually:[/dim]"
|
||||
)
|
||||
exclude_input = Prompt.ask("Exclude", default="")
|
||||
|
||||
if exclude_input.strip():
|
||||
exclude = {s.strip() for s in exclude_input.split(",")}
|
||||
return [s for s in stacks if s not in exclude]
|
||||
|
||||
# Fall back to individual selection
|
||||
console.print()
|
||||
return [
|
||||
stack for stack in stacks if Confirm.ask(f" Include [cyan]{stack}[/cyan]?", default=True)
|
||||
]
|
||||
|
||||
|
||||
def _run_discovery_flow() -> str | None:
|
||||
"""Run the interactive discovery flow and return generated config content."""
|
||||
import socket # noqa: PLC0415
|
||||
|
||||
from rich.prompt import Prompt # noqa: PLC0415
|
||||
|
||||
console.print("[bold]Compose Farm Config Discovery[/bold]")
|
||||
console.print("[dim]This will scan for compose files and generate a config.[/dim]\n")
|
||||
|
||||
# Step 1: Get compose directory
|
||||
default_dir = Path.cwd()
|
||||
compose_dir_str = Prompt.ask(
|
||||
"Compose directory",
|
||||
default=str(default_dir),
|
||||
)
|
||||
compose_dir = Path(compose_dir_str).expanduser().resolve()
|
||||
|
||||
if not compose_dir.exists():
|
||||
print_error(f"Directory does not exist: {compose_dir}")
|
||||
return None
|
||||
|
||||
if not compose_dir.is_dir():
|
||||
print_error(f"Path is not a directory: {compose_dir}")
|
||||
return None
|
||||
|
||||
# Step 2: Discover stacks
|
||||
from compose_farm.config import discover_compose_dirs # noqa: PLC0415
|
||||
|
||||
console.print(f"\n[dim]Scanning {compose_dir}...[/dim]")
|
||||
stacks = discover_compose_dirs(compose_dir)
|
||||
|
||||
if not stacks:
|
||||
print_error(f"No compose files found in {compose_dir}")
|
||||
console.print("[dim]Each stack should be in a subdirectory with a compose.yaml file.[/dim]")
|
||||
return None
|
||||
|
||||
console.print(f"[green]Found {len(stacks)} stack(s)[/green]")
|
||||
|
||||
# Step 3: Interactive selection
|
||||
selected_stacks = _interactive_stack_selection(stacks)
|
||||
|
||||
if not selected_stacks:
|
||||
console.print("\n[yellow]No stacks selected.[/yellow]")
|
||||
return None
|
||||
|
||||
# Step 4: Get hostname and address
|
||||
default_hostname = socket.gethostname()
|
||||
hostname = Prompt.ask("\nHost name", default=default_hostname)
|
||||
host_address = Prompt.ask("Host address", default="localhost")
|
||||
|
||||
# Step 5: Generate config
|
||||
console.print(f"\n[dim]Generating config with {len(selected_stacks)} stack(s)...[/dim]")
|
||||
return _generate_discovered_config(compose_dir, hostname, host_address, selected_stacks)
|
||||
|
||||
|
||||
def _report_missing_config(explicit_path: Path | None = None) -> None:
|
||||
"""Report that a config file was not found."""
|
||||
console.print("[yellow]Config file not found.[/yellow]")
|
||||
@@ -85,11 +202,15 @@ def _report_missing_config(explicit_path: Path | None = None) -> None:
|
||||
def config_init(
|
||||
path: _PathOption = None,
|
||||
force: _ForceOption = False,
|
||||
discover: _DiscoverOption = False,
|
||||
) -> None:
|
||||
"""Create a new config file with documented example.
|
||||
|
||||
The generated config file serves as a template showing all available
|
||||
options with explanatory comments.
|
||||
|
||||
Use --discover to auto-detect compose files and interactively select
|
||||
which stacks to include.
|
||||
"""
|
||||
target_path = (path.expanduser().resolve() if path else None) or default_config_path()
|
||||
|
||||
@@ -101,11 +222,17 @@ def config_init(
|
||||
console.print("[dim]Aborted.[/dim]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
if discover:
|
||||
template_content = _run_discovery_flow()
|
||||
if template_content is None:
|
||||
raise typer.Exit(0)
|
||||
else:
|
||||
template_content = _generate_template()
|
||||
|
||||
# Create parent directories
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate and write template
|
||||
template_content = _generate_template()
|
||||
# Write config file
|
||||
target_path.write_text(template_content, encoding="utf-8")
|
||||
|
||||
print_success(f"Config file created at: {target_path}")
|
||||
@@ -293,5 +420,115 @@ def config_symlink(
|
||||
console.print(f" -> {target_path}")
|
||||
|
||||
|
||||
_ListOption = Annotated[
|
||||
bool,
|
||||
typer.Option("--list", "-l", help="List available example templates."),
|
||||
]
|
||||
|
||||
|
||||
@config_app.command("example")
|
||||
def config_example(
|
||||
name: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Example template name (e.g., whoami, full)"),
|
||||
] = None,
|
||||
output_dir: Annotated[
|
||||
Path | None,
|
||||
typer.Option("--output", "-o", help="Output directory. Defaults to current directory."),
|
||||
] = None,
|
||||
list_examples: _ListOption = False,
|
||||
force: _ForceOption = False,
|
||||
) -> None:
|
||||
"""Generate example stack files from built-in templates.
|
||||
|
||||
Examples:
|
||||
cf config example --list # List available examples
|
||||
cf config example whoami # Generate whoami stack in ./whoami/
|
||||
cf config example full # Generate complete Traefik + whoami setup
|
||||
cf config example nginx -o /opt/compose # Generate in specific directory
|
||||
|
||||
"""
|
||||
from compose_farm.examples import ( # noqa: PLC0415
|
||||
EXAMPLES,
|
||||
SINGLE_STACK_EXAMPLES,
|
||||
list_example_files,
|
||||
)
|
||||
|
||||
# List mode
|
||||
if list_examples:
|
||||
console.print("[bold]Available example templates:[/bold]\n")
|
||||
console.print("[dim]Single stack examples:[/dim]")
|
||||
for example_name, description in SINGLE_STACK_EXAMPLES.items():
|
||||
console.print(f" [cyan]{example_name}[/cyan] - {description}")
|
||||
console.print()
|
||||
console.print("[dim]Complete setup:[/dim]")
|
||||
console.print(f" [cyan]full[/cyan] - {EXAMPLES['full']}")
|
||||
console.print()
|
||||
console.print("[dim]Usage: cf config example <name>[/dim]")
|
||||
return
|
||||
|
||||
# Interactive selection if no name provided
|
||||
if name is None:
|
||||
from rich.prompt import Prompt # noqa: PLC0415
|
||||
|
||||
console.print("[bold]Available example templates:[/bold]\n")
|
||||
example_names = list(EXAMPLES.keys())
|
||||
for i, (example_name, description) in enumerate(EXAMPLES.items(), 1):
|
||||
console.print(f" [{i}] [cyan]{example_name}[/cyan] - {description}")
|
||||
|
||||
console.print()
|
||||
choice = Prompt.ask(
|
||||
"Select example",
|
||||
choices=[str(i) for i in range(1, len(example_names) + 1)] + example_names,
|
||||
default="1",
|
||||
)
|
||||
|
||||
# Handle numeric or name input
|
||||
name = example_names[int(choice) - 1] if choice.isdigit() else choice
|
||||
|
||||
# Validate example name
|
||||
if name not in EXAMPLES:
|
||||
print_error(f"Unknown example: {name}")
|
||||
console.print(f"Available examples: {', '.join(EXAMPLES.keys())}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Determine output directory
|
||||
base_dir = (output_dir or Path.cwd()).expanduser().resolve()
|
||||
# For 'full' example, use current dir; for single stacks, create subdir
|
||||
target_dir = base_dir if name == "full" else base_dir / name
|
||||
|
||||
# Check for existing files
|
||||
files = list_example_files(name)
|
||||
existing_files = [f for f, _ in files if (target_dir / f).exists()]
|
||||
if existing_files and not force:
|
||||
console.print(f"[yellow]Files already exist in:[/yellow] {target_dir}")
|
||||
console.print(f"[dim] {len(existing_files)} file(s) would be overwritten[/dim]")
|
||||
if not typer.confirm("Overwrite existing files?"):
|
||||
console.print("[dim]Aborted.[/dim]")
|
||||
raise typer.Exit(0)
|
||||
|
||||
# Create directories and copy files
|
||||
for rel_path, content in files:
|
||||
file_path = target_dir / rel_path
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
console.print(f" [green]Created[/green] {file_path}")
|
||||
|
||||
print_success(f"Example '{name}' created at: {target_dir}")
|
||||
|
||||
# Show appropriate next steps
|
||||
if name == "full":
|
||||
console.print("\n[dim]Next steps:[/dim]")
|
||||
console.print(f" 1. Edit [cyan]{target_dir}/compose-farm.yaml[/cyan] with your host IP")
|
||||
console.print(" 2. Edit [cyan].env[/cyan] files with your domain")
|
||||
console.print(" 3. Create Docker network: [cyan]docker network create mynetwork[/cyan]")
|
||||
console.print(" 4. Deploy: [cyan]cf up traefik whoami[/cyan]")
|
||||
else:
|
||||
console.print("\n[dim]Next steps:[/dim]")
|
||||
console.print(f" 1. Edit [cyan]{target_dir}/.env[/cyan] with your settings")
|
||||
console.print(f" 2. Add to compose-farm.yaml: [cyan]{name}: <hostname>[/cyan]")
|
||||
console.print(f" 3. Deploy with: [cyan]cf up {name}[/cyan]")
|
||||
|
||||
|
||||
# Register config subcommand on the shared app
|
||||
app.add_typer(config_app, name="config", rich_help_panel="Configuration")
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
|
||||
import typer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from compose_farm.config import Config
|
||||
|
||||
from compose_farm.cli.app import app
|
||||
from compose_farm.cli.common import (
|
||||
AllOption,
|
||||
@@ -23,9 +26,14 @@ from compose_farm.cli.common import (
|
||||
validate_host_for_stack,
|
||||
validate_stacks,
|
||||
)
|
||||
from compose_farm.cli.management import _discover_stacks_full
|
||||
from compose_farm.console import MSG_DRY_RUN, console, print_error, print_success
|
||||
from compose_farm.executor import run_compose_on_host, run_on_stacks, run_sequential_on_stacks
|
||||
from compose_farm.operations import stop_orphaned_stacks, up_stacks
|
||||
from compose_farm.operations import (
|
||||
stop_orphaned_stacks,
|
||||
stop_stray_stacks,
|
||||
up_stacks,
|
||||
)
|
||||
from compose_farm.state import (
|
||||
get_orphaned_stacks,
|
||||
get_stack_host,
|
||||
@@ -208,8 +216,23 @@ def update(
|
||||
report_results(results)
|
||||
|
||||
|
||||
def _discover_strays(cfg: Config) -> dict[str, list[str]]:
|
||||
"""Discover stacks running on unauthorized hosts by scanning all hosts."""
|
||||
_, strays, duplicates = _discover_stacks_full(cfg)
|
||||
|
||||
# Merge duplicates into strays (for single-host stacks on multiple hosts,
|
||||
# keep correct host and stop others)
|
||||
for stack, running_hosts in duplicates.items():
|
||||
configured = cfg.get_hosts(stack)[0]
|
||||
stray_hosts = [h for h in running_hosts if h != configured]
|
||||
if stray_hosts:
|
||||
strays[stack] = stray_hosts
|
||||
|
||||
return strays
|
||||
|
||||
|
||||
@app.command(rich_help_panel="Lifecycle")
|
||||
def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
|
||||
def apply( # noqa: C901, PLR0912, PLR0915 (multi-phase reconciliation needs these branches)
|
||||
dry_run: Annotated[
|
||||
bool,
|
||||
typer.Option("--dry-run", "-n", help="Show what would change without executing"),
|
||||
@@ -218,23 +241,29 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
|
||||
bool,
|
||||
typer.Option("--no-orphans", help="Only migrate, don't stop orphaned stacks"),
|
||||
] = False,
|
||||
no_strays: Annotated[
|
||||
bool,
|
||||
typer.Option("--no-strays", help="Don't stop stray stacks (running on wrong host)"),
|
||||
] = False,
|
||||
full: Annotated[
|
||||
bool,
|
||||
typer.Option("--full", "-f", help="Also run up on all stacks to apply config changes"),
|
||||
] = False,
|
||||
config: ConfigOption = None,
|
||||
) -> None:
|
||||
"""Make reality match config (start, migrate, stop as needed).
|
||||
"""Make reality match config (start, migrate, stop strays/orphans as needed).
|
||||
|
||||
This is the "reconcile" command that ensures running stacks match your
|
||||
config file. It will:
|
||||
|
||||
1. Stop orphaned stacks (in state but removed from config)
|
||||
2. Migrate stacks on wrong host (host in state ≠ host in config)
|
||||
3. Start missing stacks (in config but not in state)
|
||||
2. Stop stray stacks (running on unauthorized hosts)
|
||||
3. Migrate stacks on wrong host (host in state ≠ host in config)
|
||||
4. Start missing stacks (in config but not in state)
|
||||
|
||||
Use --dry-run to preview changes before applying.
|
||||
Use --no-orphans to only migrate/start without stopping orphaned stacks.
|
||||
Use --no-orphans to skip stopping orphaned stacks.
|
||||
Use --no-strays to skip stopping stray stacks.
|
||||
Use --full to also run 'up' on all stacks (picks up compose/env changes).
|
||||
"""
|
||||
cfg = load_config_or_exit(config)
|
||||
@@ -242,16 +271,28 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
|
||||
migrations = get_stacks_needing_migration(cfg)
|
||||
missing = get_stacks_not_in_state(cfg)
|
||||
|
||||
strays: dict[str, list[str]] = {}
|
||||
if not no_strays:
|
||||
console.print("[dim]Scanning hosts for stray containers...[/]")
|
||||
strays = _discover_strays(cfg)
|
||||
|
||||
# For --full: refresh all stacks not already being started/migrated
|
||||
handled = set(migrations) | set(missing)
|
||||
to_refresh = [stack for stack in cfg.stacks if stack not in handled] if full else []
|
||||
|
||||
has_orphans = bool(orphaned) and not no_orphans
|
||||
has_strays = bool(strays)
|
||||
has_migrations = bool(migrations)
|
||||
has_missing = bool(missing)
|
||||
has_refresh = bool(to_refresh)
|
||||
|
||||
if not has_orphans and not has_migrations and not has_missing and not has_refresh:
|
||||
if (
|
||||
not has_orphans
|
||||
and not has_strays
|
||||
and not has_migrations
|
||||
and not has_missing
|
||||
and not has_refresh
|
||||
):
|
||||
print_success("Nothing to apply - reality matches config")
|
||||
return
|
||||
|
||||
@@ -260,6 +301,14 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
|
||||
console.print(f"[yellow]Orphaned stacks to stop ({len(orphaned)}):[/]")
|
||||
for svc, hosts in orphaned.items():
|
||||
console.print(f" [cyan]{svc}[/] on [magenta]{format_host(hosts)}[/]")
|
||||
if has_strays:
|
||||
console.print(f"[red]Stray stacks to stop ({len(strays)}):[/]")
|
||||
for stack, hosts in strays.items():
|
||||
configured = cfg.get_hosts(stack)
|
||||
console.print(
|
||||
f" [cyan]{stack}[/] on [magenta]{', '.join(hosts)}[/] "
|
||||
f"[dim](should be on {', '.join(configured)})[/]"
|
||||
)
|
||||
if has_migrations:
|
||||
console.print(f"[cyan]Stacks to migrate ({len(migrations)}):[/]")
|
||||
for stack in migrations:
|
||||
@@ -288,21 +337,26 @@ def apply( # noqa: PLR0912 (multi-phase reconciliation needs these branches)
|
||||
console.print("[yellow]Stopping orphaned stacks...[/]")
|
||||
all_results.extend(run_async(stop_orphaned_stacks(cfg)))
|
||||
|
||||
# 2. Migrate stacks on wrong host
|
||||
# 2. Stop stray stacks (running on unauthorized hosts)
|
||||
if has_strays:
|
||||
console.print("[red]Stopping stray stacks...[/]")
|
||||
all_results.extend(run_async(stop_stray_stacks(cfg, strays)))
|
||||
|
||||
# 3. Migrate stacks on wrong host
|
||||
if has_migrations:
|
||||
console.print("[cyan]Migrating stacks...[/]")
|
||||
migrate_results = run_async(up_stacks(cfg, migrations, raw=True))
|
||||
all_results.extend(migrate_results)
|
||||
maybe_regenerate_traefik(cfg, migrate_results)
|
||||
|
||||
# 3. Start missing stacks (reuse up_stacks which handles state updates)
|
||||
# 4. Start missing stacks (reuse up_stacks which handles state updates)
|
||||
if has_missing:
|
||||
console.print("[green]Starting missing stacks...[/]")
|
||||
start_results = run_async(up_stacks(cfg, missing, raw=True))
|
||||
all_results.extend(start_results)
|
||||
maybe_regenerate_traefik(cfg, start_results)
|
||||
|
||||
# 4. Refresh remaining stacks (--full: run up to apply config changes)
|
||||
# 5. Refresh remaining stacks (--full: run up to apply config changes)
|
||||
if has_refresh:
|
||||
console.print("[blue]Refreshing stacks...[/]")
|
||||
refresh_results = run_async(up_stacks(cfg, to_refresh, raw=True))
|
||||
|
||||
@@ -50,9 +50,11 @@ from compose_farm.logs import (
|
||||
write_toml,
|
||||
)
|
||||
from compose_farm.operations import (
|
||||
StackDiscoveryResult,
|
||||
check_host_compatibility,
|
||||
check_stack_requirements,
|
||||
discover_stack_host,
|
||||
discover_stack_on_all_hosts,
|
||||
)
|
||||
from compose_farm.state import get_orphaned_stacks, load_state, save_state
|
||||
from compose_farm.traefik import generate_traefik_config, render_traefik_config
|
||||
@@ -147,6 +149,80 @@ def _report_sync_changes(
|
||||
console.print(f" [red]-[/] [cyan]{stack}[/] (was on [magenta]{host_str}[/])")
|
||||
|
||||
|
||||
def _discover_stacks_full(
|
||||
cfg: Config,
|
||||
stacks: list[str] | None = None,
|
||||
) -> tuple[dict[str, str | list[str]], dict[str, list[str]], dict[str, list[str]]]:
|
||||
"""Discover running stacks with full host scanning for stray detection.
|
||||
|
||||
Returns:
|
||||
Tuple of (discovered, strays, duplicates):
|
||||
- discovered: stack -> host(s) where running correctly
|
||||
- strays: stack -> list of unauthorized hosts
|
||||
- duplicates: stack -> list of all hosts (for single-host stacks on multiple)
|
||||
|
||||
"""
|
||||
stack_list = stacks if stacks is not None else list(cfg.stacks)
|
||||
results: list[StackDiscoveryResult] = run_parallel_with_progress(
|
||||
"Discovering",
|
||||
stack_list,
|
||||
lambda s: discover_stack_on_all_hosts(cfg, s),
|
||||
)
|
||||
|
||||
discovered: dict[str, str | list[str]] = {}
|
||||
strays: dict[str, list[str]] = {}
|
||||
duplicates: dict[str, list[str]] = {}
|
||||
|
||||
for result in results:
|
||||
correct_hosts = [h for h in result.running_hosts if h in result.configured_hosts]
|
||||
if correct_hosts:
|
||||
if result.is_multi_host:
|
||||
discovered[result.stack] = correct_hosts
|
||||
else:
|
||||
discovered[result.stack] = correct_hosts[0]
|
||||
|
||||
if result.is_stray:
|
||||
strays[result.stack] = result.stray_hosts
|
||||
|
||||
if result.is_duplicate:
|
||||
duplicates[result.stack] = result.running_hosts
|
||||
|
||||
return discovered, strays, duplicates
|
||||
|
||||
|
||||
def _report_stray_stacks(
|
||||
strays: dict[str, list[str]],
|
||||
cfg: Config,
|
||||
) -> None:
|
||||
"""Report stacks running on unauthorized hosts."""
|
||||
if strays:
|
||||
console.print(f"\n[red]Stray stacks[/] (running on wrong host, {len(strays)}):")
|
||||
console.print("[dim]Run [bold]cf apply[/bold] to stop them.[/]")
|
||||
for stack in sorted(strays):
|
||||
stray_hosts = strays[stack]
|
||||
configured = cfg.get_hosts(stack)
|
||||
console.print(
|
||||
f" [red]![/] [cyan]{stack}[/] on [magenta]{', '.join(stray_hosts)}[/] "
|
||||
f"[dim](should be on {', '.join(configured)})[/]"
|
||||
)
|
||||
|
||||
|
||||
def _report_duplicate_stacks(duplicates: dict[str, list[str]], cfg: Config) -> None:
|
||||
"""Report single-host stacks running on multiple hosts."""
|
||||
if duplicates:
|
||||
console.print(
|
||||
f"\n[yellow]Duplicate stacks[/] (running on multiple hosts, {len(duplicates)}):"
|
||||
)
|
||||
console.print("[dim]Run [bold]cf apply[/bold] to stop extras.[/]")
|
||||
for stack in sorted(duplicates):
|
||||
hosts = duplicates[stack]
|
||||
configured = cfg.get_hosts(stack)[0]
|
||||
console.print(
|
||||
f" [yellow]![/] [cyan]{stack}[/] on [magenta]{', '.join(hosts)}[/] "
|
||||
f"[dim](should only be on {configured})[/]"
|
||||
)
|
||||
|
||||
|
||||
# --- Check helpers ---
|
||||
|
||||
|
||||
@@ -440,7 +516,7 @@ def refresh(
|
||||
|
||||
current_state = load_state(cfg)
|
||||
|
||||
discovered = _discover_stacks(cfg, stack_list)
|
||||
discovered, strays, duplicates = _discover_stacks_full(cfg, stack_list)
|
||||
|
||||
# Calculate changes (only for the stacks we're refreshing)
|
||||
added = [s for s in discovered if s not in current_state]
|
||||
@@ -463,6 +539,9 @@ def refresh(
|
||||
else:
|
||||
print_success("State is already in sync.")
|
||||
|
||||
_report_stray_stacks(strays, cfg)
|
||||
_report_duplicate_stacks(duplicates, cfg)
|
||||
|
||||
if dry_run:
|
||||
console.print(f"\n{MSG_DRY_RUN}")
|
||||
return
|
||||
|
||||
@@ -15,6 +15,17 @@ from .paths import config_search_paths, find_config_path
|
||||
COMPOSE_FILENAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
|
||||
|
||||
|
||||
def discover_compose_dirs(compose_dir: Path) -> list[str]:
|
||||
"""Find all directories in compose_dir that contain a compose file."""
|
||||
if not compose_dir.exists():
|
||||
return []
|
||||
return sorted(
|
||||
subdir.name
|
||||
for subdir in compose_dir.iterdir()
|
||||
if subdir.is_dir() and any((subdir / f).exists() for f in COMPOSE_FILENAMES)
|
||||
)
|
||||
|
||||
|
||||
class Host(BaseModel, extra="forbid"):
|
||||
"""SSH host configuration."""
|
||||
|
||||
@@ -105,13 +116,7 @@ class Config(BaseModel, extra="forbid"):
|
||||
|
||||
def discover_compose_dirs(self) -> set[str]:
|
||||
"""Find all directories in compose_dir that contain a compose file."""
|
||||
found: set[str] = set()
|
||||
if not self.compose_dir.exists():
|
||||
return found
|
||||
for subdir in self.compose_dir.iterdir():
|
||||
if subdir.is_dir() and any((subdir / f).exists() for f in COMPOSE_FILENAMES):
|
||||
found.add(subdir.name)
|
||||
return found
|
||||
return set(discover_compose_dirs(self.compose_dir))
|
||||
|
||||
|
||||
def _parse_hosts(raw_hosts: dict[str, Any]) -> dict[str, Host]:
|
||||
|
||||
41
src/compose_farm/examples/__init__.py
Normal file
41
src/compose_farm/examples/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Example stack templates for compose-farm."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
# All available examples: name -> description
|
||||
# "full" is special: multi-stack setup with config file
|
||||
EXAMPLES = {
|
||||
"whoami": "Simple HTTP service that returns hostname (great for testing Traefik)",
|
||||
"nginx": "Basic nginx web server with static files",
|
||||
"postgres": "PostgreSQL database with persistent volume",
|
||||
"full": "Complete setup with Traefik + whoami (includes compose-farm.yaml)",
|
||||
}
|
||||
|
||||
# Examples that are single stacks (everything except "full")
|
||||
SINGLE_STACK_EXAMPLES = {k: v for k, v in EXAMPLES.items() if k != "full"}
|
||||
|
||||
|
||||
def list_example_files(name: str) -> list[tuple[str, str]]:
|
||||
"""List files in an example template, returning (relative_path, content) tuples."""
|
||||
if name not in EXAMPLES:
|
||||
msg = f"Unknown example: {name}. Available: {', '.join(EXAMPLES.keys())}"
|
||||
raise ValueError(msg)
|
||||
|
||||
example_dir = resources.files("compose_farm.examples") / name
|
||||
example_path = Path(str(example_dir))
|
||||
files: list[tuple[str, str]] = []
|
||||
|
||||
def walk_dir(directory: Path, prefix: str = "") -> None:
|
||||
for item in sorted(directory.iterdir()):
|
||||
rel_path = f"{prefix}{item.name}" if prefix else item.name
|
||||
if item.is_file():
|
||||
content = item.read_text(encoding="utf-8")
|
||||
files.append((rel_path, content))
|
||||
elif item.is_dir() and not item.name.startswith("__"):
|
||||
walk_dir(item, f"{rel_path}/")
|
||||
|
||||
walk_dir(example_path)
|
||||
return files
|
||||
41
src/compose_farm/examples/full/README.md
Normal file
41
src/compose_farm/examples/full/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Compose Farm Full Example
|
||||
|
||||
A complete starter setup with Traefik reverse proxy and a test service.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Create the Docker network** (once per host):
|
||||
```bash
|
||||
docker network create --subnet=172.20.0.0/16 --gateway=172.20.0.1 mynetwork
|
||||
```
|
||||
|
||||
2. **Create data directory for Traefik**:
|
||||
```bash
|
||||
mkdir -p /mnt/data/traefik
|
||||
```
|
||||
|
||||
3. **Edit configuration**:
|
||||
- Update `compose-farm.yaml` with your host IP
|
||||
- Update `.env` files with your domain
|
||||
|
||||
4. **Start the stacks**:
|
||||
```bash
|
||||
cf up traefik whoami
|
||||
```
|
||||
|
||||
5. **Test**:
|
||||
- Dashboard: http://localhost:8080
|
||||
- Whoami: Add `whoami.example.com` to /etc/hosts pointing to your host
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
full/
|
||||
├── compose-farm.yaml # Compose Farm config
|
||||
├── traefik/
|
||||
│ ├── compose.yaml # Traefik reverse proxy
|
||||
│ └── .env
|
||||
└── whoami/
|
||||
├── compose.yaml # Test HTTP service
|
||||
└── .env
|
||||
```
|
||||
17
src/compose_farm/examples/full/compose-farm.yaml
Normal file
17
src/compose_farm/examples/full/compose-farm.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
# Compose Farm configuration
|
||||
# Edit the host address to match your setup
|
||||
|
||||
compose_dir: .
|
||||
|
||||
hosts:
|
||||
local: localhost # For remote hosts, use: myhost: 192.168.1.100
|
||||
|
||||
stacks:
|
||||
traefik: local
|
||||
whoami: local
|
||||
nginx: local
|
||||
postgres: local
|
||||
|
||||
# Traefik file-provider integration (optional)
|
||||
# traefik_file: ./traefik/dynamic.d/compose-farm.yml
|
||||
traefik_stack: traefik
|
||||
2
src/compose_farm/examples/full/nginx/.env
Normal file
2
src/compose_farm/examples/full/nginx/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# Environment variables for nginx stack
|
||||
DOMAIN=example.com
|
||||
26
src/compose_farm/examples/full/nginx/compose.yaml
Normal file
26
src/compose_farm/examples/full/nginx/compose.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Nginx - Basic web server
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: cf-nginx
|
||||
user: "1000:1000"
|
||||
networks:
|
||||
- mynetwork
|
||||
volumes:
|
||||
- /mnt/data/nginx/html:/usr/share/nginx/html:ro
|
||||
ports:
|
||||
- "9082:80" # Use 80:80 or 8080:80 in production
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.nginx.rule=Host(`nginx.${DOMAIN}`)
|
||||
- traefik.http.routers.nginx.entrypoints=websecure
|
||||
- traefik.http.routers.nginx-local.rule=Host(`nginx.local`)
|
||||
- traefik.http.routers.nginx-local.entrypoints=web
|
||||
- traefik.http.services.nginx.loadbalancer.server.port=80
|
||||
- kuma.nginx.http.name=Nginx
|
||||
- kuma.nginx.http.url=https://nginx.${DOMAIN}
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
5
src/compose_farm/examples/full/postgres/.env
Normal file
5
src/compose_farm/examples/full/postgres/.env
Normal file
@@ -0,0 +1,5 @@
|
||||
# Environment variables for postgres stack
|
||||
# IMPORTANT: Change these values before deploying!
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_DB=myapp
|
||||
26
src/compose_farm/examples/full/postgres/compose.yaml
Normal file
26
src/compose_farm/examples/full/postgres/compose.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# PostgreSQL - Database with persistent storage
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cf-postgres
|
||||
networks:
|
||||
- mynetwork
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-postgres}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- /mnt/data/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432" # Use 5432:5432 in production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
1
src/compose_farm/examples/full/traefik/.env
Normal file
1
src/compose_farm/examples/full/traefik/.env
Normal file
@@ -0,0 +1 @@
|
||||
DOMAIN=example.com
|
||||
37
src/compose_farm/examples/full/traefik/compose.yaml
Normal file
37
src/compose_farm/examples/full/traefik/compose.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
# Traefik - Reverse proxy and load balancer
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v2.11
|
||||
container_name: cf-traefik
|
||||
networks:
|
||||
- mynetwork
|
||||
ports:
|
||||
- "9080:80" # HTTP (use 80:80 in production)
|
||||
- "9443:443" # HTTPS (use 443:443 in production)
|
||||
- "9081:8080" # Dashboard - remove in production
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /mnt/data/traefik:/etc/traefik
|
||||
command:
|
||||
- --api.dashboard=true
|
||||
- --api.insecure=true
|
||||
- --providers.docker=true
|
||||
- --providers.docker.exposedbydefault=false
|
||||
- --providers.docker.network=mynetwork
|
||||
- --entrypoints.web.address=:80
|
||||
- --entrypoints.websecure.address=:443
|
||||
- --log.level=INFO
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)
|
||||
- traefik.http.routers.traefik.entrypoints=websecure
|
||||
- traefik.http.routers.traefik-local.rule=Host(`traefik.local`)
|
||||
- traefik.http.routers.traefik-local.entrypoints=web
|
||||
- traefik.http.services.traefik.loadbalancer.server.port=8080
|
||||
- kuma.traefik.http.name=Traefik
|
||||
- kuma.traefik.http.url=https://traefik.${DOMAIN}
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
1
src/compose_farm/examples/full/whoami/.env
Normal file
1
src/compose_farm/examples/full/whoami/.env
Normal file
@@ -0,0 +1 @@
|
||||
DOMAIN=example.com
|
||||
23
src/compose_farm/examples/full/whoami/compose.yaml
Normal file
23
src/compose_farm/examples/full/whoami/compose.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Whoami - Test service for Traefik routing
|
||||
services:
|
||||
whoami:
|
||||
image: traefik/whoami:latest
|
||||
container_name: cf-whoami
|
||||
networks:
|
||||
- mynetwork
|
||||
ports:
|
||||
- "9000:80"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.whoami.rule=Host(`whoami.${DOMAIN}`)
|
||||
- traefik.http.routers.whoami.entrypoints=websecure
|
||||
- traefik.http.routers.whoami-local.rule=Host(`whoami.local`)
|
||||
- traefik.http.routers.whoami-local.entrypoints=web
|
||||
- traefik.http.services.whoami.loadbalancer.server.port=80
|
||||
- kuma.whoami.http.name=Whoami
|
||||
- kuma.whoami.http.url=https://whoami.${DOMAIN}
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
2
src/compose_farm/examples/nginx/.env
Normal file
2
src/compose_farm/examples/nginx/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# Environment variables for nginx stack
|
||||
DOMAIN=example.com
|
||||
26
src/compose_farm/examples/nginx/compose.yaml
Normal file
26
src/compose_farm/examples/nginx/compose.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# Nginx - Basic web server
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: cf-nginx
|
||||
user: "1000:1000"
|
||||
networks:
|
||||
- mynetwork
|
||||
volumes:
|
||||
- /mnt/data/nginx/html:/usr/share/nginx/html:ro
|
||||
ports:
|
||||
- "9082:80"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.nginx.rule=Host(`nginx.${DOMAIN}`)
|
||||
- traefik.http.routers.nginx.entrypoints=websecure
|
||||
- traefik.http.routers.nginx-local.rule=Host(`nginx.local`)
|
||||
- traefik.http.routers.nginx-local.entrypoints=web
|
||||
- traefik.http.services.nginx.loadbalancer.server.port=80
|
||||
- kuma.nginx.http.name=Nginx
|
||||
- kuma.nginx.http.url=https://nginx.${DOMAIN}
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
5
src/compose_farm/examples/postgres/.env
Normal file
5
src/compose_farm/examples/postgres/.env
Normal file
@@ -0,0 +1,5 @@
|
||||
# Environment variables for postgres stack
|
||||
# IMPORTANT: Change these values before deploying!
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_DB=myapp
|
||||
26
src/compose_farm/examples/postgres/compose.yaml
Normal file
26
src/compose_farm/examples/postgres/compose.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
# PostgreSQL - Database with persistent storage
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cf-postgres
|
||||
networks:
|
||||
- mynetwork
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-postgres}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- /mnt/data/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
2
src/compose_farm/examples/whoami/.env
Normal file
2
src/compose_farm/examples/whoami/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
# Environment variables for whoami stack
|
||||
DOMAIN=example.com
|
||||
24
src/compose_farm/examples/whoami/compose.yaml
Normal file
24
src/compose_farm/examples/whoami/compose.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
# Whoami - Simple HTTP service for testing
|
||||
# Returns the container hostname - useful for testing load balancers and Traefik
|
||||
services:
|
||||
whoami:
|
||||
image: traefik/whoami:latest
|
||||
container_name: cf-whoami
|
||||
networks:
|
||||
- mynetwork
|
||||
ports:
|
||||
- "9000:80"
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.whoami.rule=Host(`whoami.${DOMAIN}`)
|
||||
- traefik.http.routers.whoami.entrypoints=websecure
|
||||
- traefik.http.routers.whoami-local.rule=Host(`whoami.local`)
|
||||
- traefik.http.routers.whoami-local.entrypoints=web
|
||||
- traefik.http.services.whoami.loadbalancer.server.port=80
|
||||
- kuma.whoami.http.name=Whoami
|
||||
- kuma.whoami.http.url=https://whoami.${DOMAIN}
|
||||
|
||||
networks:
|
||||
mynetwork:
|
||||
external: true
|
||||
@@ -101,6 +101,58 @@ async def discover_stack_host(cfg: Config, stack: str) -> tuple[str, str | list[
|
||||
return stack, None
|
||||
|
||||
|
||||
class StackDiscoveryResult(NamedTuple):
|
||||
"""Result of discovering where a stack is running across all hosts."""
|
||||
|
||||
stack: str
|
||||
configured_hosts: list[str] # From config (where it SHOULD run)
|
||||
running_hosts: list[str] # From reality (where it IS running)
|
||||
|
||||
@property
|
||||
def is_multi_host(self) -> bool:
|
||||
"""Check if this is a multi-host stack."""
|
||||
return len(self.configured_hosts) > 1
|
||||
|
||||
@property
|
||||
def stray_hosts(self) -> list[str]:
|
||||
"""Hosts where stack is running but shouldn't be."""
|
||||
return [h for h in self.running_hosts if h not in self.configured_hosts]
|
||||
|
||||
@property
|
||||
def missing_hosts(self) -> list[str]:
|
||||
"""Hosts where stack should be running but isn't."""
|
||||
return [h for h in self.configured_hosts if h not in self.running_hosts]
|
||||
|
||||
@property
|
||||
def is_stray(self) -> bool:
|
||||
"""Stack is running on unauthorized host(s)."""
|
||||
return len(self.stray_hosts) > 0
|
||||
|
||||
@property
|
||||
def is_duplicate(self) -> bool:
|
||||
"""Single-host stack running on multiple hosts."""
|
||||
return not self.is_multi_host and len(self.running_hosts) > 1
|
||||
|
||||
|
||||
async def discover_stack_on_all_hosts(cfg: Config, stack: str) -> StackDiscoveryResult:
|
||||
"""Discover where a stack is running across ALL hosts.
|
||||
|
||||
Unlike discover_stack_host(), this checks every host in parallel
|
||||
to detect strays and duplicates.
|
||||
"""
|
||||
configured_hosts = cfg.get_hosts(stack)
|
||||
all_hosts = list(cfg.hosts.keys())
|
||||
|
||||
checks = await asyncio.gather(*[check_stack_running(cfg, stack, h) for h in all_hosts])
|
||||
running_hosts = [h for h, is_running in zip(all_hosts, checks, strict=True) if is_running]
|
||||
|
||||
return StackDiscoveryResult(
|
||||
stack=stack,
|
||||
configured_hosts=configured_hosts,
|
||||
running_hosts=running_hosts,
|
||||
)
|
||||
|
||||
|
||||
async def check_stack_requirements(
|
||||
cfg: Config,
|
||||
stack: str,
|
||||
@@ -359,26 +411,33 @@ async def check_host_compatibility(
|
||||
return results
|
||||
|
||||
|
||||
async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
|
||||
"""Stop orphaned stacks (in state but not in config).
|
||||
async def _stop_stacks_on_hosts(
|
||||
cfg: Config,
|
||||
stacks_to_hosts: dict[str, list[str]],
|
||||
label: str = "",
|
||||
) -> list[CommandResult]:
|
||||
"""Stop stacks on specific hosts.
|
||||
|
||||
Runs docker compose down on each stack on its tracked host(s).
|
||||
Only removes from state on successful stop.
|
||||
Shared helper for stop_orphaned_stacks and stop_stray_stacks.
|
||||
|
||||
Args:
|
||||
cfg: Config object.
|
||||
stacks_to_hosts: Dict mapping stack name to list of hosts to stop on.
|
||||
label: Optional label for success message (e.g., "stray", "orphaned").
|
||||
|
||||
Returns:
|
||||
List of CommandResults for each stack@host.
|
||||
|
||||
Returns list of CommandResults for each stack@host.
|
||||
"""
|
||||
orphaned = get_orphaned_stacks(cfg)
|
||||
if not orphaned:
|
||||
if not stacks_to_hosts:
|
||||
return []
|
||||
|
||||
results: list[CommandResult] = []
|
||||
tasks: list[tuple[str, str, asyncio.Task[CommandResult]]] = []
|
||||
suffix = f" ({label})" if label else ""
|
||||
|
||||
# Build list of (stack, host, task) for all orphaned stacks
|
||||
for stack, hosts in orphaned.items():
|
||||
host_list = hosts if isinstance(hosts, list) else [hosts]
|
||||
for host in host_list:
|
||||
# Skip hosts no longer in config
|
||||
for stack, hosts in stacks_to_hosts.items():
|
||||
for host in hosts:
|
||||
if host not in cfg.hosts:
|
||||
print_warning(f"{stack}@{host}: host no longer in config, skipping")
|
||||
results.append(
|
||||
@@ -393,30 +452,48 @@ async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
|
||||
coro = run_compose_on_host(cfg, stack, host, "down")
|
||||
tasks.append((stack, host, asyncio.create_task(coro)))
|
||||
|
||||
# Run all down commands in parallel
|
||||
if tasks:
|
||||
for stack, host, task in tasks:
|
||||
try:
|
||||
result = await task
|
||||
results.append(result)
|
||||
if result.success:
|
||||
print_success(f"{stack}@{host}: stopped")
|
||||
else:
|
||||
print_error(f"{stack}@{host}: {result.stderr or 'failed'}")
|
||||
except Exception as e:
|
||||
print_error(f"{stack}@{host}: {e}")
|
||||
results.append(
|
||||
CommandResult(
|
||||
stack=f"{stack}@{host}",
|
||||
exit_code=1,
|
||||
success=False,
|
||||
stderr=str(e),
|
||||
)
|
||||
for stack, host, task in tasks:
|
||||
try:
|
||||
result = await task
|
||||
results.append(result)
|
||||
if result.success:
|
||||
print_success(f"{stack}@{host}: stopped{suffix}")
|
||||
else:
|
||||
print_error(f"{stack}@{host}: {result.stderr or 'failed'}")
|
||||
except Exception as e:
|
||||
print_error(f"{stack}@{host}: {e}")
|
||||
results.append(
|
||||
CommandResult(
|
||||
stack=f"{stack}@{host}",
|
||||
exit_code=1,
|
||||
success=False,
|
||||
stderr=str(e),
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
|
||||
"""Stop orphaned stacks (in state but not in config).
|
||||
|
||||
Runs docker compose down on each stack on its tracked host(s).
|
||||
Only removes from state on successful stop.
|
||||
|
||||
Returns list of CommandResults for each stack@host.
|
||||
"""
|
||||
orphaned = get_orphaned_stacks(cfg)
|
||||
if not orphaned:
|
||||
return []
|
||||
|
||||
normalized: dict[str, list[str]] = {
|
||||
stack: (hosts if isinstance(hosts, list) else [hosts]) for stack, hosts in orphaned.items()
|
||||
}
|
||||
|
||||
results = await _stop_stacks_on_hosts(cfg, normalized)
|
||||
|
||||
# Remove from state only for stacks where ALL hosts succeeded
|
||||
for stack, hosts in orphaned.items():
|
||||
host_list = hosts if isinstance(hosts, list) else [hosts]
|
||||
for stack in normalized:
|
||||
all_succeeded = all(
|
||||
r.success for r in results if r.stack.startswith(f"{stack}@") or r.stack == stack
|
||||
)
|
||||
@@ -424,3 +501,20 @@ async def stop_orphaned_stacks(cfg: Config) -> list[CommandResult]:
|
||||
remove_stack(cfg, stack)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def stop_stray_stacks(
|
||||
cfg: Config,
|
||||
strays: dict[str, list[str]],
|
||||
) -> list[CommandResult]:
|
||||
"""Stop stacks running on unauthorized hosts.
|
||||
|
||||
Args:
|
||||
cfg: Config object.
|
||||
strays: Dict mapping stack name to list of stray hosts.
|
||||
|
||||
Returns:
|
||||
List of CommandResults for each stack@host stopped.
|
||||
|
||||
"""
|
||||
return await _stop_stacks_on_hosts(cfg, strays, label="stray")
|
||||
|
||||
@@ -58,8 +58,9 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.get_orphaned_stacks", return_value={}),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
):
|
||||
apply(dry_run=False, no_orphans=False, full=False, config=None)
|
||||
apply(dry_run=False, no_orphans=False, no_strays=False, full=False, config=None)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Nothing to apply" in captured.out
|
||||
@@ -82,10 +83,11 @@ class TestApplyCommand:
|
||||
),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle.get_stack_host", return_value="host1"),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
patch("compose_farm.cli.lifecycle.stop_orphaned_stacks") as mock_stop,
|
||||
patch("compose_farm.cli.lifecycle.up_stacks") as mock_up,
|
||||
):
|
||||
apply(dry_run=True, no_orphans=False, full=False, config=None)
|
||||
apply(dry_run=True, no_orphans=False, no_strays=False, full=False, config=None)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Stacks to migrate" in captured.out
|
||||
@@ -112,6 +114,7 @@ class TestApplyCommand:
|
||||
),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle.get_stack_host", return_value="host1"),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
patch(
|
||||
"compose_farm.cli.lifecycle.run_async",
|
||||
return_value=mock_results,
|
||||
@@ -120,7 +123,7 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
|
||||
patch("compose_farm.cli.lifecycle.report_results"),
|
||||
):
|
||||
apply(dry_run=False, no_orphans=False, full=False, config=None)
|
||||
apply(dry_run=False, no_orphans=False, no_strays=False, full=False, config=None)
|
||||
|
||||
mock_up.assert_called_once()
|
||||
call_args = mock_up.call_args
|
||||
@@ -139,6 +142,7 @@ class TestApplyCommand:
|
||||
),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
patch(
|
||||
"compose_farm.cli.lifecycle.run_async",
|
||||
return_value=mock_results,
|
||||
@@ -146,7 +150,7 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.stop_orphaned_stacks") as mock_stop,
|
||||
patch("compose_farm.cli.lifecycle.report_results"),
|
||||
):
|
||||
apply(dry_run=False, no_orphans=False, full=False, config=None)
|
||||
apply(dry_run=False, no_orphans=False, no_strays=False, full=False, config=None)
|
||||
|
||||
mock_stop.assert_called_once_with(cfg)
|
||||
|
||||
@@ -169,6 +173,7 @@ class TestApplyCommand:
|
||||
),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle.get_stack_host", return_value="host1"),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
patch(
|
||||
"compose_farm.cli.lifecycle.run_async",
|
||||
return_value=mock_results,
|
||||
@@ -178,7 +183,7 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
|
||||
patch("compose_farm.cli.lifecycle.report_results"),
|
||||
):
|
||||
apply(dry_run=False, no_orphans=True, full=False, config=None)
|
||||
apply(dry_run=False, no_orphans=True, no_strays=False, full=False, config=None)
|
||||
|
||||
# Should run migrations but not orphan cleanup
|
||||
mock_up.assert_called_once()
|
||||
@@ -202,8 +207,9 @@ class TestApplyCommand:
|
||||
),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
):
|
||||
apply(dry_run=False, no_orphans=True, full=False, config=None)
|
||||
apply(dry_run=False, no_orphans=True, no_strays=False, full=False, config=None)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Nothing to apply" in captured.out
|
||||
@@ -221,6 +227,7 @@ class TestApplyCommand:
|
||||
"compose_farm.cli.lifecycle.get_stacks_not_in_state",
|
||||
return_value=["svc1"],
|
||||
),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
patch(
|
||||
"compose_farm.cli.lifecycle.run_async",
|
||||
return_value=mock_results,
|
||||
@@ -229,7 +236,7 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
|
||||
patch("compose_farm.cli.lifecycle.report_results"),
|
||||
):
|
||||
apply(dry_run=False, no_orphans=False, full=False, config=None)
|
||||
apply(dry_run=False, no_orphans=False, no_strays=False, full=False, config=None)
|
||||
|
||||
mock_up.assert_called_once()
|
||||
call_args = mock_up.call_args
|
||||
@@ -249,8 +256,9 @@ class TestApplyCommand:
|
||||
"compose_farm.cli.lifecycle.get_stacks_not_in_state",
|
||||
return_value=["svc1"],
|
||||
),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
):
|
||||
apply(dry_run=True, no_orphans=False, full=False, config=None)
|
||||
apply(dry_run=True, no_orphans=False, no_strays=False, full=False, config=None)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Stacks to start" in captured.out
|
||||
@@ -267,6 +275,7 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.get_orphaned_stacks", return_value={}),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
patch(
|
||||
"compose_farm.cli.lifecycle.run_async",
|
||||
return_value=mock_results,
|
||||
@@ -275,7 +284,7 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
|
||||
patch("compose_farm.cli.lifecycle.report_results"),
|
||||
):
|
||||
apply(dry_run=False, no_orphans=False, full=True, config=None)
|
||||
apply(dry_run=False, no_orphans=False, no_strays=False, full=True, config=None)
|
||||
|
||||
mock_up.assert_called_once()
|
||||
call_args = mock_up.call_args
|
||||
@@ -293,8 +302,9 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.get_orphaned_stacks", return_value={}),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_needing_migration", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle.get_stacks_not_in_state", return_value=[]),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
):
|
||||
apply(dry_run=True, no_orphans=False, full=True, config=None)
|
||||
apply(dry_run=True, no_orphans=False, no_strays=False, full=True, config=None)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Stacks to refresh" in captured.out
|
||||
@@ -319,6 +329,7 @@ class TestApplyCommand:
|
||||
return_value=["svc2"],
|
||||
),
|
||||
patch("compose_farm.cli.lifecycle.get_stack_host", return_value="host2"),
|
||||
patch("compose_farm.cli.lifecycle._discover_strays", return_value={}),
|
||||
patch(
|
||||
"compose_farm.cli.lifecycle.run_async",
|
||||
return_value=mock_results,
|
||||
@@ -327,7 +338,7 @@ class TestApplyCommand:
|
||||
patch("compose_farm.cli.lifecycle.maybe_regenerate_traefik"),
|
||||
patch("compose_farm.cli.lifecycle.report_results"),
|
||||
):
|
||||
apply(dry_run=False, no_orphans=False, full=True, config=None)
|
||||
apply(dry_run=False, no_orphans=False, no_strays=False, full=True, config=None)
|
||||
|
||||
# up_stacks should be called 3 times: migrate, start, refresh
|
||||
assert mock_up.call_count == 3
|
||||
|
||||
@@ -228,3 +228,99 @@ class TestConfigValidate:
|
||||
# Error goes to stderr
|
||||
output = result.stdout + (result.stderr or "")
|
||||
assert "Config file not found" in output or "not found" in output.lower()
|
||||
|
||||
|
||||
class TestConfigExample:
|
||||
"""Tests for cf config example command."""
|
||||
|
||||
def test_example_list(self, runner: CliRunner) -> None:
|
||||
result = runner.invoke(app, ["config", "example", "--list"])
|
||||
assert result.exit_code == 0
|
||||
assert "whoami" in result.stdout
|
||||
assert "nginx" in result.stdout
|
||||
assert "postgres" in result.stdout
|
||||
assert "full" in result.stdout
|
||||
|
||||
def test_example_whoami(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
result = runner.invoke(app, ["config", "example", "whoami", "-o", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "Example 'whoami' created" in result.stdout
|
||||
assert (tmp_path / "whoami" / "compose.yaml").exists()
|
||||
assert (tmp_path / "whoami" / ".env").exists()
|
||||
|
||||
def test_example_full(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
result = runner.invoke(app, ["config", "example", "full", "-o", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "Example 'full' created" in result.stdout
|
||||
assert (tmp_path / "compose-farm.yaml").exists()
|
||||
assert (tmp_path / "traefik" / "compose.yaml").exists()
|
||||
assert (tmp_path / "whoami" / "compose.yaml").exists()
|
||||
assert (tmp_path / "nginx" / "compose.yaml").exists()
|
||||
assert (tmp_path / "postgres" / "compose.yaml").exists()
|
||||
|
||||
def test_example_unknown(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
result = runner.invoke(app, ["config", "example", "unknown", "-o", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
output = result.stdout + (result.stderr or "")
|
||||
assert "Unknown example" in output
|
||||
|
||||
def test_example_force_overwrites(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
# Create first time
|
||||
runner.invoke(app, ["config", "example", "whoami", "-o", str(tmp_path)])
|
||||
# Overwrite with force
|
||||
result = runner.invoke(app, ["config", "example", "whoami", "-o", str(tmp_path), "-f"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_example_prompts_on_existing(self, runner: CliRunner, tmp_path: Path) -> None:
|
||||
# Create first time
|
||||
runner.invoke(app, ["config", "example", "whoami", "-o", str(tmp_path)])
|
||||
# Try again without force, decline
|
||||
result = runner.invoke(
|
||||
app, ["config", "example", "whoami", "-o", str(tmp_path)], input="n\n"
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Aborted" in result.stdout
|
||||
|
||||
|
||||
class TestExamplesModule:
|
||||
"""Tests for the examples module."""
|
||||
|
||||
def test_list_example_files_whoami(self) -> None:
|
||||
from compose_farm.examples import list_example_files
|
||||
|
||||
files = list_example_files("whoami")
|
||||
file_names = [f for f, _ in files]
|
||||
assert ".env" in file_names
|
||||
assert "compose.yaml" in file_names
|
||||
|
||||
def test_list_example_files_full(self) -> None:
|
||||
from compose_farm.examples import list_example_files
|
||||
|
||||
files = list_example_files("full")
|
||||
file_names = [f for f, _ in files]
|
||||
assert "compose-farm.yaml" in file_names
|
||||
assert "traefik/compose.yaml" in file_names
|
||||
assert "whoami/compose.yaml" in file_names
|
||||
|
||||
def test_list_example_files_unknown(self) -> None:
|
||||
from compose_farm.examples import list_example_files
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown example"):
|
||||
list_example_files("unknown")
|
||||
|
||||
def test_examples_dict(self) -> None:
|
||||
from compose_farm.examples import EXAMPLES, SINGLE_STACK_EXAMPLES
|
||||
|
||||
assert "whoami" in EXAMPLES
|
||||
assert "full" in EXAMPLES
|
||||
assert "full" not in SINGLE_STACK_EXAMPLES
|
||||
assert "whoami" in SINGLE_STACK_EXAMPLES
|
||||
|
||||
|
||||
class TestConfigInitDiscover:
|
||||
"""Tests for cf config init --discover."""
|
||||
|
||||
def test_discover_option_exists(self, runner: CliRunner) -> None:
|
||||
result = runner.invoke(app, ["config", "init", "--help"])
|
||||
assert "--discover" in result.stdout
|
||||
assert "-d" in result.stdout
|
||||
|
||||
@@ -211,8 +211,8 @@ class TestRefreshCommand:
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_stacks",
|
||||
return_value={"plex": "nas02"}, # plex moved to nas02
|
||||
"compose_farm.cli.management._discover_stacks_full",
|
||||
return_value=({"plex": "nas02"}, {}, {}), # plex moved to nas02
|
||||
),
|
||||
patch("compose_farm.cli.management._snapshot_stacks"),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
@@ -247,8 +247,12 @@ class TestRefreshCommand:
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_stacks",
|
||||
return_value={"plex": "nas01", "grafana": "nas02"}, # jellyfin not running
|
||||
"compose_farm.cli.management._discover_stacks_full",
|
||||
return_value=(
|
||||
{"plex": "nas01", "grafana": "nas02"},
|
||||
{},
|
||||
{},
|
||||
), # jellyfin not running
|
||||
),
|
||||
patch("compose_farm.cli.management._snapshot_stacks"),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
@@ -281,8 +285,8 @@ class TestRefreshCommand:
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_stacks",
|
||||
return_value={"plex": "nas01"}, # only plex running
|
||||
"compose_farm.cli.management._discover_stacks_full",
|
||||
return_value=({"plex": "nas01"}, {}, {}), # only plex running
|
||||
),
|
||||
patch("compose_farm.cli.management._snapshot_stacks"),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
@@ -315,8 +319,8 @@ class TestRefreshCommand:
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_stacks",
|
||||
return_value={"plex": "nas01"}, # jellyfin not running
|
||||
"compose_farm.cli.management._discover_stacks_full",
|
||||
return_value=({"plex": "nas01"}, {}, {}), # jellyfin not running
|
||||
),
|
||||
patch("compose_farm.cli.management._snapshot_stacks"),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
@@ -350,8 +354,8 @@ class TestRefreshCommand:
|
||||
return_value=existing_state,
|
||||
),
|
||||
patch(
|
||||
"compose_farm.cli.management._discover_stacks",
|
||||
return_value={"plex": "nas02"}, # would change
|
||||
"compose_farm.cli.management._discover_stacks_full",
|
||||
return_value=({"plex": "nas02"}, {}, {}), # would change
|
||||
),
|
||||
patch("compose_farm.cli.management.save_state") as mock_save,
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user