From da797c21a1d9314958e5f41edf05e608af15546a Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Sat, 18 Oct 2025 12:24:58 -0500 Subject: [PATCH] Story 1.6 finished - added sync --- docs/technical-debt.md | 74 +++++++++++++++++ scripts/create_first_admin.py | 71 ++++++++++++++++ scripts/list_users.py | 47 +++++++++++ src/cli/commands.py | 149 ++++++++++++++++++++++++++++++++++ src/deployment/bunnynet.py | 32 ++++++++ 5 files changed, 373 insertions(+) create mode 100644 docs/technical-debt.md create mode 100644 scripts/create_first_admin.py create mode 100644 scripts/list_users.py diff --git a/docs/technical-debt.md b/docs/technical-debt.md new file mode 100644 index 0000000..59a86dc --- /dev/null +++ b/docs/technical-debt.md @@ -0,0 +1,74 @@ +# Technical Debt & Future Enhancements + +This document tracks technical debt, future enhancements, and features that were deferred from the MVP. + +--- + +## Story 1.6: Deployment Infrastructure Management + +### Domain Health Check / Verification Status + +**Priority**: Medium +**Epic Suggestion**: Epic 4 (Deployment) or Epic 3 (Pre-deployment) +**Estimated Effort**: Small (1-2 days) + +#### Problem +After importing or provisioning sites, there's no way to verify: +- Domain ownership is still valid (user didn't let domain expire) +- DNS configuration is correct and pointing to bunny.net +- Custom domain is actually serving content +- SSL certificates are valid + +With 50+ domains, manual checking is impractical. + +#### Proposed Solution + +**Option 1: Active Health Check** +1. Create a health check file in each Storage Zone (e.g., `.health-check.txt`) +2. Periodically attempt to fetch it via the custom domain +3. Record results in database + +**Option 2: Use bunny.net API** +- Check if bunny.net exposes domain verification status via API +- Query verification status for each custom hostname + +**Database Changes** +Add `health_status` field to `SiteDeployment` table: +- `unknown` - Not yet checked +- `healthy` - Domain resolving and serving content +- `dns_failure` - Cannot resolve domain +- `ssl_error` - Certificate issues +- `unreachable` - Domain not responding +- `expired` - Likely domain ownership lost + +Add `last_health_check` timestamp field. + +**CLI Commands** +```bash +# Check single domain +check-site-health --domain www.example.com + +# Check all domains +check-all-sites-health + +# List unhealthy sites +list-sites --status unhealthy +``` + +**Use Cases** +- Automated monitoring to detect when domains expire +- Pre-deployment validation before pushing new content +- Dashboard showing health of entire portfolio +- Alert system for broken domains + +#### Impact +- Prevents wasted effort deploying to expired domains +- Early detection of DNS/SSL issues +- Better operational visibility across large domain portfolios + +--- + +## Future Sections + +Add new technical debt items below as they're identified during development. + diff --git a/scripts/create_first_admin.py b/scripts/create_first_admin.py new file mode 100644 index 0000000..a37e604 --- /dev/null +++ b/scripts/create_first_admin.py @@ -0,0 +1,71 @@ +""" +Bootstrap script to create the first admin user +This script does NOT require authentication (use only for initial setup) + +Usage: + python scripts/create_first_admin.py +""" + +import sys +from pathlib import Path + +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from src.database.session import db_manager +from src.database.repositories import UserRepository +from src.auth.service import AuthService + + +def create_first_admin(): + """Create the first admin user without requiring authentication""" + print("Creating first admin user...") + print("=" * 60) + + username = input("Enter admin username: ") + password = input("Enter admin password: ") + confirm_password = input("Confirm admin password: ") + + if password != confirm_password: + print("Error: Passwords do not match") + sys.exit(1) + + if len(password) < 8: + print("Warning: Password should be at least 8 characters") + response = input("Continue anyway? (yes/no): ") + if response.lower() != "yes": + sys.exit(1) + + session = db_manager.get_session() + try: + user_repo = UserRepository(session) + + if user_repo.exists(username): + print(f"Error: User '{username}' already exists") + sys.exit(1) + + auth_service = AuthService(user_repo) + user = auth_service.create_user_with_hashed_password( + username=username, + password=password, + role="Admin" + ) + + print("=" * 60) + print(f"Success! Admin user '{user.username}' created.") + print("=" * 60) + print("\nYou can now use this account with CLI commands:") + print(f" --admin-user {username}") + print(f" --admin-password [your-password]") + + except Exception as e: + print(f"Error creating admin user: {e}") + sys.exit(1) + finally: + session.close() + + +if __name__ == "__main__": + create_first_admin() + + diff --git a/scripts/list_users.py b/scripts/list_users.py new file mode 100644 index 0000000..517295b --- /dev/null +++ b/scripts/list_users.py @@ -0,0 +1,47 @@ +""" +List all users in the database + +Usage: + python scripts/list_users.py +""" + +import sys +from pathlib import Path + +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from src.database.session import db_manager +from src.database.repositories import UserRepository + + +def list_users(): + """List all users in the database""" + session = db_manager.get_session() + try: + user_repo = UserRepository(session) + users = user_repo.get_all() + + if not users: + print("No users found in database") + return + + print(f"\nTotal users: {len(users)}") + print("=" * 70) + print(f"{'ID':<5} {'Username':<20} {'Role':<10} {'Created'}") + print("=" * 70) + + for user in users: + created = user.created_at.strftime("%Y-%m-%d %H:%M:%S") + print(f"{user.id:<5} {user.username:<20} {user.role:<10} {created}") + + print("=" * 70) + + finally: + session.close() + + +if __name__ == "__main__": + list_users() + + diff --git a/src/cli/commands.py b/src/cli/commands.py index fb46254..e2cd4d3 100644 --- a/src/cli/commands.py +++ b/src/cli/commands.py @@ -601,5 +601,154 @@ def remove_site(domain: str, admin_user: Optional[str], admin_password: Optional raise click.Abort() +@app.command("sync-sites") +@click.option("--admin-user", help="Admin username for authentication") +@click.option("--admin-password", help="Admin password for authentication") +@click.option("--dry-run", is_flag=True, help="Show what would be imported without making changes") +def sync_sites(admin_user: Optional[str], admin_password: Optional[str], dry_run: bool): + """Sync existing bunny.net sites with custom domains to database (requires admin)""" + try: + # Authenticate admin + if not admin_user or not admin_password: + admin_user, admin_password = prompt_admin_credentials() + + admin = authenticate_admin(admin_user, admin_password) + if not admin: + click.echo("Error: Authentication failed or insufficient permissions", err=True) + raise click.Abort() + + # Get bunny.net API key + try: + api_key = get_bunny_account_api_key() + except ValueError as e: + click.echo(f"Error: {e}", err=True) + click.echo("Please set BUNNY_ACCOUNT_API_KEY in your .env file", err=True) + raise click.Abort() + + click.echo("\nSyncing sites from bunny.net...") + if dry_run: + click.echo("DRY RUN MODE - No changes will be made\n") + + # Initialize bunny.net client + client = BunnyNetClient(api_key) + session = db_manager.get_session() + + try: + deployment_repo = SiteDeploymentRepository(session) + + # Get all storage zones (with passwords!) + click.echo("Fetching Storage Zones from bunny.net...") + storage_zones = client.get_storage_zones() + storage_zone_map = {zone["Id"]: zone for zone in storage_zones} + click.echo(f" Found {len(storage_zones)} Storage Zones") + + # Get all pull zones + click.echo("Fetching Pull Zones from bunny.net...") + pull_zones = client.get_pull_zones() + click.echo(f" Found {len(pull_zones)} Pull Zones") + + # Process pull zones with custom hostnames + imported = 0 + skipped = 0 + errors = 0 + + click.echo("\nProcessing Pull Zones with custom domains...") + click.echo("=" * 80) + + for pz in pull_zones: + # Skip if not linked to storage zone + if not pz.get("StorageZoneId"): + continue + + storage_zone_id = pz["StorageZoneId"] + storage_zone = storage_zone_map.get(storage_zone_id) + + if not storage_zone: + continue + + # Get pull zone details to see hostnames + pz_details = client.get_pull_zone(pz["Id"]) + if not pz_details: + continue + + hostnames = pz_details.get("Hostnames", []) + + # Filter for custom hostnames (not *.b-cdn.net) + custom_hostnames = [ + h["Value"] for h in hostnames + if h.get("Value") and not h["Value"].endswith(".b-cdn.net") + ] + + if not custom_hostnames: + continue + + # Get the default b-cdn hostname + default_hostname = next( + (h["Value"] for h in hostnames if h.get("Value") and h["Value"].endswith(".b-cdn.net")), + f"{pz['Name']}.b-cdn.net" + ) + + # Import each custom hostname as a separate site deployment + for custom_hostname in custom_hostnames: + try: + # Check if already exists + if deployment_repo.exists(custom_hostname): + click.echo(f"SKIP: {custom_hostname} (already in database)") + skipped += 1 + continue + + if dry_run: + click.echo(f"WOULD IMPORT: {custom_hostname}") + click.echo(f" Storage Zone: {storage_zone['Name']} (Region: {storage_zone.get('Region', 'Unknown')})") + click.echo(f" Pull Zone: {pz['Name']} (ID: {pz['Id']})") + click.echo(f" b-cdn Hostname: {default_hostname}") + imported += 1 + else: + # Create site deployment + deployment = deployment_repo.create( + site_name=storage_zone['Name'], + custom_hostname=custom_hostname, + storage_zone_id=storage_zone['Id'], + storage_zone_name=storage_zone['Name'], + storage_zone_password=storage_zone.get('Password', ''), + storage_zone_region=storage_zone.get('Region', ''), + pull_zone_id=pz['Id'], + pull_zone_bcdn_hostname=default_hostname + ) + + click.echo(f"IMPORTED: {custom_hostname}") + click.echo(f" Storage Zone: {storage_zone['Name']} (Region: {storage_zone.get('Region', 'Unknown')})") + click.echo(f" Pull Zone: {pz['Name']} (ID: {pz['Id']})") + imported += 1 + + except Exception as e: + click.echo(f"ERROR importing {custom_hostname}: {e}", err=True) + errors += 1 + + click.echo("=" * 80) + click.echo(f"\nSync Summary:") + click.echo(f" Imported: {imported}") + click.echo(f" Skipped (already exists): {skipped}") + click.echo(f" Errors: {errors}") + + if dry_run: + click.echo("\nDRY RUN complete - no changes were made") + click.echo("Run without --dry-run to import these sites") + + except BunnyNetAuthError as e: + click.echo(f"Error: Authentication failed - {e}", err=True) + click.echo("Please check your BUNNY_ACCOUNT_API_KEY", err=True) + raise click.Abort() + except BunnyNetAPIError as e: + click.echo(f"Error: bunny.net API error - {e}", err=True) + raise click.Abort() + finally: + session.close() + + except Exception as e: + click.echo(f"Error syncing sites: {e}", err=True) + raise click.Abort() + + if __name__ == "__main__": app() diff --git a/src/deployment/bunnynet.py b/src/deployment/bunnynet.py index fc795bc..d205fad 100644 --- a/src/deployment/bunnynet.py +++ b/src/deployment/bunnynet.py @@ -242,4 +242,36 @@ class BunnyNetClient: self._make_request("POST", f"/pullzone/{pull_zone_id}/addHostname", json_data=data) return True + + def get_pull_zones(self) -> List[Dict[str, Any]]: + """ + Get all Pull Zones + + Returns: + List of Pull Zone dictionaries + + Raises: + BunnyNetAPIError: For API errors + """ + response = self._make_request("GET", "/pullzone") + return response if isinstance(response, list) else [] + + def get_pull_zone(self, pull_zone_id: int) -> Optional[Dict[str, Any]]: + """ + Get details for a specific Pull Zone + + Args: + pull_zone_id: The Pull Zone ID + + Returns: + Pull Zone details dictionary if found, None otherwise + + Raises: + BunnyNetAPIError: For API errors + """ + try: + response = self._make_request("GET", f"/pullzone/{pull_zone_id}") + return response + except BunnyNetAPIError: + return None