commit 1eba2eb205b40b9e4d94381a6bb05668191fa8b6 Author: Azwan Ngali Date: Wed Dec 17 16:51:09 2025 +0000 Initial commit diff --git a/gitea/.env b/gitea/.env new file mode 100644 index 0000000..50334a5 --- /dev/null +++ b/gitea/.env @@ -0,0 +1,11 @@ +COMPOSE_PROJECT_NAME=ascidiia-bridoon +APP_NAME=gitea +SUBDOMAIN=ascidiia-bridoon +DOMAIN=merakit.my +URL=ascidiia-bridoon.merakit.my +GITEA_VERSION=1.21 +POSTGRES_VERSION=16-alpine +DB_NAME=angali_27658dcb_gitea_ascidiia_bridoon +DB_USER=angali_27658dcb_gitea_ascidiia_bridoon +DB_PASSWORD=diapason-dukkha-munchausen +DISABLE_REGISTRATION=false diff --git a/gitea/.env.backup.20251217_091025 b/gitea/.env.backup.20251217_091025 new file mode 100644 index 0000000..7707ce2 --- /dev/null +++ b/gitea/.env.backup.20251217_091025 @@ -0,0 +1,11 @@ +COMPOSE_PROJECT_NAME=gitea-template +APP_NAME=gitea +SUBDOMAIN=gitea-template +DOMAIN=merakit.my +URL=gitea-template.merakit.my +GITEA_VERSION=1.21 +POSTGRES_VERSION=16-alpine +DB_NAME=gitea_db +DB_USER=gitea_user +DB_PASSWORD=change-me +DISABLE_REGISTRATION=false diff --git a/gitea/.env.backup.20251217_092048 b/gitea/.env.backup.20251217_092048 new file mode 100644 index 0000000..9c7da79 --- /dev/null +++ b/gitea/.env.backup.20251217_092048 @@ -0,0 +1,11 @@ +COMPOSE_PROJECT_NAME=dodman-kuichua +APP_NAME=gitea +SUBDOMAIN=dodman-kuichua +DOMAIN=merakit.my +URL=dodman-kuichua.merakit.my +GITEA_VERSION=1.21 +POSTGRES_VERSION=16-alpine +DB_NAME=angali_7675e8e6_gitea_dodman_kuichua +DB_USER=angali_7675e8e6_gitea_dodman_kuichua +DB_PASSWORD=viva-overheats-chusite +DISABLE_REGISTRATION=false diff --git a/gitea/.env.backup.20251217_160100 b/gitea/.env.backup.20251217_160100 new file mode 100644 index 0000000..01b8b91 --- /dev/null +++ b/gitea/.env.backup.20251217_160100 @@ -0,0 +1,11 @@ +COMPOSE_PROJECT_NAME=artfully-copious +APP_NAME=gitea +SUBDOMAIN=artfully-copious +DOMAIN=merakit.my +URL=artfully-copious.merakit.my +GITEA_VERSION=1.21 +POSTGRES_VERSION=16-alpine +DB_NAME=angali_2f2ec2eb_gitea_artfully_copious +DB_USER=angali_2f2ec2eb_gitea_artfully_copious +DB_PASSWORD=bannerer-tetchy-polyaxone +DISABLE_REGISTRATION=false diff --git a/gitea/README.md b/gitea/README.md new file mode 100644 index 0000000..caa4943 --- /dev/null +++ b/gitea/README.md @@ -0,0 +1,251 @@ +# Gitea Deployment Template + +Production-ready Gitea deployment with automated DNS, environment generation, and health checking. + +## Features + +- **Automated Environment Generation**: Random subdomain and secure password generation +- **DNS Management**: Automatic Cloudflare DNS record creation +- **Health Checking**: Automated deployment verification +- **Rollback Support**: Automatic rollback on deployment failure +- **Webhook Notifications**: Optional webhook notifications for deployment events +- **Deployment Tracking**: Track and manage all deployments +- **Dry-Run Mode**: Preview changes before applying + +## Architecture + +``` +gitea/ +├── docker-compose.yml # Docker Compose configuration +├── .env # Environment variables (generated) +├── deploy.py # Main deployment script +├── destroy.py # Deployment destruction script +├── requirements.txt # Python dependencies +├── deployments/ # Deployment configuration tracking +├── logs/ # Deployment logs +│ ├── success/ # Successful deployment logs +│ └── failed/ # Failed deployment logs +└── gitea_deployer/ # Python deployment module + ├── config.py # Configuration management + ├── orchestrator.py # Deployment orchestration + ├── env_generator.py # Environment generation + ├── dns_manager.py # DNS management (Cloudflare) + ├── docker_manager.py # Docker operations + ├── health.py # Health checking + ├── webhooks.py # Webhook notifications + ├── deployment_logger.py # File logging + └── deployment_config_manager.py # Deployment tracking +``` + +## Prerequisites + +- Docker and Docker Compose +- Python 3.9+ +- Cloudflare account with API token +- Traefik reverse proxy running on `proxy` network +- `/usr/share/dict/words` file (install `words` package) + +## Installation + +1. Install Python dependencies: +```bash +pip3 install -r requirements.txt +``` + +2. Set environment variables: +```bash +export CLOUDFLARE_API_TOKEN="your-token" +export CLOUDFLARE_ZONE_ID="your-zone-id" +``` + +3. Ensure Docker proxy network exists: +```bash +docker network create proxy +``` + +## Usage + +### Deploy Gitea + +Basic deployment: +```bash +./deploy.py +``` + +With options: +```bash +# Dry-run mode (preview only) +./deploy.py --dry-run + +# Debug mode +./deploy.py --log-level DEBUG + +# With webhook notifications +./deploy.py --webhook-url https://hooks.slack.com/your-webhook + +# Custom retry count for DNS conflicts +./deploy.py --max-retries 5 +``` + +### List Deployments + +```bash +./destroy.py --list +``` + +### Destroy Deployment + +By subdomain: +```bash +./destroy.py --subdomain my-gitea-site +``` + +By URL: +```bash +./destroy.py --url my-gitea-site.merakit.my +``` + +With options: +```bash +# Skip confirmation +./destroy.py --subdomain my-gitea-site --yes + +# Dry-run mode +./destroy.py --subdomain my-gitea-site --dry-run + +# Keep deployment config file +./destroy.py --subdomain my-gitea-site --keep-config +``` + +## Environment Variables + +### Required + +- `CLOUDFLARE_API_TOKEN`: Cloudflare API token with DNS edit permissions +- `CLOUDFLARE_ZONE_ID`: Cloudflare zone ID for your domain + +### Optional + +- `DEPLOYMENT_WEBHOOK_URL`: Webhook URL for deployment notifications +- `DEPLOYMENT_MAX_RETRIES`: Max retries for DNS conflicts (default: 3) +- `DEPLOYMENT_HEALTHCHECK_TIMEOUT`: Health check timeout in seconds (default: 60) +- `DEPLOYMENT_HEALTHCHECK_INTERVAL`: Health check interval in seconds (default: 10) + +## Configuration + +### Docker Compose Services + +- **postgres**: PostgreSQL 16 database +- **gitea**: Gitea 1.21 Git service + +### Generated Values + +The deployment automatically generates: + +- Random subdomain (e.g., `awesome-robot.merakit.my`) +- Database name with prefix `angali_{random}_{app}_{subdomain}` +- Database user with same pattern +- Secure memorable passwords (3-word format) + +### Customization + +Edit `.env` file to customize: + +- `GITEA_VERSION`: Gitea version (default: 1.21) +- `POSTGRES_VERSION`: PostgreSQL version (default: 16-alpine) +- `DISABLE_REGISTRATION`: Disable user registration (default: false) +- `DOMAIN`: Base domain (default: merakit.my) + +## Deployment Workflow + +1. **Validation**: Check dependencies and configuration +2. **Environment Generation**: Generate random subdomain and credentials +3. **DNS Setup**: Create Cloudflare DNS record +4. **Container Deployment**: Pull images and start services +5. **Health Check**: Verify deployment is accessible +6. **Logging**: Record deployment success/failure + +## Rollback + +If deployment fails at any stage, automatic rollback occurs: + +1. Stop and remove containers +2. Remove DNS records +3. Restore previous `.env` file + +## Troubleshooting + +### DNS Conflicts + +If subdomain is already taken, the script automatically retries with a new random subdomain (up to `max_retries` times). + +### Health Check Failures + +Health checks wait up to 60 seconds by default. Increase timeout if needed: + +```bash +export DEPLOYMENT_HEALTHCHECK_TIMEOUT=120 +./deploy.py +``` + +### Missing Dictionary File + +Install the words package: + +```bash +# Ubuntu/Debian +sudo apt-get install wamerican + +# RHEL/CentOS +sudo yum install words +``` + +## Logs + +- Success logs: `logs/success/success_{url}_{timestamp}.txt` +- Failure logs: `logs/failed/failed_{url}_{timestamp}.txt` + +## Deployment Tracking + +Deployment configurations are saved in `deployments/` directory: + +- Format: `{subdomain}_{timestamp}.json` +- Contains: containers, volumes, networks, DNS records +- Used by `destroy.py` for cleanup + +## Security Notes + +- Passwords are generated using cryptographically secure random generation +- API tokens are never logged or displayed +- SSL verification is enabled by default (use `--no-verify-ssl` only for testing) +- Database credentials are automatically generated per deployment + +## Integration + +### Webhook Notifications + +The script can send webhook notifications for: + +- `deployment_started`: When deployment begins +- `dns_added`: When DNS record is created +- `health_check_passed`: When health check succeeds +- `deployment_success`: When deployment completes +- `deployment_failed`: When deployment fails + +Example webhook payload: +```json +{ + "event_type": "deployment_success", + "timestamp": "2024-01-01T12:00:00Z", + "subdomain": "awesome-robot", + "url": "awesome-robot.merakit.my", + "message": "Deployment successful for awesome-robot.merakit.my", + "metadata": { + "duration": 45.2 + } +} +``` + +## License + +This deployment template is part of the infrastructure management system. diff --git a/gitea/deploy.py b/gitea/deploy.py new file mode 100755 index 0000000..6f6f427 --- /dev/null +++ b/gitea/deploy.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Production-ready Gitea deployment script + +Combines environment generation and deployment with: +- Configuration validation +- Rollback capability +- Dry-run mode +- Monitoring hooks +""" + +import argparse +import logging +import sys +from pathlib import Path +from typing import NoReturn + +from rich.console import Console +from rich.logging import RichHandler + +from gitea_deployer.config import ConfigurationError, DeploymentConfig +from gitea_deployer.orchestrator import DeploymentError, DeploymentOrchestrator + + +console = Console() + + +def setup_logging(log_level: str) -> None: + """ + Setup rich logging with colored output + + Args: + log_level: Logging level (DEBUG, INFO, WARNING, ERROR) + """ + logging.basicConfig( + level=log_level.upper(), + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler(console=console, rich_tracebacks=True, show_path=False)] + ) + + # Reduce noise from urllib3/requests + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("requests").setLevel(logging.WARNING) + + +def parse_args() -> argparse.Namespace: + """ + Parse CLI arguments + + Returns: + argparse.Namespace with parsed arguments + """ + parser = argparse.ArgumentParser( + description="Deploy Gitea with automatic environment generation", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Normal deployment + ./deploy.py + + # Dry-run mode (preview only) + ./deploy.py --dry-run + + # With webhook notifications + ./deploy.py --webhook-url https://hooks.slack.com/xxx + + # Debug mode + ./deploy.py --log-level DEBUG + + # Custom retry count + ./deploy.py --max-retries 5 + +Environment Variables: + CLOUDFLARE_API_TOKEN Cloudflare API token (required) + CLOUDFLARE_ZONE_ID Cloudflare zone ID (required) + DEPLOYMENT_WEBHOOK_URL Webhook URL for notifications (optional) + DEPLOYMENT_MAX_RETRIES Max retries for DNS conflicts (default: 3) + +For more information, see the documentation at: + /infra/templates/gitea/README.md + """ + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview deployment without making changes" + ) + + parser.add_argument( + "--env-file", + type=Path, + default=Path(".env"), + help="Path to .env file (default: .env)" + ) + + parser.add_argument( + "--compose-file", + type=Path, + default=Path("docker-compose.yml"), + help="Path to docker-compose.yml (default: docker-compose.yml)" + ) + + parser.add_argument( + "--max-retries", + type=int, + default=3, + help="Max retries for DNS conflicts (default: 3)" + ) + + parser.add_argument( + "--webhook-url", + type=str, + help="Webhook URL for deployment notifications" + ) + + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default="INFO", + help="Logging level (default: INFO)" + ) + + parser.add_argument( + "--no-verify-ssl", + action="store_true", + help="Skip SSL verification for health checks (not recommended for production)" + ) + + return parser.parse_args() + + +def print_banner() -> None: + """Print deployment banner""" + console.print("\n[bold cyan]╔══════════════════════════════════════════════╗[/bold cyan]") + console.print("[bold cyan]║[/bold cyan] [bold white]Gitea Production Deployment[/bold white] [bold cyan]║[/bold cyan]") + console.print("[bold cyan]╚══════════════════════════════════════════════╝[/bold cyan]\n") + + +def main() -> NoReturn: + """ + Main entry point + + Exit codes: + 0: Success + 1: Deployment failure + 130: User interrupt (Ctrl+C) + """ + args = parse_args() + setup_logging(args.log_level) + + logger = logging.getLogger(__name__) + + print_banner() + + try: + # Load configuration + logger.debug("Loading configuration...") + config = DeploymentConfig.from_env_and_args(args) + config.validate() + logger.debug("Configuration loaded successfully") + + if config.dry_run: + console.print("[bold yellow]━━━ DRY-RUN MODE: No changes will be made ━━━[/bold yellow]\n") + + # Create orchestrator and deploy + orchestrator = DeploymentOrchestrator(config) + orchestrator.deploy() + + console.print("\n[bold green]╔══════════════════════════════════════════════╗[/bold green]") + console.print("[bold green]║[/bold green] [bold white]✓ Deployment Successful![/bold white] [bold green]║[/bold green]") + console.print("[bold green]╚══════════════════════════════════════════════╝[/bold green]\n") + + sys.exit(0) + + except ConfigurationError as e: + logger.error(f"Configuration error: {e}") + console.print(f"\n[bold red]✗ Configuration error: {e}[/bold red]\n") + console.print("[yellow]Please check your environment variables and configuration.[/yellow]") + console.print("[yellow]Required: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID[/yellow]\n") + sys.exit(1) + + except DeploymentError as e: + logger.error(f"Deployment failed: {e}") + console.print(f"\n[bold red]✗ Deployment failed: {e}[/bold red]\n") + sys.exit(1) + + except KeyboardInterrupt: + logger.warning("Deployment interrupted by user") + console.print("\n[bold yellow]✗ Deployment interrupted by user[/bold yellow]\n") + sys.exit(130) + + except Exception as e: + logger.exception("Unexpected error") + console.print(f"\n[bold red]✗ Unexpected error: {e}[/bold red]\n") + console.print("[yellow]Please check the logs above for more details.[/yellow]\n") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/gitea/deployments/ascidiia-bridoon_20251217_160155.json b/gitea/deployments/ascidiia-bridoon_20251217_160155.json new file mode 100644 index 0000000..8119db6 --- /dev/null +++ b/gitea/deployments/ascidiia-bridoon_20251217_160155.json @@ -0,0 +1,23 @@ +{ + "subdomain": "ascidiia-bridoon", + "url": "ascidiia-bridoon.merakit.my", + "domain": "merakit.my", + "compose_project_name": "ascidiia-bridoon", + "db_name": "angali_27658dcb_gitea_ascidiia_bridoon", + "db_user": "angali_27658dcb_gitea_ascidiia_bridoon", + "deployment_timestamp": "2025-12-17T16:01:55.543308", + "dns_record_id": "0e5fef38bac853f3e3c65b6bdbc62f2e", + "dns_ip": "64.120.92.151", + "containers": [ + "ascidiia-bridoon_db", + "ascidiia-bridoon_gitea" + ], + "volumes": [ + "ascidiia-bridoon_db_data", + "ascidiia-bridoon_gitea_data" + ], + "networks": [ + "ascidiia-bridoon_internal" + ], + "env_file_path": "/infra/templates/gitea/.env" +} \ No newline at end of file diff --git a/gitea/destroy.py b/gitea/destroy.py new file mode 100755 index 0000000..8ca8917 --- /dev/null +++ b/gitea/destroy.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +""" +Gitea Deployment Destroyer + +Destroys Gitea deployments based on saved deployment configurations +""" + +import argparse +import logging +import subprocess +import sys +from pathlib import Path +from typing import List, NoReturn, Optional + +from rich.console import Console +from rich.logging import RichHandler +from rich.prompt import Confirm +from rich.table import Table + +from gitea_deployer.deployment_config_manager import ( + DeploymentConfigManager, + DeploymentMetadata +) +from gitea_deployer.dns_manager import DNSError, DNSManager + + +console = Console() + + +def setup_logging(log_level: str) -> None: + """ + Setup rich logging with colored output + + Args: + log_level: Logging level (DEBUG, INFO, WARNING, ERROR) + """ + logging.basicConfig( + level=log_level.upper(), + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler(console=console, rich_tracebacks=True, show_path=False)] + ) + + +def parse_args() -> argparse.Namespace: + """ + Parse CLI arguments + + Returns: + argparse.Namespace with parsed arguments + """ + parser = argparse.ArgumentParser( + description="Destroy Gitea deployments", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # List all deployments + ./destroy.py --list + + # Destroy by subdomain + ./destroy.py --subdomain my-site + + # Destroy by URL + ./destroy.py --url my-site.example.com + + # Destroy by config file + ./destroy.py --config deployments/my-site_20231215_120000.json + + # Destroy without confirmation + ./destroy.py --subdomain my-site --yes + + # Dry-run mode (preview only) + ./destroy.py --subdomain my-site --dry-run + +Environment Variables: + CLOUDFLARE_API_TOKEN Cloudflare API token (required) + CLOUDFLARE_ZONE_ID Cloudflare zone ID (required) + """ + ) + + # Action group - mutually exclusive + action_group = parser.add_mutually_exclusive_group(required=True) + action_group.add_argument( + "--list", + action="store_true", + help="List all deployments" + ) + action_group.add_argument( + "--subdomain", + type=str, + help="Subdomain to destroy" + ) + action_group.add_argument( + "--url", + type=str, + help="Full URL to destroy" + ) + action_group.add_argument( + "--config", + type=Path, + help="Path to deployment config file" + ) + + # Options + parser.add_argument( + "--yes", "-y", + action="store_true", + help="Skip confirmation prompts" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview destruction without making changes" + ) + + parser.add_argument( + "--keep-config", + action="store_true", + help="Keep deployment config file after destruction" + ) + + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default="INFO", + help="Logging level (default: INFO)" + ) + + return parser.parse_args() + + +def print_banner() -> None: + """Print destruction banner""" + console.print("\n[bold red]╔══════════════════════════════════════════════╗[/bold red]") + console.print("[bold red]║[/bold red] [bold white]Gitea Deployment Destroyer[/bold white] [bold red]║[/bold red]") + console.print("[bold red]╚══════════════════════════════════════════════╝[/bold red]\n") + + +def list_deployments(config_manager: DeploymentConfigManager) -> None: + """ + List all deployments + + Args: + config_manager: DeploymentConfigManager instance + """ + deployments = config_manager.list_deployments() + + if not deployments: + console.print("[yellow]No deployments found[/yellow]") + return + + table = Table(title="Active Deployments") + table.add_column("Subdomain", style="cyan") + table.add_column("URL", style="green") + table.add_column("Deployed", style="yellow") + table.add_column("Config File", style="blue") + + for config_file in deployments: + try: + metadata = config_manager.load_deployment(config_file) + table.add_row( + metadata.subdomain, + metadata.url, + metadata.deployment_timestamp, + config_file.name + ) + except Exception as e: + console.print(f"[red]Error loading {config_file}: {e}[/red]") + + console.print(table) + console.print(f"\n[bold]Total deployments: {len(deployments)}[/bold]\n") + + +def find_config( + args: argparse.Namespace, + config_manager: DeploymentConfigManager +) -> Optional[Path]: + """ + Find deployment config based on arguments + + Args: + args: CLI arguments + config_manager: DeploymentConfigManager instance + + Returns: + Path to config file or None + """ + if args.config: + return args.config if args.config.exists() else None + + if args.subdomain: + return config_manager.find_deployment_by_subdomain(args.subdomain) + + if args.url: + return config_manager.find_deployment_by_url(args.url) + + return None + + +def run_command(cmd: List[str], dry_run: bool = False) -> bool: + """ + Run a shell command + + Args: + cmd: Command and arguments + dry_run: If True, only print command + + Returns: + True if successful, False otherwise + """ + cmd_str = " ".join(cmd) + + if dry_run: + console.print(f"[dim]Would run: {cmd_str}[/dim]") + return True + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode != 0: + logging.warning(f"Command failed: {cmd_str}") + logging.debug(f"Error: {result.stderr}") + return False + return True + except subprocess.TimeoutExpired: + logging.error(f"Command timed out: {cmd_str}") + return False + except Exception as e: + logging.error(f"Failed to run command: {e}") + return False + + +def destroy_containers(metadata: DeploymentMetadata, dry_run: bool = False) -> bool: + """ + Stop and remove containers + + Args: + metadata: Deployment metadata + dry_run: If True, only preview + + Returns: + True if successful + """ + console.print("\n[bold yellow]═══ Destroying Containers ═══[/bold yellow]") + + success = True + + if metadata.containers: + for container in metadata.containers: + console.print(f"Stopping container: [cyan]{container}[/cyan]") + if not run_command(["docker", "stop", container], dry_run): + success = False + + console.print(f"Removing container: [cyan]{container}[/cyan]") + if not run_command(["docker", "rm", "-f", container], dry_run): + success = False + else: + # Try to stop by project name + console.print(f"Stopping docker-compose project: [cyan]{metadata.compose_project_name}[/cyan]") + if not run_command( + ["docker", "compose", "-p", metadata.compose_project_name, "down"], + dry_run + ): + success = False + + return success + + +def destroy_volumes(metadata: DeploymentMetadata, dry_run: bool = False) -> bool: + """ + Remove Docker volumes + + Args: + metadata: Deployment metadata + dry_run: If True, only preview + + Returns: + True if successful + """ + console.print("\n[bold yellow]═══ Destroying Volumes ═══[/bold yellow]") + + success = True + + if metadata.volumes: + for volume in metadata.volumes: + console.print(f"Removing volume: [cyan]{volume}[/cyan]") + if not run_command(["docker", "volume", "rm", "-f", volume], dry_run): + success = False + else: + # Try with project name + volumes = [ + f"{metadata.compose_project_name}_db_data", + f"{metadata.compose_project_name}_gitea_data" + ] + for volume in volumes: + console.print(f"Removing volume: [cyan]{volume}[/cyan]") + run_command(["docker", "volume", "rm", "-f", volume], dry_run) + + return success + + +def destroy_networks(metadata: DeploymentMetadata, dry_run: bool = False) -> bool: + """ + Remove Docker networks (except external ones) + + Args: + metadata: Deployment metadata + dry_run: If True, only preview + + Returns: + True if successful + """ + console.print("\n[bold yellow]═══ Destroying Networks ═══[/bold yellow]") + + success = True + + if metadata.networks: + for network in metadata.networks: + # Skip external networks + if network == "proxy": + console.print(f"Skipping external network: [cyan]{network}[/cyan]") + continue + + console.print(f"Removing network: [cyan]{network}[/cyan]") + if not run_command(["docker", "network", "rm", network], dry_run): + # Networks might not exist or be in use, don't fail + pass + + return success + + +def destroy_dns( + metadata: DeploymentMetadata, + dns_manager: DNSManager, + dry_run: bool = False +) -> bool: + """ + Remove DNS record + + Args: + metadata: Deployment metadata + dns_manager: DNSManager instance + dry_run: If True, only preview + + Returns: + True if successful + """ + console.print("\n[bold yellow]═══ Destroying DNS Record ═══[/bold yellow]") + + if not metadata.url: + console.print("[yellow]No URL found in metadata, skipping DNS cleanup[/yellow]") + return True + + console.print(f"Looking up DNS record: [cyan]{metadata.url}[/cyan]") + + if dry_run: + console.print("[dim]Would remove DNS record[/dim]") + return True + + try: + # Look up and remove by hostname to get the real record ID from Cloudflare + # This ensures we don't rely on potentially stale/fake IDs from the config + dns_manager.remove_record(metadata.url, dry_run=False) + console.print("[green]✓ DNS record removed[/green]") + return True + except DNSError as e: + console.print(f"[red]✗ Failed to remove DNS record: {e}[/red]") + return False + + +def destroy_deployment( + metadata: DeploymentMetadata, + config_path: Path, + args: argparse.Namespace, + dns_manager: DNSManager +) -> bool: + """ + Destroy a deployment + + Args: + metadata: Deployment metadata + config_path: Path to config file + args: CLI arguments + dns_manager: DNSManager instance + + Returns: + True if successful + """ + # Show deployment info + console.print("\n[bold]Deployment Information:[/bold]") + console.print(f" Subdomain: [cyan]{metadata.subdomain}[/cyan]") + console.print(f" URL: [cyan]{metadata.url}[/cyan]") + console.print(f" Project: [cyan]{metadata.compose_project_name}[/cyan]") + console.print(f" Deployed: [cyan]{metadata.deployment_timestamp}[/cyan]") + console.print(f" Containers: [cyan]{len(metadata.containers or [])}[/cyan]") + console.print(f" DNS Record ID: [cyan]{metadata.dns_record_id or 'N/A'}[/cyan]") + + if args.dry_run: + console.print("\n[bold yellow]━━━ DRY-RUN MODE: No changes will be made ━━━[/bold yellow]") + + # Confirm destruction + if not args.yes and not args.dry_run: + console.print() + if not Confirm.ask( + f"[bold red]Are you sure you want to destroy {metadata.url}?[/bold red]", + default=False + ): + console.print("\n[yellow]Destruction cancelled[/yellow]\n") + return False + + # Execute destruction + success = True + + # 1. Destroy containers + if not destroy_containers(metadata, args.dry_run): + success = False + + # 2. Destroy volumes + if not destroy_volumes(metadata, args.dry_run): + success = False + + # 3. Destroy networks + if not destroy_networks(metadata, args.dry_run): + success = False + + # 4. Destroy DNS + if not destroy_dns(metadata, dns_manager, args.dry_run): + success = False + + # 5. Delete config file + if not args.keep_config and not args.dry_run: + console.print("\n[bold yellow]═══ Deleting Config File ═══[/bold yellow]") + console.print(f"Deleting: [cyan]{config_path}[/cyan]") + try: + config_path.unlink() + console.print("[green]✓ Config file deleted[/green]") + except Exception as e: + console.print(f"[red]✗ Failed to delete config: {e}[/red]") + success = False + + return success + + +def main() -> NoReturn: + """ + Main entry point + + Exit codes: + 0: Success + 1: Failure + 2: Not found + """ + args = parse_args() + setup_logging(args.log_level) + + print_banner() + + config_manager = DeploymentConfigManager() + + # Handle list command + if args.list: + list_deployments(config_manager) + sys.exit(0) + + # Find deployment config + config_path = find_config(args, config_manager) + + if not config_path: + console.print("[red]✗ Deployment not found[/red]") + console.print("\nUse --list to see all deployments\n") + sys.exit(2) + + # Load deployment metadata + try: + metadata = config_manager.load_deployment(config_path) + except Exception as e: + console.print(f"[red]✗ Failed to load deployment config: {e}[/red]\n") + sys.exit(1) + + # Initialize DNS manager + import os + cloudflare_token = os.getenv("CLOUDFLARE_API_TOKEN") + cloudflare_zone = os.getenv("CLOUDFLARE_ZONE_ID") + + if not cloudflare_token or not cloudflare_zone: + console.print("[yellow]⚠ Cloudflare credentials not found[/yellow]") + console.print("[yellow] DNS record will not be removed[/yellow]") + console.print("[yellow] Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID to enable DNS cleanup[/yellow]\n") + dns_manager = None + else: + dns_manager = DNSManager(cloudflare_token, cloudflare_zone) + + # Destroy deployment + try: + success = destroy_deployment(metadata, config_path, args, dns_manager) + + if success or args.dry_run: + console.print("\n[bold green]╔══════════════════════════════════════════════╗[/bold green]") + if args.dry_run: + console.print("[bold green]║[/bold green] [bold white]✓ Dry-Run Complete![/bold white] [bold green]║[/bold green]") + else: + console.print("[bold green]║[/bold green] [bold white]✓ Destruction Successful![/bold white] [bold green]║[/bold green]") + console.print("[bold green]╚══════════════════════════════════════════════╝[/bold green]\n") + sys.exit(0) + else: + console.print("\n[bold yellow]╔══════════════════════════════════════════════╗[/bold yellow]") + console.print("[bold yellow]║[/bold yellow] [bold white]⚠ Destruction Partially Failed[/bold white] [bold yellow]║[/bold yellow]") + console.print("[bold yellow]╚══════════════════════════════════════════════╝[/bold yellow]\n") + console.print("[yellow]Some resources may not have been cleaned up.[/yellow]") + console.print("[yellow]Check the logs above for details.[/yellow]\n") + sys.exit(1) + + except KeyboardInterrupt: + console.print("\n[bold yellow]✗ Destruction interrupted by user[/bold yellow]\n") + sys.exit(130) + + except Exception as e: + console.print(f"\n[bold red]✗ Unexpected error: {e}[/bold red]\n") + logging.exception("Unexpected error") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/gitea/docker-compose.yml b/gitea/docker-compose.yml new file mode 100644 index 0000000..73cac52 --- /dev/null +++ b/gitea/docker-compose.yml @@ -0,0 +1,57 @@ +services: + postgres: + image: postgres:${POSTGRES_VERSION} + container_name: ${SUBDOMAIN}_db + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - db_data:/var/lib/postgresql/data + networks: + - internal + + gitea: + image: gitea/gitea:${GITEA_VERSION} + container_name: ${SUBDOMAIN}_gitea + restart: unless-stopped + depends_on: + - postgres + environment: + USER_UID: 1000 + USER_GID: 1000 + GITEA__database__DB_TYPE: postgres + GITEA__database__HOST: postgres:5432 + GITEA__database__NAME: ${DB_NAME} + GITEA__database__USER: ${DB_USER} + GITEA__database__PASSWD: ${DB_PASSWORD} + GITEA__server__DOMAIN: ${URL} + GITEA__server__SSH_DOMAIN: ${URL} + GITEA__server__ROOT_URL: https://${URL}/ + GITEA__security__INSTALL_LOCK: true + GITEA__service__DISABLE_REGISTRATION: ${DISABLE_REGISTRATION} + volumes: + - gitea_data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + labels: + - "traefik.enable=true" + - "traefik.http.routers.${SUBDOMAIN}.rule=Host(`${URL}`)" + - "traefik.http.routers.${SUBDOMAIN}.entrypoints=https" + - "traefik.http.routers.${SUBDOMAIN}.tls=true" + - "traefik.http.routers.${SUBDOMAIN}.tls.certresolver=letsencrypt" + - "traefik.http.services.${SUBDOMAIN}.loadbalancer.server.port=3000" + networks: + - proxy + - internal + +volumes: + db_data: + gitea_data: + +networks: + proxy: + external: true + internal: + internal: true diff --git a/gitea/gitea_deployer/__init__.py b/gitea/gitea_deployer/__init__.py new file mode 100644 index 0000000..b27051e --- /dev/null +++ b/gitea/gitea_deployer/__init__.py @@ -0,0 +1,8 @@ +""" +Gitea Deployment Automation + +Production-ready deployment system for Gitea with automated DNS, +environment generation, and health checking. +""" + +__version__ = "1.0.0" diff --git a/gitea/gitea_deployer/__pycache__/__init__.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..3175ff2 Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/__init__.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/config.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/config.cpython-39.pyc new file mode 100644 index 0000000..c81782b Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/config.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/deployment_config_manager.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/deployment_config_manager.cpython-39.pyc new file mode 100644 index 0000000..bbdd7e4 Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/deployment_config_manager.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/deployment_logger.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/deployment_logger.cpython-39.pyc new file mode 100644 index 0000000..8bd69ac Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/deployment_logger.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/dns_manager.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/dns_manager.cpython-39.pyc new file mode 100644 index 0000000..db2cdb2 Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/dns_manager.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/docker_manager.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/docker_manager.cpython-39.pyc new file mode 100644 index 0000000..b2d30f9 Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/docker_manager.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/env_generator.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/env_generator.cpython-39.pyc new file mode 100644 index 0000000..5af4e72 Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/env_generator.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/health.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/health.cpython-39.pyc new file mode 100644 index 0000000..5556aee Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/health.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/orchestrator.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/orchestrator.cpython-39.pyc new file mode 100644 index 0000000..151aded Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/orchestrator.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/__pycache__/webhooks.cpython-39.pyc b/gitea/gitea_deployer/__pycache__/webhooks.cpython-39.pyc new file mode 100644 index 0000000..bf4f0ea Binary files /dev/null and b/gitea/gitea_deployer/__pycache__/webhooks.cpython-39.pyc differ diff --git a/gitea/gitea_deployer/config.py b/gitea/gitea_deployer/config.py new file mode 100644 index 0000000..0a7690c --- /dev/null +++ b/gitea/gitea_deployer/config.py @@ -0,0 +1,187 @@ +""" +Configuration module for deployment settings + +Centralized configuration with validation from environment variables and CLI arguments +""" + +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +class ConfigurationError(Exception): + """Raised when configuration is invalid""" + pass + + +@dataclass +class DeploymentConfig: + """Main deployment configuration loaded from environment and CLI args""" + + # File paths (required - no defaults) + env_file: Path + docker_compose_file: Path + + # Cloudflare credentials (required - no defaults) + cloudflare_api_token: str = field(repr=False) # Hide in logs + cloudflare_zone_id: str + + # File paths (with defaults) + dict_file: Path = Path("/usr/share/dict/words") + + # Domain settings + base_domain: str = "merakit.my" + app_name: Optional[str] = None + + # Deployment options + dry_run: bool = False + max_retries: int = 3 + healthcheck_timeout: int = 60 # seconds + healthcheck_interval: int = 10 # seconds + verify_ssl: bool = False + + # Webhook settings (optional) + webhook_url: Optional[str] = None + webhook_timeout: int = 10 # seconds + webhook_retries: int = 3 + + # Logging + log_level: str = "INFO" + + @classmethod + def from_env_and_args(cls, args) -> "DeploymentConfig": + """ + Factory method to create config from environment and CLI args + + Args: + args: argparse.Namespace with CLI arguments + + Returns: + DeploymentConfig instance + + Raises: + ConfigurationError: If required configuration is missing + """ + logger.debug("Loading configuration from environment and arguments") + + # Get Cloudflare credentials from environment + cloudflare_api_token = os.getenv('CLOUDFLARE_API_TOKEN') + cloudflare_zone_id = os.getenv('CLOUDFLARE_ZONE_ID') + + if not cloudflare_api_token: + raise ConfigurationError( + "CLOUDFLARE_API_TOKEN environment variable is required" + ) + + if not cloudflare_zone_id: + raise ConfigurationError( + "CLOUDFLARE_ZONE_ID environment variable is required" + ) + + # Get optional webhook URL from environment or args + webhook_url = ( + getattr(args, 'webhook_url', None) + or os.getenv('DEPLOYMENT_WEBHOOK_URL') + ) + + # Get optional settings from environment with defaults + max_retries = int(os.getenv('DEPLOYMENT_MAX_RETRIES', args.max_retries)) + healthcheck_timeout = int( + os.getenv('DEPLOYMENT_HEALTHCHECK_TIMEOUT', '60') + ) + healthcheck_interval = int( + os.getenv('DEPLOYMENT_HEALTHCHECK_INTERVAL', '10') + ) + + config = cls( + env_file=args.env_file, + docker_compose_file=args.compose_file, + dict_file=Path("/usr/share/dict/words"), + cloudflare_api_token=cloudflare_api_token, + cloudflare_zone_id=cloudflare_zone_id, + base_domain="merakit.my", + app_name=None, + dry_run=args.dry_run, + max_retries=max_retries, + healthcheck_timeout=healthcheck_timeout, + healthcheck_interval=healthcheck_interval, + verify_ssl=not args.no_verify_ssl, + webhook_url=webhook_url, + webhook_timeout=10, + webhook_retries=3, + log_level=args.log_level + ) + + logger.debug(f"Configuration loaded: {config}") + return config + + def validate(self) -> None: + """ + Validate configuration completeness and correctness + + Raises: + ConfigurationError: If configuration is invalid + """ + logger.debug("Validating configuration") + + # Validate file paths exist + if not self.env_file.exists(): + raise ConfigurationError(f"Env file not found: {self.env_file}") + + if not self.docker_compose_file.exists(): + raise ConfigurationError( + f"Docker compose file not found: {self.docker_compose_file}" + ) + + if not self.dict_file.exists(): + raise ConfigurationError( + f"Dictionary file not found: {self.dict_file}. " + "Install 'words' package or ensure /usr/share/dict/words exists." + ) + + # Validate numeric ranges + if self.max_retries < 1: + raise ConfigurationError( + f"max_retries must be >= 1, got: {self.max_retries}" + ) + + if self.healthcheck_timeout < 1: + raise ConfigurationError( + f"healthcheck_timeout must be >= 1, got: {self.healthcheck_timeout}" + ) + + if self.healthcheck_interval < 1: + raise ConfigurationError( + f"healthcheck_interval must be >= 1, got: {self.healthcheck_interval}" + ) + + if self.healthcheck_interval >= self.healthcheck_timeout: + raise ConfigurationError( + f"healthcheck_interval ({self.healthcheck_interval}) must be < " + f"healthcheck_timeout ({self.healthcheck_timeout})" + ) + + # Validate log level + valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + if self.log_level.upper() not in valid_log_levels: + raise ConfigurationError( + f"Invalid log_level: {self.log_level}. " + f"Must be one of: {', '.join(valid_log_levels)}" + ) + + logger.debug("Configuration validation successful") + + def __repr__(self) -> str: + """String representation with masked sensitive values""" + return ( + f"DeploymentConfig(" + f"env_file={self.env_file}, " + f"dry_run={self.dry_run}, " + f"max_retries={self.max_retries}, " + f"cloudflare_api_token=*****, " + f"webhook_url={self.webhook_url})" + ) diff --git a/gitea/gitea_deployer/deployment_config_manager.py b/gitea/gitea_deployer/deployment_config_manager.py new file mode 100644 index 0000000..3d3b009 --- /dev/null +++ b/gitea/gitea_deployer/deployment_config_manager.py @@ -0,0 +1,153 @@ +""" +Deployment Configuration Manager + +Manages saving and loading deployment configurations for tracking and cleanup +""" + +import json +import logging +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + + +logger = logging.getLogger(__name__) + + +@dataclass +class DeploymentMetadata: + """Metadata for a single deployment""" + subdomain: str + url: str + domain: str + compose_project_name: str + db_name: str + db_user: str + deployment_timestamp: str + dns_record_id: Optional[str] = None + dns_ip: Optional[str] = None + containers: Optional[List[str]] = None + volumes: Optional[List[str]] = None + networks: Optional[List[str]] = None + env_file_path: Optional[str] = None + + +class DeploymentConfigManager: + """Manages deployment configuration persistence""" + + def __init__(self, config_dir: Path = Path("deployments")): + """ + Initialize deployment config manager + + Args: + config_dir: Directory to store deployment configs + """ + self.config_dir = config_dir + self.config_dir.mkdir(exist_ok=True) + self._logger = logging.getLogger(f"{__name__}.DeploymentConfigManager") + + def save_deployment(self, metadata: DeploymentMetadata) -> Path: + """ + Save deployment configuration to disk + + Args: + metadata: DeploymentMetadata instance + + Returns: + Path to saved config file + """ + # Create filename based on subdomain and timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{metadata.subdomain}_{timestamp}.json" + config_path = self.config_dir / filename + + # Convert to dict and save as JSON + config_data = asdict(metadata) + + with open(config_path, 'w') as f: + json.dump(config_data, f, indent=2) + + self._logger.info(f"Saved deployment config: {config_path}") + return config_path + + def load_deployment(self, config_file: Path) -> DeploymentMetadata: + """ + Load deployment configuration from disk + + Args: + config_file: Path to config file + + Returns: + DeploymentMetadata instance + + Raises: + FileNotFoundError: If config file doesn't exist + ValueError: If config file is invalid + """ + if not config_file.exists(): + raise FileNotFoundError(f"Config file not found: {config_file}") + + with open(config_file, 'r') as f: + config_data = json.load(f) + + return DeploymentMetadata(**config_data) + + def list_deployments(self) -> List[Path]: + """ + List all deployment config files + + Returns: + List of config file paths sorted by modification time (newest first) + """ + config_files = list(self.config_dir.glob("*.json")) + return sorted(config_files, key=lambda p: p.stat().st_mtime, reverse=True) + + def find_deployment_by_subdomain(self, subdomain: str) -> Optional[Path]: + """ + Find the most recent deployment config for a subdomain + + Args: + subdomain: Subdomain to search for + + Returns: + Path to config file or None if not found + """ + matching_files = list(self.config_dir.glob(f"{subdomain}_*.json")) + if not matching_files: + return None + + # Return most recent + return max(matching_files, key=lambda p: p.stat().st_mtime) + + def find_deployment_by_url(self, url: str) -> Optional[Path]: + """ + Find deployment config by URL + + Args: + url: Full URL to search for + + Returns: + Path to config file or None if not found + """ + for config_file in self.list_deployments(): + try: + metadata = self.load_deployment(config_file) + if metadata.url == url: + return config_file + except (ValueError, json.JSONDecodeError) as e: + self._logger.warning(f"Failed to load config {config_file}: {e}") + continue + + return None + + def delete_deployment_config(self, config_file: Path) -> None: + """ + Delete deployment config file + + Args: + config_file: Path to config file + """ + if config_file.exists(): + config_file.unlink() + self._logger.info(f"Deleted deployment config: {config_file}") diff --git a/gitea/gitea_deployer/deployment_logger.py b/gitea/gitea_deployer/deployment_logger.py new file mode 100644 index 0000000..c96f08c --- /dev/null +++ b/gitea/gitea_deployer/deployment_logger.py @@ -0,0 +1,218 @@ +""" +Deployment logging module + +Handles writing deployment logs to success/failed directories +""" + +import logging +from datetime import datetime +from pathlib import Path +from typing import Optional + + +logger = logging.getLogger(__name__) + + +class DeploymentFileLogger: + """Logs deployment results to files""" + + def __init__(self, logs_dir: Path = Path("logs")): + """ + Initialize deployment file logger + + Args: + logs_dir: Base directory for logs (default: logs/) + """ + self._logs_dir = logs_dir + self._success_dir = logs_dir / "success" + self._failed_dir = logs_dir / "failed" + self._logger = logging.getLogger(f"{__name__}.DeploymentFileLogger") + + # Ensure directories exist + self._ensure_directories() + + def _ensure_directories(self) -> None: + """Create log directories if they don't exist""" + for directory in [self._success_dir, self._failed_dir]: + directory.mkdir(parents=True, exist_ok=True) + self._logger.debug(f"Ensured directory exists: {directory}") + + def _sanitize_url(self, url: str) -> str: + """ + Sanitize URL for use in filename + + Args: + url: URL to sanitize + + Returns: + Sanitized URL safe for filename + """ + # Remove protocol if present + url = url.replace("https://", "").replace("http://", "") + # Replace invalid filename characters + return url.replace("/", "_").replace(":", "_") + + def _generate_filename(self, status: str, url: str, timestamp: datetime) -> str: + """ + Generate log filename + + Format: success_url_date.txt or failed_url_date.txt + + Args: + status: 'success' or 'failed' + url: Deployment URL + timestamp: Deployment timestamp + + Returns: + Filename string + """ + sanitized_url = self._sanitize_url(url) + date_str = timestamp.strftime("%Y%m%d_%H%M%S") + return f"{status}_{sanitized_url}_{date_str}.txt" + + def log_success( + self, + url: str, + subdomain: str, + duration: float, + timestamp: Optional[datetime] = None + ) -> Path: + """ + Log successful deployment + + Args: + url: Deployment URL + subdomain: Subdomain used + duration: Deployment duration in seconds + timestamp: Deployment timestamp (default: now) + + Returns: + Path to created log file + """ + if timestamp is None: + timestamp = datetime.now() + + filename = self._generate_filename("success", url, timestamp) + log_file = self._success_dir / filename + + log_content = self._format_success_log( + url, subdomain, duration, timestamp + ) + + log_file.write_text(log_content) + self._logger.info(f"✓ Success log written: {log_file}") + + return log_file + + def log_failure( + self, + url: str, + subdomain: str, + error: str, + timestamp: Optional[datetime] = None + ) -> Path: + """ + Log failed deployment + + Args: + url: Deployment URL (may be empty if failed early) + subdomain: Subdomain used (may be empty if failed early) + error: Error message + timestamp: Deployment timestamp (default: now) + + Returns: + Path to created log file + """ + if timestamp is None: + timestamp = datetime.now() + + # Handle case where URL is empty (failed before URL generation) + log_url = url if url else "unknown" + filename = self._generate_filename("failed", log_url, timestamp) + log_file = self._failed_dir / filename + + log_content = self._format_failure_log( + url, subdomain, error, timestamp + ) + + log_file.write_text(log_content) + self._logger.info(f"✓ Failure log written: {log_file}") + + return log_file + + def _format_success_log( + self, + url: str, + subdomain: str, + duration: float, + timestamp: datetime + ) -> str: + """ + Format success log content + + Args: + url: Deployment URL + subdomain: Subdomain used + duration: Deployment duration in seconds + timestamp: Deployment timestamp + + Returns: + Formatted log content + """ + return f"""╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: {timestamp.strftime("%Y-%m-%d %H:%M:%S")} +Status: SUCCESS +URL: https://{url} +Subdomain: {subdomain} +Duration: {duration:.2f} seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. +""" + + def _format_failure_log( + self, + url: str, + subdomain: str, + error: str, + timestamp: datetime + ) -> str: + """ + Format failure log content + + Args: + url: Deployment URL (may be empty) + subdomain: Subdomain used (may be empty) + error: Error message + timestamp: Deployment timestamp + + Returns: + Formatted log content + """ + url_display = f"https://{url}" if url else "N/A (failed before URL generation)" + subdomain_display = subdomain if subdomain else "N/A" + + return f"""╔══════════════════════════════════════════════╗ +║ DEPLOYMENT FAILURE LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: {timestamp.strftime("%Y-%m-%d %H:%M:%S")} +Status: FAILED +URL: {url_display} +Subdomain: {subdomain_display} + +═══════════════════════════════════════════════ + +ERROR: +{error} + +═══════════════════════════════════════════════ + +Deployment failed. See error details above. +All changes have been rolled back. +""" diff --git a/gitea/gitea_deployer/dns_manager.py b/gitea/gitea_deployer/dns_manager.py new file mode 100644 index 0000000..7b77dc2 --- /dev/null +++ b/gitea/gitea_deployer/dns_manager.py @@ -0,0 +1,286 @@ +""" +DNS management module with Cloudflare API integration + +Direct Python API calls replacing cloudflare-add.sh and cloudflare-remove.sh +""" + +import logging +from dataclasses import dataclass +from typing import Dict, Optional + +import requests + + +logger = logging.getLogger(__name__) + + +class DNSError(Exception): + """Raised when DNS operations fail""" + pass + + +@dataclass +class DNSRecord: + """Represents a DNS record""" + record_id: str + hostname: str + ip: str + record_type: str + + +class DNSManager: + """Python wrapper for Cloudflare DNS operations""" + + def __init__(self, api_token: str, zone_id: str): + """ + Initialize DNS manager + + Args: + api_token: Cloudflare API token + zone_id: Cloudflare zone ID + """ + self._api_token = api_token + self._zone_id = zone_id + self._base_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" + self._headers = { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json" + } + self._logger = logging.getLogger(f"{__name__}.DNSManager") + + def check_record_exists(self, hostname: str) -> bool: + """ + Check if DNS record exists using Cloudflare API + + Args: + hostname: Fully qualified domain name + + Returns: + True if record exists, False otherwise + + Raises: + DNSError: If API call fails + """ + self._logger.debug(f"Checking if DNS record exists: {hostname}") + + try: + params = {"name": hostname} + response = requests.get( + self._base_url, + headers=self._headers, + params=params, + timeout=30 + ) + response.raise_for_status() + + data = response.json() + + if not data.get("success", False): + errors = data.get("errors", []) + raise DNSError(f"Cloudflare API error: {errors}") + + records = data.get("result", []) + exists = len(records) > 0 + + if exists: + self._logger.debug(f"DNS record exists: {hostname}") + else: + self._logger.debug(f"DNS record does not exist: {hostname}") + + return exists + + except requests.RequestException as e: + raise DNSError(f"Failed to check DNS record existence: {e}") from e + + def add_record( + self, + hostname: str, + ip: str, + dry_run: bool = False + ) -> DNSRecord: + """ + Add DNS A record + + Args: + hostname: Fully qualified domain name + ip: IP address for A record + dry_run: If True, only log what would be done + + Returns: + DNSRecord with record_id for rollback + + Raises: + DNSError: If API call fails + """ + if dry_run: + self._logger.info( + f"[DRY-RUN] Would add DNS record: {hostname} -> {ip}" + ) + return DNSRecord( + record_id="dry-run-id", + hostname=hostname, + ip=ip, + record_type="A" + ) + + self._logger.info(f"Adding DNS record: {hostname} -> {ip}") + + try: + payload = { + "type": "A", + "name": hostname, + "content": ip, + "ttl": 1, # Automatic TTL + "proxied": False # DNS only, not proxied through Cloudflare + } + + response = requests.post( + self._base_url, + headers=self._headers, + json=payload, + timeout=30 + ) + response.raise_for_status() + + data = response.json() + + if not data.get("success", False): + errors = data.get("errors", []) + raise DNSError(f"Cloudflare API error: {errors}") + + result = data.get("result", {}) + record_id = result.get("id") + + if not record_id: + raise DNSError("No record ID returned from Cloudflare API") + + self._logger.info(f"DNS record added successfully: {record_id}") + + return DNSRecord( + record_id=record_id, + hostname=hostname, + ip=ip, + record_type="A" + ) + + except requests.RequestException as e: + raise DNSError(f"Failed to add DNS record: {e}") from e + + def remove_record(self, hostname: str, dry_run: bool = False) -> None: + """ + Remove DNS record by hostname + + Args: + hostname: Fully qualified domain name + dry_run: If True, only log what would be done + + Raises: + DNSError: If API call fails + """ + if dry_run: + self._logger.info(f"[DRY-RUN] Would remove DNS record: {hostname}") + return + + self._logger.info(f"Removing DNS record: {hostname}") + + try: + # First, get the record ID + params = {"name": hostname} + response = requests.get( + self._base_url, + headers=self._headers, + params=params, + timeout=30 + ) + response.raise_for_status() + + data = response.json() + + if not data.get("success", False): + errors = data.get("errors", []) + raise DNSError(f"Cloudflare API error: {errors}") + + records = data.get("result", []) + + if not records: + self._logger.warning(f"No DNS record found for: {hostname}") + return + + # Remove all matching records (typically just one) + for record in records: + record_id = record.get("id") + if record_id: + self.remove_record_by_id(record_id, dry_run=False) + + except requests.RequestException as e: + raise DNSError(f"Failed to remove DNS record: {e}") from e + + def remove_record_by_id(self, record_id: str, dry_run: bool = False) -> None: + """ + Remove DNS record by ID (more reliable for rollback) + + Args: + record_id: Cloudflare DNS record ID + dry_run: If True, only log what would be done + + Raises: + DNSError: If API call fails + """ + if dry_run: + self._logger.info( + f"[DRY-RUN] Would remove DNS record by ID: {record_id}" + ) + return + + self._logger.info(f"Removing DNS record by ID: {record_id}") + + try: + url = f"{self._base_url}/{record_id}" + response = requests.delete( + url, + headers=self._headers, + timeout=30 + ) + + # Handle 404/405 gracefully - record doesn't exist or can't be deleted + if response.status_code in [404, 405]: + self._logger.warning( + f"DNS record {record_id} not found or cannot be deleted (may already be removed)" + ) + return + + response.raise_for_status() + + data = response.json() + + if not data.get("success", False): + errors = data.get("errors", []) + raise DNSError(f"Cloudflare API error: {errors}") + + self._logger.info(f"DNS record removed successfully: {record_id}") + + except requests.RequestException as e: + raise DNSError(f"Failed to remove DNS record: {e}") from e + + def get_public_ip(self) -> str: + """ + Get public IP address from external service + + Returns: + Public IP address as string + + Raises: + DNSError: If IP retrieval fails + """ + self._logger.debug("Retrieving public IP address") + + try: + response = requests.get("https://ipv4.icanhazip.com", timeout=10) + response.raise_for_status() + ip = response.text.strip() + + self._logger.debug(f"Public IP: {ip}") + return ip + + except requests.RequestException as e: + raise DNSError(f"Failed to retrieve public IP: {e}") from e diff --git a/gitea/gitea_deployer/docker_manager.py b/gitea/gitea_deployer/docker_manager.py new file mode 100644 index 0000000..27a3a30 --- /dev/null +++ b/gitea/gitea_deployer/docker_manager.py @@ -0,0 +1,276 @@ +""" +Docker management module + +Wrapper for Docker Compose operations with validation and error handling +""" + +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import List + + +logger = logging.getLogger(__name__) + + +class DockerError(Exception): + """Raised when Docker operations fail""" + pass + + +@dataclass +class ContainerInfo: + """Information about a running container""" + container_id: str + name: str + status: str + + +class DockerManager: + """Docker Compose operations wrapper""" + + def __init__(self, compose_file: Path, env_file: Path): + """ + Initialize Docker manager + + Args: + compose_file: Path to docker-compose.yml + env_file: Path to .env file + """ + self._compose_file = compose_file + self._env_file = env_file + self._logger = logging.getLogger(f"{__name__}.DockerManager") + + def _run_command( + self, + cmd: List[str], + check: bool = True, + capture_output: bool = True + ) -> subprocess.CompletedProcess: + """ + Run docker compose command + + Args: + cmd: Command list to execute + check: Whether to raise on non-zero exit + capture_output: Whether to capture stdout/stderr + + Returns: + CompletedProcess instance + + Raises: + DockerError: If command fails and check=True + """ + self._logger.debug(f"Running: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + check=check, + capture_output=capture_output, + text=True, + cwd=self._compose_file.parent + ) + return result + + except subprocess.CalledProcessError as e: + error_msg = f"Docker command failed: {e.stderr or e.stdout or str(e)}" + self._logger.error(error_msg) + raise DockerError(error_msg) from e + except FileNotFoundError as e: + raise DockerError( + f"Docker command not found. Is Docker installed? {e}" + ) from e + + def validate_compose_file(self) -> None: + """ + Validate docker-compose.yml syntax + + Raises: + DockerError: If compose file is invalid + """ + self._logger.debug("Validating docker-compose.yml") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "config", "--quiet" + ] + + try: + self._run_command(cmd) + self._logger.debug("docker-compose.yml is valid") + + except DockerError as e: + raise DockerError(f"Invalid docker-compose.yml: {e}") from e + + def pull_images(self, dry_run: bool = False) -> None: + """ + Pull required Docker images + + Args: + dry_run: If True, only log what would be done + + Raises: + DockerError: If pull fails + """ + if dry_run: + self._logger.info("[DRY-RUN] Would pull Docker images") + return + + self._logger.info("Pulling Docker images") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "pull" + ] + + self._run_command(cmd) + self._logger.info("Docker images pulled successfully") + + def start_services(self, dry_run: bool = False) -> List[ContainerInfo]: + """ + Start Docker Compose services + + Args: + dry_run: If True, only log what would be done + + Returns: + List of created containers for rollback + + Raises: + DockerError: If start fails + """ + if dry_run: + self._logger.info("[DRY-RUN] Would start Docker services") + return [] + + self._logger.info("Starting Docker services") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "up", "-d" + ] + + self._run_command(cmd) + + # Get container info for rollback + containers = self.get_container_status() + + self._logger.info( + f"Docker services started successfully: {len(containers)} containers" + ) + + return containers + + def stop_services(self, dry_run: bool = False) -> None: + """ + Stop Docker Compose services + + Args: + dry_run: If True, only log what would be done + + Raises: + DockerError: If stop fails + """ + if dry_run: + self._logger.info("[DRY-RUN] Would stop Docker services") + return + + self._logger.info("Stopping Docker services") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "down" + ] + + self._run_command(cmd) + self._logger.info("Docker services stopped successfully") + + def stop_services_and_remove_volumes(self, dry_run: bool = False) -> None: + """ + Stop services and remove volumes (full cleanup) + + Args: + dry_run: If True, only log what would be done + + Raises: + DockerError: If stop fails + """ + if dry_run: + self._logger.info("[DRY-RUN] Would stop Docker services and remove volumes") + return + + self._logger.info("Stopping Docker services and removing volumes") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "down", "-v" + ] + + self._run_command(cmd) + self._logger.info("Docker services stopped and volumes removed") + + def get_container_status(self) -> List[ContainerInfo]: + """ + Get status of containers for this project + + Returns: + List of ContainerInfo objects + + Raises: + DockerError: If status check fails + """ + self._logger.debug("Getting container status") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "ps", "-q" + ] + + result = self._run_command(cmd) + + container_ids = [ + cid.strip() + for cid in result.stdout.strip().split('\n') + if cid.strip() + ] + + containers = [] + for container_id in container_ids: + # Get container details + inspect_cmd = ["docker", "inspect", container_id, "--format", "{{.Name}}:{{.State.Status}}"] + try: + inspect_result = self._run_command(inspect_cmd) + name_status = inspect_result.stdout.strip() + if ':' in name_status: + name, status = name_status.split(':', 1) + # Remove leading slash from container name + name = name.lstrip('/') + containers.append(ContainerInfo( + container_id=container_id, + name=name, + status=status + )) + except DockerError: + # If inspect fails, just record the ID + containers.append(ContainerInfo( + container_id=container_id, + name="unknown", + status="unknown" + )) + + self._logger.debug(f"Found {len(containers)} containers") + return containers diff --git a/gitea/gitea_deployer/env_generator.py b/gitea/gitea_deployer/env_generator.py new file mode 100644 index 0000000..846e025 --- /dev/null +++ b/gitea/gitea_deployer/env_generator.py @@ -0,0 +1,390 @@ +""" +Environment generation module - replaces generate-env.sh + +Provides pure Python implementations for: +- Random word selection from dictionary +- Memorable password generation +- Environment file generation and manipulation +""" + +import logging +import os +import random +import re +import secrets +import shutil +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class EnvValues: + """Container for generated environment values""" + subdomain: str + domain: str + url: str + db_name: str + db_user: str + db_password: str + compose_project_name: str + + +class WordGenerator: + """Pure Python implementation of dictionary word selection""" + + def __init__(self, dict_file: Path): + """ + Initialize word generator + + Args: + dict_file: Path to dictionary file (e.g., /usr/share/dict/words) + """ + self._dict_file = dict_file + self._words_cache: Optional[List[str]] = None + self._logger = logging.getLogger(f"{__name__}.WordGenerator") + + def _load_and_filter_words(self) -> List[str]: + """ + Load dictionary and filter to 4-10 char lowercase words + + Returns: + List of filtered words + + Raises: + FileNotFoundError: If dictionary file doesn't exist + ValueError: If no valid words found + """ + if not self._dict_file.exists(): + raise FileNotFoundError(f"Dictionary file not found: {self._dict_file}") + + self._logger.debug(f"Loading words from {self._dict_file}") + + # Read and filter words matching pattern: ^[a-z]{4,10}$ + pattern = re.compile(r'^[a-z]{4,10}$') + words = [] + + with open(self._dict_file, 'r', encoding='utf-8') as f: + for line in f: + word = line.strip() + if pattern.match(word): + words.append(word) + + if not words: + raise ValueError(f"No valid words found in {self._dict_file}") + + self._logger.debug(f"Loaded {len(words)} valid words") + return words + + def get_random_word(self) -> str: + """ + Get single random word from filtered list + + Returns: + Random word (4-10 chars, lowercase) + """ + # Load and cache words on first use + if self._words_cache is None: + self._words_cache = self._load_and_filter_words() + + return random.choice(self._words_cache) + + def get_random_words(self, count: int) -> List[str]: + """ + Get multiple random words efficiently + + Args: + count: Number of words to retrieve + + Returns: + List of random words + """ + # Load and cache words on first use + if self._words_cache is None: + self._words_cache = self._load_and_filter_words() + + return random.choices(self._words_cache, k=count) + + +class PasswordGenerator: + """Generate memorable passwords from dictionary words""" + + def __init__(self, word_generator: WordGenerator): + """ + Initialize password generator + + Args: + word_generator: WordGenerator instance for word selection + """ + self._word_generator = word_generator + self._logger = logging.getLogger(f"{__name__}.PasswordGenerator") + + def generate_memorable_password(self, word_count: int = 3) -> str: + """ + Generate password from N random nouns joined by hyphens + + Args: + word_count: Number of words to use (default: 3) + + Returns: + Password string like "templon-infantly-yielding" + """ + words = self._word_generator.get_random_words(word_count) + password = '-'.join(words) + self._logger.debug(f"Generated {word_count}-word password") + return password + + def generate_random_string(self, length: int = 8) -> str: + """ + Generate alphanumeric random string using secrets module + + Args: + length: Length of string to generate (default: 8) + + Returns: + Random alphanumeric string + """ + # Use secrets for cryptographically secure random generation + # Generate hex and convert to lowercase alphanumeric + return secrets.token_hex(length // 2 + 1)[:length] + + +class EnvFileGenerator: + """Pure Python .env file manipulation (replaces bash sed logic)""" + + def __init__( + self, + env_file: Path, + word_generator: WordGenerator, + password_generator: PasswordGenerator, + base_domain: str = "merakit.my", + app_name: Optional[str] = None + ): + """ + Initialize environment file generator + + Args: + env_file: Path to .env file + word_generator: WordGenerator instance + password_generator: PasswordGenerator instance + base_domain: Base domain for URL generation (default: "merakit.my") + app_name: Application name (default: read from .env or "gitea") + """ + self._env_file = env_file + self._word_generator = word_generator + self._password_generator = password_generator + self._base_domain = base_domain + self._app_name = app_name + self._logger = logging.getLogger(f"{__name__}.EnvFileGenerator") + + def generate_values(self) -> EnvValues: + """ + Generate all environment values + + Returns: + EnvValues dataclass with all generated values + """ + self._logger.info("Generating environment values") + + # Read current .env to get app_name if not provided + current_env = self.read_current_env() + app_name = self._app_name or current_env.get('APP_NAME', 'gitea') + + # 1. Generate subdomain: two random words + word1 = self._word_generator.get_random_word() + word2 = self._word_generator.get_random_word() + subdomain = f"{word1}-{word2}" + + # 2. Construct URL + url = f"{subdomain}.{self._base_domain}" + + # 3. Generate random string for DB identifiers + random_str = self._password_generator.generate_random_string(8) + + # 4. Generate DB identifiers with truncation logic + db_name = self._generate_db_name(random_str, app_name, subdomain) + db_user = self._generate_db_user(random_str, app_name, subdomain) + + # 5. Generate password + db_password = self._password_generator.generate_memorable_password(3) + + self._logger.info(f"Generated values for subdomain: {subdomain}") + self._logger.debug(f"URL: {url}") + self._logger.debug(f"DB_NAME: {db_name}") + self._logger.debug(f"DB_USER: {db_user}") + + return EnvValues( + subdomain=subdomain, + domain=self._base_domain, + url=url, + db_name=db_name, + db_user=db_user, + db_password=db_password, + compose_project_name=subdomain + ) + + def _generate_db_name(self, random_str: str, app_name: str, subdomain: str) -> str: + """ + Format: angali_{random8}_{app}_{subdomain}, truncate to 64 chars + + Args: + random_str: Random 8-char string + app_name: Application name + subdomain: Subdomain with hyphens + + Returns: + Database name (max 64 chars) + """ + # Replace hyphens with underscores for DB compatibility + subdomain_safe = subdomain.replace('-', '_') + db_name = f"angali_{random_str}_{app_name}_{subdomain_safe}" + + # Truncate to PostgreSQL limit of 63 chars (64 - 1 for null terminator) + return db_name[:63] + + def _generate_db_user(self, random_str: str, app_name: str, subdomain: str) -> str: + """ + Format: angali_{random8}_{app}_{subdomain}, truncate to 63 chars + + Args: + random_str: Random 8-char string + app_name: Application name + subdomain: Subdomain with hyphens + + Returns: + Database username (max 63 chars) + """ + # Replace hyphens with underscores for DB compatibility + subdomain_safe = subdomain.replace('-', '_') + db_user = f"angali_{random_str}_{app_name}_{subdomain_safe}" + + # Truncate to PostgreSQL limit of 63 chars + return db_user[:63] + + def read_current_env(self) -> Dict[str, str]: + """ + Parse existing .env file into dict + + Returns: + Dictionary of environment variables + """ + env_dict = {} + + if not self._env_file.exists(): + self._logger.warning(f"Env file not found: {self._env_file}") + return env_dict + + with open(self._env_file, 'r') as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Parse KEY=VALUE format + if '=' in line: + key, value = line.split('=', 1) + # Remove quotes if present + value = value.strip('"').strip("'") + env_dict[key.strip()] = value + + self._logger.debug(f"Read {len(env_dict)} variables from {self._env_file}") + return env_dict + + def backup_env_file(self) -> Path: + """ + Create timestamped backup of .env file + + Returns: + Path to backup file + + Raises: + FileNotFoundError: If .env file doesn't exist + """ + if not self._env_file.exists(): + raise FileNotFoundError(f"Cannot backup non-existent file: {self._env_file}") + + # Create backup with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = self._env_file.parent / f"{self._env_file.name}.backup.{timestamp}" + + shutil.copy2(self._env_file, backup_path) + self._logger.info(f"Created backup: {backup_path}") + + return backup_path + + def update_env_file(self, values: EnvValues, dry_run: bool = False) -> None: + """ + Update .env file with new values (Python dict manipulation) + + Uses atomic write pattern: write to temp file, then rename + + Args: + values: EnvValues to write + dry_run: If True, only log what would be done + + Raises: + FileNotFoundError: If .env file doesn't exist + """ + if not self._env_file.exists(): + raise FileNotFoundError(f"Env file not found: {self._env_file}") + + if dry_run: + self._logger.info(f"[DRY-RUN] Would update {self._env_file} with:") + for key, value in asdict(values).items(): + if 'password' in key.lower(): + self._logger.info(f" {key.upper()}=********") + else: + self._logger.info(f" {key.upper()}={value}") + return + + # Read current env + current_env = self.read_current_env() + + # Update with new values + current_env.update({ + 'COMPOSE_PROJECT_NAME': values.compose_project_name, + 'SUBDOMAIN': values.subdomain, + 'DOMAIN': values.domain, + 'URL': values.url, + 'DB_NAME': values.db_name, + 'DB_USER': values.db_user, + 'DB_PASSWORD': values.db_password + }) + + # Write atomically: write to temp file, then rename + temp_file = self._env_file.parent / f"{self._env_file.name}.tmp" + + try: + with open(temp_file, 'w') as f: + for key, value in current_env.items(): + f.write(f"{key}={value}\n") + + # Atomic rename + os.replace(temp_file, self._env_file) + self._logger.info(f"Updated {self._env_file} successfully") + + except Exception as e: + # Cleanup temp file on error + if temp_file.exists(): + temp_file.unlink() + raise RuntimeError(f"Failed to update env file: {e}") from e + + def restore_env_file(self, backup_path: Path) -> None: + """ + Restore .env from backup + + Args: + backup_path: Path to backup file + + Raises: + FileNotFoundError: If backup file doesn't exist + """ + if not backup_path.exists(): + raise FileNotFoundError(f"Backup file not found: {backup_path}") + + shutil.copy2(backup_path, self._env_file) + self._logger.info(f"Restored {self._env_file} from {backup_path}") diff --git a/gitea/gitea_deployer/health.py b/gitea/gitea_deployer/health.py new file mode 100644 index 0000000..7b4ad68 --- /dev/null +++ b/gitea/gitea_deployer/health.py @@ -0,0 +1,128 @@ +""" +Health check module + +HTTP health checking with retry logic and progress indicators +""" + +import logging +import time + +import requests + + +logger = logging.getLogger(__name__) + + +class HealthCheckError(Exception): + """Raised when health check fails""" + pass + + +class HealthChecker: + """HTTP health check with retry logic""" + + def __init__( + self, + timeout: int, + interval: int, + verify_ssl: bool + ): + """ + Initialize health checker + + Args: + timeout: Total timeout in seconds + interval: Check interval in seconds + verify_ssl: Whether to verify SSL certificates + """ + self._timeout = timeout + self._interval = interval + self._verify_ssl = verify_ssl + self._logger = logging.getLogger(f"{__name__}.HealthChecker") + + def check_health(self, url: str, dry_run: bool = False) -> bool: + """ + Perform health check with retries + + Args: + url: URL to check (e.g., https://example.com) + dry_run: If True, only log what would be done + + Returns: + True if health check passed, False otherwise + """ + if dry_run: + self._logger.info(f"[DRY-RUN] Would check health of {url}") + return True + + self._logger.info( + f"Checking health of {url} for up to {self._timeout} seconds" + ) + + start_time = time.time() + attempt = 0 + + while True: + attempt += 1 + elapsed = time.time() - start_time + + if elapsed > self._timeout: + self._logger.error( + f"Health check timed out after {elapsed:.1f} seconds " + f"({attempt} attempts)" + ) + return False + + # Perform single check + if self._single_check(url): + self._logger.info( + f"Health check passed after {elapsed:.1f} seconds " + f"({attempt} attempts)" + ) + return True + + # Wait before next attempt + remaining = self._timeout - elapsed + if remaining > 0: + wait_time = min(self._interval, remaining) + self._logger.debug( + f"Attempt {attempt} failed, retrying in {wait_time:.1f}s " + f"(elapsed: {elapsed:.1f}s, timeout: {self._timeout}s)" + ) + time.sleep(wait_time) + else: + # No time remaining + self._logger.error(f"Health check timed out after {attempt} attempts") + return False + + def _single_check(self, url: str) -> bool: + """ + Single health check attempt + + Args: + url: URL to check + + Returns: + True if valid HTTP response (2xx or 3xx) received, False otherwise + """ + try: + response = requests.get( + url, + timeout=5, + verify=self._verify_ssl, + allow_redirects=True + ) + + # Accept any 2xx or 3xx status code as valid + if 200 <= response.status_code < 400: + self._logger.debug(f"Health check successful: HTTP {response.status_code}") + return True + else: + self._logger.debug( + f"Health check failed: HTTP {response.status_code}" + ) + return False + + except requests.RequestException as e: + self._logger.debug(f"Health check failed: {type(e).__name__}: {e}") + return False diff --git a/gitea/gitea_deployer/orchestrator.py b/gitea/gitea_deployer/orchestrator.py new file mode 100644 index 0000000..2276cca --- /dev/null +++ b/gitea/gitea_deployer/orchestrator.py @@ -0,0 +1,626 @@ +""" +Deployment orchestration module + +Main deployment workflow with rollback tracking and execution +""" + +import logging +import shutil +import time +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + +from .config import DeploymentConfig +from .deployment_config_manager import DeploymentConfigManager, DeploymentMetadata +from .deployment_logger import DeploymentFileLogger +from .dns_manager import DNSError, DNSManager, DNSRecord +from .docker_manager import DockerError, DockerManager +from .env_generator import EnvFileGenerator, EnvValues, PasswordGenerator, WordGenerator +from .health import HealthCheckError, HealthChecker +from .webhooks import WebhookNotifier + + +logger = logging.getLogger(__name__) + + +class DeploymentError(Exception): + """Base exception for deployment errors""" + pass + + +class ValidationError(DeploymentError): + """Validation failed""" + pass + + +@dataclass +class DeploymentAction: + """Represents a single deployment action""" + action_type: str # 'dns_added', 'containers_started', 'env_updated' + timestamp: datetime + details: Dict[str, Any] + rollback_data: Dict[str, Any] + + +class DeploymentTracker: + """Track deployment actions for rollback""" + + def __init__(self): + """Initialize deployment tracker""" + self._actions: List[DeploymentAction] = [] + self._logger = logging.getLogger(f"{__name__}.DeploymentTracker") + + def record_action(self, action: DeploymentAction) -> None: + """ + Record a deployment action + + Args: + action: DeploymentAction to record + """ + self._actions.append(action) + self._logger.debug(f"Recorded action: {action.action_type}") + + def get_actions(self) -> List[DeploymentAction]: + """ + Get all recorded actions + + Returns: + List of DeploymentAction objects + """ + return self._actions.copy() + + def clear(self) -> None: + """Clear tracking history""" + self._actions.clear() + self._logger.debug("Cleared action history") + + +class DeploymentOrchestrator: + """Main orchestrator coordinating all deployment steps""" + + def __init__(self, config: DeploymentConfig): + """ + Initialize deployment orchestrator + + Args: + config: DeploymentConfig instance + """ + self._config = config + self._logger = logging.getLogger(f"{__name__}.DeploymentOrchestrator") + + # Initialize components + self._word_generator = WordGenerator(config.dict_file) + self._password_generator = PasswordGenerator(self._word_generator) + self._env_generator = EnvFileGenerator( + config.env_file, + self._word_generator, + self._password_generator, + config.base_domain, + config.app_name + ) + self._dns_manager = DNSManager( + config.cloudflare_api_token, + config.cloudflare_zone_id + ) + self._docker_manager = DockerManager( + config.docker_compose_file, + config.env_file + ) + self._webhook_notifier = WebhookNotifier( + config.webhook_url, + config.webhook_timeout, + config.webhook_retries + ) + self._health_checker = HealthChecker( + config.healthcheck_timeout, + config.healthcheck_interval, + config.verify_ssl + ) + self._tracker = DeploymentTracker() + self._deployment_logger = DeploymentFileLogger() + self._config_manager = DeploymentConfigManager() + + def deploy(self) -> None: + """ + Main deployment workflow + + Raises: + DeploymentError: If deployment fails + """ + start_time = time.time() + env_values = None + dns_record_id = None + dns_ip = None + containers = [] + + try: + # Phase 1: Validation + self._phase_validate() + + # Phase 2: Environment Generation (with retry on DNS conflicts) + env_values = self._phase_generate_env_with_retries() + + # Send deployment_started webhook + self._webhook_notifier.deployment_started( + env_values.subdomain, + env_values.url + ) + + # Phase 3: DNS Setup + dns_record_id, dns_ip = self._phase_setup_dns(env_values) + + # Phase 4: Container Deployment + containers = self._phase_deploy_containers() + + # Phase 5: Health Check + self._phase_health_check(env_values.url) + + # Success + duration = time.time() - start_time + self._webhook_notifier.deployment_success( + env_values.subdomain, + env_values.url, + duration + ) + self._logger.info( + f"✓ Deployment successful! URL: https://{env_values.url} " + f"(took {duration:.1f}s)" + ) + + # Log success to file + self._deployment_logger.log_success( + env_values.url, + env_values.subdomain, + duration + ) + + # Save deployment configuration + self._save_deployment_config( + env_values, + dns_record_id, + dns_ip, + containers + ) + + except Exception as e: + self._logger.error(f"✗ Deployment failed: {e}") + + # Send failure webhook + if env_values: + self._webhook_notifier.deployment_failed( + env_values.subdomain, + str(e), + env_values.url + ) + else: + self._webhook_notifier.deployment_failed("", str(e), "") + + # Log failure to file + if env_values: + self._deployment_logger.log_failure( + env_values.url, + env_values.subdomain, + str(e) + ) + else: + self._deployment_logger.log_failure( + "", + "", + str(e) + ) + + # Rollback + self._logger.info("Starting rollback...") + self._rollback_all() + + raise DeploymentError(f"Deployment failed: {e}") from e + + def _phase_validate(self) -> None: + """ + Phase 1: Pre-deployment validation + + Raises: + ValidationError: If validation fails + """ + self._logger.info("═══ Phase 1: Validation ═══") + + # Check system dependencies + self._validate_dependencies() + + # Validate environment file + if not self._config.env_file.exists(): + raise ValidationError(f"Env file not found: {self._config.env_file}") + + # Validate Docker Compose file + try: + self._docker_manager.validate_compose_file() + except DockerError as e: + raise ValidationError(f"Invalid docker-compose.yml: {e}") from e + + # Check external Docker network exists + self._validate_docker_network("proxy") + + self._logger.info("✓ Validation complete") + + def _validate_dependencies(self) -> None: + """ + Validate system dependencies + + Raises: + ValidationError: If dependencies are missing + """ + import shutil as sh + + required_commands = ["docker", "curl"] + + for cmd in required_commands: + if not sh.which(cmd): + raise ValidationError( + f"Required command not found: {cmd}. " + f"Please install {cmd} and try again." + ) + + # Check Docker daemon is running + try: + import subprocess + result = subprocess.run( + ["docker", "info"], + capture_output=True, + timeout=5 + ) + if result.returncode != 0: + raise ValidationError( + "Docker daemon is not running. Please start Docker." + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + raise ValidationError(f"Failed to check Docker daemon: {e}") from e + + def _validate_docker_network(self, network_name: str) -> None: + """ + Check external Docker network exists + + Args: + network_name: Network name to check + + Raises: + ValidationError: If network doesn't exist + """ + import subprocess + + try: + result = subprocess.run( + ["docker", "network", "inspect", network_name], + capture_output=True, + timeout=5 + ) + if result.returncode != 0: + raise ValidationError( + f"Docker network '{network_name}' not found. " + f"Please create it with: docker network create {network_name}" + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + raise ValidationError( + f"Failed to check Docker network: {e}" + ) from e + + def _phase_generate_env_with_retries(self) -> EnvValues: + """ + Phase 2: Generate environment with DNS conflict retry + + Returns: + EnvValues with generated values + + Raises: + DeploymentError: If unable to generate unique subdomain + """ + self._logger.info("═══ Phase 2: Environment Generation ═══") + + for attempt in range(1, self._config.max_retries + 1): + # Generate new values + env_values = self._env_generator.generate_values() + + self._logger.info(f"Generated subdomain: {env_values.subdomain}") + + # Check DNS conflict + try: + if not self._dns_manager.check_record_exists(env_values.url): + # No conflict, proceed + self._logger.info(f"✓ Subdomain available: {env_values.subdomain}") + + # Create backup + backup_path = self._env_generator.backup_env_file() + + # Update .env file + self._env_generator.update_env_file( + env_values, + dry_run=self._config.dry_run + ) + + # Track for rollback + self._tracker.record_action(DeploymentAction( + action_type="env_updated", + timestamp=datetime.now(), + details={"env_values": asdict(env_values)}, + rollback_data={"backup_path": str(backup_path)} + )) + + return env_values + + else: + self._logger.warning( + f"✗ DNS conflict for {env_values.url}, " + f"regenerating... (attempt {attempt}/{self._config.max_retries})" + ) + + except DNSError as e: + self._logger.warning( + f"DNS check failed: {e}. " + f"Assuming no conflict and proceeding..." + ) + # If DNS check fails, proceed anyway (fail open) + backup_path = self._env_generator.backup_env_file() + self._env_generator.update_env_file( + env_values, + dry_run=self._config.dry_run + ) + self._tracker.record_action(DeploymentAction( + action_type="env_updated", + timestamp=datetime.now(), + details={"env_values": asdict(env_values)}, + rollback_data={"backup_path": str(backup_path)} + )) + return env_values + + raise DeploymentError( + f"Failed to generate unique subdomain after {self._config.max_retries} attempts" + ) + + def _phase_setup_dns(self, env_values: EnvValues) -> tuple: + """ + Phase 3: Add DNS record + + Args: + env_values: EnvValues with subdomain and URL + + Returns: + Tuple of (record_id, ip) + + Raises: + DNSError: If DNS setup fails + """ + self._logger.info("═══ Phase 3: DNS Setup ═══") + + # Get public IP + ip = self._dns_manager.get_public_ip() + self._logger.info(f"Public IP: {ip}") + + # Add DNS record + dns_record = self._dns_manager.add_record( + env_values.url, + ip, + dry_run=self._config.dry_run + ) + + self._logger.info(f"✓ DNS record added: {env_values.url} -> {ip}") + + # Track for rollback + self._tracker.record_action(DeploymentAction( + action_type="dns_added", + timestamp=datetime.now(), + details={"hostname": env_values.url, "ip": ip}, + rollback_data={"record_id": dns_record.record_id} + )) + + # Send webhook notification + self._webhook_notifier.dns_added(env_values.url, ip) + + return dns_record.record_id, ip + + def _phase_deploy_containers(self) -> List: + """ + Phase 4: Start Docker containers + + Returns: + List of container information + + Raises: + DockerError: If container deployment fails + """ + self._logger.info("═══ Phase 4: Container Deployment ═══") + + # Pull images + self._logger.info("Pulling Docker images...") + self._docker_manager.pull_images(dry_run=self._config.dry_run) + + # Start services + self._logger.info("Starting Docker services...") + containers = self._docker_manager.start_services( + dry_run=self._config.dry_run + ) + + self._logger.info( + f"✓ Docker services started: {len(containers)} containers" + ) + + # Track for rollback + self._tracker.record_action(DeploymentAction( + action_type="containers_started", + timestamp=datetime.now(), + details={"containers": [asdict(c) for c in containers]}, + rollback_data={} + )) + + return containers + + def _phase_health_check(self, url: str) -> None: + """ + Phase 5: Health check + + Args: + url: URL to check (without https://) + + Raises: + HealthCheckError: If health check fails + """ + self._logger.info("═══ Phase 5: Health Check ═══") + + health_url = f"https://{url}" + start_time = time.time() + + if not self._health_checker.check_health( + health_url, + dry_run=self._config.dry_run + ): + raise HealthCheckError(f"Health check failed for {health_url}") + + duration = time.time() - start_time + self._logger.info(f"✓ Health check passed (took {duration:.1f}s)") + + # Send webhook notification + self._webhook_notifier.health_check_passed(url, duration) + + def _rollback_all(self) -> None: + """Rollback all tracked actions in reverse order""" + actions = list(reversed(self._tracker.get_actions())) + + if not actions: + self._logger.info("No actions to rollback") + return + + self._logger.info(f"Rolling back {len(actions)} actions...") + + for action in actions: + try: + self._rollback_action(action) + except Exception as e: + # Log but don't fail rollback + self._logger.error( + f"Failed to rollback action {action.action_type}: {e}" + ) + + self._logger.info("Rollback complete") + + def _rollback_action(self, action: DeploymentAction) -> None: + """ + Rollback single action based on type + + Args: + action: DeploymentAction to rollback + """ + if action.action_type == "dns_added": + self._rollback_dns(action) + elif action.action_type == "containers_started": + self._rollback_containers(action) + elif action.action_type == "env_updated": + self._rollback_env(action) + else: + self._logger.warning(f"Unknown action type: {action.action_type}") + + def _rollback_dns(self, action: DeploymentAction) -> None: + """ + Rollback DNS changes + + Args: + action: DeploymentAction with DNS details + """ + record_id = action.rollback_data.get("record_id") + if record_id: + self._logger.info(f"Rolling back DNS record: {record_id}") + try: + self._dns_manager.remove_record_by_id( + record_id, + dry_run=self._config.dry_run + ) + self._logger.info("✓ DNS record removed") + except DNSError as e: + self._logger.error(f"Failed to remove DNS record: {e}") + + def _rollback_containers(self, action: DeploymentAction) -> None: + """ + Stop and remove containers + + Args: + action: DeploymentAction with container details + """ + self._logger.info("Rolling back Docker containers") + try: + self._docker_manager.stop_services(dry_run=self._config.dry_run) + self._logger.info("✓ Docker services stopped") + except DockerError as e: + self._logger.error(f"Failed to stop Docker services: {e}") + + def _rollback_env(self, action: DeploymentAction) -> None: + """ + Restore .env file from backup + + Args: + action: DeploymentAction with backup path + """ + backup_path_str = action.rollback_data.get("backup_path") + if backup_path_str: + backup_path = Path(backup_path_str) + if backup_path.exists(): + self._logger.info(f"Rolling back .env file from {backup_path}") + try: + self._env_generator.restore_env_file(backup_path) + self._logger.info("✓ .env file restored") + except Exception as e: + self._logger.error(f"Failed to restore .env file: {e}") + else: + self._logger.warning(f"Backup file not found: {backup_path}") + + def _save_deployment_config( + self, + env_values: EnvValues, + dns_record_id: str, + dns_ip: str, + containers: List + ) -> None: + """ + Save deployment configuration for later cleanup + + Args: + env_values: EnvValues with deployment info + dns_record_id: Cloudflare DNS record ID + dns_ip: IP address used in DNS + containers: List of container information + """ + try: + # Extract container names, volumes, and networks + container_names = [c.name for c in containers if hasattr(c, 'name')] + + # Get volumes and networks from docker-compose + volumes = [ + f"{env_values.compose_project_name}_db_data", + f"{env_values.compose_project_name}_gitea_data" + ] + + networks = [ + f"{env_values.compose_project_name}_internal" + ] + + # Create metadata + metadata = DeploymentMetadata( + subdomain=env_values.subdomain, + url=env_values.url, + domain=env_values.domain, + compose_project_name=env_values.compose_project_name, + db_name=env_values.db_name, + db_user=env_values.db_user, + deployment_timestamp=datetime.now().isoformat(), + dns_record_id=dns_record_id, + dns_ip=dns_ip, + containers=container_names, + volumes=volumes, + networks=networks, + env_file_path=str(self._config.env_file.absolute()) + ) + + # Save configuration + config_path = self._config_manager.save_deployment(metadata) + self._logger.info(f"✓ Deployment config saved: {config_path}") + + except Exception as e: + self._logger.warning(f"Failed to save deployment config: {e}") diff --git a/gitea/gitea_deployer/webhooks.py b/gitea/gitea_deployer/webhooks.py new file mode 100644 index 0000000..3616c2e --- /dev/null +++ b/gitea/gitea_deployer/webhooks.py @@ -0,0 +1,199 @@ +""" +Webhook notifications module + +Send deployment event notifications with retry logic +""" + +import logging +import time +from dataclasses import asdict, dataclass +from datetime import datetime +from typing import Any, Dict, Optional + +import requests + + +logger = logging.getLogger(__name__) + + +@dataclass +class WebhookEvent: + """Webhook event data""" + event_type: str # deployment_started, deployment_success, etc. + timestamp: str + subdomain: str + url: str + message: str + metadata: Dict[str, Any] + + +class WebhookNotifier: + """Send webhook notifications with retry logic""" + + def __init__( + self, + webhook_url: Optional[str], + timeout: int, + max_retries: int + ): + """ + Initialize webhook notifier + + Args: + webhook_url: Webhook URL to send notifications to (None to disable) + timeout: Request timeout in seconds + max_retries: Maximum number of retry attempts + """ + self._webhook_url = webhook_url + self._timeout = timeout + self._max_retries = max_retries + self._logger = logging.getLogger(f"{__name__}.WebhookNotifier") + + if not webhook_url: + self._logger.debug("Webhook notifications disabled (no URL configured)") + + def notify(self, event: WebhookEvent) -> None: + """ + Send webhook notification with retry + + Args: + event: WebhookEvent to send + + Note: + Failures are logged but don't raise exceptions to avoid + failing deployments due to webhook issues + """ + if not self._webhook_url: + return + + payload = asdict(event) + + self._logger.debug(f"Sending webhook: {event.event_type}") + + for attempt in range(1, self._max_retries + 1): + try: + response = requests.post( + self._webhook_url, + json=payload, + timeout=self._timeout + ) + response.raise_for_status() + + self._logger.debug( + f"Webhook sent successfully: {event.event_type} " + f"(attempt {attempt})" + ) + return + + except requests.RequestException as e: + self._logger.warning( + f"Webhook delivery failed (attempt {attempt}/{self._max_retries}): {e}" + ) + + if attempt < self._max_retries: + # Exponential backoff: 1s, 2s, 4s, etc. + backoff = 2 ** (attempt - 1) + self._logger.debug(f"Retrying in {backoff}s...") + time.sleep(backoff) + + self._logger.error( + f"Failed to deliver webhook after {self._max_retries} attempts: " + f"{event.event_type}" + ) + + def deployment_started(self, subdomain: str, url: str) -> None: + """ + Convenience method for deployment_started event + + Args: + subdomain: Subdomain being deployed + url: Full URL being deployed + """ + event = WebhookEvent( + event_type="deployment_started", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=subdomain, + url=url, + message=f"Deployment started for {url}", + metadata={} + ) + self.notify(event) + + def deployment_success( + self, + subdomain: str, + url: str, + duration: float + ) -> None: + """ + Convenience method for deployment_success event + + Args: + subdomain: Subdomain that was deployed + url: Full URL that was deployed + duration: Deployment duration in seconds + """ + event = WebhookEvent( + event_type="deployment_success", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=subdomain, + url=url, + message=f"Deployment successful for {url}", + metadata={"duration": round(duration, 2)} + ) + self.notify(event) + + def deployment_failed(self, subdomain: str, error: str, url: str = "") -> None: + """ + Convenience method for deployment_failed event + + Args: + subdomain: Subdomain that failed to deploy + error: Error message + url: Full URL (may be empty if deployment failed early) + """ + event = WebhookEvent( + event_type="deployment_failed", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=subdomain, + url=url, + message=f"Deployment failed: {error}", + metadata={"error": error} + ) + self.notify(event) + + def dns_added(self, hostname: str, ip: str) -> None: + """ + Convenience method for dns_added event + + Args: + hostname: Hostname that was added to DNS + ip: IP address the hostname points to + """ + event = WebhookEvent( + event_type="dns_added", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=hostname.split('.')[0], # Extract subdomain + url=hostname, + message=f"DNS record added for {hostname}", + metadata={"ip": ip} + ) + self.notify(event) + + def health_check_passed(self, url: str, duration: float) -> None: + """ + Convenience method for health_check_passed event + + Args: + url: URL that passed health check + duration: Time taken for health check in seconds + """ + event = WebhookEvent( + event_type="health_check_passed", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=url.split('.')[0].replace('https://', '').replace('http://', ''), + url=url, + message=f"Health check passed for {url}", + metadata={"duration": round(duration, 2)} + ) + self.notify(event) diff --git a/gitea/logs/success/success_artfully-copious.merakit.my_20251217_092143.txt b/gitea/logs/success/success_artfully-copious.merakit.my_20251217_092143.txt new file mode 100644 index 0000000..871ec54 --- /dev/null +++ b/gitea/logs/success/success_artfully-copious.merakit.my_20251217_092143.txt @@ -0,0 +1,14 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-17 09:21:43 +Status: SUCCESS +URL: https://artfully-copious.merakit.my +Subdomain: artfully-copious +Duration: 56.29 seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. diff --git a/gitea/logs/success/success_ascidiia-bridoon.merakit.my_20251217_160155.txt b/gitea/logs/success/success_ascidiia-bridoon.merakit.my_20251217_160155.txt new file mode 100644 index 0000000..1e17b6e --- /dev/null +++ b/gitea/logs/success/success_ascidiia-bridoon.merakit.my_20251217_160155.txt @@ -0,0 +1,14 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-17 16:01:55 +Status: SUCCESS +URL: https://ascidiia-bridoon.merakit.my +Subdomain: ascidiia-bridoon +Duration: 56.02 seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. diff --git a/gitea/requirements.txt b/gitea/requirements.txt new file mode 100644 index 0000000..8839043 --- /dev/null +++ b/gitea/requirements.txt @@ -0,0 +1,4 @@ +# Core dependencies +requests>=2.31.0 +rich>=13.7.0 +python-dotenv>=1.0.0 diff --git a/scripts/.claude/settings.local.json b/scripts/.claude/settings.local.json new file mode 100644 index 0000000..c098ddb --- /dev/null +++ b/scripts/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod:*)", + "Bash(echo:*)", + "Bash(curl:*)", + "Bash(if [ -z \"$CLOUDFLARE_API_TOKEN\" ])", + "Bash(then echo \"Token is empty\")", + "Bash(else echo \"Token exists with length: $#CLOUDFLARE_API_TOKEN\")", + "Bash(fi)", + "Bash(tee:*)", + "Bash(printf:*)", + "Bash(env)", + "Bash(./cloudflare-remove.sh:*)", + "Bash(bash:*)" + ] + } +} diff --git a/scripts/cloudflare-add.sh b/scripts/cloudflare-add.sh new file mode 100755 index 0000000..bcf7a77 --- /dev/null +++ b/scripts/cloudflare-add.sh @@ -0,0 +1,229 @@ +#!/bin/bash + +set -euo pipefail + +# Cloudflare API credentials +CF_API_TOKEN="${CLOUDFLARE_API_TOKEN:-}" +CF_ZONE_ID="${CLOUDFLARE_ZONE_ID:-}" + +# Dictionary files +DICT_FILE="/usr/share/dict/words" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +usage() { + echo "Usage: $0 --hostname --ip " + echo " $0 --random --domain --ip " + echo "" + echo "Options:" + echo " --hostname Specific hostname to add (e.g., test.example.com)" + echo " --random Generate random hostname" + echo " --domain Base domain for random hostname (e.g., example.org)" + echo " --ip IP address for A record" + echo "" + echo "Environment variables required:" + echo " CLOUDFLARE_API_TOKEN" + echo " CLOUDFLARE_ZONE_ID" + exit 1 +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" >&2 +} + +log_info() { + echo -e "${YELLOW}[INFO]${NC} $1" >&2 +} + +check_requirements() { + if [[ -z "$CF_API_TOKEN" ]]; then + log_error "CLOUDFLARE_API_TOKEN environment variable not set" + exit 1 + fi + + if [[ -z "$CF_ZONE_ID" ]]; then + log_error "CLOUDFLARE_ZONE_ID environment variable not set" + exit 1 + fi + + if ! command -v curl &> /dev/null; then + log_error "curl is required but not installed" + exit 1 + fi + + if ! command -v jq &> /dev/null; then + log_error "jq is required but not installed" + exit 1 + fi +} + +get_random_word() { + if [[ ! -f "$DICT_FILE" ]]; then + log_error "Dictionary file not found: $DICT_FILE" + exit 1 + fi + + # Get random word: lowercase, letters only, 3-10 characters + grep -E '^[a-z]{3,10}$' "$DICT_FILE" | shuf -n 1 +} + +generate_random_hostname() { + local domain=$1 + local word1=$(get_random_word) + local word2=$(get_random_word) + echo "${word1}-${word2}.${domain}" +} + +check_dns_exists() { + local hostname=$1 + + log_info "Checking if DNS record exists for: $hostname" + + local response=$(curl -s -X GET \ + "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records?name=${hostname}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json") + + local success=$(echo "$response" | jq -r '.success') + + if [[ "$success" != "true" ]]; then + log_error "Cloudflare API request failed" + echo "$response" | jq '.' + exit 1 + fi + + local count=$(echo "$response" | jq -r '.result | length') + + if [[ "$count" -gt 0 ]]; then + return 0 # Record exists + else + return 1 # Record does not exist + fi +} + +add_dns_record() { + local hostname=$1 + local ip=$2 + + log_info "Adding DNS record: $hostname -> $ip" + + local response=$(curl -s -X POST \ + "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "{ + \"type\": \"A\", + \"name\": \"${hostname}\", + \"content\": \"${ip}\", + \"ttl\": 1, + \"proxied\": false + }") + + local success=$(echo "$response" | jq -r '.success') + + if [[ "$success" == "true" ]]; then + log_success "DNS record added successfully: $hostname -> $ip" + echo "$response" | jq -r '.result | "Record ID: \(.id)"' + return 0 + else + log_error "Failed to add DNS record" + echo "$response" | jq '.' + return 1 + fi +} + +# Parse arguments +HOSTNAME="" +IP="" +RANDOM_MODE=false +DOMAIN="" + +while [[ $# -gt 0 ]]; do + case $1 in + --hostname) + HOSTNAME="$2" + shift 2 + ;; + --ip) + IP="$2" + shift 2 + ;; + --random) + RANDOM_MODE=true + shift + ;; + --domain) + DOMAIN="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac +done + +# Validate arguments +if [[ -z "$IP" ]]; then + log_error "IP address is required" + usage +fi + +if [[ "$RANDOM_MODE" == true ]]; then + if [[ -z "$DOMAIN" ]]; then + log_error "Domain is required when using --random mode" + usage + fi +else + if [[ -z "$HOSTNAME" ]]; then + log_error "Hostname is required" + usage + fi +fi + +# Check requirements +check_requirements + +# Generate or use provided hostname +if [[ "$RANDOM_MODE" == true ]]; then + MAX_ATTEMPTS=50 + attempt=1 + + while [[ $attempt -le $MAX_ATTEMPTS ]]; do + HOSTNAME=$(generate_random_hostname "$DOMAIN") + log_info "Generated hostname (attempt $attempt): $HOSTNAME" + + if ! check_dns_exists "$HOSTNAME"; then + log_success "Hostname is available: $HOSTNAME" + break + else + log_info "Hostname already exists, generating new one..." + attempt=$((attempt + 1)) + fi + done + + if [[ $attempt -gt $MAX_ATTEMPTS ]]; then + log_error "Failed to generate unique hostname after $MAX_ATTEMPTS attempts" + exit 1 + fi +else + if check_dns_exists "$HOSTNAME"; then + log_error "DNS record already exists for: $HOSTNAME" + exit 1 + fi +fi + +# Add the DNS record +add_dns_record "$HOSTNAME" "$IP" + diff --git a/scripts/cloudflare-remove.sh b/scripts/cloudflare-remove.sh new file mode 100755 index 0000000..dcc98aa --- /dev/null +++ b/scripts/cloudflare-remove.sh @@ -0,0 +1,327 @@ +#!/bin/bash + +set -euo pipefail + +# Cloudflare API credentials +CF_API_TOKEN="${CLOUDFLARE_API_TOKEN:-}" +CF_ZONE_ID="${CLOUDFLARE_ZONE_ID:-}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +usage() { + echo "Usage: $0 --hostname " + echo " $0 --record-id " + echo " $0 --all-matching " + echo "" + echo "Options:" + echo " --hostname Remove DNS record by hostname (e.g., test.example.com)" + echo " --record-id Remove DNS record by Cloudflare record ID" + echo " --all-matching Remove all DNS records matching pattern (e.g., '*.example.com')" + echo "" + echo "Environment variables required:" + echo " CLOUDFLARE_API_TOKEN" + echo " CLOUDFLARE_ZONE_ID" + exit 1 +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&2 +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" >&2 +} + +log_info() { + echo -e "${YELLOW}[INFO]${NC} $1" >&2 +} + +check_requirements() { + if [[ -z "$CF_API_TOKEN" ]]; then + log_error "CLOUDFLARE_API_TOKEN environment variable not set" + exit 1 + fi + + if [[ -z "$CF_ZONE_ID" ]]; then + log_error "CLOUDFLARE_ZONE_ID environment variable not set" + exit 1 + fi + + if ! command -v curl &> /dev/null; then + log_error "curl is required but not installed" + exit 1 + fi + + if ! command -v jq &> /dev/null; then + log_error "jq is required but not installed" + exit 1 + fi +} + +get_dns_records_by_hostname() { + local hostname=$1 + + log_info "Looking up DNS records for: $hostname" + + local response=$(curl -s -X GET \ + "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records?name=${hostname}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json") + + local success=$(echo "$response" | jq -r '.success') + + if [[ "$success" != "true" ]]; then + log_error "Cloudflare API request failed" + echo "$response" | jq '.' + exit 1 + fi + + echo "$response" +} + +get_all_dns_records() { + log_info "Fetching all DNS records in zone" + + local response=$(curl -s -X GET \ + "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records?per_page=1000" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json") + + local success=$(echo "$response" | jq -r '.success') + + if [[ "$success" != "true" ]]; then + log_error "Cloudflare API request failed" + echo "$response" | jq '.' + exit 1 + fi + + echo "$response" +} + +delete_dns_record() { + local record_id=$1 + local hostname=$2 + + log_info "Deleting DNS record: $hostname (ID: $record_id)" + + local response=$(curl -s -X DELETE \ + "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records/${record_id}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json") + + local success=$(echo "$response" | jq -r '.success') + + if [[ "$success" == "true" ]]; then + log_success "DNS record deleted successfully: $hostname (ID: $record_id)" + return 0 + else + log_error "Failed to delete DNS record: $hostname (ID: $record_id)" + echo "$response" | jq '.' + return 1 + fi +} + +delete_by_hostname() { + local hostname=$1 + + local response=$(get_dns_records_by_hostname "$hostname") + local count=$(echo "$response" | jq -r '.result | length') + + if [[ "$count" -eq 0 ]]; then + log_error "No DNS records found for: $hostname" + exit 1 + fi + + log_info "Found $count record(s) for: $hostname" + + local deleted=0 + local failed=0 + + while IFS= read -r record; do + local record_id=$(echo "$record" | jq -r '.id') + local record_name=$(echo "$record" | jq -r '.name') + local record_type=$(echo "$record" | jq -r '.type') + local record_content=$(echo "$record" | jq -r '.content') + + log_info "Found: $record_name ($record_type) -> $record_content" + + if delete_dns_record "$record_id" "$record_name"; then + deleted=$((deleted + 1)) + else + failed=$((failed + 1)) + fi + done < <(echo "$response" | jq -c '.result[]') + + log_info "Summary: $deleted deleted, $failed failed" + + if [[ $failed -gt 0 ]]; then + exit 1 + fi +} + +delete_by_record_id() { + local record_id=$1 + + # First, get the record details + log_info "Fetching record details for ID: $record_id" + + local response=$(curl -s -X GET \ + "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/dns_records/${record_id}" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json") + + local success=$(echo "$response" | jq -r '.success') + + if [[ "$success" != "true" ]]; then + log_error "Record not found or API request failed" + echo "$response" | jq '.' + exit 1 + fi + + local hostname=$(echo "$response" | jq -r '.result.name') + local record_type=$(echo "$response" | jq -r '.result.type') + local content=$(echo "$response" | jq -r '.result.content') + + log_info "Record found: $hostname ($record_type) -> $content" + + delete_dns_record "$record_id" "$hostname" +} + +delete_all_matching() { + local pattern=$1 + + log_info "Searching for records matching pattern: $pattern" + + local response=$(get_all_dns_records) + local all_records=$(echo "$response" | jq -c '.result[]') + + local matching_records=() + + while IFS= read -r record; do + local record_name=$(echo "$record" | jq -r '.name') + + # Simple pattern matching (supports * wildcard) + if [[ "$pattern" == *"*"* ]]; then + # Convert pattern to regex + local regex="${pattern//\*/.*}" + if [[ "$record_name" =~ ^${regex}$ ]]; then + matching_records+=("$record") + fi + else + # Exact match + if [[ "$record_name" == "$pattern" ]]; then + matching_records+=("$record") + fi + fi + done < <(echo "$all_records") + + local count=${#matching_records[@]} + + if [[ $count -eq 0 ]]; then + log_error "No DNS records found matching pattern: $pattern" + exit 1 + fi + + log_info "Found $count record(s) matching pattern: $pattern" + + # List matching records + for record in "${matching_records[@]}"; do + local record_name=$(echo "$record" | jq -r '.name') + local record_type=$(echo "$record" | jq -r '.type') + local content=$(echo "$record" | jq -r '.content') + log_info " - $record_name ($record_type) -> $content" + done + + # Confirm deletion + echo "" + read -p "Delete all $count record(s)? [y/N] " -n 1 -r + echo "" + + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Deletion cancelled" + exit 0 + fi + + local deleted=0 + local failed=0 + + for record in "${matching_records[@]}"; do + local record_id=$(echo "$record" | jq -r '.id') + local record_name=$(echo "$record" | jq -r '.name') + + if delete_dns_record "$record_id" "$record_name"; then + deleted=$((deleted + 1)) + else + failed=$((failed + 1)) + fi + done + + log_info "Summary: $deleted deleted, $failed failed" + + if [[ $failed -gt 0 ]]; then + exit 1 + fi +} + +# Parse arguments +HOSTNAME="" +RECORD_ID="" +PATTERN="" +MODE="" + +while [[ $# -gt 0 ]]; do + case $1 in + --hostname) + HOSTNAME="$2" + MODE="hostname" + shift 2 + ;; + --record-id) + RECORD_ID="$2" + MODE="record-id" + shift 2 + ;; + --all-matching) + PATTERN="$2" + MODE="pattern" + shift 2 + ;; + -h|--help) + usage + ;; + *) + log_error "Unknown option: $1" + usage + ;; + esac +done + +# Validate arguments +if [[ -z "$MODE" ]]; then + log_error "No deletion mode specified" + usage +fi + +# Check requirements +check_requirements + +# Execute based on mode +case $MODE in + hostname) + delete_by_hostname "$HOSTNAME" + ;; + record-id) + delete_by_record_id "$RECORD_ID" + ;; + pattern) + delete_all_matching "$PATTERN" + ;; + *) + log_error "Invalid mode: $MODE" + exit 1 + ;; +esac diff --git a/wordpress/.claude/settings.local.json b/wordpress/.claude/settings.local.json new file mode 100644 index 0000000..5bf99dc --- /dev/null +++ b/wordpress/.claude/settings.local.json @@ -0,0 +1,37 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod:*)", + "Bash(test:*)", + "Bash(python3:*)", + "Bash(docker network create:*)", + "Bash(bash:*)", + "Bash(cat:*)", + "Bash(docker compose config:*)", + "Bash(docker compose:*)", + "Bash(docker ps:*)", + "Bash(docker volume:*)", + "Bash(docker network:*)", + "Bash(docker exec:*)", + "Bash(docker inspect:*)", + "Bash(curl:*)", + "Bash(nslookup:*)", + "Bash(dig:*)", + "Bash(tree:*)", + "Bash(ls:*)", + "Bash(pip3 install:*)", + "Bash(find:*)", + "Bash(pip install:*)", + "Bash(python -m json.tool:*)", + "Bash(pkill:*)", + "Bash(python test_integration.py:*)", + "Bash(docker run:*)", + "Bash(redis-cli ping:*)", + "Bash(mkdir:*)", + "Bash(./destroy.py:*)", + "Bash(lsof:*)", + "Bash(netstat:*)", + "Bash(kill:*)" + ] + } +} diff --git a/wordpress/.env b/wordpress/.env new file mode 100644 index 0000000..91d83bd --- /dev/null +++ b/wordpress/.env @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=daidle-allotrylic +APP_NAME=wordpress +SUBDOMAIN=daidle-allotrylic +DOMAIN=merakit.my +URL=daidle-allotrylic.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_ddc6c26a_wordpress_daidle_allotrylic +DB_USER=angali_ddc6c26a_wordpress_daidle +DB_PASSWORD=emblazer-stairway-sweety +DB_ROOT_PASSWORD=idaein-silkgrower-tariffism +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup b/wordpress/.env.backup new file mode 100644 index 0000000..c585a79 --- /dev/null +++ b/wordpress/.env.backup @@ -0,0 +1,22 @@ +# App +COMPOSE_PROJECT_NAME=emyd-tartarian +APP_NAME=wordpress +SUBDOMAIN=emyd-tartarian +DOMAIN=merakit.my +URL=emyd-tartarian.merakit.my + +# Versions +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 + +# Database +DB_NAME=angali_guzagmpc_wordpress_emyd_tartarian +DB_USER=angali_guzagmpc_wordpress_emyd_t +DB_PASSWORD=creditrix-lutein-discolors +DB_ROOT_PASSWORD=sixtieths-murines-rabbling + +# WordPress +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M + diff --git a/wordpress/.env.backup.20251216_163858 b/wordpress/.env.backup.20251216_163858 new file mode 100644 index 0000000..00d61fd --- /dev/null +++ b/wordpress/.env.backup.20251216_163858 @@ -0,0 +1,22 @@ +# App +COMPOSE_PROJECT_NAME=litterers-apotropaic +APP_NAME=wordpress +SUBDOMAIN=litterers-apotropaic +DOMAIN=merakit.my +URL=litterers-apotropaic.merakit.my + +# Versions +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 + +# Database +DB_NAME=angali_xewzeu15_wordpress_litterers_apotropaic +DB_USER=angali_xewzeu15_wordpress_litter +DB_PASSWORD=templon-infantly-yielding +DB_ROOT_PASSWORD=beplumed-falus-tendry + +# WordPress +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M + diff --git a/wordpress/.env.backup.20251216_164443 b/wordpress/.env.backup.20251216_164443 new file mode 100644 index 0000000..8ef1d0a --- /dev/null +++ b/wordpress/.env.backup.20251216_164443 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=modif-sporidial +APP_NAME=wordpress +SUBDOMAIN=modif-sporidial +DOMAIN=merakit.my +URL=modif-sporidial.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_a08f84d9_wordpress_modif_sporidial +DB_USER=angali_a08f84d9_wordpress_modif_ +DB_PASSWORD=fumeroot-rummest-tiltboard +DB_ROOT_PASSWORD=unalike-prologizer-axonic +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251216_164618 b/wordpress/.env.backup.20251216_164618 new file mode 100644 index 0000000..8ef1d0a --- /dev/null +++ b/wordpress/.env.backup.20251216_164618 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=modif-sporidial +APP_NAME=wordpress +SUBDOMAIN=modif-sporidial +DOMAIN=merakit.my +URL=modif-sporidial.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_a08f84d9_wordpress_modif_sporidial +DB_USER=angali_a08f84d9_wordpress_modif_ +DB_PASSWORD=fumeroot-rummest-tiltboard +DB_ROOT_PASSWORD=unalike-prologizer-axonic +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251216_164814 b/wordpress/.env.backup.20251216_164814 new file mode 100644 index 0000000..8ef1d0a --- /dev/null +++ b/wordpress/.env.backup.20251216_164814 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=modif-sporidial +APP_NAME=wordpress +SUBDOMAIN=modif-sporidial +DOMAIN=merakit.my +URL=modif-sporidial.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_a08f84d9_wordpress_modif_sporidial +DB_USER=angali_a08f84d9_wordpress_modif_ +DB_PASSWORD=fumeroot-rummest-tiltboard +DB_ROOT_PASSWORD=unalike-prologizer-axonic +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251216_165109 b/wordpress/.env.backup.20251216_165109 new file mode 100644 index 0000000..b1e1170 --- /dev/null +++ b/wordpress/.env.backup.20251216_165109 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=dtente-yali +APP_NAME=wordpress +SUBDOMAIN=dtente-yali +DOMAIN=merakit.my +URL=dtente-yali.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_1fc30955_wordpress_dtente_yali +DB_USER=angali_1fc30955_wordpress_dtente +DB_PASSWORD=chronic-urophanic-subminimal +DB_ROOT_PASSWORD=determiner-reaks-cochleated +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251216_170611 b/wordpress/.env.backup.20251216_170611 new file mode 100644 index 0000000..15ac362 --- /dev/null +++ b/wordpress/.env.backup.20251216_170611 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=rappini-misseated +APP_NAME=wordpress +SUBDOMAIN=rappini-misseated +DOMAIN=merakit.my +URL=rappini-misseated.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_d6646fab_wordpress_rappini_misseated +DB_USER=angali_d6646fab_wordpress_rappin +DB_PASSWORD=painterish-tayir-mentalist +DB_ROOT_PASSWORD=venemous-haymow-overbend +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251216_184629 b/wordpress/.env.backup.20251216_184629 new file mode 100644 index 0000000..f8f9728 --- /dev/null +++ b/wordpress/.env.backup.20251216_184629 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=emetic-fuglemen +APP_NAME=wordpress +SUBDOMAIN=emetic-fuglemen +DOMAIN=merakit.my +URL=emetic-fuglemen.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_a8c12895_wordpress_emetic_fuglemen +DB_USER=angali_a8c12895_wordpress_emetic +DB_PASSWORD=heteroside-budder-chipyard +DB_ROOT_PASSWORD=overkeen-gangliated-describer +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251217_061213 b/wordpress/.env.backup.20251217_061213 new file mode 100644 index 0000000..2a5d35e --- /dev/null +++ b/wordpress/.env.backup.20251217_061213 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=exing-calcinator +APP_NAME=wordpress +SUBDOMAIN=exing-calcinator +DOMAIN=merakit.my +URL=exing-calcinator.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_f9404c19_wordpress_exing_calcinator +DB_USER=angali_f9404c19_wordpress_exing_ +DB_PASSWORD=blencorn-raniform-sectism +DB_ROOT_PASSWORD=florilege-haya-thin +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251217_061237 b/wordpress/.env.backup.20251217_061237 new file mode 100644 index 0000000..2a5d35e --- /dev/null +++ b/wordpress/.env.backup.20251217_061237 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=exing-calcinator +APP_NAME=wordpress +SUBDOMAIN=exing-calcinator +DOMAIN=merakit.my +URL=exing-calcinator.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_f9404c19_wordpress_exing_calcinator +DB_USER=angali_f9404c19_wordpress_exing_ +DB_PASSWORD=blencorn-raniform-sectism +DB_ROOT_PASSWORD=florilege-haya-thin +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251217_061526 b/wordpress/.env.backup.20251217_061526 new file mode 100644 index 0000000..2a5d35e --- /dev/null +++ b/wordpress/.env.backup.20251217_061526 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=exing-calcinator +APP_NAME=wordpress +SUBDOMAIN=exing-calcinator +DOMAIN=merakit.my +URL=exing-calcinator.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_f9404c19_wordpress_exing_calcinator +DB_USER=angali_f9404c19_wordpress_exing_ +DB_PASSWORD=blencorn-raniform-sectism +DB_ROOT_PASSWORD=florilege-haya-thin +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251217_065205 b/wordpress/.env.backup.20251217_065205 new file mode 100644 index 0000000..ea356ab --- /dev/null +++ b/wordpress/.env.backup.20251217_065205 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=ankylotic-unactable +APP_NAME=wordpress +SUBDOMAIN=ankylotic-unactable +DOMAIN=merakit.my +URL=ankylotic-unactable.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_6aa981f6_wordpress_ankylotic_unactable +DB_USER=angali_6aa981f6_wordpress_ankylo +DB_PASSWORD=mesoskelic-leopard-libertines +DB_ROOT_PASSWORD=lavature-barmkin-slipsoles +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251217_070700 b/wordpress/.env.backup.20251217_070700 new file mode 100644 index 0000000..fecf94a --- /dev/null +++ b/wordpress/.env.backup.20251217_070700 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=slenderly-spareable +APP_NAME=wordpress +SUBDOMAIN=slenderly-spareable +DOMAIN=merakit.my +URL=slenderly-spareable.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_94934db7_wordpress_slenderly_spareable +DB_USER=angali_94934db7_wordpress_slende +DB_PASSWORD=chaped-toothwort-transform +DB_ROOT_PASSWORD=outearn-testar-platinise +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/.env.backup.20251217_071039 b/wordpress/.env.backup.20251217_071039 new file mode 100644 index 0000000..fecf94a --- /dev/null +++ b/wordpress/.env.backup.20251217_071039 @@ -0,0 +1,14 @@ +COMPOSE_PROJECT_NAME=slenderly-spareable +APP_NAME=wordpress +SUBDOMAIN=slenderly-spareable +DOMAIN=merakit.my +URL=slenderly-spareable.merakit.my +WORDPRESS_VERSION=6.5-php8.2-apache +MARIADB_VERSION=11.3 +DB_NAME=angali_94934db7_wordpress_slenderly_spareable +DB_USER=angali_94934db7_wordpress_slende +DB_PASSWORD=chaped-toothwort-transform +DB_ROOT_PASSWORD=outearn-testar-platinise +WP_TABLE_PREFIX=wp_ +WP_MEMORY_LIMIT=256M +WP_MAX_MEMORY_LIMIT=256M diff --git a/wordpress/DESTROY.md b/wordpress/DESTROY.md new file mode 100644 index 0000000..77eb711 --- /dev/null +++ b/wordpress/DESTROY.md @@ -0,0 +1,354 @@ +# WordPress Deployment Destruction Guide + +This document explains how to destroy WordPress deployments using the config-based destruction system. + +## Overview + +The WordPress deployment system now automatically saves configuration for each successful deployment in the `deployments/` directory. These configurations can be used to cleanly destroy environments, removing all associated resources. + +## Deployment Config Repository + +Each successful deployment creates a JSON config file in `deployments/` containing: + +- **Subdomain and URL**: Deployment identifiers +- **Docker Resources**: Container names, volumes, networks +- **DNS Information**: Cloudflare record ID and IP address +- **Database Details**: Database name and user +- **Timestamps**: When the deployment was created + +Example config file: `deployments/my-site_20251217_120000.json` + +```json +{ + "subdomain": "my-site", + "url": "my-site.example.com", + "domain": "example.com", + "compose_project_name": "my-site", + "db_name": "wp_db_my_site", + "db_user": "wp_user_my_site", + "deployment_timestamp": "2025-12-17T12:00:00", + "dns_record_id": "abc123xyz", + "dns_ip": "203.0.113.1", + "containers": ["my-site_wp", "my-site_db"], + "volumes": ["my-site_db_data", "my-site_wp_data"], + "networks": ["my-site_internal"], + "env_file_path": "/path/to/.env" +} +``` + +## Using the Destroy Script + +### Prerequisites + +Set the following environment variables (required for DNS cleanup): + +```bash +export CLOUDFLARE_API_TOKEN="your_token" +export CLOUDFLARE_ZONE_ID="your_zone_id" +``` + +If these are not set, the script will still work but DNS records won't be removed. + +### List All Deployments + +View all tracked deployments: + +```bash +./destroy.py --list +``` + +This displays a table with: +- Subdomain +- URL +- Deployment timestamp +- Config file name + +### Destroy a Deployment + +#### By Subdomain (Recommended) + +```bash +./destroy.py --subdomain my-site +``` + +#### By URL + +```bash +./destroy.py --url my-site.example.com +``` + +#### By Config File + +```bash +./destroy.py --config deployments/my-site_20251217_120000.json +``` + +### Options + +#### Skip Confirmation + +Use `-y` or `--yes` to skip the confirmation prompt: + +```bash +./destroy.py --subdomain my-site --yes +``` + +#### Dry Run + +Preview what would be destroyed without making changes: + +```bash +./destroy.py --subdomain my-site --dry-run +``` + +#### Keep Config File + +By default, the config file is deleted after destruction. To keep it: + +```bash +./destroy.py --subdomain my-site --keep-config +``` + +#### Debug Mode + +Enable verbose logging: + +```bash +./destroy.py --subdomain my-site --log-level DEBUG +``` + +## What Gets Destroyed + +The destroy script removes the following resources in order: + +1. **Docker Containers** + - Stops all containers + - Removes containers forcefully + +2. **Docker Volumes** + - Removes database volume (e.g., `project_db_data`) + - Removes WordPress volume (e.g., `project_wp_data`) + +3. **Docker Networks** + - Removes internal networks + - Skips external networks like `proxy` + +4. **DNS Records** + - Removes the Cloudflare DNS record using the saved record ID + - Requires Cloudflare credentials + +5. **Config File** + - Deletes the deployment config file (unless `--keep-config` is used) + +## Safety Features + +### Confirmation Prompt + +By default, the script asks for confirmation before destroying: + +``` +Are you sure you want to destroy my-site.example.com? [y/N] +``` + +### Dry-Run Mode + +Test the destruction process without making changes: + +```bash +./destroy.py --subdomain my-site --dry-run +``` + +This shows exactly what commands would be executed. + +### Graceful Failures + +- If DNS credentials are missing, the script continues and skips DNS cleanup +- If a resource doesn't exist, the script logs a warning and continues +- Partial failures are reported, allowing manual cleanup of remaining resources + +## Exit Codes + +- `0`: Success +- `1`: Failure (partial or complete) +- `2`: Deployment not found +- `130`: User cancelled (Ctrl+C) + +## Examples + +### Example 1: Clean Destruction + +```bash +# List deployments +./destroy.py --list + +# Destroy with confirmation +./destroy.py --subdomain test-site + +# Output: +# Deployment Information: +# Subdomain: test-site +# URL: test-site.example.com +# Project: test-site +# Deployed: 2025-12-17T12:00:00 +# Containers: 2 +# DNS Record ID: abc123 +# +# Are you sure you want to destroy test-site.example.com? [y/N]: y +# +# ═══ Destroying Containers ═══ +# Stopping container: test-site_wp +# Removing container: test-site_wp +# ... +# +# ✓ Destruction Successful! +``` + +### Example 2: Batch Destruction + +Destroy multiple deployments in one command: + +```bash +#!/bin/bash +# destroy_all.sh - Destroy all test deployments + +for subdomain in test-1 test-2 test-3; do + ./destroy.py --subdomain "$subdomain" --yes +done +``` + +### Example 3: Conditional Destruction + +Destroy deployments older than 7 days: + +```bash +#!/bin/bash +# cleanup_old.sh + +for config in deployments/*.json; do + age=$(( ($(date +%s) - $(stat -c %Y "$config")) / 86400 )) + if [ $age -gt 7 ]; then + echo "Destroying $config (age: $age days)" + ./destroy.py --config "$config" --yes + fi +done +``` + +## Troubleshooting + +### "Deployment not found" + +The deployment config doesn't exist. Check available deployments: + +```bash +./destroy.py --list +``` + +### "Failed to remove DNS record" + +Possible causes: +- Cloudflare credentials not set +- DNS record already deleted +- Invalid record ID in config + +The script will continue and clean up other resources. + +### "Command failed: docker stop" + +Container might already be stopped. The script continues with removal. + +### Containers Still Running + +If containers aren't removed, manually stop them: + +```bash +docker ps | grep my-site +docker stop my-site_wp my-site_db +docker rm my-site_wp my-site_db +``` + +### Volumes Not Removed + +Volumes may be in use by other containers: + +```bash +docker volume ls | grep my-site +docker volume rm my-site_db_data my-site_wp_data +``` + +## Integration with Deployment + +The deployment orchestrator automatically saves configs after successful deployments. The config is saved in `deployments/` with the format: + +``` +deployments/{subdomain}_{timestamp}.json +``` + +This happens automatically in `wordpress_deployer/orchestrator.py` after Phase 5 (Health Check) completes successfully. + +## Advanced Usage + +### Manual Config Creation + +If you need to create a config manually for an existing deployment: + +```python +from wordpress_deployer.deployment_config_manager import ( + DeploymentConfigManager, + DeploymentMetadata +) + +manager = DeploymentConfigManager() + +metadata = DeploymentMetadata( + subdomain="my-site", + url="my-site.example.com", + domain="example.com", + compose_project_name="my-site", + db_name="wp_db", + db_user="wp_user", + deployment_timestamp="2025-12-17T12:00:00", + dns_record_id="abc123", + dns_ip="203.0.113.1", + containers=["my-site_wp", "my-site_db"], + volumes=["my-site_db_data", "my-site_wp_data"], + networks=["my-site_internal"], + env_file_path="/path/to/.env" +) + +manager.save_deployment(metadata) +``` + +### Programmatic Destruction + +Use the destroy script in Python: + +```python +import subprocess +import sys + +result = subprocess.run( + ["./destroy.py", "--subdomain", "my-site", "--yes"], + capture_output=True, + text=True +) + +if result.returncode == 0: + print("Destruction successful") +else: + print(f"Destruction failed: {result.stderr}") + sys.exit(1) +``` + +## Best Practices + +1. **Always Test with Dry-Run**: Use `--dry-run` first to preview destruction +2. **Keep Config Backups**: Use `--keep-config` for audit trails +3. **Verify Before Batch Operations**: List deployments before bulk destruction +4. **Monitor Partial Failures**: Check logs for resources that weren't cleaned up +5. **Set Cloudflare Credentials**: Always configure DNS credentials to ensure complete cleanup + +## See Also + +- [Main README](README.md) - Deployment documentation +- [deploy.py](deploy.py) - Deployment script +- [wordpress_deployer/](wordpress_deployer/) - Core deployment modules diff --git a/wordpress/deploy.py b/wordpress/deploy.py new file mode 100755 index 0000000..8eedba0 --- /dev/null +++ b/wordpress/deploy.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Production-ready WordPress deployment script + +Combines environment generation and deployment with: +- Configuration validation +- Rollback capability +- Dry-run mode +- Monitoring hooks +""" + +import argparse +import logging +import sys +from pathlib import Path +from typing import NoReturn + +from rich.console import Console +from rich.logging import RichHandler + +from wordpress_deployer.config import ConfigurationError, DeploymentConfig +from wordpress_deployer.orchestrator import DeploymentError, DeploymentOrchestrator + + +console = Console() + + +def setup_logging(log_level: str) -> None: + """ + Setup rich logging with colored output + + Args: + log_level: Logging level (DEBUG, INFO, WARNING, ERROR) + """ + logging.basicConfig( + level=log_level.upper(), + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler(console=console, rich_tracebacks=True, show_path=False)] + ) + + # Reduce noise from urllib3/requests + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("requests").setLevel(logging.WARNING) + + +def parse_args() -> argparse.Namespace: + """ + Parse CLI arguments + + Returns: + argparse.Namespace with parsed arguments + """ + parser = argparse.ArgumentParser( + description="Deploy WordPress with automatic environment generation", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Normal deployment + ./deploy.py + + # Dry-run mode (preview only) + ./deploy.py --dry-run + + # With webhook notifications + ./deploy.py --webhook-url https://hooks.slack.com/xxx + + # Debug mode + ./deploy.py --log-level DEBUG + + # Custom retry count + ./deploy.py --max-retries 5 + +Environment Variables: + CLOUDFLARE_API_TOKEN Cloudflare API token (required) + CLOUDFLARE_ZONE_ID Cloudflare zone ID (required) + DEPLOYMENT_WEBHOOK_URL Webhook URL for notifications (optional) + DEPLOYMENT_MAX_RETRIES Max retries for DNS conflicts (default: 3) + +For more information, see the documentation at: + /infra/templates/wordpress/README.md + """ + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview deployment without making changes" + ) + + parser.add_argument( + "--env-file", + type=Path, + default=Path(".env"), + help="Path to .env file (default: .env)" + ) + + parser.add_argument( + "--compose-file", + type=Path, + default=Path("docker-compose.yml"), + help="Path to docker-compose.yml (default: docker-compose.yml)" + ) + + parser.add_argument( + "--max-retries", + type=int, + default=3, + help="Max retries for DNS conflicts (default: 3)" + ) + + parser.add_argument( + "--webhook-url", + type=str, + help="Webhook URL for deployment notifications" + ) + + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default="INFO", + help="Logging level (default: INFO)" + ) + + parser.add_argument( + "--no-verify-ssl", + action="store_true", + help="Skip SSL verification for health checks (not recommended for production)" + ) + + return parser.parse_args() + + +def print_banner() -> None: + """Print deployment banner""" + console.print("\n[bold cyan]╔══════════════════════════════════════════════╗[/bold cyan]") + console.print("[bold cyan]║[/bold cyan] [bold white]WordPress Production Deployment[/bold white] [bold cyan]║[/bold cyan]") + console.print("[bold cyan]╚══════════════════════════════════════════════╝[/bold cyan]\n") + + +def main() -> NoReturn: + """ + Main entry point + + Exit codes: + 0: Success + 1: Deployment failure + 130: User interrupt (Ctrl+C) + """ + args = parse_args() + setup_logging(args.log_level) + + logger = logging.getLogger(__name__) + + print_banner() + + try: + # Load configuration + logger.debug("Loading configuration...") + config = DeploymentConfig.from_env_and_args(args) + config.validate() + logger.debug("Configuration loaded successfully") + + if config.dry_run: + console.print("[bold yellow]━━━ DRY-RUN MODE: No changes will be made ━━━[/bold yellow]\n") + + # Create orchestrator and deploy + orchestrator = DeploymentOrchestrator(config) + orchestrator.deploy() + + console.print("\n[bold green]╔══════════════════════════════════════════════╗[/bold green]") + console.print("[bold green]║[/bold green] [bold white]✓ Deployment Successful![/bold white] [bold green]║[/bold green]") + console.print("[bold green]╚══════════════════════════════════════════════╝[/bold green]\n") + + sys.exit(0) + + except ConfigurationError as e: + logger.error(f"Configuration error: {e}") + console.print(f"\n[bold red]✗ Configuration error: {e}[/bold red]\n") + console.print("[yellow]Please check your environment variables and configuration.[/yellow]") + console.print("[yellow]Required: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID[/yellow]\n") + sys.exit(1) + + except DeploymentError as e: + logger.error(f"Deployment failed: {e}") + console.print(f"\n[bold red]✗ Deployment failed: {e}[/bold red]\n") + sys.exit(1) + + except KeyboardInterrupt: + logger.warning("Deployment interrupted by user") + console.print("\n[bold yellow]✗ Deployment interrupted by user[/bold yellow]\n") + sys.exit(130) + + except Exception as e: + logger.exception("Unexpected error") + console.print(f"\n[bold red]✗ Unexpected error: {e}[/bold red]\n") + console.print("[yellow]Please check the logs above for more details.[/yellow]\n") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/wordpress/destroy.py b/wordpress/destroy.py new file mode 100755 index 0000000..4679cb6 --- /dev/null +++ b/wordpress/destroy.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +""" +WordPress Deployment Destroyer + +Destroys WordPress deployments based on saved deployment configurations +""" + +import argparse +import logging +import subprocess +import sys +from pathlib import Path +from typing import List, NoReturn, Optional + +from rich.console import Console +from rich.logging import RichHandler +from rich.prompt import Confirm +from rich.table import Table + +from wordpress_deployer.deployment_config_manager import ( + DeploymentConfigManager, + DeploymentMetadata +) +from wordpress_deployer.dns_manager import DNSError, DNSManager + + +console = Console() + + +def setup_logging(log_level: str) -> None: + """ + Setup rich logging with colored output + + Args: + log_level: Logging level (DEBUG, INFO, WARNING, ERROR) + """ + logging.basicConfig( + level=log_level.upper(), + format="%(message)s", + datefmt="[%X]", + handlers=[RichHandler(console=console, rich_tracebacks=True, show_path=False)] + ) + + +def parse_args() -> argparse.Namespace: + """ + Parse CLI arguments + + Returns: + argparse.Namespace with parsed arguments + """ + parser = argparse.ArgumentParser( + description="Destroy WordPress deployments", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # List all deployments + ./destroy.py --list + + # Destroy by subdomain + ./destroy.py --subdomain my-site + + # Destroy by URL + ./destroy.py --url my-site.example.com + + # Destroy by config file + ./destroy.py --config deployments/my-site_20231215_120000.json + + # Destroy without confirmation + ./destroy.py --subdomain my-site --yes + + # Dry-run mode (preview only) + ./destroy.py --subdomain my-site --dry-run + +Environment Variables: + CLOUDFLARE_API_TOKEN Cloudflare API token (required) + CLOUDFLARE_ZONE_ID Cloudflare zone ID (required) + """ + ) + + # Action group - mutually exclusive + action_group = parser.add_mutually_exclusive_group(required=True) + action_group.add_argument( + "--list", + action="store_true", + help="List all deployments" + ) + action_group.add_argument( + "--subdomain", + type=str, + help="Subdomain to destroy" + ) + action_group.add_argument( + "--url", + type=str, + help="Full URL to destroy" + ) + action_group.add_argument( + "--config", + type=Path, + help="Path to deployment config file" + ) + + # Options + parser.add_argument( + "--yes", "-y", + action="store_true", + help="Skip confirmation prompts" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview destruction without making changes" + ) + + parser.add_argument( + "--keep-config", + action="store_true", + help="Keep deployment config file after destruction" + ) + + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + default="INFO", + help="Logging level (default: INFO)" + ) + + return parser.parse_args() + + +def print_banner() -> None: + """Print destruction banner""" + console.print("\n[bold red]╔══════════════════════════════════════════════╗[/bold red]") + console.print("[bold red]║[/bold red] [bold white]WordPress Deployment Destroyer[/bold white] [bold red]║[/bold red]") + console.print("[bold red]╚══════════════════════════════════════════════╝[/bold red]\n") + + +def list_deployments(config_manager: DeploymentConfigManager) -> None: + """ + List all deployments + + Args: + config_manager: DeploymentConfigManager instance + """ + deployments = config_manager.list_deployments() + + if not deployments: + console.print("[yellow]No deployments found[/yellow]") + return + + table = Table(title="Active Deployments") + table.add_column("Subdomain", style="cyan") + table.add_column("URL", style="green") + table.add_column("Deployed", style="yellow") + table.add_column("Config File", style="blue") + + for config_file in deployments: + try: + metadata = config_manager.load_deployment(config_file) + table.add_row( + metadata.subdomain, + metadata.url, + metadata.deployment_timestamp, + config_file.name + ) + except Exception as e: + console.print(f"[red]Error loading {config_file}: {e}[/red]") + + console.print(table) + console.print(f"\n[bold]Total deployments: {len(deployments)}[/bold]\n") + + +def find_config( + args: argparse.Namespace, + config_manager: DeploymentConfigManager +) -> Optional[Path]: + """ + Find deployment config based on arguments + + Args: + args: CLI arguments + config_manager: DeploymentConfigManager instance + + Returns: + Path to config file or None + """ + if args.config: + return args.config if args.config.exists() else None + + if args.subdomain: + return config_manager.find_deployment_by_subdomain(args.subdomain) + + if args.url: + return config_manager.find_deployment_by_url(args.url) + + return None + + +def run_command(cmd: List[str], dry_run: bool = False) -> bool: + """ + Run a shell command + + Args: + cmd: Command and arguments + dry_run: If True, only print command + + Returns: + True if successful, False otherwise + """ + cmd_str = " ".join(cmd) + + if dry_run: + console.print(f"[dim]Would run: {cmd_str}[/dim]") + return True + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode != 0: + logging.warning(f"Command failed: {cmd_str}") + logging.debug(f"Error: {result.stderr}") + return False + return True + except subprocess.TimeoutExpired: + logging.error(f"Command timed out: {cmd_str}") + return False + except Exception as e: + logging.error(f"Failed to run command: {e}") + return False + + +def destroy_containers(metadata: DeploymentMetadata, dry_run: bool = False) -> bool: + """ + Stop and remove containers + + Args: + metadata: Deployment metadata + dry_run: If True, only preview + + Returns: + True if successful + """ + console.print("\n[bold yellow]═══ Destroying Containers ═══[/bold yellow]") + + success = True + + if metadata.containers: + for container in metadata.containers: + console.print(f"Stopping container: [cyan]{container}[/cyan]") + if not run_command(["docker", "stop", container], dry_run): + success = False + + console.print(f"Removing container: [cyan]{container}[/cyan]") + if not run_command(["docker", "rm", "-f", container], dry_run): + success = False + else: + # Try to stop by project name + console.print(f"Stopping docker-compose project: [cyan]{metadata.compose_project_name}[/cyan]") + if not run_command( + ["docker", "compose", "-p", metadata.compose_project_name, "down"], + dry_run + ): + success = False + + return success + + +def destroy_volumes(metadata: DeploymentMetadata, dry_run: bool = False) -> bool: + """ + Remove Docker volumes + + Args: + metadata: Deployment metadata + dry_run: If True, only preview + + Returns: + True if successful + """ + console.print("\n[bold yellow]═══ Destroying Volumes ═══[/bold yellow]") + + success = True + + if metadata.volumes: + for volume in metadata.volumes: + console.print(f"Removing volume: [cyan]{volume}[/cyan]") + if not run_command(["docker", "volume", "rm", "-f", volume], dry_run): + success = False + else: + # Try with project name + volumes = [ + f"{metadata.compose_project_name}_db_data", + f"{metadata.compose_project_name}_wp_data" + ] + for volume in volumes: + console.print(f"Removing volume: [cyan]{volume}[/cyan]") + run_command(["docker", "volume", "rm", "-f", volume], dry_run) + + return success + + +def destroy_networks(metadata: DeploymentMetadata, dry_run: bool = False) -> bool: + """ + Remove Docker networks (except external ones) + + Args: + metadata: Deployment metadata + dry_run: If True, only preview + + Returns: + True if successful + """ + console.print("\n[bold yellow]═══ Destroying Networks ═══[/bold yellow]") + + success = True + + if metadata.networks: + for network in metadata.networks: + # Skip external networks + if network == "proxy": + console.print(f"Skipping external network: [cyan]{network}[/cyan]") + continue + + console.print(f"Removing network: [cyan]{network}[/cyan]") + if not run_command(["docker", "network", "rm", network], dry_run): + # Networks might not exist or be in use, don't fail + pass + + return success + + +def destroy_dns( + metadata: DeploymentMetadata, + dns_manager: DNSManager, + dry_run: bool = False +) -> bool: + """ + Remove DNS record + + Args: + metadata: Deployment metadata + dns_manager: DNSManager instance + dry_run: If True, only preview + + Returns: + True if successful + """ + console.print("\n[bold yellow]═══ Destroying DNS Record ═══[/bold yellow]") + + if not metadata.url: + console.print("[yellow]No URL found in metadata, skipping DNS cleanup[/yellow]") + return True + + console.print(f"Looking up DNS record: [cyan]{metadata.url}[/cyan]") + + if dry_run: + console.print("[dim]Would remove DNS record[/dim]") + return True + + try: + # Look up and remove by hostname to get the real record ID from Cloudflare + # This ensures we don't rely on potentially stale/fake IDs from the config + dns_manager.remove_record(metadata.url, dry_run=False) + console.print("[green]✓ DNS record removed[/green]") + return True + except DNSError as e: + console.print(f"[red]✗ Failed to remove DNS record: {e}[/red]") + return False + + +def destroy_deployment( + metadata: DeploymentMetadata, + config_path: Path, + args: argparse.Namespace, + dns_manager: DNSManager +) -> bool: + """ + Destroy a deployment + + Args: + metadata: Deployment metadata + config_path: Path to config file + args: CLI arguments + dns_manager: DNSManager instance + + Returns: + True if successful + """ + # Show deployment info + console.print("\n[bold]Deployment Information:[/bold]") + console.print(f" Subdomain: [cyan]{metadata.subdomain}[/cyan]") + console.print(f" URL: [cyan]{metadata.url}[/cyan]") + console.print(f" Project: [cyan]{metadata.compose_project_name}[/cyan]") + console.print(f" Deployed: [cyan]{metadata.deployment_timestamp}[/cyan]") + console.print(f" Containers: [cyan]{len(metadata.containers or [])}[/cyan]") + console.print(f" DNS Record ID: [cyan]{metadata.dns_record_id or 'N/A'}[/cyan]") + + if args.dry_run: + console.print("\n[bold yellow]━━━ DRY-RUN MODE: No changes will be made ━━━[/bold yellow]") + + # Confirm destruction + if not args.yes and not args.dry_run: + console.print() + if not Confirm.ask( + f"[bold red]Are you sure you want to destroy {metadata.url}?[/bold red]", + default=False + ): + console.print("\n[yellow]Destruction cancelled[/yellow]\n") + return False + + # Execute destruction + success = True + + # 1. Destroy containers + if not destroy_containers(metadata, args.dry_run): + success = False + + # 2. Destroy volumes + if not destroy_volumes(metadata, args.dry_run): + success = False + + # 3. Destroy networks + if not destroy_networks(metadata, args.dry_run): + success = False + + # 4. Destroy DNS + if not destroy_dns(metadata, dns_manager, args.dry_run): + success = False + + # 5. Delete config file + if not args.keep_config and not args.dry_run: + console.print("\n[bold yellow]═══ Deleting Config File ═══[/bold yellow]") + console.print(f"Deleting: [cyan]{config_path}[/cyan]") + try: + config_path.unlink() + console.print("[green]✓ Config file deleted[/green]") + except Exception as e: + console.print(f"[red]✗ Failed to delete config: {e}[/red]") + success = False + + return success + + +def main() -> NoReturn: + """ + Main entry point + + Exit codes: + 0: Success + 1: Failure + 2: Not found + """ + args = parse_args() + setup_logging(args.log_level) + + print_banner() + + config_manager = DeploymentConfigManager() + + # Handle list command + if args.list: + list_deployments(config_manager) + sys.exit(0) + + # Find deployment config + config_path = find_config(args, config_manager) + + if not config_path: + console.print("[red]✗ Deployment not found[/red]") + console.print("\nUse --list to see all deployments\n") + sys.exit(2) + + # Load deployment metadata + try: + metadata = config_manager.load_deployment(config_path) + except Exception as e: + console.print(f"[red]✗ Failed to load deployment config: {e}[/red]\n") + sys.exit(1) + + # Initialize DNS manager + import os + cloudflare_token = os.getenv("CLOUDFLARE_API_TOKEN") + cloudflare_zone = os.getenv("CLOUDFLARE_ZONE_ID") + + if not cloudflare_token or not cloudflare_zone: + console.print("[yellow]⚠ Cloudflare credentials not found[/yellow]") + console.print("[yellow] DNS record will not be removed[/yellow]") + console.print("[yellow] Set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID to enable DNS cleanup[/yellow]\n") + dns_manager = None + else: + dns_manager = DNSManager(cloudflare_token, cloudflare_zone) + + # Destroy deployment + try: + success = destroy_deployment(metadata, config_path, args, dns_manager) + + if success or args.dry_run: + console.print("\n[bold green]╔══════════════════════════════════════════════╗[/bold green]") + if args.dry_run: + console.print("[bold green]║[/bold green] [bold white]✓ Dry-Run Complete![/bold white] [bold green]║[/bold green]") + else: + console.print("[bold green]║[/bold green] [bold white]✓ Destruction Successful![/bold white] [bold green]║[/bold green]") + console.print("[bold green]╚══════════════════════════════════════════════╝[/bold green]\n") + sys.exit(0) + else: + console.print("\n[bold yellow]╔══════════════════════════════════════════════╗[/bold yellow]") + console.print("[bold yellow]║[/bold yellow] [bold white]⚠ Destruction Partially Failed[/bold white] [bold yellow]║[/bold yellow]") + console.print("[bold yellow]╚══════════════════════════════════════════════╝[/bold yellow]\n") + console.print("[yellow]Some resources may not have been cleaned up.[/yellow]") + console.print("[yellow]Check the logs above for details.[/yellow]\n") + sys.exit(1) + + except KeyboardInterrupt: + console.print("\n[bold yellow]✗ Destruction interrupted by user[/bold yellow]\n") + sys.exit(130) + + except Exception as e: + console.print(f"\n[bold red]✗ Unexpected error: {e}[/bold red]\n") + logging.exception("Unexpected error") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/wordpress/docker-compose.yml b/wordpress/docker-compose.yml new file mode 100644 index 0000000..635809a --- /dev/null +++ b/wordpress/docker-compose.yml @@ -0,0 +1,56 @@ +services: + mariadb: + image: mariadb:${MARIADB_VERSION} + container_name: ${SUBDOMAIN}_db + restart: unless-stopped + environment: + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + - db_data:/var/lib/mysql + networks: + - internal + + wordpress: + image: wordpress:${WORDPRESS_VERSION} + container_name: ${SUBDOMAIN}_wp + restart: unless-stopped + depends_on: + - mariadb + environment: + WORDPRESS_DB_HOST: mariadb:3306 + WORDPRESS_DB_NAME: ${DB_NAME} + WORDPRESS_DB_USER: ${DB_USER} + WORDPRESS_DB_PASSWORD: ${DB_PASSWORD} + WORDPRESS_TABLE_PREFIX: ${WP_TABLE_PREFIX} + WORDPRESS_CONFIG_EXTRA: | + define('WP_MEMORY_LIMIT', '${WP_MEMORY_LIMIT}'); + define('WP_MAX_MEMORY_LIMIT', '${WP_MAX_MEMORY_LIMIT}'); + define('DISALLOW_FILE_EDIT', true); + define('AUTOMATIC_UPDATER_DISABLED', true); + define('FS_METHOD', 'direct'); + volumes: + - wp_data:/var/www/html + labels: + - "traefik.enable=true" + - "traefik.http.routers.${SUBDOMAIN}.rule=Host(`${URL}`)" + - "traefik.http.routers.${SUBDOMAIN}.entrypoints=https" + - "traefik.http.routers.${SUBDOMAIN}.tls=true" + - "traefik.http.routers.${SUBDOMAIN}.tls.certresolver=letsencrypt" + - "traefik.http.services.${SUBDOMAIN}.loadbalancer.server.port=80" + networks: + - proxy + - internal + +volumes: + db_data: + wp_data: + +networks: + proxy: + external: true + internal: + internal: true + diff --git a/wordpress/logs/failed/failed_caimito-hedgeless.merakit.my_20251217_070805.txt b/wordpress/logs/failed/failed_caimito-hedgeless.merakit.my_20251217_070805.txt new file mode 100644 index 0000000..eb29a5f --- /dev/null +++ b/wordpress/logs/failed/failed_caimito-hedgeless.merakit.my_20251217_070805.txt @@ -0,0 +1,18 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT FAILURE LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-17 07:08:05 +Status: FAILED +URL: https://caimito-hedgeless.merakit.my +Subdomain: caimito-hedgeless + +═══════════════════════════════════════════════ + +ERROR: +Health check failed for https://caimito-hedgeless.merakit.my + +═══════════════════════════════════════════════ + +Deployment failed. See error details above. +All changes have been rolled back. diff --git a/wordpress/logs/failed/failed_insuring-refocuses.merakit.my_20251217_061237.txt b/wordpress/logs/failed/failed_insuring-refocuses.merakit.my_20251217_061237.txt new file mode 100644 index 0000000..3ab1770 --- /dev/null +++ b/wordpress/logs/failed/failed_insuring-refocuses.merakit.my_20251217_061237.txt @@ -0,0 +1,18 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT FAILURE LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-17 06:12:37 +Status: FAILED +URL: https://insuring-refocuses.merakit.my +Subdomain: insuring-refocuses + +═══════════════════════════════════════════════ + +ERROR: +Failed to add DNS record: 401 Client Error: Unauthorized for url: https://api.cloudflare.com/client/v4/zones/7eb0d48b7e396e0cc8b06ac1a7fe667a/dns_records + +═══════════════════════════════════════════════ + +Deployment failed. See error details above. +All changes have been rolled back. diff --git a/wordpress/logs/failed/failed_juslted-doodlebug.merakit.my_20251217_061213.txt b/wordpress/logs/failed/failed_juslted-doodlebug.merakit.my_20251217_061213.txt new file mode 100644 index 0000000..e31ad78 --- /dev/null +++ b/wordpress/logs/failed/failed_juslted-doodlebug.merakit.my_20251217_061213.txt @@ -0,0 +1,18 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT FAILURE LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-17 06:12:13 +Status: FAILED +URL: https://juslted-doodlebug.merakit.my +Subdomain: juslted-doodlebug + +═══════════════════════════════════════════════ + +ERROR: +Failed to add DNS record: 401 Client Error: Unauthorized for url: https://api.cloudflare.com/client/v4/zones/7eb0d48b7e396e0cc8b06ac1a7fe667a/dns_records + +═══════════════════════════════════════════════ + +Deployment failed. See error details above. +All changes have been rolled back. diff --git a/wordpress/logs/success/success_ankylotic-unactable.merakit.my_20251217_061635.txt b/wordpress/logs/success/success_ankylotic-unactable.merakit.my_20251217_061635.txt new file mode 100644 index 0000000..4ed4366 --- /dev/null +++ b/wordpress/logs/success/success_ankylotic-unactable.merakit.my_20251217_061635.txt @@ -0,0 +1,14 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-17 06:16:35 +Status: SUCCESS +URL: https://ankylotic-unactable.merakit.my +Subdomain: ankylotic-unactable +Duration: 70.30 seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. diff --git a/wordpress/logs/success/success_daidle-allotrylic.merakit.my_20251217_071135.txt b/wordpress/logs/success/success_daidle-allotrylic.merakit.my_20251217_071135.txt new file mode 100644 index 0000000..9845bdc --- /dev/null +++ b/wordpress/logs/success/success_daidle-allotrylic.merakit.my_20251217_071135.txt @@ -0,0 +1,14 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-17 07:11:35 +Status: SUCCESS +URL: https://daidle-allotrylic.merakit.my +Subdomain: daidle-allotrylic +Duration: 57.28 seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. diff --git a/wordpress/logs/success/success_emetic-fuglemen.merakit.my_20251216_170709.txt b/wordpress/logs/success/success_emetic-fuglemen.merakit.my_20251216_170709.txt new file mode 100644 index 0000000..eeb4e46 --- /dev/null +++ b/wordpress/logs/success/success_emetic-fuglemen.merakit.my_20251216_170709.txt @@ -0,0 +1,14 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-16 17:07:09 +Status: SUCCESS +URL: https://emetic-fuglemen.merakit.my +Subdomain: emetic-fuglemen +Duration: 58.80 seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. diff --git a/wordpress/logs/success/success_exing-calcinator.merakit.my_20251216_184725.txt b/wordpress/logs/success/success_exing-calcinator.merakit.my_20251216_184725.txt new file mode 100644 index 0000000..81b89d5 --- /dev/null +++ b/wordpress/logs/success/success_exing-calcinator.merakit.my_20251216_184725.txt @@ -0,0 +1,14 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-16 18:47:25 +Status: SUCCESS +URL: https://exing-calcinator.merakit.my +Subdomain: exing-calcinator +Duration: 57.69 seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. diff --git a/wordpress/logs/success/success_slenderly-spareable.merakit.my_20251217_065302.txt b/wordpress/logs/success/success_slenderly-spareable.merakit.my_20251217_065302.txt new file mode 100644 index 0000000..abaf146 --- /dev/null +++ b/wordpress/logs/success/success_slenderly-spareable.merakit.my_20251217_065302.txt @@ -0,0 +1,14 @@ +╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: 2025-12-17 06:53:02 +Status: SUCCESS +URL: https://slenderly-spareable.merakit.my +Subdomain: slenderly-spareable +Duration: 58.05 seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. diff --git a/wordpress/requirements.txt b/wordpress/requirements.txt new file mode 100644 index 0000000..8839043 --- /dev/null +++ b/wordpress/requirements.txt @@ -0,0 +1,4 @@ +# Core dependencies +requests>=2.31.0 +rich>=13.7.0 +python-dotenv>=1.0.0 diff --git a/wordpress/wordpress_deployer/__init__.py b/wordpress/wordpress_deployer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wordpress/wordpress_deployer/__pycache__/__init__.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 0000000..6607d0c Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/__init__.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/config.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/config.cpython-39.pyc new file mode 100644 index 0000000..1a09435 Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/config.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/deployment_config_manager.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/deployment_config_manager.cpython-39.pyc new file mode 100644 index 0000000..bf5cc1c Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/deployment_config_manager.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/deployment_logger.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/deployment_logger.cpython-39.pyc new file mode 100644 index 0000000..9fd366b Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/deployment_logger.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/dns_manager.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/dns_manager.cpython-39.pyc new file mode 100644 index 0000000..b99eeeb Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/dns_manager.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/docker_manager.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/docker_manager.cpython-39.pyc new file mode 100644 index 0000000..42ee583 Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/docker_manager.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/env_generator.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/env_generator.cpython-39.pyc new file mode 100644 index 0000000..69b5917 Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/env_generator.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/health.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/health.cpython-39.pyc new file mode 100644 index 0000000..256bb0e Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/health.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/orchestrator.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/orchestrator.cpython-39.pyc new file mode 100644 index 0000000..00c705b Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/orchestrator.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/__pycache__/webhooks.cpython-39.pyc b/wordpress/wordpress_deployer/__pycache__/webhooks.cpython-39.pyc new file mode 100644 index 0000000..e33286b Binary files /dev/null and b/wordpress/wordpress_deployer/__pycache__/webhooks.cpython-39.pyc differ diff --git a/wordpress/wordpress_deployer/config.py b/wordpress/wordpress_deployer/config.py new file mode 100644 index 0000000..0a7690c --- /dev/null +++ b/wordpress/wordpress_deployer/config.py @@ -0,0 +1,187 @@ +""" +Configuration module for deployment settings + +Centralized configuration with validation from environment variables and CLI arguments +""" + +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +class ConfigurationError(Exception): + """Raised when configuration is invalid""" + pass + + +@dataclass +class DeploymentConfig: + """Main deployment configuration loaded from environment and CLI args""" + + # File paths (required - no defaults) + env_file: Path + docker_compose_file: Path + + # Cloudflare credentials (required - no defaults) + cloudflare_api_token: str = field(repr=False) # Hide in logs + cloudflare_zone_id: str + + # File paths (with defaults) + dict_file: Path = Path("/usr/share/dict/words") + + # Domain settings + base_domain: str = "merakit.my" + app_name: Optional[str] = None + + # Deployment options + dry_run: bool = False + max_retries: int = 3 + healthcheck_timeout: int = 60 # seconds + healthcheck_interval: int = 10 # seconds + verify_ssl: bool = False + + # Webhook settings (optional) + webhook_url: Optional[str] = None + webhook_timeout: int = 10 # seconds + webhook_retries: int = 3 + + # Logging + log_level: str = "INFO" + + @classmethod + def from_env_and_args(cls, args) -> "DeploymentConfig": + """ + Factory method to create config from environment and CLI args + + Args: + args: argparse.Namespace with CLI arguments + + Returns: + DeploymentConfig instance + + Raises: + ConfigurationError: If required configuration is missing + """ + logger.debug("Loading configuration from environment and arguments") + + # Get Cloudflare credentials from environment + cloudflare_api_token = os.getenv('CLOUDFLARE_API_TOKEN') + cloudflare_zone_id = os.getenv('CLOUDFLARE_ZONE_ID') + + if not cloudflare_api_token: + raise ConfigurationError( + "CLOUDFLARE_API_TOKEN environment variable is required" + ) + + if not cloudflare_zone_id: + raise ConfigurationError( + "CLOUDFLARE_ZONE_ID environment variable is required" + ) + + # Get optional webhook URL from environment or args + webhook_url = ( + getattr(args, 'webhook_url', None) + or os.getenv('DEPLOYMENT_WEBHOOK_URL') + ) + + # Get optional settings from environment with defaults + max_retries = int(os.getenv('DEPLOYMENT_MAX_RETRIES', args.max_retries)) + healthcheck_timeout = int( + os.getenv('DEPLOYMENT_HEALTHCHECK_TIMEOUT', '60') + ) + healthcheck_interval = int( + os.getenv('DEPLOYMENT_HEALTHCHECK_INTERVAL', '10') + ) + + config = cls( + env_file=args.env_file, + docker_compose_file=args.compose_file, + dict_file=Path("/usr/share/dict/words"), + cloudflare_api_token=cloudflare_api_token, + cloudflare_zone_id=cloudflare_zone_id, + base_domain="merakit.my", + app_name=None, + dry_run=args.dry_run, + max_retries=max_retries, + healthcheck_timeout=healthcheck_timeout, + healthcheck_interval=healthcheck_interval, + verify_ssl=not args.no_verify_ssl, + webhook_url=webhook_url, + webhook_timeout=10, + webhook_retries=3, + log_level=args.log_level + ) + + logger.debug(f"Configuration loaded: {config}") + return config + + def validate(self) -> None: + """ + Validate configuration completeness and correctness + + Raises: + ConfigurationError: If configuration is invalid + """ + logger.debug("Validating configuration") + + # Validate file paths exist + if not self.env_file.exists(): + raise ConfigurationError(f"Env file not found: {self.env_file}") + + if not self.docker_compose_file.exists(): + raise ConfigurationError( + f"Docker compose file not found: {self.docker_compose_file}" + ) + + if not self.dict_file.exists(): + raise ConfigurationError( + f"Dictionary file not found: {self.dict_file}. " + "Install 'words' package or ensure /usr/share/dict/words exists." + ) + + # Validate numeric ranges + if self.max_retries < 1: + raise ConfigurationError( + f"max_retries must be >= 1, got: {self.max_retries}" + ) + + if self.healthcheck_timeout < 1: + raise ConfigurationError( + f"healthcheck_timeout must be >= 1, got: {self.healthcheck_timeout}" + ) + + if self.healthcheck_interval < 1: + raise ConfigurationError( + f"healthcheck_interval must be >= 1, got: {self.healthcheck_interval}" + ) + + if self.healthcheck_interval >= self.healthcheck_timeout: + raise ConfigurationError( + f"healthcheck_interval ({self.healthcheck_interval}) must be < " + f"healthcheck_timeout ({self.healthcheck_timeout})" + ) + + # Validate log level + valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + if self.log_level.upper() not in valid_log_levels: + raise ConfigurationError( + f"Invalid log_level: {self.log_level}. " + f"Must be one of: {', '.join(valid_log_levels)}" + ) + + logger.debug("Configuration validation successful") + + def __repr__(self) -> str: + """String representation with masked sensitive values""" + return ( + f"DeploymentConfig(" + f"env_file={self.env_file}, " + f"dry_run={self.dry_run}, " + f"max_retries={self.max_retries}, " + f"cloudflare_api_token=*****, " + f"webhook_url={self.webhook_url})" + ) diff --git a/wordpress/wordpress_deployer/deployment_config_manager.py b/wordpress/wordpress_deployer/deployment_config_manager.py new file mode 100644 index 0000000..3d3b009 --- /dev/null +++ b/wordpress/wordpress_deployer/deployment_config_manager.py @@ -0,0 +1,153 @@ +""" +Deployment Configuration Manager + +Manages saving and loading deployment configurations for tracking and cleanup +""" + +import json +import logging +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + + +logger = logging.getLogger(__name__) + + +@dataclass +class DeploymentMetadata: + """Metadata for a single deployment""" + subdomain: str + url: str + domain: str + compose_project_name: str + db_name: str + db_user: str + deployment_timestamp: str + dns_record_id: Optional[str] = None + dns_ip: Optional[str] = None + containers: Optional[List[str]] = None + volumes: Optional[List[str]] = None + networks: Optional[List[str]] = None + env_file_path: Optional[str] = None + + +class DeploymentConfigManager: + """Manages deployment configuration persistence""" + + def __init__(self, config_dir: Path = Path("deployments")): + """ + Initialize deployment config manager + + Args: + config_dir: Directory to store deployment configs + """ + self.config_dir = config_dir + self.config_dir.mkdir(exist_ok=True) + self._logger = logging.getLogger(f"{__name__}.DeploymentConfigManager") + + def save_deployment(self, metadata: DeploymentMetadata) -> Path: + """ + Save deployment configuration to disk + + Args: + metadata: DeploymentMetadata instance + + Returns: + Path to saved config file + """ + # Create filename based on subdomain and timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{metadata.subdomain}_{timestamp}.json" + config_path = self.config_dir / filename + + # Convert to dict and save as JSON + config_data = asdict(metadata) + + with open(config_path, 'w') as f: + json.dump(config_data, f, indent=2) + + self._logger.info(f"Saved deployment config: {config_path}") + return config_path + + def load_deployment(self, config_file: Path) -> DeploymentMetadata: + """ + Load deployment configuration from disk + + Args: + config_file: Path to config file + + Returns: + DeploymentMetadata instance + + Raises: + FileNotFoundError: If config file doesn't exist + ValueError: If config file is invalid + """ + if not config_file.exists(): + raise FileNotFoundError(f"Config file not found: {config_file}") + + with open(config_file, 'r') as f: + config_data = json.load(f) + + return DeploymentMetadata(**config_data) + + def list_deployments(self) -> List[Path]: + """ + List all deployment config files + + Returns: + List of config file paths sorted by modification time (newest first) + """ + config_files = list(self.config_dir.glob("*.json")) + return sorted(config_files, key=lambda p: p.stat().st_mtime, reverse=True) + + def find_deployment_by_subdomain(self, subdomain: str) -> Optional[Path]: + """ + Find the most recent deployment config for a subdomain + + Args: + subdomain: Subdomain to search for + + Returns: + Path to config file or None if not found + """ + matching_files = list(self.config_dir.glob(f"{subdomain}_*.json")) + if not matching_files: + return None + + # Return most recent + return max(matching_files, key=lambda p: p.stat().st_mtime) + + def find_deployment_by_url(self, url: str) -> Optional[Path]: + """ + Find deployment config by URL + + Args: + url: Full URL to search for + + Returns: + Path to config file or None if not found + """ + for config_file in self.list_deployments(): + try: + metadata = self.load_deployment(config_file) + if metadata.url == url: + return config_file + except (ValueError, json.JSONDecodeError) as e: + self._logger.warning(f"Failed to load config {config_file}: {e}") + continue + + return None + + def delete_deployment_config(self, config_file: Path) -> None: + """ + Delete deployment config file + + Args: + config_file: Path to config file + """ + if config_file.exists(): + config_file.unlink() + self._logger.info(f"Deleted deployment config: {config_file}") diff --git a/wordpress/wordpress_deployer/deployment_logger.py b/wordpress/wordpress_deployer/deployment_logger.py new file mode 100644 index 0000000..c96f08c --- /dev/null +++ b/wordpress/wordpress_deployer/deployment_logger.py @@ -0,0 +1,218 @@ +""" +Deployment logging module + +Handles writing deployment logs to success/failed directories +""" + +import logging +from datetime import datetime +from pathlib import Path +from typing import Optional + + +logger = logging.getLogger(__name__) + + +class DeploymentFileLogger: + """Logs deployment results to files""" + + def __init__(self, logs_dir: Path = Path("logs")): + """ + Initialize deployment file logger + + Args: + logs_dir: Base directory for logs (default: logs/) + """ + self._logs_dir = logs_dir + self._success_dir = logs_dir / "success" + self._failed_dir = logs_dir / "failed" + self._logger = logging.getLogger(f"{__name__}.DeploymentFileLogger") + + # Ensure directories exist + self._ensure_directories() + + def _ensure_directories(self) -> None: + """Create log directories if they don't exist""" + for directory in [self._success_dir, self._failed_dir]: + directory.mkdir(parents=True, exist_ok=True) + self._logger.debug(f"Ensured directory exists: {directory}") + + def _sanitize_url(self, url: str) -> str: + """ + Sanitize URL for use in filename + + Args: + url: URL to sanitize + + Returns: + Sanitized URL safe for filename + """ + # Remove protocol if present + url = url.replace("https://", "").replace("http://", "") + # Replace invalid filename characters + return url.replace("/", "_").replace(":", "_") + + def _generate_filename(self, status: str, url: str, timestamp: datetime) -> str: + """ + Generate log filename + + Format: success_url_date.txt or failed_url_date.txt + + Args: + status: 'success' or 'failed' + url: Deployment URL + timestamp: Deployment timestamp + + Returns: + Filename string + """ + sanitized_url = self._sanitize_url(url) + date_str = timestamp.strftime("%Y%m%d_%H%M%S") + return f"{status}_{sanitized_url}_{date_str}.txt" + + def log_success( + self, + url: str, + subdomain: str, + duration: float, + timestamp: Optional[datetime] = None + ) -> Path: + """ + Log successful deployment + + Args: + url: Deployment URL + subdomain: Subdomain used + duration: Deployment duration in seconds + timestamp: Deployment timestamp (default: now) + + Returns: + Path to created log file + """ + if timestamp is None: + timestamp = datetime.now() + + filename = self._generate_filename("success", url, timestamp) + log_file = self._success_dir / filename + + log_content = self._format_success_log( + url, subdomain, duration, timestamp + ) + + log_file.write_text(log_content) + self._logger.info(f"✓ Success log written: {log_file}") + + return log_file + + def log_failure( + self, + url: str, + subdomain: str, + error: str, + timestamp: Optional[datetime] = None + ) -> Path: + """ + Log failed deployment + + Args: + url: Deployment URL (may be empty if failed early) + subdomain: Subdomain used (may be empty if failed early) + error: Error message + timestamp: Deployment timestamp (default: now) + + Returns: + Path to created log file + """ + if timestamp is None: + timestamp = datetime.now() + + # Handle case where URL is empty (failed before URL generation) + log_url = url if url else "unknown" + filename = self._generate_filename("failed", log_url, timestamp) + log_file = self._failed_dir / filename + + log_content = self._format_failure_log( + url, subdomain, error, timestamp + ) + + log_file.write_text(log_content) + self._logger.info(f"✓ Failure log written: {log_file}") + + return log_file + + def _format_success_log( + self, + url: str, + subdomain: str, + duration: float, + timestamp: datetime + ) -> str: + """ + Format success log content + + Args: + url: Deployment URL + subdomain: Subdomain used + duration: Deployment duration in seconds + timestamp: Deployment timestamp + + Returns: + Formatted log content + """ + return f"""╔══════════════════════════════════════════════╗ +║ DEPLOYMENT SUCCESS LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: {timestamp.strftime("%Y-%m-%d %H:%M:%S")} +Status: SUCCESS +URL: https://{url} +Subdomain: {subdomain} +Duration: {duration:.2f} seconds + +═══════════════════════════════════════════════ + +Deployment completed successfully. +All services are running and health checks passed. +""" + + def _format_failure_log( + self, + url: str, + subdomain: str, + error: str, + timestamp: datetime + ) -> str: + """ + Format failure log content + + Args: + url: Deployment URL (may be empty) + subdomain: Subdomain used (may be empty) + error: Error message + timestamp: Deployment timestamp + + Returns: + Formatted log content + """ + url_display = f"https://{url}" if url else "N/A (failed before URL generation)" + subdomain_display = subdomain if subdomain else "N/A" + + return f"""╔══════════════════════════════════════════════╗ +║ DEPLOYMENT FAILURE LOG ║ +╚══════════════════════════════════════════════╝ + +Timestamp: {timestamp.strftime("%Y-%m-%d %H:%M:%S")} +Status: FAILED +URL: {url_display} +Subdomain: {subdomain_display} + +═══════════════════════════════════════════════ + +ERROR: +{error} + +═══════════════════════════════════════════════ + +Deployment failed. See error details above. +All changes have been rolled back. +""" diff --git a/wordpress/wordpress_deployer/dns_manager.py b/wordpress/wordpress_deployer/dns_manager.py new file mode 100644 index 0000000..7b77dc2 --- /dev/null +++ b/wordpress/wordpress_deployer/dns_manager.py @@ -0,0 +1,286 @@ +""" +DNS management module with Cloudflare API integration + +Direct Python API calls replacing cloudflare-add.sh and cloudflare-remove.sh +""" + +import logging +from dataclasses import dataclass +from typing import Dict, Optional + +import requests + + +logger = logging.getLogger(__name__) + + +class DNSError(Exception): + """Raised when DNS operations fail""" + pass + + +@dataclass +class DNSRecord: + """Represents a DNS record""" + record_id: str + hostname: str + ip: str + record_type: str + + +class DNSManager: + """Python wrapper for Cloudflare DNS operations""" + + def __init__(self, api_token: str, zone_id: str): + """ + Initialize DNS manager + + Args: + api_token: Cloudflare API token + zone_id: Cloudflare zone ID + """ + self._api_token = api_token + self._zone_id = zone_id + self._base_url = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records" + self._headers = { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json" + } + self._logger = logging.getLogger(f"{__name__}.DNSManager") + + def check_record_exists(self, hostname: str) -> bool: + """ + Check if DNS record exists using Cloudflare API + + Args: + hostname: Fully qualified domain name + + Returns: + True if record exists, False otherwise + + Raises: + DNSError: If API call fails + """ + self._logger.debug(f"Checking if DNS record exists: {hostname}") + + try: + params = {"name": hostname} + response = requests.get( + self._base_url, + headers=self._headers, + params=params, + timeout=30 + ) + response.raise_for_status() + + data = response.json() + + if not data.get("success", False): + errors = data.get("errors", []) + raise DNSError(f"Cloudflare API error: {errors}") + + records = data.get("result", []) + exists = len(records) > 0 + + if exists: + self._logger.debug(f"DNS record exists: {hostname}") + else: + self._logger.debug(f"DNS record does not exist: {hostname}") + + return exists + + except requests.RequestException as e: + raise DNSError(f"Failed to check DNS record existence: {e}") from e + + def add_record( + self, + hostname: str, + ip: str, + dry_run: bool = False + ) -> DNSRecord: + """ + Add DNS A record + + Args: + hostname: Fully qualified domain name + ip: IP address for A record + dry_run: If True, only log what would be done + + Returns: + DNSRecord with record_id for rollback + + Raises: + DNSError: If API call fails + """ + if dry_run: + self._logger.info( + f"[DRY-RUN] Would add DNS record: {hostname} -> {ip}" + ) + return DNSRecord( + record_id="dry-run-id", + hostname=hostname, + ip=ip, + record_type="A" + ) + + self._logger.info(f"Adding DNS record: {hostname} -> {ip}") + + try: + payload = { + "type": "A", + "name": hostname, + "content": ip, + "ttl": 1, # Automatic TTL + "proxied": False # DNS only, not proxied through Cloudflare + } + + response = requests.post( + self._base_url, + headers=self._headers, + json=payload, + timeout=30 + ) + response.raise_for_status() + + data = response.json() + + if not data.get("success", False): + errors = data.get("errors", []) + raise DNSError(f"Cloudflare API error: {errors}") + + result = data.get("result", {}) + record_id = result.get("id") + + if not record_id: + raise DNSError("No record ID returned from Cloudflare API") + + self._logger.info(f"DNS record added successfully: {record_id}") + + return DNSRecord( + record_id=record_id, + hostname=hostname, + ip=ip, + record_type="A" + ) + + except requests.RequestException as e: + raise DNSError(f"Failed to add DNS record: {e}") from e + + def remove_record(self, hostname: str, dry_run: bool = False) -> None: + """ + Remove DNS record by hostname + + Args: + hostname: Fully qualified domain name + dry_run: If True, only log what would be done + + Raises: + DNSError: If API call fails + """ + if dry_run: + self._logger.info(f"[DRY-RUN] Would remove DNS record: {hostname}") + return + + self._logger.info(f"Removing DNS record: {hostname}") + + try: + # First, get the record ID + params = {"name": hostname} + response = requests.get( + self._base_url, + headers=self._headers, + params=params, + timeout=30 + ) + response.raise_for_status() + + data = response.json() + + if not data.get("success", False): + errors = data.get("errors", []) + raise DNSError(f"Cloudflare API error: {errors}") + + records = data.get("result", []) + + if not records: + self._logger.warning(f"No DNS record found for: {hostname}") + return + + # Remove all matching records (typically just one) + for record in records: + record_id = record.get("id") + if record_id: + self.remove_record_by_id(record_id, dry_run=False) + + except requests.RequestException as e: + raise DNSError(f"Failed to remove DNS record: {e}") from e + + def remove_record_by_id(self, record_id: str, dry_run: bool = False) -> None: + """ + Remove DNS record by ID (more reliable for rollback) + + Args: + record_id: Cloudflare DNS record ID + dry_run: If True, only log what would be done + + Raises: + DNSError: If API call fails + """ + if dry_run: + self._logger.info( + f"[DRY-RUN] Would remove DNS record by ID: {record_id}" + ) + return + + self._logger.info(f"Removing DNS record by ID: {record_id}") + + try: + url = f"{self._base_url}/{record_id}" + response = requests.delete( + url, + headers=self._headers, + timeout=30 + ) + + # Handle 404/405 gracefully - record doesn't exist or can't be deleted + if response.status_code in [404, 405]: + self._logger.warning( + f"DNS record {record_id} not found or cannot be deleted (may already be removed)" + ) + return + + response.raise_for_status() + + data = response.json() + + if not data.get("success", False): + errors = data.get("errors", []) + raise DNSError(f"Cloudflare API error: {errors}") + + self._logger.info(f"DNS record removed successfully: {record_id}") + + except requests.RequestException as e: + raise DNSError(f"Failed to remove DNS record: {e}") from e + + def get_public_ip(self) -> str: + """ + Get public IP address from external service + + Returns: + Public IP address as string + + Raises: + DNSError: If IP retrieval fails + """ + self._logger.debug("Retrieving public IP address") + + try: + response = requests.get("https://ipv4.icanhazip.com", timeout=10) + response.raise_for_status() + ip = response.text.strip() + + self._logger.debug(f"Public IP: {ip}") + return ip + + except requests.RequestException as e: + raise DNSError(f"Failed to retrieve public IP: {e}") from e diff --git a/wordpress/wordpress_deployer/docker_manager.py b/wordpress/wordpress_deployer/docker_manager.py new file mode 100644 index 0000000..27a3a30 --- /dev/null +++ b/wordpress/wordpress_deployer/docker_manager.py @@ -0,0 +1,276 @@ +""" +Docker management module + +Wrapper for Docker Compose operations with validation and error handling +""" + +import logging +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import List + + +logger = logging.getLogger(__name__) + + +class DockerError(Exception): + """Raised when Docker operations fail""" + pass + + +@dataclass +class ContainerInfo: + """Information about a running container""" + container_id: str + name: str + status: str + + +class DockerManager: + """Docker Compose operations wrapper""" + + def __init__(self, compose_file: Path, env_file: Path): + """ + Initialize Docker manager + + Args: + compose_file: Path to docker-compose.yml + env_file: Path to .env file + """ + self._compose_file = compose_file + self._env_file = env_file + self._logger = logging.getLogger(f"{__name__}.DockerManager") + + def _run_command( + self, + cmd: List[str], + check: bool = True, + capture_output: bool = True + ) -> subprocess.CompletedProcess: + """ + Run docker compose command + + Args: + cmd: Command list to execute + check: Whether to raise on non-zero exit + capture_output: Whether to capture stdout/stderr + + Returns: + CompletedProcess instance + + Raises: + DockerError: If command fails and check=True + """ + self._logger.debug(f"Running: {' '.join(cmd)}") + + try: + result = subprocess.run( + cmd, + check=check, + capture_output=capture_output, + text=True, + cwd=self._compose_file.parent + ) + return result + + except subprocess.CalledProcessError as e: + error_msg = f"Docker command failed: {e.stderr or e.stdout or str(e)}" + self._logger.error(error_msg) + raise DockerError(error_msg) from e + except FileNotFoundError as e: + raise DockerError( + f"Docker command not found. Is Docker installed? {e}" + ) from e + + def validate_compose_file(self) -> None: + """ + Validate docker-compose.yml syntax + + Raises: + DockerError: If compose file is invalid + """ + self._logger.debug("Validating docker-compose.yml") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "config", "--quiet" + ] + + try: + self._run_command(cmd) + self._logger.debug("docker-compose.yml is valid") + + except DockerError as e: + raise DockerError(f"Invalid docker-compose.yml: {e}") from e + + def pull_images(self, dry_run: bool = False) -> None: + """ + Pull required Docker images + + Args: + dry_run: If True, only log what would be done + + Raises: + DockerError: If pull fails + """ + if dry_run: + self._logger.info("[DRY-RUN] Would pull Docker images") + return + + self._logger.info("Pulling Docker images") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "pull" + ] + + self._run_command(cmd) + self._logger.info("Docker images pulled successfully") + + def start_services(self, dry_run: bool = False) -> List[ContainerInfo]: + """ + Start Docker Compose services + + Args: + dry_run: If True, only log what would be done + + Returns: + List of created containers for rollback + + Raises: + DockerError: If start fails + """ + if dry_run: + self._logger.info("[DRY-RUN] Would start Docker services") + return [] + + self._logger.info("Starting Docker services") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "up", "-d" + ] + + self._run_command(cmd) + + # Get container info for rollback + containers = self.get_container_status() + + self._logger.info( + f"Docker services started successfully: {len(containers)} containers" + ) + + return containers + + def stop_services(self, dry_run: bool = False) -> None: + """ + Stop Docker Compose services + + Args: + dry_run: If True, only log what would be done + + Raises: + DockerError: If stop fails + """ + if dry_run: + self._logger.info("[DRY-RUN] Would stop Docker services") + return + + self._logger.info("Stopping Docker services") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "down" + ] + + self._run_command(cmd) + self._logger.info("Docker services stopped successfully") + + def stop_services_and_remove_volumes(self, dry_run: bool = False) -> None: + """ + Stop services and remove volumes (full cleanup) + + Args: + dry_run: If True, only log what would be done + + Raises: + DockerError: If stop fails + """ + if dry_run: + self._logger.info("[DRY-RUN] Would stop Docker services and remove volumes") + return + + self._logger.info("Stopping Docker services and removing volumes") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "down", "-v" + ] + + self._run_command(cmd) + self._logger.info("Docker services stopped and volumes removed") + + def get_container_status(self) -> List[ContainerInfo]: + """ + Get status of containers for this project + + Returns: + List of ContainerInfo objects + + Raises: + DockerError: If status check fails + """ + self._logger.debug("Getting container status") + + cmd = [ + "docker", "compose", + "-f", str(self._compose_file), + "--env-file", str(self._env_file), + "ps", "-q" + ] + + result = self._run_command(cmd) + + container_ids = [ + cid.strip() + for cid in result.stdout.strip().split('\n') + if cid.strip() + ] + + containers = [] + for container_id in container_ids: + # Get container details + inspect_cmd = ["docker", "inspect", container_id, "--format", "{{.Name}}:{{.State.Status}}"] + try: + inspect_result = self._run_command(inspect_cmd) + name_status = inspect_result.stdout.strip() + if ':' in name_status: + name, status = name_status.split(':', 1) + # Remove leading slash from container name + name = name.lstrip('/') + containers.append(ContainerInfo( + container_id=container_id, + name=name, + status=status + )) + except DockerError: + # If inspect fails, just record the ID + containers.append(ContainerInfo( + container_id=container_id, + name="unknown", + status="unknown" + )) + + self._logger.debug(f"Found {len(containers)} containers") + return containers diff --git a/wordpress/wordpress_deployer/env_generator.py b/wordpress/wordpress_deployer/env_generator.py new file mode 100644 index 0000000..31f18e6 --- /dev/null +++ b/wordpress/wordpress_deployer/env_generator.py @@ -0,0 +1,394 @@ +""" +Environment generation module - replaces generate-env.sh + +Provides pure Python implementations for: +- Random word selection from dictionary +- Memorable password generation +- Environment file generation and manipulation +""" + +import logging +import os +import random +import re +import secrets +import shutil +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class EnvValues: + """Container for generated environment values""" + subdomain: str + domain: str + url: str + db_name: str + db_user: str + db_password: str + db_root_password: str + compose_project_name: str + + +class WordGenerator: + """Pure Python implementation of dictionary word selection""" + + def __init__(self, dict_file: Path): + """ + Initialize word generator + + Args: + dict_file: Path to dictionary file (e.g., /usr/share/dict/words) + """ + self._dict_file = dict_file + self._words_cache: Optional[List[str]] = None + self._logger = logging.getLogger(f"{__name__}.WordGenerator") + + def _load_and_filter_words(self) -> List[str]: + """ + Load dictionary and filter to 4-10 char lowercase words + + Returns: + List of filtered words + + Raises: + FileNotFoundError: If dictionary file doesn't exist + ValueError: If no valid words found + """ + if not self._dict_file.exists(): + raise FileNotFoundError(f"Dictionary file not found: {self._dict_file}") + + self._logger.debug(f"Loading words from {self._dict_file}") + + # Read and filter words matching pattern: ^[a-z]{4,10}$ + pattern = re.compile(r'^[a-z]{4,10}$') + words = [] + + with open(self._dict_file, 'r', encoding='utf-8') as f: + for line in f: + word = line.strip() + if pattern.match(word): + words.append(word) + + if not words: + raise ValueError(f"No valid words found in {self._dict_file}") + + self._logger.debug(f"Loaded {len(words)} valid words") + return words + + def get_random_word(self) -> str: + """ + Get single random word from filtered list + + Returns: + Random word (4-10 chars, lowercase) + """ + # Load and cache words on first use + if self._words_cache is None: + self._words_cache = self._load_and_filter_words() + + return random.choice(self._words_cache) + + def get_random_words(self, count: int) -> List[str]: + """ + Get multiple random words efficiently + + Args: + count: Number of words to retrieve + + Returns: + List of random words + """ + # Load and cache words on first use + if self._words_cache is None: + self._words_cache = self._load_and_filter_words() + + return random.choices(self._words_cache, k=count) + + +class PasswordGenerator: + """Generate memorable passwords from dictionary words""" + + def __init__(self, word_generator: WordGenerator): + """ + Initialize password generator + + Args: + word_generator: WordGenerator instance for word selection + """ + self._word_generator = word_generator + self._logger = logging.getLogger(f"{__name__}.PasswordGenerator") + + def generate_memorable_password(self, word_count: int = 3) -> str: + """ + Generate password from N random nouns joined by hyphens + + Args: + word_count: Number of words to use (default: 3) + + Returns: + Password string like "templon-infantly-yielding" + """ + words = self._word_generator.get_random_words(word_count) + password = '-'.join(words) + self._logger.debug(f"Generated {word_count}-word password") + return password + + def generate_random_string(self, length: int = 8) -> str: + """ + Generate alphanumeric random string using secrets module + + Args: + length: Length of string to generate (default: 8) + + Returns: + Random alphanumeric string + """ + # Use secrets for cryptographically secure random generation + # Generate hex and convert to lowercase alphanumeric + return secrets.token_hex(length // 2 + 1)[:length] + + +class EnvFileGenerator: + """Pure Python .env file manipulation (replaces bash sed logic)""" + + def __init__( + self, + env_file: Path, + word_generator: WordGenerator, + password_generator: PasswordGenerator, + base_domain: str = "merakit.my", + app_name: Optional[str] = None + ): + """ + Initialize environment file generator + + Args: + env_file: Path to .env file + word_generator: WordGenerator instance + password_generator: PasswordGenerator instance + base_domain: Base domain for URL generation (default: "merakit.my") + app_name: Application name (default: read from .env or "wordpress") + """ + self._env_file = env_file + self._word_generator = word_generator + self._password_generator = password_generator + self._base_domain = base_domain + self._app_name = app_name + self._logger = logging.getLogger(f"{__name__}.EnvFileGenerator") + + def generate_values(self) -> EnvValues: + """ + Generate all environment values + + Returns: + EnvValues dataclass with all generated values + """ + self._logger.info("Generating environment values") + + # Read current .env to get app_name if not provided + current_env = self.read_current_env() + app_name = self._app_name or current_env.get('APP_NAME', 'wordpress') + + # 1. Generate subdomain: two random words + word1 = self._word_generator.get_random_word() + word2 = self._word_generator.get_random_word() + subdomain = f"{word1}-{word2}" + + # 2. Construct URL + url = f"{subdomain}.{self._base_domain}" + + # 3. Generate random string for DB identifiers + random_str = self._password_generator.generate_random_string(8) + + # 4. Generate DB identifiers with truncation logic + db_name = self._generate_db_name(random_str, app_name, subdomain) + db_user = self._generate_db_user(random_str, app_name, subdomain) + + # 5. Generate passwords + db_password = self._password_generator.generate_memorable_password(3) + db_root_password = self._password_generator.generate_memorable_password(3) + + self._logger.info(f"Generated values for subdomain: {subdomain}") + self._logger.debug(f"URL: {url}") + self._logger.debug(f"DB_NAME: {db_name}") + self._logger.debug(f"DB_USER: {db_user}") + + return EnvValues( + subdomain=subdomain, + domain=self._base_domain, + url=url, + db_name=db_name, + db_user=db_user, + db_password=db_password, + db_root_password=db_root_password, + compose_project_name=subdomain + ) + + def _generate_db_name(self, random_str: str, app_name: str, subdomain: str) -> str: + """ + Format: angali_{random8}_{app}_{subdomain}, truncate to 64 chars + + Args: + random_str: Random 8-char string + app_name: Application name + subdomain: Subdomain with hyphens + + Returns: + Database name (max 64 chars) + """ + # Replace hyphens with underscores for DB compatibility + subdomain_safe = subdomain.replace('-', '_') + db_name = f"angali_{random_str}_{app_name}_{subdomain_safe}" + + # Truncate to MySQL limit of 64 chars + return db_name[:64] + + def _generate_db_user(self, random_str: str, app_name: str, subdomain: str) -> str: + """ + Format: angali_{random8}_{app}_{subdomain}, truncate to 32 chars + + Args: + random_str: Random 8-char string + app_name: Application name + subdomain: Subdomain with hyphens + + Returns: + Database username (max 32 chars) + """ + # Replace hyphens with underscores for DB compatibility + subdomain_safe = subdomain.replace('-', '_') + db_user = f"angali_{random_str}_{app_name}_{subdomain_safe}" + + # Truncate to MySQL limit of 32 chars for usernames + return db_user[:32] + + def read_current_env(self) -> Dict[str, str]: + """ + Parse existing .env file into dict + + Returns: + Dictionary of environment variables + """ + env_dict = {} + + if not self._env_file.exists(): + self._logger.warning(f"Env file not found: {self._env_file}") + return env_dict + + with open(self._env_file, 'r') as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + + # Parse KEY=VALUE format + if '=' in line: + key, value = line.split('=', 1) + # Remove quotes if present + value = value.strip('"').strip("'") + env_dict[key.strip()] = value + + self._logger.debug(f"Read {len(env_dict)} variables from {self._env_file}") + return env_dict + + def backup_env_file(self) -> Path: + """ + Create timestamped backup of .env file + + Returns: + Path to backup file + + Raises: + FileNotFoundError: If .env file doesn't exist + """ + if not self._env_file.exists(): + raise FileNotFoundError(f"Cannot backup non-existent file: {self._env_file}") + + # Create backup with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_path = self._env_file.parent / f"{self._env_file.name}.backup.{timestamp}" + + shutil.copy2(self._env_file, backup_path) + self._logger.info(f"Created backup: {backup_path}") + + return backup_path + + def update_env_file(self, values: EnvValues, dry_run: bool = False) -> None: + """ + Update .env file with new values (Python dict manipulation) + + Uses atomic write pattern: write to temp file, then rename + + Args: + values: EnvValues to write + dry_run: If True, only log what would be done + + Raises: + FileNotFoundError: If .env file doesn't exist + """ + if not self._env_file.exists(): + raise FileNotFoundError(f"Env file not found: {self._env_file}") + + if dry_run: + self._logger.info(f"[DRY-RUN] Would update {self._env_file} with:") + for key, value in asdict(values).items(): + if 'password' in key.lower(): + self._logger.info(f" {key.upper()}=********") + else: + self._logger.info(f" {key.upper()}={value}") + return + + # Read current env + current_env = self.read_current_env() + + # Update with new values + current_env.update({ + 'COMPOSE_PROJECT_NAME': values.compose_project_name, + 'SUBDOMAIN': values.subdomain, + 'DOMAIN': values.domain, + 'URL': values.url, + 'DB_NAME': values.db_name, + 'DB_USER': values.db_user, + 'DB_PASSWORD': values.db_password, + 'DB_ROOT_PASSWORD': values.db_root_password + }) + + # Write atomically: write to temp file, then rename + temp_file = self._env_file.parent / f"{self._env_file.name}.tmp" + + try: + with open(temp_file, 'w') as f: + for key, value in current_env.items(): + f.write(f"{key}={value}\n") + + # Atomic rename + os.replace(temp_file, self._env_file) + self._logger.info(f"Updated {self._env_file} successfully") + + except Exception as e: + # Cleanup temp file on error + if temp_file.exists(): + temp_file.unlink() + raise RuntimeError(f"Failed to update env file: {e}") from e + + def restore_env_file(self, backup_path: Path) -> None: + """ + Restore .env from backup + + Args: + backup_path: Path to backup file + + Raises: + FileNotFoundError: If backup file doesn't exist + """ + if not backup_path.exists(): + raise FileNotFoundError(f"Backup file not found: {backup_path}") + + shutil.copy2(backup_path, self._env_file) + self._logger.info(f"Restored {self._env_file} from {backup_path}") diff --git a/wordpress/wordpress_deployer/health.py b/wordpress/wordpress_deployer/health.py new file mode 100644 index 0000000..7b4ad68 --- /dev/null +++ b/wordpress/wordpress_deployer/health.py @@ -0,0 +1,128 @@ +""" +Health check module + +HTTP health checking with retry logic and progress indicators +""" + +import logging +import time + +import requests + + +logger = logging.getLogger(__name__) + + +class HealthCheckError(Exception): + """Raised when health check fails""" + pass + + +class HealthChecker: + """HTTP health check with retry logic""" + + def __init__( + self, + timeout: int, + interval: int, + verify_ssl: bool + ): + """ + Initialize health checker + + Args: + timeout: Total timeout in seconds + interval: Check interval in seconds + verify_ssl: Whether to verify SSL certificates + """ + self._timeout = timeout + self._interval = interval + self._verify_ssl = verify_ssl + self._logger = logging.getLogger(f"{__name__}.HealthChecker") + + def check_health(self, url: str, dry_run: bool = False) -> bool: + """ + Perform health check with retries + + Args: + url: URL to check (e.g., https://example.com) + dry_run: If True, only log what would be done + + Returns: + True if health check passed, False otherwise + """ + if dry_run: + self._logger.info(f"[DRY-RUN] Would check health of {url}") + return True + + self._logger.info( + f"Checking health of {url} for up to {self._timeout} seconds" + ) + + start_time = time.time() + attempt = 0 + + while True: + attempt += 1 + elapsed = time.time() - start_time + + if elapsed > self._timeout: + self._logger.error( + f"Health check timed out after {elapsed:.1f} seconds " + f"({attempt} attempts)" + ) + return False + + # Perform single check + if self._single_check(url): + self._logger.info( + f"Health check passed after {elapsed:.1f} seconds " + f"({attempt} attempts)" + ) + return True + + # Wait before next attempt + remaining = self._timeout - elapsed + if remaining > 0: + wait_time = min(self._interval, remaining) + self._logger.debug( + f"Attempt {attempt} failed, retrying in {wait_time:.1f}s " + f"(elapsed: {elapsed:.1f}s, timeout: {self._timeout}s)" + ) + time.sleep(wait_time) + else: + # No time remaining + self._logger.error(f"Health check timed out after {attempt} attempts") + return False + + def _single_check(self, url: str) -> bool: + """ + Single health check attempt + + Args: + url: URL to check + + Returns: + True if valid HTTP response (2xx or 3xx) received, False otherwise + """ + try: + response = requests.get( + url, + timeout=5, + verify=self._verify_ssl, + allow_redirects=True + ) + + # Accept any 2xx or 3xx status code as valid + if 200 <= response.status_code < 400: + self._logger.debug(f"Health check successful: HTTP {response.status_code}") + return True + else: + self._logger.debug( + f"Health check failed: HTTP {response.status_code}" + ) + return False + + except requests.RequestException as e: + self._logger.debug(f"Health check failed: {type(e).__name__}: {e}") + return False diff --git a/wordpress/wordpress_deployer/orchestrator.py b/wordpress/wordpress_deployer/orchestrator.py new file mode 100644 index 0000000..899802c --- /dev/null +++ b/wordpress/wordpress_deployer/orchestrator.py @@ -0,0 +1,626 @@ +""" +Deployment orchestration module + +Main deployment workflow with rollback tracking and execution +""" + +import logging +import shutil +import time +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + +from .config import DeploymentConfig +from .deployment_config_manager import DeploymentConfigManager, DeploymentMetadata +from .deployment_logger import DeploymentFileLogger +from .dns_manager import DNSError, DNSManager, DNSRecord +from .docker_manager import DockerError, DockerManager +from .env_generator import EnvFileGenerator, EnvValues, PasswordGenerator, WordGenerator +from .health import HealthCheckError, HealthChecker +from .webhooks import WebhookNotifier + + +logger = logging.getLogger(__name__) + + +class DeploymentError(Exception): + """Base exception for deployment errors""" + pass + + +class ValidationError(DeploymentError): + """Validation failed""" + pass + + +@dataclass +class DeploymentAction: + """Represents a single deployment action""" + action_type: str # 'dns_added', 'containers_started', 'env_updated' + timestamp: datetime + details: Dict[str, Any] + rollback_data: Dict[str, Any] + + +class DeploymentTracker: + """Track deployment actions for rollback""" + + def __init__(self): + """Initialize deployment tracker""" + self._actions: List[DeploymentAction] = [] + self._logger = logging.getLogger(f"{__name__}.DeploymentTracker") + + def record_action(self, action: DeploymentAction) -> None: + """ + Record a deployment action + + Args: + action: DeploymentAction to record + """ + self._actions.append(action) + self._logger.debug(f"Recorded action: {action.action_type}") + + def get_actions(self) -> List[DeploymentAction]: + """ + Get all recorded actions + + Returns: + List of DeploymentAction objects + """ + return self._actions.copy() + + def clear(self) -> None: + """Clear tracking history""" + self._actions.clear() + self._logger.debug("Cleared action history") + + +class DeploymentOrchestrator: + """Main orchestrator coordinating all deployment steps""" + + def __init__(self, config: DeploymentConfig): + """ + Initialize deployment orchestrator + + Args: + config: DeploymentConfig instance + """ + self._config = config + self._logger = logging.getLogger(f"{__name__}.DeploymentOrchestrator") + + # Initialize components + self._word_generator = WordGenerator(config.dict_file) + self._password_generator = PasswordGenerator(self._word_generator) + self._env_generator = EnvFileGenerator( + config.env_file, + self._word_generator, + self._password_generator, + config.base_domain, + config.app_name + ) + self._dns_manager = DNSManager( + config.cloudflare_api_token, + config.cloudflare_zone_id + ) + self._docker_manager = DockerManager( + config.docker_compose_file, + config.env_file + ) + self._webhook_notifier = WebhookNotifier( + config.webhook_url, + config.webhook_timeout, + config.webhook_retries + ) + self._health_checker = HealthChecker( + config.healthcheck_timeout, + config.healthcheck_interval, + config.verify_ssl + ) + self._tracker = DeploymentTracker() + self._deployment_logger = DeploymentFileLogger() + self._config_manager = DeploymentConfigManager() + + def deploy(self) -> None: + """ + Main deployment workflow + + Raises: + DeploymentError: If deployment fails + """ + start_time = time.time() + env_values = None + dns_record_id = None + dns_ip = None + containers = [] + + try: + # Phase 1: Validation + self._phase_validate() + + # Phase 2: Environment Generation (with retry on DNS conflicts) + env_values = self._phase_generate_env_with_retries() + + # Send deployment_started webhook + self._webhook_notifier.deployment_started( + env_values.subdomain, + env_values.url + ) + + # Phase 3: DNS Setup + dns_record_id, dns_ip = self._phase_setup_dns(env_values) + + # Phase 4: Container Deployment + containers = self._phase_deploy_containers() + + # Phase 5: Health Check + self._phase_health_check(env_values.url) + + # Success + duration = time.time() - start_time + self._webhook_notifier.deployment_success( + env_values.subdomain, + env_values.url, + duration + ) + self._logger.info( + f"✓ Deployment successful! URL: https://{env_values.url} " + f"(took {duration:.1f}s)" + ) + + # Log success to file + self._deployment_logger.log_success( + env_values.url, + env_values.subdomain, + duration + ) + + # Save deployment configuration + self._save_deployment_config( + env_values, + dns_record_id, + dns_ip, + containers + ) + + except Exception as e: + self._logger.error(f"✗ Deployment failed: {e}") + + # Send failure webhook + if env_values: + self._webhook_notifier.deployment_failed( + env_values.subdomain, + str(e), + env_values.url + ) + else: + self._webhook_notifier.deployment_failed("", str(e), "") + + # Log failure to file + if env_values: + self._deployment_logger.log_failure( + env_values.url, + env_values.subdomain, + str(e) + ) + else: + self._deployment_logger.log_failure( + "", + "", + str(e) + ) + + # Rollback + self._logger.info("Starting rollback...") + self._rollback_all() + + raise DeploymentError(f"Deployment failed: {e}") from e + + def _phase_validate(self) -> None: + """ + Phase 1: Pre-deployment validation + + Raises: + ValidationError: If validation fails + """ + self._logger.info("═══ Phase 1: Validation ═══") + + # Check system dependencies + self._validate_dependencies() + + # Validate environment file + if not self._config.env_file.exists(): + raise ValidationError(f"Env file not found: {self._config.env_file}") + + # Validate Docker Compose file + try: + self._docker_manager.validate_compose_file() + except DockerError as e: + raise ValidationError(f"Invalid docker-compose.yml: {e}") from e + + # Check external Docker network exists + self._validate_docker_network("proxy") + + self._logger.info("✓ Validation complete") + + def _validate_dependencies(self) -> None: + """ + Validate system dependencies + + Raises: + ValidationError: If dependencies are missing + """ + import shutil as sh + + required_commands = ["docker", "curl"] + + for cmd in required_commands: + if not sh.which(cmd): + raise ValidationError( + f"Required command not found: {cmd}. " + f"Please install {cmd} and try again." + ) + + # Check Docker daemon is running + try: + import subprocess + result = subprocess.run( + ["docker", "info"], + capture_output=True, + timeout=5 + ) + if result.returncode != 0: + raise ValidationError( + "Docker daemon is not running. Please start Docker." + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + raise ValidationError(f"Failed to check Docker daemon: {e}") from e + + def _validate_docker_network(self, network_name: str) -> None: + """ + Check external Docker network exists + + Args: + network_name: Network name to check + + Raises: + ValidationError: If network doesn't exist + """ + import subprocess + + try: + result = subprocess.run( + ["docker", "network", "inspect", network_name], + capture_output=True, + timeout=5 + ) + if result.returncode != 0: + raise ValidationError( + f"Docker network '{network_name}' not found. " + f"Please create it with: docker network create {network_name}" + ) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + raise ValidationError( + f"Failed to check Docker network: {e}" + ) from e + + def _phase_generate_env_with_retries(self) -> EnvValues: + """ + Phase 2: Generate environment with DNS conflict retry + + Returns: + EnvValues with generated values + + Raises: + DeploymentError: If unable to generate unique subdomain + """ + self._logger.info("═══ Phase 2: Environment Generation ═══") + + for attempt in range(1, self._config.max_retries + 1): + # Generate new values + env_values = self._env_generator.generate_values() + + self._logger.info(f"Generated subdomain: {env_values.subdomain}") + + # Check DNS conflict + try: + if not self._dns_manager.check_record_exists(env_values.url): + # No conflict, proceed + self._logger.info(f"✓ Subdomain available: {env_values.subdomain}") + + # Create backup + backup_path = self._env_generator.backup_env_file() + + # Update .env file + self._env_generator.update_env_file( + env_values, + dry_run=self._config.dry_run + ) + + # Track for rollback + self._tracker.record_action(DeploymentAction( + action_type="env_updated", + timestamp=datetime.now(), + details={"env_values": asdict(env_values)}, + rollback_data={"backup_path": str(backup_path)} + )) + + return env_values + + else: + self._logger.warning( + f"✗ DNS conflict for {env_values.url}, " + f"regenerating... (attempt {attempt}/{self._config.max_retries})" + ) + + except DNSError as e: + self._logger.warning( + f"DNS check failed: {e}. " + f"Assuming no conflict and proceeding..." + ) + # If DNS check fails, proceed anyway (fail open) + backup_path = self._env_generator.backup_env_file() + self._env_generator.update_env_file( + env_values, + dry_run=self._config.dry_run + ) + self._tracker.record_action(DeploymentAction( + action_type="env_updated", + timestamp=datetime.now(), + details={"env_values": asdict(env_values)}, + rollback_data={"backup_path": str(backup_path)} + )) + return env_values + + raise DeploymentError( + f"Failed to generate unique subdomain after {self._config.max_retries} attempts" + ) + + def _phase_setup_dns(self, env_values: EnvValues) -> tuple: + """ + Phase 3: Add DNS record + + Args: + env_values: EnvValues with subdomain and URL + + Returns: + Tuple of (record_id, ip) + + Raises: + DNSError: If DNS setup fails + """ + self._logger.info("═══ Phase 3: DNS Setup ═══") + + # Get public IP + ip = self._dns_manager.get_public_ip() + self._logger.info(f"Public IP: {ip}") + + # Add DNS record + dns_record = self._dns_manager.add_record( + env_values.url, + ip, + dry_run=self._config.dry_run + ) + + self._logger.info(f"✓ DNS record added: {env_values.url} -> {ip}") + + # Track for rollback + self._tracker.record_action(DeploymentAction( + action_type="dns_added", + timestamp=datetime.now(), + details={"hostname": env_values.url, "ip": ip}, + rollback_data={"record_id": dns_record.record_id} + )) + + # Send webhook notification + self._webhook_notifier.dns_added(env_values.url, ip) + + return dns_record.record_id, ip + + def _phase_deploy_containers(self) -> List: + """ + Phase 4: Start Docker containers + + Returns: + List of container information + + Raises: + DockerError: If container deployment fails + """ + self._logger.info("═══ Phase 4: Container Deployment ═══") + + # Pull images + self._logger.info("Pulling Docker images...") + self._docker_manager.pull_images(dry_run=self._config.dry_run) + + # Start services + self._logger.info("Starting Docker services...") + containers = self._docker_manager.start_services( + dry_run=self._config.dry_run + ) + + self._logger.info( + f"✓ Docker services started: {len(containers)} containers" + ) + + # Track for rollback + self._tracker.record_action(DeploymentAction( + action_type="containers_started", + timestamp=datetime.now(), + details={"containers": [asdict(c) for c in containers]}, + rollback_data={} + )) + + return containers + + def _phase_health_check(self, url: str) -> None: + """ + Phase 5: Health check + + Args: + url: URL to check (without https://) + + Raises: + HealthCheckError: If health check fails + """ + self._logger.info("═══ Phase 5: Health Check ═══") + + health_url = f"https://{url}" + start_time = time.time() + + if not self._health_checker.check_health( + health_url, + dry_run=self._config.dry_run + ): + raise HealthCheckError(f"Health check failed for {health_url}") + + duration = time.time() - start_time + self._logger.info(f"✓ Health check passed (took {duration:.1f}s)") + + # Send webhook notification + self._webhook_notifier.health_check_passed(url, duration) + + def _rollback_all(self) -> None: + """Rollback all tracked actions in reverse order""" + actions = list(reversed(self._tracker.get_actions())) + + if not actions: + self._logger.info("No actions to rollback") + return + + self._logger.info(f"Rolling back {len(actions)} actions...") + + for action in actions: + try: + self._rollback_action(action) + except Exception as e: + # Log but don't fail rollback + self._logger.error( + f"Failed to rollback action {action.action_type}: {e}" + ) + + self._logger.info("Rollback complete") + + def _rollback_action(self, action: DeploymentAction) -> None: + """ + Rollback single action based on type + + Args: + action: DeploymentAction to rollback + """ + if action.action_type == "dns_added": + self._rollback_dns(action) + elif action.action_type == "containers_started": + self._rollback_containers(action) + elif action.action_type == "env_updated": + self._rollback_env(action) + else: + self._logger.warning(f"Unknown action type: {action.action_type}") + + def _rollback_dns(self, action: DeploymentAction) -> None: + """ + Rollback DNS changes + + Args: + action: DeploymentAction with DNS details + """ + record_id = action.rollback_data.get("record_id") + if record_id: + self._logger.info(f"Rolling back DNS record: {record_id}") + try: + self._dns_manager.remove_record_by_id( + record_id, + dry_run=self._config.dry_run + ) + self._logger.info("✓ DNS record removed") + except DNSError as e: + self._logger.error(f"Failed to remove DNS record: {e}") + + def _rollback_containers(self, action: DeploymentAction) -> None: + """ + Stop and remove containers + + Args: + action: DeploymentAction with container details + """ + self._logger.info("Rolling back Docker containers") + try: + self._docker_manager.stop_services(dry_run=self._config.dry_run) + self._logger.info("✓ Docker services stopped") + except DockerError as e: + self._logger.error(f"Failed to stop Docker services: {e}") + + def _rollback_env(self, action: DeploymentAction) -> None: + """ + Restore .env file from backup + + Args: + action: DeploymentAction with backup path + """ + backup_path_str = action.rollback_data.get("backup_path") + if backup_path_str: + backup_path = Path(backup_path_str) + if backup_path.exists(): + self._logger.info(f"Rolling back .env file from {backup_path}") + try: + self._env_generator.restore_env_file(backup_path) + self._logger.info("✓ .env file restored") + except Exception as e: + self._logger.error(f"Failed to restore .env file: {e}") + else: + self._logger.warning(f"Backup file not found: {backup_path}") + + def _save_deployment_config( + self, + env_values: EnvValues, + dns_record_id: str, + dns_ip: str, + containers: List + ) -> None: + """ + Save deployment configuration for later cleanup + + Args: + env_values: EnvValues with deployment info + dns_record_id: Cloudflare DNS record ID + dns_ip: IP address used in DNS + containers: List of container information + """ + try: + # Extract container names, volumes, and networks + container_names = [c.name for c in containers if hasattr(c, 'name')] + + # Get volumes and networks from docker-compose + volumes = [ + f"{env_values.compose_project_name}_db_data", + f"{env_values.compose_project_name}_wp_data" + ] + + networks = [ + f"{env_values.compose_project_name}_internal" + ] + + # Create metadata + metadata = DeploymentMetadata( + subdomain=env_values.subdomain, + url=env_values.url, + domain=env_values.domain, + compose_project_name=env_values.compose_project_name, + db_name=env_values.db_name, + db_user=env_values.db_user, + deployment_timestamp=datetime.now().isoformat(), + dns_record_id=dns_record_id, + dns_ip=dns_ip, + containers=container_names, + volumes=volumes, + networks=networks, + env_file_path=str(self._config.env_file.absolute()) + ) + + # Save configuration + config_path = self._config_manager.save_deployment(metadata) + self._logger.info(f"✓ Deployment config saved: {config_path}") + + except Exception as e: + self._logger.warning(f"Failed to save deployment config: {e}") diff --git a/wordpress/wordpress_deployer/webhooks.py b/wordpress/wordpress_deployer/webhooks.py new file mode 100644 index 0000000..3616c2e --- /dev/null +++ b/wordpress/wordpress_deployer/webhooks.py @@ -0,0 +1,199 @@ +""" +Webhook notifications module + +Send deployment event notifications with retry logic +""" + +import logging +import time +from dataclasses import asdict, dataclass +from datetime import datetime +from typing import Any, Dict, Optional + +import requests + + +logger = logging.getLogger(__name__) + + +@dataclass +class WebhookEvent: + """Webhook event data""" + event_type: str # deployment_started, deployment_success, etc. + timestamp: str + subdomain: str + url: str + message: str + metadata: Dict[str, Any] + + +class WebhookNotifier: + """Send webhook notifications with retry logic""" + + def __init__( + self, + webhook_url: Optional[str], + timeout: int, + max_retries: int + ): + """ + Initialize webhook notifier + + Args: + webhook_url: Webhook URL to send notifications to (None to disable) + timeout: Request timeout in seconds + max_retries: Maximum number of retry attempts + """ + self._webhook_url = webhook_url + self._timeout = timeout + self._max_retries = max_retries + self._logger = logging.getLogger(f"{__name__}.WebhookNotifier") + + if not webhook_url: + self._logger.debug("Webhook notifications disabled (no URL configured)") + + def notify(self, event: WebhookEvent) -> None: + """ + Send webhook notification with retry + + Args: + event: WebhookEvent to send + + Note: + Failures are logged but don't raise exceptions to avoid + failing deployments due to webhook issues + """ + if not self._webhook_url: + return + + payload = asdict(event) + + self._logger.debug(f"Sending webhook: {event.event_type}") + + for attempt in range(1, self._max_retries + 1): + try: + response = requests.post( + self._webhook_url, + json=payload, + timeout=self._timeout + ) + response.raise_for_status() + + self._logger.debug( + f"Webhook sent successfully: {event.event_type} " + f"(attempt {attempt})" + ) + return + + except requests.RequestException as e: + self._logger.warning( + f"Webhook delivery failed (attempt {attempt}/{self._max_retries}): {e}" + ) + + if attempt < self._max_retries: + # Exponential backoff: 1s, 2s, 4s, etc. + backoff = 2 ** (attempt - 1) + self._logger.debug(f"Retrying in {backoff}s...") + time.sleep(backoff) + + self._logger.error( + f"Failed to deliver webhook after {self._max_retries} attempts: " + f"{event.event_type}" + ) + + def deployment_started(self, subdomain: str, url: str) -> None: + """ + Convenience method for deployment_started event + + Args: + subdomain: Subdomain being deployed + url: Full URL being deployed + """ + event = WebhookEvent( + event_type="deployment_started", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=subdomain, + url=url, + message=f"Deployment started for {url}", + metadata={} + ) + self.notify(event) + + def deployment_success( + self, + subdomain: str, + url: str, + duration: float + ) -> None: + """ + Convenience method for deployment_success event + + Args: + subdomain: Subdomain that was deployed + url: Full URL that was deployed + duration: Deployment duration in seconds + """ + event = WebhookEvent( + event_type="deployment_success", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=subdomain, + url=url, + message=f"Deployment successful for {url}", + metadata={"duration": round(duration, 2)} + ) + self.notify(event) + + def deployment_failed(self, subdomain: str, error: str, url: str = "") -> None: + """ + Convenience method for deployment_failed event + + Args: + subdomain: Subdomain that failed to deploy + error: Error message + url: Full URL (may be empty if deployment failed early) + """ + event = WebhookEvent( + event_type="deployment_failed", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=subdomain, + url=url, + message=f"Deployment failed: {error}", + metadata={"error": error} + ) + self.notify(event) + + def dns_added(self, hostname: str, ip: str) -> None: + """ + Convenience method for dns_added event + + Args: + hostname: Hostname that was added to DNS + ip: IP address the hostname points to + """ + event = WebhookEvent( + event_type="dns_added", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=hostname.split('.')[0], # Extract subdomain + url=hostname, + message=f"DNS record added for {hostname}", + metadata={"ip": ip} + ) + self.notify(event) + + def health_check_passed(self, url: str, duration: float) -> None: + """ + Convenience method for health_check_passed event + + Args: + url: URL that passed health check + duration: Time taken for health check in seconds + """ + event = WebhookEvent( + event_type="health_check_passed", + timestamp=datetime.utcnow().isoformat() + "Z", + subdomain=url.split('.')[0].replace('https://', '').replace('http://', ''), + url=url, + message=f"Health check passed for {url}", + metadata={"duration": round(duration, 2)} + ) + self.notify(event)