dayhoff-tools 1.9.26__py3-none-any.whl → 1.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. dayhoff_tools/cli/engine/__init__.py +1 -323
  2. dayhoff_tools/cli/engine/coffee.py +110 -0
  3. dayhoff_tools/cli/engine/config_ssh.py +113 -0
  4. dayhoff_tools/cli/engine/debug.py +79 -0
  5. dayhoff_tools/cli/engine/gami.py +160 -0
  6. dayhoff_tools/cli/engine/idle.py +148 -0
  7. dayhoff_tools/cli/engine/launch.py +101 -0
  8. dayhoff_tools/cli/engine/list.py +116 -0
  9. dayhoff_tools/cli/engine/repair.py +128 -0
  10. dayhoff_tools/cli/engine/resize.py +195 -0
  11. dayhoff_tools/cli/engine/ssh.py +62 -0
  12. dayhoff_tools/cli/engine/{engine_core.py → status.py} +6 -201
  13. dayhoff_tools/cli/engine_studio_commands.py +323 -0
  14. dayhoff_tools/cli/engine_studio_utils/__init__.py +1 -0
  15. dayhoff_tools/cli/engine_studio_utils/api_utils.py +47 -0
  16. dayhoff_tools/cli/engine_studio_utils/aws_utils.py +102 -0
  17. dayhoff_tools/cli/engine_studio_utils/constants.py +21 -0
  18. dayhoff_tools/cli/engine_studio_utils/formatting.py +210 -0
  19. dayhoff_tools/cli/engine_studio_utils/ssh_utils.py +141 -0
  20. dayhoff_tools/cli/main.py +1 -2
  21. dayhoff_tools/cli/studio/__init__.py +1 -0
  22. dayhoff_tools/cli/studio/attach.py +314 -0
  23. dayhoff_tools/cli/studio/create.py +48 -0
  24. dayhoff_tools/cli/studio/delete.py +71 -0
  25. dayhoff_tools/cli/studio/detach.py +56 -0
  26. dayhoff_tools/cli/studio/list.py +81 -0
  27. dayhoff_tools/cli/studio/reset.py +90 -0
  28. dayhoff_tools/cli/studio/resize.py +134 -0
  29. dayhoff_tools/cli/studio/status.py +78 -0
  30. {dayhoff_tools-1.9.26.dist-info → dayhoff_tools-1.10.1.dist-info}/METADATA +1 -1
  31. dayhoff_tools-1.10.1.dist-info/RECORD +61 -0
  32. dayhoff_tools/cli/engine/engine_maintenance.py +0 -431
  33. dayhoff_tools/cli/engine/engine_management.py +0 -505
  34. dayhoff_tools/cli/engine/shared.py +0 -501
  35. dayhoff_tools/cli/engine/studio_commands.py +0 -825
  36. dayhoff_tools-1.9.26.dist-info/RECORD +0 -39
  37. /dayhoff_tools/cli/engine/{engine_lifecycle.py → lifecycle.py} +0 -0
  38. {dayhoff_tools-1.9.26.dist-info → dayhoff_tools-1.10.1.dist-info}/WHEEL +0 -0
  39. {dayhoff_tools-1.9.26.dist-info → dayhoff_tools-1.10.1.dist-info}/entry_points.txt +0 -0
