Story 1.6 finished - added sync

main
PeninsulaInd 2025-10-18 12:24:58 -05:00
parent 4cada9df42
commit da797c21a1
5 changed files with 373 additions and 0 deletions

View File

@ -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.

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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