feat(cli): add config init --discover and config example commands

- Add --discover flag to `cf config init` for interactive stack detection
  - Scans compose_dir for directories with compose files
  - Interactive selection of which stacks to include
  - Auto-detects hostname for default host configuration

- Add `cf config example` command with built-in templates
  - Simple examples: whoami, nginx, postgres
  - Full example: complete Traefik + whoami setup with config
  - Examples use cf- prefix to avoid conflicts with real stacks

- Add examples package with template files
This commit is contained in:
Bas Nijholt
2025-12-21 22:34:37 -08:00
parent dd16becad1
commit ae5844f0db
14 changed files with 486 additions and 2 deletions

View File

@@ -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,109 @@ def _get_config_file(path: Path | None) -> Path | None:
return config_path.resolve() if config_path else None
def _discover_compose_dirs(compose_dir: Path) -> list[str]:
"""Find all directories containing compose files."""
from compose_farm.config import COMPOSE_FILENAMES # noqa: PLC0415
if not compose_dir.exists():
return []
return [
subdir.name
for subdir in sorted(compose_dir.iterdir())
if subdir.is_dir() and any((subdir / f).exists() for f in COMPOSE_FILENAMES)
]
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 from a list using checkboxes."""
from rich.prompt import Confirm # noqa: PLC0415
console.print("\n[bold]Found stacks:[/bold]")
console.print("[dim]Select which stacks to include in your config.[/dim]\n")
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
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 +194,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 +214,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 +412,119 @@ 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,
FULL_EXAMPLE,
FULL_EXAMPLE_DESC,
list_example_files,
)
# Build combined list for display
all_examples = {**EXAMPLES, FULL_EXAMPLE: FULL_EXAMPLE_DESC}
# 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 EXAMPLES.items():
console.print(f" [cyan]{example_name}[/cyan] - {description}")
console.print()
console.print("[dim]Complete setup:[/dim]")
console.print(f" [cyan]{FULL_EXAMPLE}[/cyan] - {FULL_EXAMPLE_DESC}")
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(all_examples.keys())
for i, (example_name, description) in enumerate(all_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 all_examples:
print_error(f"Unknown example: {name}")
console.print(f"Available examples: {', '.join(all_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_EXAMPLE 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_EXAMPLE:
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")

View File

@@ -0,0 +1,45 @@
"""Example stack templates for compose-farm."""
from __future__ import annotations
from importlib import resources
from pathlib import Path
# Simple examples: single stack templates
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 example: complete multi-stack setup with config
FULL_EXAMPLE = "full"
FULL_EXAMPLE_DESC = "Complete setup with Traefik + whoami (includes compose-farm.yaml)"
def get_example_path(name: str) -> Path:
"""Get the path to an example template directory."""
if name != FULL_EXAMPLE and name not in EXAMPLES:
msg = f"Unknown example: {name}. Available: {', '.join(EXAMPLES.keys())}, {FULL_EXAMPLE}"
raise ValueError(msg)
example_dir = resources.files("compose_farm.examples") / name
return Path(str(example_dir))
def list_example_files(name: str) -> list[tuple[str, str]]:
"""List files in an example template, returning (relative_path, content) tuples."""
example_path = get_example_path(name)
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

View 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
```

View File

@@ -0,0 +1,15 @@
# Compose Farm configuration
# Edit the host address to match your setup
compose_dir: .
hosts:
myhost: 192.168.1.100 # Change to your host IP or use 'localhost' for local testing
stacks:
traefik: myhost
whoami: myhost
# Traefik file-provider integration (optional)
# traefik_file: ./traefik/dynamic.d/compose-farm.yml
traefik_stack: traefik

View File

@@ -0,0 +1 @@
DOMAIN=example.com

View File

@@ -0,0 +1,35 @@
# Traefik - Reverse proxy and load balancer
name: cf-traefik
services:
traefik:
image: traefik:v2.11
container_name: traefik
networks:
- mynetwork
ports:
- "80:80"
- "443:443"
- "8080: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.service=api@internal
restart: unless-stopped
networks:
mynetwork:
external: true

View File

@@ -0,0 +1 @@
DOMAIN=example.com

View File

@@ -0,0 +1,23 @@
# Whoami - Test service for Traefik routing
name: cf-whoami
services:
whoami:
image: traefik/whoami:latest
container_name: whoami
networks:
- mynetwork
restart: unless-stopped
labels:
- traefik.enable=true
# HTTPS routing
- traefik.http.routers.whoami.rule=Host(`whoami.${DOMAIN}`)
- traefik.http.routers.whoami.entrypoints=websecure
# HTTP routing (for testing without TLS)
- traefik.http.routers.whoami-http.rule=Host(`whoami.${DOMAIN}`)
- traefik.http.routers.whoami-http.entrypoints=web
- traefik.http.services.whoami.loadbalancer.server.port=80
networks:
mynetwork:
external: true

View File

@@ -0,0 +1,2 @@
# Environment variables for nginx stack
DOMAIN=example.com

View File

@@ -0,0 +1,28 @@
# Nginx - Basic web server
name: cf-nginx
services:
nginx:
image: nginx:alpine
container_name: nginx
user: "1000:1000"
networks:
- mynetwork
volumes:
- /mnt/data/nginx/html:/usr/share/nginx/html:ro
- /mnt/data/nginx/conf.d:/etc/nginx/conf.d:ro
ports:
- "8081:80"
restart: unless-stopped
labels:
# Traefik routing (adjust domain as needed)
- 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
networks:
mynetwork:
external: true

View 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

View File

@@ -0,0 +1,28 @@
# PostgreSQL - Database with persistent storage
name: cf-postgres
services:
postgres:
image: postgres:16-alpine
container_name: 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:
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 30s
timeout: 10s
retries: 3
networks:
mynetwork:
external: true

View File

@@ -0,0 +1,2 @@
# Environment variables for whoami stack
DOMAIN=example.com

View File

@@ -0,0 +1,25 @@
# Whoami - Simple HTTP service for testing
# Returns the container hostname - useful for testing load balancers and Traefik
name: cf-whoami
services:
whoami:
image: traefik/whoami:latest
container_name: whoami
networks:
- mynetwork
ports:
- "8080:80"
restart: unless-stopped
labels:
# Traefik routing (adjust domain as needed)
- 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
networks:
mynetwork:
external: true