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.
- dayhoff_tools/cli/engine/__init__.py +1 -323
- dayhoff_tools/cli/engine/coffee.py +110 -0
- dayhoff_tools/cli/engine/config_ssh.py +113 -0
- dayhoff_tools/cli/engine/debug.py +79 -0
- dayhoff_tools/cli/engine/gami.py +160 -0
- dayhoff_tools/cli/engine/idle.py +148 -0
- dayhoff_tools/cli/engine/launch.py +101 -0
- dayhoff_tools/cli/engine/list.py +116 -0
- dayhoff_tools/cli/engine/repair.py +128 -0
- dayhoff_tools/cli/engine/resize.py +195 -0
- dayhoff_tools/cli/engine/ssh.py +62 -0
- dayhoff_tools/cli/engine/{engine_core.py → status.py} +6 -201
- dayhoff_tools/cli/engine_studio_commands.py +323 -0
- dayhoff_tools/cli/engine_studio_utils/__init__.py +1 -0
- dayhoff_tools/cli/engine_studio_utils/api_utils.py +47 -0
- dayhoff_tools/cli/engine_studio_utils/aws_utils.py +102 -0
- dayhoff_tools/cli/engine_studio_utils/constants.py +21 -0
- dayhoff_tools/cli/engine_studio_utils/formatting.py +210 -0
- dayhoff_tools/cli/engine_studio_utils/ssh_utils.py +141 -0
- dayhoff_tools/cli/main.py +1 -2
- dayhoff_tools/cli/studio/__init__.py +1 -0
- dayhoff_tools/cli/studio/attach.py +314 -0
- dayhoff_tools/cli/studio/create.py +48 -0
- dayhoff_tools/cli/studio/delete.py +71 -0
- dayhoff_tools/cli/studio/detach.py +56 -0
- dayhoff_tools/cli/studio/list.py +81 -0
- dayhoff_tools/cli/studio/reset.py +90 -0
- dayhoff_tools/cli/studio/resize.py +134 -0
- dayhoff_tools/cli/studio/status.py +78 -0
- {dayhoff_tools-1.9.26.dist-info → dayhoff_tools-1.10.1.dist-info}/METADATA +1 -1
- dayhoff_tools-1.10.1.dist-info/RECORD +61 -0
- dayhoff_tools/cli/engine/engine_maintenance.py +0 -431
- dayhoff_tools/cli/engine/engine_management.py +0 -505
- dayhoff_tools/cli/engine/shared.py +0 -501
- dayhoff_tools/cli/engine/studio_commands.py +0 -825
- dayhoff_tools-1.9.26.dist-info/RECORD +0 -39
- /dayhoff_tools/cli/engine/{engine_lifecycle.py → lifecycle.py} +0 -0
- {dayhoff_tools-1.9.26.dist-info → dayhoff_tools-1.10.1.dist-info}/WHEEL +0 -0
- {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]")
|