Story 1.6 finished - added sync
parent
4cada9df42
commit
da797c21a1
|
|
@ -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.
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -243,3 +243,35 @@ 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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue