Merakit-Deploy/gitea/destroy.py

530 lines
16 KiB
Python
Executable File

#!/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()