gpu-dev 0.3.5__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.
- gpu_dev-0.3.5.dist-info/METADATA +687 -0
- gpu_dev-0.3.5.dist-info/RECORD +14 -0
- gpu_dev-0.3.5.dist-info/WHEEL +5 -0
- gpu_dev-0.3.5.dist-info/entry_points.txt +4 -0
- gpu_dev-0.3.5.dist-info/top_level.txt +1 -0
- gpu_dev_cli/__init__.py +9 -0
- gpu_dev_cli/auth.py +158 -0
- gpu_dev_cli/cli.py +3754 -0
- gpu_dev_cli/config.py +248 -0
- gpu_dev_cli/disks.py +523 -0
- gpu_dev_cli/interactive.py +702 -0
- gpu_dev_cli/name_generator.py +117 -0
- gpu_dev_cli/reservations.py +2231 -0
- gpu_dev_cli/ssh_proxy.py +106 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
"""Interactive CLI components for GPU Developer CLI"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Dict, List, Optional, Any
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import questionary
|
|
8
|
+
from questionary import Style
|
|
9
|
+
|
|
10
|
+
INTERACTIVE_AVAILABLE = True
|
|
11
|
+
except ImportError:
|
|
12
|
+
INTERACTIVE_AVAILABLE = False
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
# Custom style for questionary - softer colors
|
|
21
|
+
custom_style = Style(
|
|
22
|
+
[
|
|
23
|
+
("question", "fg:#5f87af bold"), # Soft blue
|
|
24
|
+
("answer", "fg:#5f87af bold"), # Soft blue
|
|
25
|
+
("pointer", "fg:#5f87af bold"), # Soft blue
|
|
26
|
+
("highlighted", "fg:#5f87af"), # Soft blue, no bold
|
|
27
|
+
("selected", "fg:#87af87"), # Soft green
|
|
28
|
+
("separator", "fg:#808080"), # Neutral gray
|
|
29
|
+
("instruction", ""),
|
|
30
|
+
("text", ""),
|
|
31
|
+
("disabled", "fg:#858585 italic"),
|
|
32
|
+
]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_interactive_support() -> bool:
|
|
37
|
+
"""Check if interactive mode is available"""
|
|
38
|
+
if not INTERACTIVE_AVAILABLE:
|
|
39
|
+
console.print(
|
|
40
|
+
"[red]❌ Interactive mode requires 'questionary'. Install with: pip install questionary[/red]"
|
|
41
|
+
)
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
if not sys.stdin.isatty():
|
|
45
|
+
console.print(
|
|
46
|
+
"[yellow]⚠️ Non-interactive terminal detected. Use command-line flags instead.[/yellow]"
|
|
47
|
+
)
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def select_gpu_type_interactive(
|
|
54
|
+
availability_info: Dict[str, Dict[str, Any]],
|
|
55
|
+
) -> Optional[str]:
|
|
56
|
+
"""Interactive GPU type selection with availability table"""
|
|
57
|
+
if not check_interactive_support():
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Display availability table first
|
|
61
|
+
console.print("\n[cyan]🖥️ GPU Availability:[/cyan]")
|
|
62
|
+
table = Table()
|
|
63
|
+
table.add_column("GPU Type", style="cyan")
|
|
64
|
+
table.add_column("Available", style="green")
|
|
65
|
+
table.add_column("Total", style="blue")
|
|
66
|
+
table.add_column("Queue Length", style="yellow")
|
|
67
|
+
table.add_column("Est. Wait Time", style="magenta")
|
|
68
|
+
|
|
69
|
+
choices = []
|
|
70
|
+
for gpu_type, info in availability_info.items():
|
|
71
|
+
available = info.get("available", 0)
|
|
72
|
+
total = info.get("total", 0)
|
|
73
|
+
queue_length = info.get("queue_length", 0)
|
|
74
|
+
est_wait = info.get("estimated_wait_minutes", 0)
|
|
75
|
+
|
|
76
|
+
# Format wait time
|
|
77
|
+
if available > 0:
|
|
78
|
+
wait_display = "Available now"
|
|
79
|
+
status_indicator = "✅"
|
|
80
|
+
elif est_wait == 0:
|
|
81
|
+
wait_display = "Unknown"
|
|
82
|
+
status_indicator = "⚠️"
|
|
83
|
+
elif est_wait < 60:
|
|
84
|
+
wait_display = f"{int(est_wait)}min"
|
|
85
|
+
status_indicator = "⏳"
|
|
86
|
+
else:
|
|
87
|
+
hours = int(est_wait // 60)
|
|
88
|
+
minutes = int(est_wait % 60)
|
|
89
|
+
if minutes == 0:
|
|
90
|
+
wait_display = f"{hours}h"
|
|
91
|
+
else:
|
|
92
|
+
wait_display = f"{hours}h {minutes}min"
|
|
93
|
+
status_indicator = "⏳"
|
|
94
|
+
|
|
95
|
+
# Color code availability
|
|
96
|
+
if available > 0:
|
|
97
|
+
available_display = f"[green]{available}[/green]"
|
|
98
|
+
else:
|
|
99
|
+
available_display = f"[red]{available}[/red]"
|
|
100
|
+
|
|
101
|
+
table.add_row(
|
|
102
|
+
gpu_type.upper(),
|
|
103
|
+
available_display,
|
|
104
|
+
str(total),
|
|
105
|
+
str(queue_length),
|
|
106
|
+
wait_display,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Create choice label with status
|
|
110
|
+
choice_label = (
|
|
111
|
+
f"{status_indicator} {gpu_type.upper()} ({available}/{total} available)"
|
|
112
|
+
)
|
|
113
|
+
if queue_length > 0:
|
|
114
|
+
choice_label += f" - {queue_length} in queue"
|
|
115
|
+
|
|
116
|
+
choices.append(questionary.Choice(title=choice_label, value=gpu_type))
|
|
117
|
+
|
|
118
|
+
console.print(table)
|
|
119
|
+
console.print()
|
|
120
|
+
|
|
121
|
+
# Interactive selection
|
|
122
|
+
try:
|
|
123
|
+
answer = questionary.select(
|
|
124
|
+
"Select GPU type:", choices=choices, style=custom_style
|
|
125
|
+
).ask()
|
|
126
|
+
|
|
127
|
+
return answer
|
|
128
|
+
except (KeyboardInterrupt, EOFError):
|
|
129
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def select_gpu_count_interactive(gpu_type: str, max_gpus: int) -> Optional[int]:
|
|
134
|
+
"""Interactive GPU count selection"""
|
|
135
|
+
if not check_interactive_support():
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
# Generate valid choices based on GPU type limits
|
|
139
|
+
if gpu_type.startswith("cpu-"):
|
|
140
|
+
# CPU instances don't have GPUs, but we still need a "count" for nodes
|
|
141
|
+
valid_counts = [0] # 0 GPUs for CPU-only instances
|
|
142
|
+
multinode_counts = [] # No multinode for CPU instances
|
|
143
|
+
elif gpu_type in ["t4", "l4", "a10g"]:
|
|
144
|
+
valid_counts = [1, 2, 4]
|
|
145
|
+
# Add multinode options
|
|
146
|
+
multinode_counts = [8, 12, 16, 20, 24] # multiples of 4
|
|
147
|
+
elif gpu_type == "g5g":
|
|
148
|
+
valid_counts = [1, 2]
|
|
149
|
+
multinode_counts = [4, 8] # multiples of 4
|
|
150
|
+
elif gpu_type == "t4-small":
|
|
151
|
+
valid_counts = [1]
|
|
152
|
+
multinode_counts = [2, 3, 4, 5, 6] # multiples of 1
|
|
153
|
+
else: # a100, h100, h200, b200
|
|
154
|
+
valid_counts = [1, 2, 4, 8]
|
|
155
|
+
# Add multinode options
|
|
156
|
+
multinode_counts = [16, 24, 32, 40, 48] # multiples of 8
|
|
157
|
+
|
|
158
|
+
# Filter single-node by actual max for this GPU type
|
|
159
|
+
valid_counts = [count for count in valid_counts if count <= max_gpus]
|
|
160
|
+
|
|
161
|
+
# Add multinode options (multiples of max_gpus)
|
|
162
|
+
multinode_counts = [
|
|
163
|
+
count for count in multinode_counts if count % max_gpus == 0]
|
|
164
|
+
|
|
165
|
+
choices = []
|
|
166
|
+
|
|
167
|
+
# Add single-node options
|
|
168
|
+
for count in valid_counts:
|
|
169
|
+
if count == 1:
|
|
170
|
+
label = f"1 GPU (single node)"
|
|
171
|
+
else:
|
|
172
|
+
label = f"{count} GPUs (single node)"
|
|
173
|
+
choices.append(questionary.Choice(title=label, value=count))
|
|
174
|
+
|
|
175
|
+
# Add separator and multinode options
|
|
176
|
+
if multinode_counts:
|
|
177
|
+
choices.append(questionary.Separator(
|
|
178
|
+
"--- Multinode (Distributed) ---"))
|
|
179
|
+
for count in multinode_counts:
|
|
180
|
+
nodes = count // max_gpus
|
|
181
|
+
label = f"{count} GPUs ({nodes} nodes × {max_gpus} GPUs)"
|
|
182
|
+
choices.append(questionary.Choice(title=label, value=count))
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
if gpu_type.startswith("cpu-"):
|
|
186
|
+
question = f"Reserve {gpu_type.upper()} CPU instance?"
|
|
187
|
+
else:
|
|
188
|
+
question = f"How many {gpu_type.upper()} GPUs?"
|
|
189
|
+
|
|
190
|
+
answer = questionary.select(
|
|
191
|
+
question, choices=choices, style=custom_style
|
|
192
|
+
).ask()
|
|
193
|
+
|
|
194
|
+
return answer
|
|
195
|
+
except (KeyboardInterrupt, EOFError):
|
|
196
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def select_duration_interactive() -> Optional[float]:
|
|
201
|
+
"""Interactive duration selection"""
|
|
202
|
+
if not check_interactive_support():
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
# Common duration choices - cleaner labels
|
|
206
|
+
choices = [
|
|
207
|
+
questionary.Choice("15 minutes", 0.25),
|
|
208
|
+
questionary.Choice("30 minutes", 0.5),
|
|
209
|
+
questionary.Choice("1 hour", 1.0),
|
|
210
|
+
questionary.Choice("2 hours", 2.0),
|
|
211
|
+
questionary.Choice("4 hours", 4.0),
|
|
212
|
+
questionary.Choice("8 hours (default)", 8.0),
|
|
213
|
+
questionary.Choice("12 hours", 12.0),
|
|
214
|
+
questionary.Choice("24 hours (max)", 24.0),
|
|
215
|
+
questionary.Choice("Custom duration", "custom"),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
answer = questionary.select(
|
|
220
|
+
"How long do you need the reservation?", choices=choices, style=custom_style
|
|
221
|
+
).ask()
|
|
222
|
+
|
|
223
|
+
if answer == "custom":
|
|
224
|
+
# Ask for custom duration
|
|
225
|
+
custom_duration = questionary.text(
|
|
226
|
+
"Enter duration in hours (decimal allowed, max 24):",
|
|
227
|
+
validate=lambda x: _validate_duration(x),
|
|
228
|
+
style=custom_style,
|
|
229
|
+
).ask()
|
|
230
|
+
|
|
231
|
+
if custom_duration:
|
|
232
|
+
return float(custom_duration)
|
|
233
|
+
else:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
return answer
|
|
237
|
+
except (KeyboardInterrupt, EOFError):
|
|
238
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def select_jupyter_interactive() -> Optional[bool]:
|
|
243
|
+
"""Interactive Jupyter Lab selection"""
|
|
244
|
+
if not check_interactive_support():
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
answer = questionary.confirm(
|
|
249
|
+
"Enable Jupyter Lab? (can be enabled later)",
|
|
250
|
+
default=False,
|
|
251
|
+
style=custom_style,
|
|
252
|
+
).ask()
|
|
253
|
+
|
|
254
|
+
return answer
|
|
255
|
+
except (KeyboardInterrupt, EOFError):
|
|
256
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def select_reservation_interactive(
|
|
261
|
+
reservations: List[Dict[str, Any]], action: str
|
|
262
|
+
) -> Optional[str]:
|
|
263
|
+
"""Interactive reservation selection for cancel/edit operations"""
|
|
264
|
+
if not check_interactive_support():
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
if not reservations:
|
|
268
|
+
console.print(
|
|
269
|
+
f"[yellow]No reservations available to {action}.[/yellow]")
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
# Display reservations table
|
|
273
|
+
console.print(
|
|
274
|
+
f"\n[cyan]📋 Your reservations (available to {action}):[/cyan]")
|
|
275
|
+
|
|
276
|
+
table = Table()
|
|
277
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
278
|
+
table.add_column("GPUs", style="magenta")
|
|
279
|
+
table.add_column("Status", style="yellow")
|
|
280
|
+
table.add_column("Created", style="blue")
|
|
281
|
+
table.add_column("Expires/ETA", style="red")
|
|
282
|
+
|
|
283
|
+
choices = []
|
|
284
|
+
|
|
285
|
+
for reservation in reservations:
|
|
286
|
+
try:
|
|
287
|
+
reservation_id = reservation.get("reservation_id", "unknown")
|
|
288
|
+
gpu_count = reservation.get("gpu_count", 1)
|
|
289
|
+
gpu_type = reservation.get("gpu_type", "unknown")
|
|
290
|
+
status = reservation.get("status", "unknown")
|
|
291
|
+
created_at = reservation.get("created_at", "N/A")
|
|
292
|
+
|
|
293
|
+
# Format GPU information
|
|
294
|
+
if gpu_type and gpu_type not in ["unknown", "Unknown"]:
|
|
295
|
+
gpu_display = f"{gpu_count}x {gpu_type.upper()}"
|
|
296
|
+
else:
|
|
297
|
+
gpu_display = str(gpu_count)
|
|
298
|
+
|
|
299
|
+
# Format expiration time or ETA
|
|
300
|
+
expires_at = reservation.get("expires_at", "N/A")
|
|
301
|
+
if status == "active" and expires_at != "N/A":
|
|
302
|
+
from datetime import datetime
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
if isinstance(expires_at, str):
|
|
306
|
+
if expires_at.endswith("Z"):
|
|
307
|
+
expires_dt_utc = datetime.fromisoformat(
|
|
308
|
+
expires_at.replace("Z", "+00:00")
|
|
309
|
+
)
|
|
310
|
+
elif "+" in expires_at or expires_at.endswith("00:00"):
|
|
311
|
+
expires_dt_utc = datetime.fromisoformat(expires_at)
|
|
312
|
+
else:
|
|
313
|
+
from datetime import timezone
|
|
314
|
+
|
|
315
|
+
naive_dt = datetime.fromisoformat(expires_at)
|
|
316
|
+
expires_dt_utc = naive_dt.replace(
|
|
317
|
+
tzinfo=timezone.utc)
|
|
318
|
+
|
|
319
|
+
expires_dt = expires_dt_utc.astimezone()
|
|
320
|
+
expires_formatted = expires_dt.strftime("%m-%d %H:%M")
|
|
321
|
+
else:
|
|
322
|
+
expires_dt = datetime.fromtimestamp(expires_at)
|
|
323
|
+
expires_formatted = expires_dt.strftime("%m-%d %H:%M")
|
|
324
|
+
except (ValueError, TypeError):
|
|
325
|
+
expires_formatted = "Invalid"
|
|
326
|
+
elif status in ["queued", "pending"]:
|
|
327
|
+
estimated_wait = reservation.get("estimated_wait_minutes", "?")
|
|
328
|
+
if estimated_wait != "?" and estimated_wait is not None:
|
|
329
|
+
expires_formatted = f"~{estimated_wait}min"
|
|
330
|
+
else:
|
|
331
|
+
expires_formatted = "Calculating..."
|
|
332
|
+
else:
|
|
333
|
+
expires_formatted = "N/A"
|
|
334
|
+
|
|
335
|
+
# Format created_at datetime
|
|
336
|
+
created_formatted = "N/A"
|
|
337
|
+
if created_at and created_at != "N/A":
|
|
338
|
+
try:
|
|
339
|
+
from datetime import datetime
|
|
340
|
+
|
|
341
|
+
if isinstance(created_at, str):
|
|
342
|
+
if created_at.endswith("Z"):
|
|
343
|
+
created_dt_utc = datetime.fromisoformat(
|
|
344
|
+
created_at.replace("Z", "+00:00")
|
|
345
|
+
)
|
|
346
|
+
elif "+" in created_at or created_at.endswith("00:00"):
|
|
347
|
+
created_dt_utc = datetime.fromisoformat(created_at)
|
|
348
|
+
else:
|
|
349
|
+
from datetime import timezone
|
|
350
|
+
|
|
351
|
+
naive_dt = datetime.fromisoformat(created_at)
|
|
352
|
+
created_dt_utc = naive_dt.replace(
|
|
353
|
+
tzinfo=timezone.utc)
|
|
354
|
+
|
|
355
|
+
created_dt = created_dt_utc.astimezone()
|
|
356
|
+
created_formatted = created_dt.strftime("%m-%d %H:%M")
|
|
357
|
+
else:
|
|
358
|
+
created_dt = datetime.fromtimestamp(created_at)
|
|
359
|
+
created_formatted = created_dt.strftime("%m-%d %H:%M")
|
|
360
|
+
except (ValueError, TypeError):
|
|
361
|
+
if len(str(created_at)) > 10:
|
|
362
|
+
created_formatted = str(created_at)[:10]
|
|
363
|
+
else:
|
|
364
|
+
created_formatted = str(created_at)
|
|
365
|
+
|
|
366
|
+
table.add_row(
|
|
367
|
+
str(reservation_id)[:8],
|
|
368
|
+
gpu_display,
|
|
369
|
+
str(status),
|
|
370
|
+
created_formatted,
|
|
371
|
+
expires_formatted,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Create choice for interactive selection
|
|
375
|
+
choice_label = f"{reservation_id[:8]} - {gpu_display} ({status})"
|
|
376
|
+
choices.append(questionary.Choice(
|
|
377
|
+
title=choice_label, value=reservation_id))
|
|
378
|
+
|
|
379
|
+
except Exception as row_error:
|
|
380
|
+
console.print(
|
|
381
|
+
f"[yellow]⚠️ Skipping malformed reservation: {str(row_error)}[/yellow]"
|
|
382
|
+
)
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
console.print(table)
|
|
386
|
+
console.print()
|
|
387
|
+
|
|
388
|
+
if not choices:
|
|
389
|
+
console.print(
|
|
390
|
+
f"[yellow]No valid reservations found to {action}.[/yellow]")
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
# Add "all" option for cancel action when there are multiple reservations
|
|
394
|
+
if action == "cancel" and len(choices) > 1:
|
|
395
|
+
choices.append(
|
|
396
|
+
questionary.Choice(
|
|
397
|
+
title="🗑️ Cancel ALL reservations above", value="__ALL__"
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# Add quit option at the end for all actions
|
|
402
|
+
action_verb = "cancel" if action == "cancel" else "edit"
|
|
403
|
+
choices.append(
|
|
404
|
+
questionary.Choice(
|
|
405
|
+
title=f"❌ Quit (don't {action_verb} anything)", value="__QUIT__"
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
try:
|
|
410
|
+
answer = questionary.select(
|
|
411
|
+
f"Select reservation to {action}:", choices=choices, style=custom_style
|
|
412
|
+
).ask()
|
|
413
|
+
|
|
414
|
+
return answer
|
|
415
|
+
except (KeyboardInterrupt, EOFError):
|
|
416
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _validate_duration(duration_str: str) -> bool:
|
|
421
|
+
"""Validate duration input"""
|
|
422
|
+
try:
|
|
423
|
+
duration = float(duration_str)
|
|
424
|
+
if duration < 0.0833: # Less than 5 minutes
|
|
425
|
+
return "Minimum duration is 5 minutes (0.0833 hours)"
|
|
426
|
+
if duration > 24:
|
|
427
|
+
return "Maximum duration is 24 hours"
|
|
428
|
+
return True
|
|
429
|
+
except ValueError:
|
|
430
|
+
return "Please enter a valid number"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def ask_name_interactive() -> Optional[str]:
|
|
434
|
+
"""Ask for optional reservation name"""
|
|
435
|
+
if not check_interactive_support():
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
answer = questionary.text(
|
|
440
|
+
"Reservation name (optional, press Enter to skip):", style=custom_style
|
|
441
|
+
).ask()
|
|
442
|
+
|
|
443
|
+
# Return None if empty string
|
|
444
|
+
return answer.strip() if answer and answer.strip() else None
|
|
445
|
+
except (KeyboardInterrupt, EOFError):
|
|
446
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def select_edit_action_interactive() -> Optional[str]:
|
|
451
|
+
"""Interactive edit action selection"""
|
|
452
|
+
if not check_interactive_support():
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
choices = [
|
|
456
|
+
questionary.Choice("Enable Jupyter Lab", "enable_jupyter"),
|
|
457
|
+
questionary.Choice("Disable Jupyter Lab", "disable_jupyter"),
|
|
458
|
+
questionary.Choice("Add secondary user", "add_user"),
|
|
459
|
+
questionary.Choice("Extend reservation duration", "extend"),
|
|
460
|
+
]
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
answer = questionary.select(
|
|
464
|
+
"What would you like to edit?", choices=choices, style=custom_style
|
|
465
|
+
).ask()
|
|
466
|
+
|
|
467
|
+
return answer
|
|
468
|
+
except (KeyboardInterrupt, EOFError):
|
|
469
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def ask_github_username_interactive() -> Optional[str]:
|
|
474
|
+
"""Ask for GitHub username to add"""
|
|
475
|
+
if not check_interactive_support():
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
try:
|
|
479
|
+
answer = questionary.text(
|
|
480
|
+
"Enter GitHub username to add:",
|
|
481
|
+
validate=lambda x: _validate_github_username(x),
|
|
482
|
+
style=custom_style,
|
|
483
|
+
).ask()
|
|
484
|
+
|
|
485
|
+
return answer.strip() if answer else None
|
|
486
|
+
except (KeyboardInterrupt, EOFError):
|
|
487
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def ask_extension_hours_interactive() -> Optional[float]:
|
|
492
|
+
"""Ask for extension hours"""
|
|
493
|
+
if not check_interactive_support():
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
# Offer common extension choices
|
|
498
|
+
choices = [
|
|
499
|
+
questionary.Choice("1 hour", 1.0),
|
|
500
|
+
questionary.Choice("2 hours", 2.0),
|
|
501
|
+
questionary.Choice("4 hours", 4.0),
|
|
502
|
+
questionary.Choice("8 hours", 8.0),
|
|
503
|
+
questionary.Choice("12 hours", 12.0),
|
|
504
|
+
questionary.Choice("24 hours (max)", 24.0),
|
|
505
|
+
questionary.Choice("Custom extension", "custom"),
|
|
506
|
+
]
|
|
507
|
+
|
|
508
|
+
answer = questionary.select(
|
|
509
|
+
"How many hours to extend?", choices=choices, style=custom_style
|
|
510
|
+
).ask()
|
|
511
|
+
|
|
512
|
+
if answer == "custom":
|
|
513
|
+
# Ask for custom extension
|
|
514
|
+
custom_extension = questionary.text(
|
|
515
|
+
"Enter extension hours (decimal allowed, max 24):",
|
|
516
|
+
validate=lambda x: _validate_extension(x),
|
|
517
|
+
style=custom_style,
|
|
518
|
+
).ask()
|
|
519
|
+
|
|
520
|
+
if custom_extension:
|
|
521
|
+
return float(custom_extension)
|
|
522
|
+
else:
|
|
523
|
+
return None
|
|
524
|
+
|
|
525
|
+
return answer
|
|
526
|
+
except (KeyboardInterrupt, EOFError):
|
|
527
|
+
console.print("\n[yellow]Selection cancelled.[/yellow]")
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _validate_github_username(username: str) -> bool:
|
|
532
|
+
"""Validate GitHub username format"""
|
|
533
|
+
if not username or not username.strip():
|
|
534
|
+
return "GitHub username cannot be empty"
|
|
535
|
+
|
|
536
|
+
username = username.strip()
|
|
537
|
+
if not username.replace("-", "").replace("_", "").replace(".", "").isalnum():
|
|
538
|
+
return "Invalid GitHub username format"
|
|
539
|
+
|
|
540
|
+
if len(username) > 39: # GitHub's max username length
|
|
541
|
+
return "GitHub username too long (max 39 characters)"
|
|
542
|
+
|
|
543
|
+
return True
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _validate_extension(hours_str: str) -> bool:
|
|
547
|
+
"""Validate extension hours input"""
|
|
548
|
+
try:
|
|
549
|
+
hours = float(hours_str)
|
|
550
|
+
if hours <= 0:
|
|
551
|
+
return "Extension hours must be positive"
|
|
552
|
+
if hours > 24:
|
|
553
|
+
return "Maximum extension is 24 hours"
|
|
554
|
+
return True
|
|
555
|
+
except ValueError:
|
|
556
|
+
return "Please enter a valid number"
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def select_disk_interactive(user_id: str, config: Any) -> Optional[str]:
|
|
560
|
+
"""
|
|
561
|
+
Interactive disk selection for reserve command.
|
|
562
|
+
Returns:
|
|
563
|
+
- disk_name: User selected an existing disk
|
|
564
|
+
- "__no_disk__": User explicitly chose no disk
|
|
565
|
+
- "__create_new__": User wants to create a new disk (handled internally)
|
|
566
|
+
- "__cancelled__": User cancelled (Ctrl+C or EOF)
|
|
567
|
+
"""
|
|
568
|
+
if not check_interactive_support():
|
|
569
|
+
return "__cancelled__"
|
|
570
|
+
|
|
571
|
+
from .disks import list_disks
|
|
572
|
+
|
|
573
|
+
while True: # Loop to support "Refresh list"
|
|
574
|
+
try:
|
|
575
|
+
# Get user's disks
|
|
576
|
+
disks = list_disks(user_id, config)
|
|
577
|
+
|
|
578
|
+
# Build choices
|
|
579
|
+
choices = []
|
|
580
|
+
|
|
581
|
+
if disks:
|
|
582
|
+
# Add header
|
|
583
|
+
choices.append(questionary.Separator("=== Your Disks ==="))
|
|
584
|
+
|
|
585
|
+
for disk in disks:
|
|
586
|
+
disk_name = disk['name']
|
|
587
|
+
size_gb = disk['size_gb']
|
|
588
|
+
disk_size = disk.get('disk_size', '') # Actual used size like "23G"
|
|
589
|
+
snapshot_count = disk['snapshot_count']
|
|
590
|
+
|
|
591
|
+
# Format display name - show used/total like "23G / 1024GB"
|
|
592
|
+
if disk_size:
|
|
593
|
+
size_display = f"{disk_size} / {size_gb}GB"
|
|
594
|
+
else:
|
|
595
|
+
size_display = f"{size_gb}GB"
|
|
596
|
+
display_parts = [f"{disk_name} ({size_display}, {snapshot_count} snapshots)"]
|
|
597
|
+
|
|
598
|
+
# Check if disk is deleted or in use
|
|
599
|
+
if disk.get('is_deleted', False):
|
|
600
|
+
display_parts.append("[DELETED]")
|
|
601
|
+
delete_date = disk.get('delete_date', 'unknown')
|
|
602
|
+
choices.append(questionary.Choice(
|
|
603
|
+
title=" ".join(display_parts),
|
|
604
|
+
value=None,
|
|
605
|
+
disabled=f"Soft-deleted, expires {delete_date}"
|
|
606
|
+
))
|
|
607
|
+
elif disk['in_use']:
|
|
608
|
+
display_parts.append("[IN USE]")
|
|
609
|
+
# Disable this choice
|
|
610
|
+
choices.append(questionary.Choice(
|
|
611
|
+
title=" ".join(display_parts),
|
|
612
|
+
value=None,
|
|
613
|
+
disabled="Currently in use by another reservation"
|
|
614
|
+
))
|
|
615
|
+
else:
|
|
616
|
+
choices.append(questionary.Choice(
|
|
617
|
+
title=" ".join(display_parts),
|
|
618
|
+
value=disk_name
|
|
619
|
+
))
|
|
620
|
+
|
|
621
|
+
# Add options for creating new disk or no disk
|
|
622
|
+
choices.append(questionary.Separator("=== Options ==="))
|
|
623
|
+
choices.append(questionary.Choice(
|
|
624
|
+
title="Create new disk",
|
|
625
|
+
value="__create_new__"
|
|
626
|
+
))
|
|
627
|
+
choices.append(questionary.Choice(
|
|
628
|
+
title="No disk (temporary storage only)",
|
|
629
|
+
value="__no_disk__"
|
|
630
|
+
))
|
|
631
|
+
choices.append(questionary.Choice(
|
|
632
|
+
title="↻ Refresh list",
|
|
633
|
+
value="__refresh__"
|
|
634
|
+
))
|
|
635
|
+
|
|
636
|
+
# Show selection
|
|
637
|
+
answer = questionary.select(
|
|
638
|
+
"Select a persistent disk:",
|
|
639
|
+
choices=choices,
|
|
640
|
+
style=custom_style,
|
|
641
|
+
).ask()
|
|
642
|
+
|
|
643
|
+
if answer is None:
|
|
644
|
+
# User cancelled (Ctrl+C)
|
|
645
|
+
return "__cancelled__"
|
|
646
|
+
|
|
647
|
+
if answer == "__refresh__":
|
|
648
|
+
console.print("[cyan]Refreshing disk list...[/cyan]")
|
|
649
|
+
continue # Loop back to refresh
|
|
650
|
+
|
|
651
|
+
if answer == "__no_disk__":
|
|
652
|
+
# Return special marker to indicate explicit "no disk" choice
|
|
653
|
+
return "__no_disk__"
|
|
654
|
+
|
|
655
|
+
if answer == "__create_new__":
|
|
656
|
+
# Ask for disk name
|
|
657
|
+
disk_name = questionary.text(
|
|
658
|
+
"Enter name for new disk (alphanumeric, hyphens, underscores):",
|
|
659
|
+
validate=lambda x: _validate_disk_name(x),
|
|
660
|
+
style=custom_style,
|
|
661
|
+
).ask()
|
|
662
|
+
|
|
663
|
+
if not disk_name:
|
|
664
|
+
return "__cancelled__"
|
|
665
|
+
|
|
666
|
+
# Validate the disk name (actual disk created by Lambda on first use)
|
|
667
|
+
from .disks import create_disk
|
|
668
|
+
success = create_disk(disk_name, user_id, config)
|
|
669
|
+
if success:
|
|
670
|
+
console.print(f"[cyan]✓ Will create disk '{disk_name}' with this reservation[/cyan]")
|
|
671
|
+
return disk_name
|
|
672
|
+
else:
|
|
673
|
+
console.print("[red]Invalid disk name. Continuing without persistent disk.[/red]")
|
|
674
|
+
return "__cancelled__"
|
|
675
|
+
|
|
676
|
+
# Return selected disk name
|
|
677
|
+
return answer
|
|
678
|
+
|
|
679
|
+
except EOFError:
|
|
680
|
+
# Handle EOF (e.g., piped input) gracefully
|
|
681
|
+
return "__cancelled__"
|
|
682
|
+
except KeyboardInterrupt:
|
|
683
|
+
# Handle Ctrl+C explicitly
|
|
684
|
+
return "__cancelled__"
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _validate_disk_name(disk_name: str) -> bool:
|
|
688
|
+
"""Validate disk name format"""
|
|
689
|
+
if not disk_name or not disk_name.strip():
|
|
690
|
+
return "Disk name cannot be empty"
|
|
691
|
+
|
|
692
|
+
disk_name = disk_name.strip()
|
|
693
|
+
|
|
694
|
+
# Check alphanumeric + hyphens + underscores
|
|
695
|
+
import re
|
|
696
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', disk_name):
|
|
697
|
+
return "Disk name must contain only letters, numbers, hyphens, and underscores"
|
|
698
|
+
|
|
699
|
+
if len(disk_name) > 50:
|
|
700
|
+
return "Disk name too long (max 50 characters)"
|
|
701
|
+
|
|
702
|
+
return True
|