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()
|
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__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|
|
||||||
|
|
@ -243,3 +243,35 @@ class BunnyNetClient:
|
||||||
self._make_request("POST", f"/pullzone/{pull_zone_id}/addHostname", json_data=data)
|
self._make_request("POST", f"/pullzone/{pull_zone_id}/addHostname", json_data=data)
|
||||||
return True
|
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