@@ -1,825 +0,0 @@
1
- """Studio management commands: create, attach, detach, delete, list, reset, resize."""
2
-
3
- import time
4
- from datetime import timedelta
5
- from typing import Dict, Optional
6
-
7
- import boto3
8
- import typer
9
- from botocore.exceptions import ClientError
10
- from rich import box
11
- from rich.panel import Panel
12
- from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
13
- from rich.prompt import Confirm, IntPrompt, Prompt
14
- from rich.table import Table
15
-
16
- from .shared import (
17
- check_aws_sso,
18
- check_session_manager_plugin,
19
- console,
20
- format_duration,
21
- get_ssh_public_key,
22
- get_studio_disk_usage_via_ssm,
23
- get_user_studio,
24
- make_api_request,
25
- resolve_engine,
26
- update_ssh_config_entry,
27
- )
28
-
29
-
30
- def create_studio(
31
- size_gb: int = typer.Option(50, "--size", "-s", help="Studio size in GB"),
32
- ):
33
- """Create a new studio for the current user."""
34
- username = check_aws_sso()
35
-
36
- # Check if user already has a studio
37
- existing = get_user_studio(username)
38
- if existing:
39
- console.print(
40
- f"[yellow]You already have a studio: {existing['studio_id']}[/yellow]"
41
- )
42
- return
43
-
44
- console.print(f"Creating {size_gb}GB studio for user [cyan]{username}[/cyan]...")
45
-
46
- with Progress(
47
- SpinnerColumn(),
48
- TextColumn("[progress.description]{task.description}"),
49
- transient=True,
50
- ) as progress:
51
- progress.add_task("Creating studio volume...", total=None)
52
-
53
- response = make_api_request(
54
- "POST",
55
- "/studios",
56
- json_data={"user": username, "size_gb": size_gb},
57
- )
58
-
59
- if response.status_code == 201:
60
- data = response.json()
61
- console.print(f"[green]✓ Studio created successfully![/green]")
62
- console.print(f"Studio ID: [cyan]{data['studio_id']}[/cyan]")
63
- console.print(f"Size: {data['size_gb']}GB")
64
- console.print(f"\nNext step: [cyan]dh studio attach <engine-name>[/cyan]")
65
- else:
66
- error = response.json().get("error", "Unknown error")
67
- console.print(f"[red]❌ Failed to create studio: {error}[/red]")
68
-
69
-
70
- def studio_status(
71
- user: Optional[str] = typer.Option(
72
- None, "--user", "-u", help="Check status for a different user (admin only)"
73
- ),
74
- ):
75
- """Show status of your studio."""
76
- username = check_aws_sso()
77
-
78
- # Use specified user if provided, otherwise use current user
79
- target_user = user if user else username
80
-
81
- # Add warning when checking another user's studio
82
- if target_user != username:
83
- console.print(
84
- f"[yellow]⚠️ Checking studio status for user: {target_user}[/yellow]"
85
- )
86
-
87
- studio = get_user_studio(target_user)
88
- if not studio:
89
- if target_user == username:
90
- console.print("[yellow]You don't have a studio yet.[/yellow]")
91
- console.print("Create one with: [cyan]dh studio create[/cyan]")
92
- else:
93
- console.print(f"[yellow]User {target_user} doesn't have a studio.[/yellow]")
94
- return
95
-
96
- # Create status panel
97
- # Format status with colors
98
- status = studio["status"]
99
- if status == "in-use":
100
- status_display = "[bright_blue]attached[/bright_blue]"
101
- elif status in ["attaching", "detaching"]:
102
- status_display = f"[yellow]{status}[/yellow]"
103
- else:
104
- status_display = f"[green]{status}[/green]"
105
-
106
- status_lines = [
107
- f"[bold]Studio ID:[/bold] {studio['studio_id']}",
108
- f"[bold]User:[/bold] {studio['user']}",
109
- f"[bold]Status:[/bold] {status_display}",
110
- f"[bold]Size:[/bold] {studio['size_gb']}GB",
111
- f"[bold]Created:[/bold] {studio['creation_date']}",
112
- ]
113
-
114
- if studio.get("attached_vm_id"):
115
- status_lines.append(f"[bold]Attached to:[/bold] {studio['attached_vm_id']}")
116
-
117
- # Try to get engine details
118
- response = make_api_request("GET", "/engines")
119
- if response.status_code == 200:
120
- engines = response.json().get("engines", [])
121
- attached_engine = next(
122
- (e for e in engines if e["instance_id"] == studio["attached_vm_id"]),
123
- None,
124
- )
125
- if attached_engine:
126
- status_lines.append(
127
- f"[bold]Engine Name:[/bold] {attached_engine['name']}"
128
- )
129
-
130
- panel = Panel(
131
- "\n".join(status_lines),
132
- title="Studio Details",
133
- border_style="blue",
134
- )
135
- console.print(panel)
136
-
137
-
138
- def _is_studio_attached(target_studio_id: str, target_vm_id: str) -> bool:
139
- """Return True when the given studio already shows as attached to the VM.
140
-
141
- Using this extra check lets us stop the outer retry loop as soon as the
142
- asynchronous attach operation actually finishes, even in the unlikely
143
- event that the operation-tracking DynamoDB record is not yet updated.
144
- """
145
- # First try the per-studio endpoint – fastest.
146
- resp = make_api_request("GET", f"/studios/{target_studio_id}")
147
- if resp.status_code == 200:
148
- data = resp.json()
149
- if (
150
- data.get("status") == "in-use"
151
- and data.get("attached_vm_id") == target_vm_id
152
- ):
153
- return True
154
- # Fallback: list + filter (covers edge-cases where the direct endpoint
155
- # is slower to update IAM/APIGW mapping than the list endpoint).
156
- list_resp = make_api_request("GET", "/studios")
157
- if list_resp.status_code == 200:
158
- for stu in list_resp.json().get("studios", []):
159
- if (
160
- stu.get("studio_id") == target_studio_id
161
- and stu.get("status") == "in-use"
162
- and stu.get("attached_vm_id") == target_vm_id
163
- ):
164
- return True
165
- return False
166
-
167
-
168
- def attach_studio(
169
- engine_name_or_id: str = typer.Argument(help="Engine name or instance ID"),
170
- user: Optional[str] = typer.Option(
171
- None, "--user", "-u", help="Attach a different user's studio (admin only)"
172
- ),
173
- ):
174
- """Attach your studio to an engine."""
175
- username = check_aws_sso()
176
-
177
- # Check for Session Manager Plugin since we'll update SSH config
178
- if not check_session_manager_plugin():
179
- raise typer.Exit(1)
180
-
181
- # Use specified user if provided, otherwise use current user
182
- target_user = user if user else username
183
-
184
- # Add confirmation when attaching another user's studio
185
- if target_user != username:
186
- console.print(f"[yellow]⚠️ Managing studio for user: {target_user}[/yellow]")
187
- if not Confirm.ask(f"Are you sure you want to attach {target_user}'s studio?"):
188
- console.print("Operation cancelled.")
189
- return
190
-
191
- # Get user's studio
192
- studio = get_user_studio(target_user)
193
- if not studio:
194
- if target_user == username:
195
- console.print("[yellow]You don't have a studio yet.[/yellow]")
196
- if Confirm.ask("Would you like to create one now?"):
197
- size = IntPrompt.ask("Studio size (GB)", default=50)
198
- response = make_api_request(
199
- "POST",
200
- "/studios",
201
- json_data={"user": username, "size_gb": size},
202
- )
203
- if response.status_code != 201:
204
- console.print("[red]❌ Failed to create studio[/red]")
205
- raise typer.Exit(1)
206
- studio = response.json()
207
- studio["studio_id"] = studio["studio_id"] # Normalize key
208
- else:
209
- raise typer.Exit(0)
210
- else:
211
- console.print(f"[red]❌ User {target_user} doesn't have a studio.[/red]")
212
- raise typer.Exit(1)
213
-
214
- # Check if already attached
215
- if studio.get("status") == "in-use":
216
- console.print(
217
- f"[yellow]Studio is already attached to {studio.get('attached_vm_id')}[/yellow]"
218
- )
219
- if not Confirm.ask("Detach and reattach to new engine?"):
220
- return
221
- # Detach first
222
- response = make_api_request("POST", f"/studios/{studio['studio_id']}/detach")
223
- if response.status_code != 200:
224
- console.print("[red]❌ Failed to detach studio[/red]")
225
- raise typer.Exit(1)
226
-
227
- # Get all engines to resolve name
228
- response = make_api_request("GET", "/engines")
229
- if response.status_code != 200:
230
- console.print("[red]❌ Failed to fetch engines[/red]")
231
- raise typer.Exit(1)
232
-
233
- engines = response.json().get("engines", [])
234
- engine = resolve_engine(engine_name_or_id, engines)
235
-
236
- # Flag to track if we started the engine in this command (affects retry length)
237
- engine_started_now: bool = False
238
-
239
- if engine["state"].lower() != "running":
240
- console.print(f"[yellow]⚠️ Engine is {engine['state']}[/yellow]")
241
- if engine["state"].lower() == "stopped" and Confirm.ask(
242
- "Start the engine first?"
243
- ):
244
- response = make_api_request(
245
- "POST", f"/engines/{engine['instance_id']}/start"
246
- )
247
- if response.status_code != 200:
248
- console.print("[red]❌ Failed to start engine[/red]")
249
- raise typer.Exit(1)
250
- console.print("[green]✓ Engine started[/green]")
251
- # Mark that we booted the engine so attach loop gets extended retries
252
- engine_started_now = True
253
- # No further waiting here – attachment attempts below handle retry logic while the
254
- # engine finishes booting.
255
- else:
256
- raise typer.Exit(1)
257
-
258
- # Retrieve SSH public key (required for authorised_keys provisioning)
259
- try:
260
- public_key = get_ssh_public_key()
261
- except FileNotFoundError as e:
262
- console.print(f"[red]❌ {e}[/red]")
263
- raise typer.Exit(1)
264
-
265
- console.print(f"Attaching studio to engine [cyan]{engine['name']}[/cyan]...")
266
-
267
- # Determine retry strategy based on whether we just started the engine
268
- if engine_started_now:
269
- max_attempts = 40 # About 7 minutes total with exponential backoff
270
- base_delay = 8
271
- max_delay = 20
272
- else:
273
- max_attempts = 15 # About 2 minutes total with exponential backoff
274
- base_delay = 5
275
- max_delay = 10
276
-
277
- # Unified retry loop with exponential backoff
278
- with Progress(
279
- SpinnerColumn(),
280
- TimeElapsedColumn(),
281
- TextColumn("[progress.description]{task.description}"),
282
- transient=True,
283
- ) as prog:
284
- desc = (
285
- "Attaching studio (engine is still booting)…"
286
- if engine_started_now
287
- else "Attaching studio…"
288
- )
289
- task = prog.add_task(desc, total=None)
290
-
291
- consecutive_not_ready = 0
292
- last_error = None
293
-
294
- for attempt in range(max_attempts):
295
- # Check if the attach already completed
296
- if _is_studio_attached(studio["studio_id"], engine["instance_id"]):
297
- success = True
298
- break
299
-
300
- success, error_msg = _attempt_studio_attach(
301
- studio, engine, target_user, public_key
302
- )
303
-
304
- if success:
305
- break # success!
306
-
307
- if error_msg:
308
- # Fatal error – bubble up immediately
309
- console.print(f"[red]❌ Failed to attach studio: {error_msg}[/red]")
310
-
311
- # Suggest repair command if engine seems broken
312
- if "not ready" in error_msg.lower() and attempt > 5:
313
- console.print(
314
- f"\n[yellow]Engine may be in a bad state. Try:[/yellow]"
315
- )
316
- console.print(f"[dim] dh engine repair {engine['name']}[/dim]")
317
- return
318
-
319
- # Track consecutive "not ready" responses
320
- consecutive_not_ready += 1
321
- last_error = "Engine not ready"
322
-
323
- # Update progress display
324
- if attempt % 3 == 0:
325
- prog.update(
326
- task,
327
- description=f"{desc} attempt {attempt+1}/{max_attempts}",
328
- )
329
-
330
- # If engine seems stuck after many attempts, show a hint
331
- if consecutive_not_ready > 10 and attempt == 10:
332
- console.print(
333
- "[yellow]Engine is taking longer than expected to become ready.[/yellow]"
334
- )
335
- console.print(
336
- "[dim]This can happen after GAMI creation or if the engine is still bootstrapping.[/dim]"
337
- )
338
-
339
- # Exponential backoff with jitter
340
- delay = min(base_delay * (1.5 ** min(attempt, 5)), max_delay)
341
- delay += time.time() % 2 # Add 0-2 seconds of jitter
342
- time.sleep(delay)
343
-
344
- else:
345
- # All attempts exhausted
346
- console.print(
347
- f"[yellow]Engine is not becoming ready after {max_attempts} attempts.[/yellow]"
348
- )
349
- if last_error:
350
- console.print(f"[dim]Last issue: {last_error}[/dim]")
351
- console.print("\n[yellow]You can try:[/yellow]")
352
- console.print(
353
- f" 1. Wait a minute and retry: [cyan]dh studio attach {engine['name']}[/cyan]"
354
- )
355
- console.print(
356
- f" 2. Check engine status: [cyan]dh engine status {engine['name']}[/cyan]"
357
- )
358
- console.print(
359
- f" 3. Repair the engine: [cyan]dh engine repair {engine['name']}[/cyan]"
360
- )
361
- return
362
-
363
- # Successful attach path
364
- console.print(f"[green]✓ Studio attached successfully![/green]")
365
-
366
- # Update SSH config - use target_user for the connection
367
- update_ssh_config_entry(engine["name"], engine["instance_id"], target_user)
368
- console.print(f"[green]✓ SSH config updated[/green]")
369
- console.print(f"\nConnect with: [cyan]ssh {engine['name']}[/cyan]")
370
- console.print(f"Files are at: [cyan]/studios/{target_user}[/cyan]")
371
-
372
-
373
- def _attempt_studio_attach(studio, engine, target_user, public_key):
374
- response = make_api_request(
375
- "POST",
376
- f"/studios/{studio['studio_id']}/attach",
377
- json_data={
378
- "vm_id": engine["instance_id"],
379
- "user": target_user,
380
- "public_key": public_key,
381
- },
382
- )
383
-
384
- # Fast-path success
385
- if response.status_code == 200:
386
- return True, None
387
-
388
- # Asynchronous path – API returned 202 Accepted and operation tracking ID
389
- if response.status_code == 202:
390
- # The operation status polling is broken in the Lambda, so we just
391
- # wait and check if the studio is actually attached
392
- time.sleep(5) # Give the async operation a moment to start
393
-
394
- # Check periodically if the studio is attached
395
- for check in range(20): # Check for up to 60 seconds
396
- if _is_studio_attached(studio["studio_id"], engine["instance_id"]):
397
- return True, None
398
- time.sleep(3)
399
-
400
- # If we get here, attachment didn't complete in reasonable time
401
- return False, None # Return None to trigger retry
402
-
403
- # --- determine if we should retry ---
404
- recoverable = False
405
- error_text = response.json().get("error", "Unknown error")
406
- err_msg = error_text.lower()
407
-
408
- # Check for "Studio is not available (status: in-use)" which means it's already attached
409
- if (
410
- response.status_code == 400
411
- and "not available" in err_msg
412
- and "in-use" in err_msg
413
- ):
414
- # Studio is already attached somewhere - check if it's to THIS engine
415
- if _is_studio_attached(studio["studio_id"], engine["instance_id"]):
416
- return True, None # It's attached to our target engine - success!
417
- else:
418
- return False, error_text # It's attached elsewhere - fatal error
419
-
420
- if response.status_code in (409, 503):
421
- recoverable = True
422
- else:
423
- RECOVERABLE_PATTERNS = [
424
- "not ready",
425
- "still starting",
426
- "initializing",
427
- "failed to mount",
428
- "device busy",
429
- "pending", # VM state pending
430
- ]
431
- FATAL_PATTERNS = [
432
- "permission",
433
- ]
434
- if any(p in err_msg for p in FATAL_PATTERNS):
435
- recoverable = False
436
- elif any(p in err_msg for p in RECOVERABLE_PATTERNS):
437
- recoverable = True
438
-
439
- if not recoverable:
440
- # fatal – abort immediately
441
- return False, error_text
442
-
443
- # recoverable – signal caller to retry without treating as error
444
- return False, None
445
-
446
-
447
- # Note: _poll_operation was removed because the Lambda's operation tracking is broken.
448
- # We now use _is_studio_attached() to check if the studio is actually attached instead.
449
-
450
-
451
- def detach_studio(
452
- user: Optional[str] = typer.Option(
453
- None, "--user", "-u", help="Detach a different user's studio (admin only)"
454
- ),
455
- ):
456
- """Detach your studio from its current engine."""
457
- username = check_aws_sso()
458
-
459
- # Use specified user if provided, otherwise use current user
460
- target_user = user if user else username
461
-
462
- # Add confirmation when detaching another user's studio
463
- if target_user != username:
464
- console.print(f"[yellow]⚠️ Managing studio for user: {target_user}[/yellow]")
465
- if not Confirm.ask(f"Are you sure you want to detach {target_user}'s studio?"):
466
- console.print("Operation cancelled.")
467
- return
468
-
469
- studio = get_user_studio(target_user)
470
- if not studio:
471
- if target_user == username:
472
- console.print("[yellow]You don't have a studio.[/yellow]")
473
- else:
474
- console.print(f"[yellow]User {target_user} doesn't have a studio.[/yellow]")
475
- return
476
-
477
- if studio.get("status") != "in-use":
478
- if target_user == username:
479
- console.print("[yellow]Your studio is not attached to any engine.[/yellow]")
480
- else:
481
- console.print(
482
- f"[yellow]{target_user}'s studio is not attached to any engine.[/yellow]"
483
- )
484
- return
485
-
486
- console.print(f"Detaching studio from {studio.get('attached_vm_id')}...")
487
-
488
- response = make_api_request("POST", f"/studios/{studio['studio_id']}/detach")
489
-
490
- if response.status_code == 200:
491
- console.print(f"[green]✓ Studio detached successfully![/green]")
492
- else:
493
- error = response.json().get("error", "Unknown error")
494
- console.print(f"[red]❌ Failed to detach studio: {error}[/red]")
495
-
496
-
497
- def delete_studio(
498
- user: Optional[str] = typer.Option(
499
- None, "--user", "-u", help="Delete a different user's studio (admin only)"
500
- ),
501
- ):
502
- """Delete your studio permanently."""
503
- username = check_aws_sso()
504
-
505
- # Use specified user if provided, otherwise use current user
506
- target_user = user if user else username
507
-
508
- # Extra warning when deleting another user's studio
509
- if target_user != username:
510
- console.print(
511
- f"[red]⚠️ ADMIN ACTION: Deleting studio for user: {target_user}[/red]"
512
- )
513
-
514
- studio = get_user_studio(target_user)
515
- if not studio:
516
- if target_user == username:
517
- console.print("[yellow]You don't have a studio to delete.[/yellow]")
518
- else:
519
- console.print(
520
- f"[yellow]User {target_user} doesn't have a studio to delete.[/yellow]"
521
- )
522
- return
523
-
524
- console.print(
525
- "[red]⚠️ WARNING: This will permanently delete the studio and all data![/red]"
526
- )
527
- console.print(f"Studio ID: {studio['studio_id']}")
528
- console.print(f"User: {target_user}")
529
- console.print(f"Size: {studio['size_gb']}GB")
530
-
531
- # Multiple confirmations
532
- if not Confirm.ask(
533
- f"\nAre you sure you want to delete {target_user}'s studio?"
534
- if target_user != username
535
- else "\nAre you sure you want to delete your studio?"
536
- ):
537
- console.print("Deletion cancelled.")
538
- return
539
-
540
- if not Confirm.ask("[red]This action cannot be undone. Continue?[/red]"):
541
- console.print("Deletion cancelled.")
542
- return
543
-
544
- typed_confirm = Prompt.ask('Type "DELETE" to confirm permanent deletion')
545
- if typed_confirm != "DELETE":
546
- console.print("Deletion cancelled.")
547
- return
548
-
549
- response = make_api_request("DELETE", f"/studios/{studio['studio_id']}")
550
-
551
- if response.status_code == 200:
552
- console.print(f"[green]✓ Studio deleted successfully![/green]")
553
- else:
554
- error = response.json().get("error", "Unknown error")
555
- console.print(f"[red]❌ Failed to delete studio: {error}[/red]")
556
-
557
-
558
- def list_studios(
559
- all_users: bool = typer.Option(
560
- False, "--all", "-a", help="Show all users' studios"
561
- ),
562
- ):
563
- """List studios."""
564
- username = check_aws_sso()
565
-
566
- response = make_api_request("GET", "/studios")
567
-
568
- if response.status_code == 200:
569
- studios = response.json().get("studios", [])
570
-
571
- if not studios:
572
- console.print("No studios found.")
573
- return
574
-
575
- # Get all engines to map instance IDs to names
576
- engines_response = make_api_request("GET", "/engines")
577
- engines = {}
578
- if engines_response.status_code == 200:
579
- for engine in engines_response.json().get("engines", []):
580
- engines[engine["instance_id"]] = engine["name"]
581
-
582
- # Create table
583
- table = Table(title="Studios", box=box.ROUNDED)
584
- table.add_column("Studio ID", style="cyan")
585
- table.add_column("User")
586
- table.add_column("Status")
587
- table.add_column("Size", justify="right")
588
- table.add_column("Disk Usage", justify="right")
589
- table.add_column("Attached To")
590
-
591
- for studio in studios:
592
- # Change status display
593
- if studio["status"] == "in-use":
594
- status_display = "[bright_blue]attached[/bright_blue]"
595
- elif studio["status"] in ["attaching", "detaching"]:
596
- status_display = "[yellow]" + studio["status"] + "[/yellow]"
597
- else:
598
- status_display = "[green]available[/green]"
599
-
600
- # Format attached engine info
601
- attached_to = "-"
602
- disk_usage = "?/?"
603
- if studio.get("attached_vm_id"):
604
- vm_id = studio["attached_vm_id"]
605
- engine_name = engines.get(vm_id, "unknown")
606
- attached_to = f"{engine_name} ({vm_id})"
607
-
608
- # Try to get disk usage if attached
609
- if studio["status"] == "in-use":
610
- usage = get_studio_disk_usage_via_ssm(vm_id, studio["user"])
611
- if usage:
612
- disk_usage = usage
613
-
614
- table.add_row(
615
- studio["studio_id"],
616
- studio["user"],
617
- status_display,
618
- f"{studio['size_gb']}GB",
619
- disk_usage,
620
- attached_to,
621
- )
622
-
623
- console.print(table)
624
- else:
625
- error = response.json().get("error", "Unknown error")
626
- console.print(f"[red]❌ Failed to list studios: {error}[/red]")
627
-
628
-
629
- def reset_studio(
630
- user: Optional[str] = typer.Option(
631
- None, "--user", "-u", help="Reset a different user's studio"
632
- ),
633
- ):
634
- """Reset a stuck studio (admin operation)."""
635
- username = check_aws_sso()
636
-
637
- # Use specified user if provided, otherwise use current user
638
- target_user = user if user else username
639
-
640
- # Add warning when resetting another user's studio
641
- if target_user != username:
642
- console.print(f"[yellow]⚠️ Resetting studio for user: {target_user}[/yellow]")
643
-
644
- studio = get_user_studio(target_user)
645
- if not studio:
646
- if target_user == username:
647
- console.print("[yellow]You don't have a studio.[/yellow]")
648
- else:
649
- console.print(f"[yellow]User {target_user} doesn't have a studio.[/yellow]")
650
- return
651
-
652
- console.print(f"[yellow]⚠️ This will force-reset the studio state[/yellow]")
653
- console.print(f"Current status: {studio['status']}")
654
- if studio.get("attached_vm_id"):
655
- console.print(f"Listed as attached to: {studio['attached_vm_id']}")
656
-
657
- if not Confirm.ask("\nReset studio state?"):
658
- console.print("Reset cancelled.")
659
- return
660
-
661
- # Direct DynamoDB update
662
- console.print("Resetting studio state...")
663
-
664
- dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
665
- table = dynamodb.Table("dev-studios")
666
-
667
- try:
668
- # Check if volume is actually attached
669
- ec2 = boto3.client("ec2", region_name="us-east-1")
670
- volumes = ec2.describe_volumes(VolumeIds=[studio["studio_id"]])
671
-
672
- if volumes["Volumes"]:
673
- volume = volumes["Volumes"][0]
674
- attachments = volume.get("Attachments", [])
675
- if attachments:
676
- console.print(
677
- f"[red]Volume is still attached to {attachments[0]['InstanceId']}![/red]"
678
- )
679
- if Confirm.ask("Force-detach the volume?"):
680
- ec2.detach_volume(
681
- VolumeId=studio["studio_id"],
682
- InstanceId=attachments[0]["InstanceId"],
683
- Force=True,
684
- )
685
- console.print("Waiting for volume to detach...")
686
- waiter = ec2.get_waiter("volume_available")
687
- waiter.wait(VolumeIds=[studio["studio_id"]])
688
-
689
- # Reset in DynamoDB – align attribute names with Studio Manager backend
690
- table.update_item(
691
- Key={"StudioID": studio["studio_id"]},
692
- UpdateExpression="SET #st = :status, AttachedVMID = :vm_id, AttachedDevice = :device",
693
- ExpressionAttributeNames={"#st": "Status"},
694
- ExpressionAttributeValues={
695
- ":status": "available",
696
- ":vm_id": None,
697
- ":device": None,
698
- },
699
- )
700
-
701
- console.print(f"[green]✓ Studio reset to available state![/green]")
702
-
703
- except ClientError as e:
704
- console.print(f"[red]❌ Failed to reset studio: {e}[/red]")
705
-
706
-
707
- def resize_studio(
708
- size: int = typer.Option(..., "--size", "-s", help="New size in GB"),
709
- user: Optional[str] = typer.Option(
710
- None, "--user", "-u", help="Resize a different user's studio (admin only)"
711
- ),
712
- ):
713
- """Resize your studio volume (requires detachment)."""
714
- username = check_aws_sso()
715
-
716
- # Use specified user if provided, otherwise use current user
717
- target_user = user if user else username
718
-
719
- # Add warning when resizing another user's studio
720
- if target_user != username:
721
- console.print(f"[yellow]⚠️ Resizing studio for user: {target_user}[/yellow]")
722
-
723
- studio = get_user_studio(target_user)
724
- if not studio:
725
- if target_user == username:
726
- console.print("[yellow]You don't have a studio yet.[/yellow]")
727
- else:
728
- console.print(f"[yellow]User {target_user} doesn't have a studio.[/yellow]")
729
- return
730
-
731
- current_size = studio["size_gb"]
732
-
733
- if size <= current_size:
734
- console.print(
735
- f"[red]❌ New size ({size}GB) must be larger than current size ({current_size}GB)[/red]"
736
- )
737
- raise typer.Exit(1)
738
-
739
- # Check if studio is attached
740
- if studio["status"] == "in-use":
741
- console.print("[yellow]⚠️ Studio must be detached before resizing[/yellow]")
742
- console.print(f"Currently attached to: {studio.get('attached_vm_id')}")
743
-
744
- if not Confirm.ask("\nDetach studio and proceed with resize?"):
745
- console.print("Resize cancelled.")
746
- return
747
-
748
- # Detach the studio
749
- console.print("Detaching studio...")
750
- response = make_api_request("POST", f"/studios/{studio['studio_id']}/detach")
751
- if response.status_code != 200:
752
- console.print("[red]❌ Failed to detach studio[/red]")
753
- raise typer.Exit(1)
754
-
755
- console.print("[green]✓ Studio detached[/green]")
756
-
757
- # Wait a moment for detachment to complete
758
- time.sleep(5)
759
-
760
- console.print(f"[yellow]Resizing studio from {current_size}GB to {size}GB[/yellow]")
761
-
762
- # Call the resize API
763
- resize_response = make_api_request(
764
- "POST", f"/studios/{studio['studio_id']}/resize", json_data={"size": size}
765
- )
766
-
767
- if resize_response.status_code != 200:
768
- error = resize_response.json().get("error", "Unknown error")
769
- console.print(f"[red]❌ Failed to resize studio: {error}[/red]")
770
- raise typer.Exit(1)
771
-
772
- # Wait for volume modification to complete
773
- ec2 = boto3.client("ec2", region_name="us-east-1")
774
- console.print("Resizing volume...")
775
-
776
- # Track progress
777
- last_progress = 0
778
-
779
- while True:
780
- try:
781
- mod_state = ec2.describe_volumes_modifications(
782
- VolumeIds=[studio["studio_id"]]
783
- )
784
- if not mod_state["VolumesModifications"]:
785
- break # Modification complete
786
-
787
- modification = mod_state["VolumesModifications"][0]
788
- state = modification["ModificationState"]
789
- progress = modification.get("Progress", 0)
790
-
791
- # Show progress updates only for the resize phase
792
- if state == "modifying" and progress > last_progress:
793
- console.print(f"[yellow]Progress: {progress}%[/yellow]")
794
- last_progress = progress
795
-
796
- # Exit as soon as optimization starts (resize is complete)
797
- if state == "optimizing":
798
- console.print(
799
- f"[green]✓ Studio resized successfully to {size}GB![/green]"
800
- )
801
- console.print(
802
- "[dim]AWS is optimizing the volume in the background (no action needed).[/dim]"
803
- )
804
- break
805
-
806
- if state == "completed":
807
- console.print(
808
- f"[green]✓ Studio resized successfully to {size}GB![/green]"
809
- )
810
- break
811
- elif state == "failed":
812
- console.print("[red]❌ Volume modification failed[/red]")
813
- raise typer.Exit(1)
814
-
815
- time.sleep(2) # Check more frequently for better UX
816
-
817
- except ClientError:
818
- # Modification might be complete
819
- console.print(f"[green]✓ Studio resized successfully to {size}GB![/green]")
820
- break
821
-
822
- console.print(
823
- "\n[dim]The filesystem will be automatically expanded when you next attach the studio.[/dim]"
824
- )
825
- console.print(f"To attach: [cyan]dh studio attach <engine-name>[/cyan]")