File size: 14,007 Bytes
4efde5d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
#!/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