suna/backend/utils/scripts/delete_free_user_sandboxes.py

378 lines
14 KiB
Python

#!/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