diff --git a/src/compose_farm/cli/config.py b/src/compose_farm/cli/config.py index 0580b2f..1e3ae33 100644 --- a/src/compose_farm/cli/config.py +++ b/src/compose_farm/cli/config.py @@ -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 [/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}: [/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") diff --git a/src/compose_farm/examples/__init__.py b/src/compose_farm/examples/__init__.py new file mode 100644 index 0000000..1c38a5f --- /dev/null +++ b/src/compose_farm/examples/__init__.py @@ -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 diff --git a/src/compose_farm/examples/full/README.md b/src/compose_farm/examples/full/README.md new file mode 100644 index 0000000..164bca2 --- /dev/null +++ b/src/compose_farm/examples/full/README.md @@ -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 +``` diff --git a/src/compose_farm/examples/full/compose-farm.yaml b/src/compose_farm/examples/full/compose-farm.yaml new file mode 100644 index 0000000..a06a729 --- /dev/null +++ b/src/compose_farm/examples/full/compose-farm.yaml @@ -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 diff --git a/src/compose_farm/examples/full/traefik/.env b/src/compose_farm/examples/full/traefik/.env new file mode 100644 index 0000000..caeb545 --- /dev/null +++ b/src/compose_farm/examples/full/traefik/.env @@ -0,0 +1 @@ +DOMAIN=example.com diff --git a/src/compose_farm/examples/full/traefik/compose.yaml b/src/compose_farm/examples/full/traefik/compose.yaml new file mode 100644 index 0000000..abad290 --- /dev/null +++ b/src/compose_farm/examples/full/traefik/compose.yaml @@ -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 diff --git a/src/compose_farm/examples/full/whoami/.env b/src/compose_farm/examples/full/whoami/.env new file mode 100644 index 0000000..caeb545 --- /dev/null +++ b/src/compose_farm/examples/full/whoami/.env @@ -0,0 +1 @@ +DOMAIN=example.com diff --git a/src/compose_farm/examples/full/whoami/compose.yaml b/src/compose_farm/examples/full/whoami/compose.yaml new file mode 100644 index 0000000..ce33aa0 --- /dev/null +++ b/src/compose_farm/examples/full/whoami/compose.yaml @@ -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 diff --git a/src/compose_farm/examples/nginx/.env b/src/compose_farm/examples/nginx/.env new file mode 100644 index 0000000..98c839d --- /dev/null +++ b/src/compose_farm/examples/nginx/.env @@ -0,0 +1,2 @@ +# Environment variables for nginx stack +DOMAIN=example.com diff --git a/src/compose_farm/examples/nginx/compose.yaml b/src/compose_farm/examples/nginx/compose.yaml new file mode 100644 index 0000000..71e70cd --- /dev/null +++ b/src/compose_farm/examples/nginx/compose.yaml @@ -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 diff --git a/src/compose_farm/examples/postgres/.env b/src/compose_farm/examples/postgres/.env new file mode 100644 index 0000000..fa8d3b8 --- /dev/null +++ b/src/compose_farm/examples/postgres/.env @@ -0,0 +1,5 @@ +# Environment variables for postgres stack +# IMPORTANT: Change these values before deploying! +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changeme +POSTGRES_DB=myapp diff --git a/src/compose_farm/examples/postgres/compose.yaml b/src/compose_farm/examples/postgres/compose.yaml new file mode 100644 index 0000000..7f5bb02 --- /dev/null +++ b/src/compose_farm/examples/postgres/compose.yaml @@ -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 diff --git a/src/compose_farm/examples/whoami/.env b/src/compose_farm/examples/whoami/.env new file mode 100644 index 0000000..a637b15 --- /dev/null +++ b/src/compose_farm/examples/whoami/.env @@ -0,0 +1,2 @@ +# Environment variables for whoami stack +DOMAIN=example.com diff --git a/src/compose_farm/examples/whoami/compose.yaml b/src/compose_farm/examples/whoami/compose.yaml new file mode 100644 index 0000000..ea4d271 --- /dev/null +++ b/src/compose_farm/examples/whoami/compose.yaml @@ -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