530 lines
16 KiB
Python
Executable File
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()
|