#!/usr/bin/env python3 """ Script to delete sandboxes for free tier users based on sandbox IDs. For each SANDBOX_ID provided: 1. Finds matching project by checking JSONB data in projects table 2. Gets account_id from the matching project row 3. Checks user's Stripe subscription status via billing system 4. If user is on free tier, deletes the sandbox via Daytona API Usage: python delete_free_user_sandboxes.py [--dry-run] [--sandbox-ids ID1,ID2,ID3] [--use-json file.json] """ PROD_SUPABASE_URL = "https://jbriwassebxdwoieikga.supabase.co" # Your production Supabase URL PROD_SUPABASE_KEY = "" # Your production Supabase service role key PROD_STRIPE_SECRET_KEY = "" # Your production Stripe secret key import dotenv import os dotenv.load_dotenv(".env") # Override with production credentials if provided if PROD_SUPABASE_URL: os.environ['SUPABASE_URL'] = PROD_SUPABASE_URL if PROD_SUPABASE_KEY: os.environ['SUPABASE_SERVICE_ROLE_KEY'] = PROD_SUPABASE_KEY if PROD_STRIPE_SECRET_KEY: os.environ['STRIPE_SECRET_KEY'] = PROD_STRIPE_SECRET_KEY import sys import argparse import json import re from datetime import datetime from typing import List, Optional, Dict, Set from utils.config import config from utils.logger import logger from services.supabase import DBConnection from services.billing import get_user_subscription, get_subscription_tier try: from daytona import Daytona except ImportError: print("Error: Daytona Python SDK not found. Please install it with: pip install daytona") sys.exit(1) def parse_sandbox_string(sandbox_str: str) -> Optional[str]: """Parse sandbox string representation to extract ID.""" # Extract ID using regex id_match = re.search(r"id='([^']+)'", sandbox_str) return id_match.group(1) if id_match else None def get_sandbox_ids_from_json(json_file: str) -> List[str]: """Extract all sandbox IDs from JSON file.""" try: with open(json_file, 'r') as f: sandboxes_data = json.load(f) sandbox_ids = [] for sandbox_str in sandboxes_data: sandbox_id = parse_sandbox_string(sandbox_str) if sandbox_id: sandbox_ids.append(sandbox_id) return sandbox_ids except Exception as e: logger.error(f"Failed to parse JSON file: {e}") return [] async def find_project_by_sandbox_id(client, sandbox_id: str) -> Optional[Dict]: """ Find project that contains the given sandbox_id in its JSONB data. Args: client: Supabase client sandbox_id: The sandbox ID to search for Returns: Project row dict if found, None otherwise """ try: # Query projects table for JSONB data containing the sandbox ID # The JSONB structure is like: {"id": "sandbox_id", "pass": "...", ...} result = await client.table('projects') \ .select('project_id, account_id, sandbox') \ .eq('sandbox->>id', sandbox_id) \ .execute() if result.data and len(result.data) > 0: project = result.data[0] logger.debug(f"Found project {project['project_id']} for sandbox {sandbox_id}") return project return None except Exception as e: logger.error(f"Error searching for project with sandbox {sandbox_id}: {e}") return None async def is_user_free_tier(user_id: str) -> tuple[bool, str]: """ Check if user is on free tier. Args: user_id: The user ID to check Returns: Tuple of (is_free_tier, subscription_info) """ try: # Get user's subscription subscription = await get_user_subscription(user_id) if not subscription: # No subscription = free tier return True, "no_subscription" # Extract price ID from subscription price_id = None if subscription.get('items') and subscription['items'].get('data') and len(subscription['items']['data']) > 0: price_id = subscription['items']['data'][0]['price']['id'] else: price_id = subscription.get('price_id', config.STRIPE_FREE_TIER_ID) # Check if price ID matches free tier is_free = price_id == config.STRIPE_FREE_TIER_ID subscription_info = f"price_id={price_id}, free_tier_id={config.STRIPE_FREE_TIER_ID}" return is_free, subscription_info except Exception as e: logger.error(f"Error checking subscription for user {user_id}: {e}") # Default to false (don't delete) if we can't determine subscription return False, f"error: {str(e)}" async def delete_sandbox_if_free_user( daytona_client, supabase_client, sandbox_id: str, dry_run: bool = False ) -> tuple[bool, str]: """ Delete sandbox if the associated user is on free tier. Args: daytona_client: Daytona API client supabase_client: Supabase database client sandbox_id: The sandbox ID to potentially delete dry_run: If True, only simulate the action Returns: Tuple of (action_taken, reason) """ try: # Find project associated with this sandbox project = await find_project_by_sandbox_id(supabase_client, sandbox_id) if not project: return False, "project_not_found" account_id = project['account_id'] project_id = project['project_id'] # Check if user is on free tier is_free, subscription_info = await is_user_free_tier(account_id) if not is_free: return False, f"paid_user ({subscription_info})" # User is on free tier - delete sandbox if dry_run: return True, f"would_delete (project: {project_id}, user: {account_id}, {subscription_info})" else: # Actually delete the sandbox try: sandbox = daytona_client.get(sandbox_id) sandbox.delete() logger.info(f"Successfully deleted sandbox {sandbox_id} for free user {account_id}") return True, f"deleted (project: {project_id}, user: {account_id}, {subscription_info})" except Exception as delete_error: logger.error(f"Failed to delete sandbox {sandbox_id}: {delete_error}") return False, f"delete_failed: {str(delete_error)}" except Exception as e: logger.error(f"Error processing sandbox {sandbox_id}: {e}") return False, f"error: {str(e)}" async def delete_free_user_sandboxes( sandbox_ids: List[str], dry_run: bool = False ) -> Dict[str, int]: """ Main function to delete sandboxes for free tier users. Args: sandbox_ids: List of sandbox IDs to process dry_run: If True, only simulate actions Returns: Dictionary with statistics """ # Initialize clients try: daytona = Daytona() logger.info("✓ Connected to Daytona") except Exception as e: logger.error(f"✗ Failed to connect to Daytona: {e}") return {"error": 1} try: db = DBConnection() await db.initialize() supabase_client = await db.client logger.info("✓ Connected to Supabase") except Exception as e: logger.error(f"✗ Failed to connect to Supabase: {e}") return {"error": 1} # Track statistics stats = { "total_processed": 0, "deleted": 0, "skipped_paid_user": 0, "skipped_project_not_found": 0, "errors": 0 } logger.info(f"Processing {len(sandbox_ids)} sandbox IDs...") # Process each sandbox ID for i, sandbox_id in enumerate(sandbox_ids): stats["total_processed"] += 1 logger.info(f"[{i+1}/{len(sandbox_ids)}] Processing sandbox: {sandbox_id}") action_taken, reason = await delete_sandbox_if_free_user( daytona, supabase_client, sandbox_id, dry_run ) if action_taken: stats["deleted"] += 1 status = "WOULD DELETE" if dry_run else "DELETED" logger.info(f" ✓ {status}: {reason}") elif "paid_user" in reason: stats["skipped_paid_user"] += 1 logger.info(f" → SKIPPED (paid user): {reason}") elif "project_not_found" in reason: stats["skipped_project_not_found"] += 1 logger.info(f" → SKIPPED (no project): {reason}") else: stats["errors"] += 1 logger.warning(f" ✗ ERROR: {reason}") # Cleanup database connection try: await db.disconnect() logger.debug("✓ Database connection closed") except Exception as e: logger.warning(f"Error closing database connection: {e}") return stats def main(): parser = argparse.ArgumentParser( description="Delete sandboxes for free tier users", epilog=""" Examples: # Dry run with specific sandbox IDs python delete_free_user_sandboxes.py --dry-run --sandbox-ids "id1,id2,id3" # Process sandboxes from JSON file (limited to 10 for testing) python delete_free_user_sandboxes.py --dry-run --use-json raw_sandboxes_20250817_194448.json --limit 10 # Actually delete sandboxes (remove --dry-run when ready) python delete_free_user_sandboxes.py --use-json raw_sandboxes_20250817_194448.json --limit 100 """, formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument('--dry-run', action='store_true', help='Show what would be deleted without actually deleting') parser.add_argument('--sandbox-ids', type=str, help='Comma-separated list of sandbox IDs to process') parser.add_argument('--use-json', type=str, help='JSON file containing sandbox data (e.g., raw_sandboxes_20250817_194448.json)') parser.add_argument('--limit', type=int, help='Limit the number of sandboxes to process (for testing)') parser.add_argument('--force', action='store_true', help='Required for processing more than 50 sandboxes without dry-run') args = parser.parse_args() # Verify configuration logger.info("Configuration check:") logger.info(f" Daytona API Key: {'✓ Configured' if config.DAYTONA_API_KEY else '✗ Missing'}") logger.info(f" Daytona API URL: {config.DAYTONA_SERVER_URL}") logger.info(f" Daytona Target: {config.DAYTONA_TARGET}") logger.info(f" Supabase URL: {'✓ Configured' if config.SUPABASE_URL else '✗ Missing'}") logger.info(f" Stripe Free Tier ID: {config.STRIPE_FREE_TIER_ID}") logger.info("") if args.dry_run: logger.info("=== DRY RUN MODE ===") logger.info("No actual deletions will be performed") logger.info("") # Get sandbox IDs to process sandbox_ids = [] if args.sandbox_ids: sandbox_ids = [sid.strip() for sid in args.sandbox_ids.split(',') if sid.strip()] logger.info(f"Using {len(sandbox_ids)} sandbox IDs from command line") elif args.use_json: sandbox_ids = get_sandbox_ids_from_json(args.use_json) logger.info(f"Extracted {len(sandbox_ids)} sandbox IDs from {args.use_json}") else: logger.error("Error: Must specify either --sandbox-ids or --use-json") parser.print_help() sys.exit(1) if not sandbox_ids: logger.error("No sandbox IDs to process") sys.exit(1) # Apply limit if specified if args.limit and args.limit > 0: original_count = len(sandbox_ids) sandbox_ids = sandbox_ids[:args.limit] logger.info(f"Limited processing to {len(sandbox_ids)} sandboxes (from {original_count})") # Safety check - prevent accidental mass deletion if not args.dry_run and len(sandbox_ids) > 50 and not args.force: logger.error(f"Safety check: Attempting to delete {len(sandbox_ids)} sandboxes without --dry-run") logger.error("This operation would delete many sandboxes. Please:") logger.error("1. First run with --dry-run to see what would be deleted") logger.error("2. Use --limit to process a smaller batch") logger.error("3. Use --force flag if you really want to delete more than 50 sandboxes") sys.exit(1) # Log some sample IDs logger.info(f"Sample sandbox IDs: {sandbox_ids[:5]}...") logger.info("") # Run the deletion process import asyncio async def run(): stats = await delete_free_user_sandboxes(sandbox_ids, dry_run=args.dry_run) # Print summary logger.info("") logger.info("=== SUMMARY ===") logger.info(f"Total processed: {stats.get('total_processed', 0)}") logger.info(f"Deleted: {stats.get('deleted', 0)}") logger.info(f"Skipped (paid users): {stats.get('skipped_paid_user', 0)}") logger.info(f"Skipped (no project): {stats.get('skipped_project_not_found', 0)}") logger.info(f"Errors: {stats.get('errors', 0)}") success = stats.get('errors', 0) == 0 return success try: success = asyncio.run(run()) sys.exit(0 if success else 1) except Exception as e: logger.error(f"Script failed: {e}") sys.exit(1) if __name__ == "__main__": main() # Usage examples: # # 1. Dry run with specific sandbox IDs: # uv run python -m utils.scripts.delete_free_user_sandboxes --dry-run --sandbox-ids "id1,id2,id3" # # 2. Test with JSON file (limit to 10 sandboxes): # uv run python -m utils.scripts.delete_free_user_sandboxes --dry-run --use-json raw_sandboxes_20250817_194448.json --limit 10 # # 3. Actually delete free user sandboxes (remove --dry-run when ready): # uv run python -m utils.scripts.delete_free_user_sandboxes --use-json raw_sandboxes_20250817_194448.json --limit 100 --force