lyceum-cli 1.0.25__py3-none-any.whl → 1.0.27__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.
- lyceum/external/auth/login.py +18 -18
- lyceum/external/compute/execution/docker.py +4 -2
- lyceum/external/compute/execution/docker_compose.py +263 -0
- lyceum/external/compute/execution/notebook.py +0 -2
- lyceum/external/compute/execution/python.py +2 -1
- lyceum/external/compute/inference/batch.py +8 -10
- lyceum/external/vms/instances.py +301 -0
- lyceum/external/vms/management.py +383 -0
- lyceum/main.py +3 -0
- lyceum/shared/config.py +19 -24
- lyceum/shared/display.py +12 -31
- lyceum/shared/streaming.py +17 -45
- {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/METADATA +1 -1
- lyceum_cli-1.0.27.dist-info/RECORD +34 -0
- {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/WHEEL +1 -1
- {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/top_level.txt +0 -1
- lyceum/external/compute/execution/docker_config.py +0 -123
- lyceum/external/storage/files.py +0 -273
- lyceum_cli-1.0.25.dist-info/RECORD +0 -46
- tests/__init__.py +0 -1
- tests/conftest.py +0 -200
- tests/unit/__init__.py +0 -1
- tests/unit/external/__init__.py +0 -1
- tests/unit/external/compute/__init__.py +0 -1
- tests/unit/external/compute/execution/__init__.py +0 -1
- tests/unit/external/compute/execution/test_data.py +0 -33
- tests/unit/external/compute/execution/test_dependency_resolver.py +0 -257
- tests/unit/external/compute/execution/test_python_helpers.py +0 -406
- tests/unit/external/compute/execution/test_python_run.py +0 -289
- tests/unit/shared/__init__.py +0 -1
- tests/unit/shared/test_config.py +0 -341
- tests/unit/shared/test_streaming.py +0 -259
- /lyceum/external/{storage → vms}/__init__.py +0 -0
- {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VM instance management CLI commands.
|
|
3
|
+
|
|
4
|
+
Provides commands for:
|
|
5
|
+
- Starting VM instances with specified hardware profiles
|
|
6
|
+
- Listing user's VM instances
|
|
7
|
+
- Getting instance status
|
|
8
|
+
- Terminating instances
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
|
|
19
|
+
from lyceum.shared.config import config
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(help="Manage VM instances")
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_headers() -> dict[str, str]:
|
|
26
|
+
"""Get authorization headers for API requests."""
|
|
27
|
+
# Ensure we have a valid token
|
|
28
|
+
config.get_client()
|
|
29
|
+
return {"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("start")
|
|
33
|
+
def start_instance(
|
|
34
|
+
hardware_profile: str = typer.Option(
|
|
35
|
+
..., "--profile", "-p", help="Hardware profile (e.g., 'a100', 'h100', 'cpu')"
|
|
36
|
+
),
|
|
37
|
+
ssh_key: str = typer.Option(
|
|
38
|
+
..., "--key", "-k", help="SSH public key for accessing the instance"
|
|
39
|
+
),
|
|
40
|
+
name: Optional[str] = typer.Option(
|
|
41
|
+
None, "--name", "-n", help="Optional friendly name for the instance"
|
|
42
|
+
),
|
|
43
|
+
vcpu_min: int = typer.Option(1, "--vcpu", help="Minimum vCPU count"),
|
|
44
|
+
memory_min: int = typer.Option(8, "--memory", "-m", help="Minimum memory in GB"),
|
|
45
|
+
disk_min: int = typer.Option(20, "--disk", "-d", help="Minimum disk in GB"),
|
|
46
|
+
gpu_type: Optional[str] = typer.Option(None, "--gpu-type", help="GPU type (e.g., 'A100', 'H100')"),
|
|
47
|
+
gpu_count: Optional[int] = typer.Option(None, "--gpu-count", help="Number of GPUs"),
|
|
48
|
+
gpu_memory: Optional[int] = typer.Option(None, "--gpu-memory", help="GPU memory in GB"),
|
|
49
|
+
):
|
|
50
|
+
"""
|
|
51
|
+
Start a new VM instance with specified hardware profile.
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
lyceum vms start -p a100 -k "ssh-rsa AAAAB3..." -n "training-vm"
|
|
55
|
+
"""
|
|
56
|
+
console.print(f"[cyan]Starting VM instance with profile: {hardware_profile}...[/cyan]")
|
|
57
|
+
|
|
58
|
+
# Construct instance specs
|
|
59
|
+
instance_specs = {
|
|
60
|
+
"vcpu": {"min": vcpu_min},
|
|
61
|
+
"memory": {"min": memory_min},
|
|
62
|
+
"disk": {"min": disk_min},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if gpu_type:
|
|
66
|
+
instance_specs["gpu_type"] = gpu_type
|
|
67
|
+
if gpu_count:
|
|
68
|
+
instance_specs["gpu_count"] = gpu_count
|
|
69
|
+
if gpu_memory:
|
|
70
|
+
instance_specs["gpu_memory"] = {"value": gpu_memory}
|
|
71
|
+
|
|
72
|
+
payload = {
|
|
73
|
+
"instance_specs": instance_specs,
|
|
74
|
+
"user_public_key": ssh_key,
|
|
75
|
+
"hardware_profile": hardware_profile,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if name:
|
|
79
|
+
payload["name"] = name
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
base_url = config.base_url
|
|
83
|
+
url = f"{base_url}/api/v2/external/vms/create"
|
|
84
|
+
|
|
85
|
+
with httpx.Client(timeout=30.0) as client:
|
|
86
|
+
response = client.post(url, json=payload, headers=get_headers())
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
data = response.json()
|
|
89
|
+
|
|
90
|
+
console.print("[green]✓[/green] VM instance created successfully!")
|
|
91
|
+
console.print(f"\n[bold]Instance ID:[/bold] {data['vm_id']}")
|
|
92
|
+
console.print(f"[bold]Status:[/bold] {data['status']}")
|
|
93
|
+
if data.get("ip_address"):
|
|
94
|
+
console.print(f"[bold]IP Address:[/bold] {data['ip_address']}")
|
|
95
|
+
console.print(f"\n[cyan]Connect via:[/cyan] ssh root@{data['ip_address']}")
|
|
96
|
+
if data.get("name"):
|
|
97
|
+
console.print(f"[bold]Name:[/bold] {data['name']}")
|
|
98
|
+
|
|
99
|
+
except httpx.HTTPStatusError as e:
|
|
100
|
+
if e.response.status_code == 402:
|
|
101
|
+
console.print("[red]Error:[/red] Insufficient credits")
|
|
102
|
+
try:
|
|
103
|
+
detail = e.response.json().get("detail", "")
|
|
104
|
+
console.print(f"[yellow]{detail}[/yellow]")
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
else:
|
|
108
|
+
console.print(f"[red]Error:[/red] HTTP {e.response.status_code}")
|
|
109
|
+
try:
|
|
110
|
+
console.print(f"[yellow]{e.response.json().get('detail', e.response.text)}[/yellow]")
|
|
111
|
+
except Exception:
|
|
112
|
+
console.print(f"[yellow]{e.response.text}[/yellow]")
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
except httpx.RequestError as e:
|
|
115
|
+
console.print(f"[red]Error:[/red] Failed to connect to API: {e}")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("list")
|
|
123
|
+
def list_instances():
|
|
124
|
+
"""
|
|
125
|
+
List all VM instances for the current user.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
lyceum vms list
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
base_url = config.base_url
|
|
132
|
+
url = f"{base_url}/api/v2/external/vms/list"
|
|
133
|
+
|
|
134
|
+
with httpx.Client(timeout=30.0) as client:
|
|
135
|
+
response = client.get(url, headers=get_headers())
|
|
136
|
+
response.raise_for_status()
|
|
137
|
+
data = response.json()
|
|
138
|
+
|
|
139
|
+
vms = data.get("vms", [])
|
|
140
|
+
|
|
141
|
+
if not vms:
|
|
142
|
+
console.print("[yellow]No VM instances found.[/yellow]")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
table = Table(title="Your VM Instances", show_header=True, header_style="bold magenta")
|
|
146
|
+
table.add_column("VM ID", style="cyan")
|
|
147
|
+
table.add_column("Name", style="green")
|
|
148
|
+
table.add_column("Status", style="yellow")
|
|
149
|
+
table.add_column("IP Address", style="blue")
|
|
150
|
+
table.add_column("Billed ($)", style="red", justify="right")
|
|
151
|
+
table.add_column("Created At", style="white")
|
|
152
|
+
|
|
153
|
+
for vm in vms:
|
|
154
|
+
table.add_row(
|
|
155
|
+
vm.get("vm_id", ""),
|
|
156
|
+
vm.get("name", "-"),
|
|
157
|
+
vm.get("status", ""),
|
|
158
|
+
vm.get("ip_address", "-"),
|
|
159
|
+
f"{vm.get('billed', 0):.4f}",
|
|
160
|
+
vm.get("created_at", "")[:19] if vm.get("created_at") else "-",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
console.print(table)
|
|
164
|
+
console.print(f"\n[cyan]Total instances: {data.get('total', 0)}[/cyan]")
|
|
165
|
+
|
|
166
|
+
except httpx.HTTPStatusError as e:
|
|
167
|
+
console.print(f"[red]Error:[/red] HTTP {e.response.status_code}")
|
|
168
|
+
try:
|
|
169
|
+
console.print(f"[yellow]{e.response.json().get('detail', e.response.text)}[/yellow]")
|
|
170
|
+
except Exception:
|
|
171
|
+
console.print(f"[yellow]{e.response.text}[/yellow]")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
except httpx.RequestError as e:
|
|
174
|
+
console.print(f"[red]Error:[/red] Failed to connect to API: {e}")
|
|
175
|
+
raise typer.Exit(1)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command("status")
|
|
182
|
+
def instance_status(
|
|
183
|
+
vm_id: str = typer.Argument(..., help="VM instance ID"),
|
|
184
|
+
):
|
|
185
|
+
"""
|
|
186
|
+
Get detailed status of a specific VM instance.
|
|
187
|
+
|
|
188
|
+
Example:
|
|
189
|
+
lyceum vms status vm-abc123
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
base_url = config.base_url
|
|
193
|
+
url = f"{base_url}/api/v2/external/vms/{vm_id}/status"
|
|
194
|
+
|
|
195
|
+
with httpx.Client(timeout=30.0) as client:
|
|
196
|
+
response = client.get(url, headers=get_headers())
|
|
197
|
+
response.raise_for_status()
|
|
198
|
+
data = response.json()
|
|
199
|
+
|
|
200
|
+
console.print(f"\n[bold cyan]VM Instance Details[/bold cyan]")
|
|
201
|
+
console.print(f"[bold]VM ID:[/bold] {data['vm_id']}")
|
|
202
|
+
if data.get("name"):
|
|
203
|
+
console.print(f"[bold]Name:[/bold] {data['name']}")
|
|
204
|
+
console.print(f"[bold]Status:[/bold] {data['status']}")
|
|
205
|
+
if data.get("ip_address"):
|
|
206
|
+
console.print(f"[bold]IP Address:[/bold] {data['ip_address']}")
|
|
207
|
+
if data.get("uptime_seconds"):
|
|
208
|
+
hours = data["uptime_seconds"] // 3600
|
|
209
|
+
minutes = (data["uptime_seconds"] % 3600) // 60
|
|
210
|
+
seconds = data["uptime_seconds"] % 60
|
|
211
|
+
console.print(f"[bold]Uptime:[/bold] {hours}h {minutes}m {seconds}s")
|
|
212
|
+
if data.get("billed") is not None:
|
|
213
|
+
console.print(f"[bold]Total Billed:[/bold] ${data['billed']:.4f}")
|
|
214
|
+
console.print(f"[bold]Created At:[/bold] {data.get('created_at', '-')}")
|
|
215
|
+
|
|
216
|
+
if data.get("instance_specs"):
|
|
217
|
+
console.print(f"\n[bold cyan]Hardware Specs:[/bold cyan]")
|
|
218
|
+
specs = data["instance_specs"]
|
|
219
|
+
if "vcpu" in specs:
|
|
220
|
+
console.print(f" vCPU: {specs['vcpu']}")
|
|
221
|
+
if "memory" in specs:
|
|
222
|
+
console.print(f" Memory: {specs['memory']}")
|
|
223
|
+
if "disk" in specs:
|
|
224
|
+
console.print(f" Disk: {specs['disk']}")
|
|
225
|
+
if "gpu_type" in specs:
|
|
226
|
+
console.print(f" GPU Type: {specs['gpu_type']}")
|
|
227
|
+
if "gpu_count" in specs:
|
|
228
|
+
console.print(f" GPU Count: {specs['gpu_count']}")
|
|
229
|
+
if "gpu_memory" in specs:
|
|
230
|
+
console.print(f" GPU Memory: {specs['gpu_memory']}")
|
|
231
|
+
|
|
232
|
+
except httpx.HTTPStatusError as e:
|
|
233
|
+
if e.response.status_code == 404:
|
|
234
|
+
console.print(f"[red]Error:[/red] VM instance '{vm_id}' not found")
|
|
235
|
+
else:
|
|
236
|
+
console.print(f"[red]Error:[/red] HTTP {e.response.status_code}")
|
|
237
|
+
try:
|
|
238
|
+
console.print(f"[yellow]{e.response.json().get('detail', e.response.text)}[/yellow]")
|
|
239
|
+
except Exception:
|
|
240
|
+
console.print(f"[yellow]{e.response.text}[/yellow]")
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
except httpx.RequestError as e:
|
|
243
|
+
console.print(f"[red]Error:[/red] Failed to connect to API: {e}")
|
|
244
|
+
raise typer.Exit(1)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
247
|
+
raise typer.Exit(1)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command("terminate")
|
|
251
|
+
def terminate_instance(
|
|
252
|
+
vm_id: str = typer.Argument(..., help="VM instance ID to terminate"),
|
|
253
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
|
|
254
|
+
):
|
|
255
|
+
"""
|
|
256
|
+
Terminate and delete a VM instance permanently.
|
|
257
|
+
|
|
258
|
+
Example:
|
|
259
|
+
lyceum vms terminate vm-abc123
|
|
260
|
+
"""
|
|
261
|
+
if not force:
|
|
262
|
+
confirm = typer.confirm(
|
|
263
|
+
f"Are you sure you want to terminate VM instance '{vm_id}'? This action cannot be undone."
|
|
264
|
+
)
|
|
265
|
+
if not confirm:
|
|
266
|
+
console.print("[yellow]Termination cancelled.[/yellow]")
|
|
267
|
+
raise typer.Exit(0)
|
|
268
|
+
|
|
269
|
+
console.print(f"[cyan]Terminating VM instance {vm_id}...[/cyan]")
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
base_url = config.base_url
|
|
273
|
+
url = f"{base_url}/api/v2/external/vms/{vm_id}"
|
|
274
|
+
|
|
275
|
+
with httpx.Client(timeout=30.0) as client:
|
|
276
|
+
response = client.delete(url, headers=get_headers())
|
|
277
|
+
response.raise_for_status()
|
|
278
|
+
data = response.json()
|
|
279
|
+
|
|
280
|
+
console.print(f"[green]✓[/green] {data.get('message', 'VM instance terminated successfully')}")
|
|
281
|
+
|
|
282
|
+
except httpx.HTTPStatusError as e:
|
|
283
|
+
if e.response.status_code == 404:
|
|
284
|
+
console.print(f"[red]Error:[/red] VM instance '{vm_id}' not found")
|
|
285
|
+
else:
|
|
286
|
+
console.print(f"[red]Error:[/red] HTTP {e.response.status_code}")
|
|
287
|
+
try:
|
|
288
|
+
console.print(f"[yellow]{e.response.json().get('detail', e.response.text)}[/yellow]")
|
|
289
|
+
except Exception:
|
|
290
|
+
console.print(f"[yellow]{e.response.text}[/yellow]")
|
|
291
|
+
raise typer.Exit(1)
|
|
292
|
+
except httpx.RequestError as e:
|
|
293
|
+
console.print(f"[red]Error:[/red] Failed to connect to API: {e}")
|
|
294
|
+
raise typer.Exit(1)
|
|
295
|
+
except Exception as e:
|
|
296
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
297
|
+
raise typer.Exit(1)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
if __name__ == "__main__":
|
|
301
|
+
app()
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""VM instance management commands"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.live import Live
|
|
9
|
+
from rich.spinner import Spinner
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from ...shared.config import config
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
vms_app = typer.Typer(name="vms", help="VM instance management commands")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@vms_app.command("start")
|
|
20
|
+
def start_instance(
|
|
21
|
+
hardware_profile: str = typer.Option(
|
|
22
|
+
"a100", "--hardware-profile", "-h", help="Hardware profile (cpu, a100, h100, etc.)"
|
|
23
|
+
),
|
|
24
|
+
public_key: str = typer.Option(..., "--key", "-k", help="SSH public key for VM access"),
|
|
25
|
+
name: str | None = typer.Option(None, "--name", "-n", help="Friendly name for the instance"),
|
|
26
|
+
cpu: int | None = typer.Option(None, "--cpu", help="Number of CPU cores (uses hardware profile default if not specified)"),
|
|
27
|
+
memory: int | None = typer.Option(None, "--memory", help="Memory in GB (uses hardware profile default if not specified)"),
|
|
28
|
+
disk: int | None = typer.Option(None, "--disk", help="Disk size in GB (uses hardware profile default if not specified)"),
|
|
29
|
+
gpu_count: int | None = typer.Option(None, "--gpu-count", help="Number of GPUs (uses hardware profile default if not specified)"),
|
|
30
|
+
):
|
|
31
|
+
"""Start a new VM instance"""
|
|
32
|
+
try:
|
|
33
|
+
# Ensure we have a valid token
|
|
34
|
+
config.get_client()
|
|
35
|
+
|
|
36
|
+
# Create instance specs - only include values that were explicitly provided
|
|
37
|
+
instance_specs = {}
|
|
38
|
+
if cpu is not None:
|
|
39
|
+
instance_specs["cpu"] = cpu
|
|
40
|
+
if memory is not None:
|
|
41
|
+
instance_specs["memory"] = memory
|
|
42
|
+
if disk is not None:
|
|
43
|
+
instance_specs["disk"] = disk
|
|
44
|
+
if gpu_count is not None:
|
|
45
|
+
instance_specs["gpu_count"] = gpu_count
|
|
46
|
+
|
|
47
|
+
# Create request payload
|
|
48
|
+
payload = {
|
|
49
|
+
"instance_specs": instance_specs,
|
|
50
|
+
"user_public_key": public_key,
|
|
51
|
+
"hardware_profile": hardware_profile,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if name:
|
|
55
|
+
payload["name"] = name
|
|
56
|
+
|
|
57
|
+
# Make API request
|
|
58
|
+
console.print("[dim]Creating VM instance...[/dim]")
|
|
59
|
+
response = httpx.post(
|
|
60
|
+
f"{config.base_url}/api/v2/external/vms/create",
|
|
61
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
62
|
+
json=payload,
|
|
63
|
+
timeout=60.0,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if response.status_code != 200:
|
|
67
|
+
console.print(f"[red]Error: HTTP {response.status_code}[/red]")
|
|
68
|
+
try:
|
|
69
|
+
error_data = response.json()
|
|
70
|
+
console.print(f"[red]{error_data.get('detail', response.text)}[/red]")
|
|
71
|
+
except Exception:
|
|
72
|
+
console.print(f"[red]{response.text}[/red]")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
data = response.json()
|
|
76
|
+
|
|
77
|
+
console.print("[green]✅ VM instance created successfully![/green]")
|
|
78
|
+
console.print(f"[bold]VM ID:[/bold] {data['vm_id']}")
|
|
79
|
+
console.print(f"[bold]Status:[/bold] {data['status']}")
|
|
80
|
+
if data.get("name"):
|
|
81
|
+
console.print(f"[bold]Name:[/bold] {data['name']}")
|
|
82
|
+
console.print(f"[dim]Created at: {data['created_at']}[/dim]")
|
|
83
|
+
|
|
84
|
+
# If instance is pending, poll for readiness
|
|
85
|
+
if data['status'] == 'pending':
|
|
86
|
+
console.print("\n[yellow]Instance is provisioning...[/yellow]")
|
|
87
|
+
|
|
88
|
+
vm_id = data['vm_id']
|
|
89
|
+
poll_interval = 30 # seconds
|
|
90
|
+
max_attempts = 20 # 10 minutes max
|
|
91
|
+
attempt = 0
|
|
92
|
+
|
|
93
|
+
with Live(Spinner("dots", text="Provisioning instance..."), console=console, refresh_per_second=4) as live:
|
|
94
|
+
while attempt < max_attempts:
|
|
95
|
+
time.sleep(poll_interval)
|
|
96
|
+
attempt += 1
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
status_response = httpx.get(
|
|
100
|
+
f"{config.base_url}/api/v2/external/vms/{vm_id}/status",
|
|
101
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
102
|
+
timeout=30.0,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if status_response.status_code == 200:
|
|
106
|
+
status_data = status_response.json()
|
|
107
|
+
current_status = status_data.get('status')
|
|
108
|
+
|
|
109
|
+
if current_status == 'ready' or current_status == 'running':
|
|
110
|
+
live.stop()
|
|
111
|
+
console.print(f"\n[green]✅ Instance is now {current_status}![/green]")
|
|
112
|
+
|
|
113
|
+
if status_data.get("ip_address"):
|
|
114
|
+
ip_addr = status_data['ip_address']
|
|
115
|
+
console.print(f"[bold]IP Address:[/bold] {ip_addr}")
|
|
116
|
+
|
|
117
|
+
# Handle IP:PORT format
|
|
118
|
+
if ':' in ip_addr:
|
|
119
|
+
ip, port = ip_addr.split(':', 1)
|
|
120
|
+
console.print(f"\n[cyan]SSH command:[/cyan] ssh -i <your-key> -p {port} ubuntu@{ip}")
|
|
121
|
+
else:
|
|
122
|
+
console.print(f"\n[cyan]SSH command:[/cyan] ssh -i <your-key> ubuntu@{ip_addr}")
|
|
123
|
+
break
|
|
124
|
+
elif current_status in ['failed', 'terminated', 'error']:
|
|
125
|
+
live.stop()
|
|
126
|
+
console.print(f"\n[red]❌ Instance provisioning failed with status: {current_status}[/red]")
|
|
127
|
+
break
|
|
128
|
+
else:
|
|
129
|
+
live.update(Spinner("dots", text=f"Provisioning instance... (status: {current_status}, attempt {attempt}/{max_attempts})"))
|
|
130
|
+
except Exception as e:
|
|
131
|
+
# Continue polling even if status check fails
|
|
132
|
+
live.update(Spinner("dots", text=f"Provisioning instance... (attempt {attempt}/{max_attempts})"))
|
|
133
|
+
|
|
134
|
+
if attempt >= max_attempts:
|
|
135
|
+
live.stop()
|
|
136
|
+
console.print(f"\n[yellow]⚠️ Polling timed out. Instance may still be provisioning.[/yellow]")
|
|
137
|
+
console.print(f"[dim]Check status with: lyceum vms instance-status {vm_id}[/dim]")
|
|
138
|
+
|
|
139
|
+
elif data.get("ip_address"):
|
|
140
|
+
ip_addr = data['ip_address']
|
|
141
|
+
console.print(f"[bold]IP Address:[/bold] {ip_addr}")
|
|
142
|
+
|
|
143
|
+
# Handle IP:PORT format
|
|
144
|
+
if ':' in ip_addr:
|
|
145
|
+
ip, port = ip_addr.split(':', 1)
|
|
146
|
+
console.print(f"\n[cyan]SSH command:[/cyan] ssh -i <your-key> -p {port} ubuntu@{ip}")
|
|
147
|
+
else:
|
|
148
|
+
console.print(f"\n[cyan]SSH command:[/cyan] ssh -i <your-key> ubuntu@{ip_addr}")
|
|
149
|
+
|
|
150
|
+
except httpx.TimeoutException:
|
|
151
|
+
console.print("[red]Error: Request timed out[/red]")
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
155
|
+
raise typer.Exit(1)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@vms_app.command("list")
|
|
159
|
+
def list_instances():
|
|
160
|
+
"""List all your VM instances (both active and inactive)"""
|
|
161
|
+
try:
|
|
162
|
+
# Ensure we have a valid token
|
|
163
|
+
config.get_client()
|
|
164
|
+
|
|
165
|
+
# Make API request
|
|
166
|
+
response = httpx.get(
|
|
167
|
+
f"{config.base_url}/api/v2/external/vms/list",
|
|
168
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
169
|
+
timeout=30.0,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if response.status_code != 200:
|
|
173
|
+
console.print(f"[red]Error: HTTP {response.status_code}[/red]")
|
|
174
|
+
console.print(f"[red]{response.text}[/red]")
|
|
175
|
+
raise typer.Exit(1)
|
|
176
|
+
|
|
177
|
+
data = response.json()
|
|
178
|
+
vms = data.get("vms", [])
|
|
179
|
+
|
|
180
|
+
if not vms:
|
|
181
|
+
console.print("[yellow]No VM instances found.[/yellow]")
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# Create table
|
|
185
|
+
table = Table(title="VM Instances")
|
|
186
|
+
table.add_column("VM ID", style="cyan")
|
|
187
|
+
table.add_column("Name", style="green")
|
|
188
|
+
table.add_column("Status", style="yellow")
|
|
189
|
+
table.add_column("IP Address", style="blue")
|
|
190
|
+
table.add_column("Hardware", style="magenta")
|
|
191
|
+
table.add_column("Billed ($)", style="red")
|
|
192
|
+
table.add_column("Created At", style="dim")
|
|
193
|
+
|
|
194
|
+
for vm in vms:
|
|
195
|
+
table.add_row(
|
|
196
|
+
vm["vm_id"],
|
|
197
|
+
vm.get("name", "-"),
|
|
198
|
+
vm["status"],
|
|
199
|
+
vm.get("ip_address", "-"),
|
|
200
|
+
vm.get("hardware_profile", "-"),
|
|
201
|
+
f"{vm.get('billed', 0):.4f}" if vm.get("billed") is not None else "-",
|
|
202
|
+
vm["created_at"],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
console.print(table)
|
|
206
|
+
|
|
207
|
+
except httpx.TimeoutException:
|
|
208
|
+
console.print("[red]Error: Request timed out[/red]")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
except Exception as e:
|
|
211
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@vms_app.command("availability")
|
|
216
|
+
def list_availability():
|
|
217
|
+
"""List available VM hardware profiles"""
|
|
218
|
+
try:
|
|
219
|
+
# Ensure we have a valid token
|
|
220
|
+
config.get_client()
|
|
221
|
+
|
|
222
|
+
# Make API request
|
|
223
|
+
response = httpx.get(
|
|
224
|
+
f"{config.base_url}/api/v2/external/vms/availability",
|
|
225
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
226
|
+
timeout=30.0,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if response.status_code != 200:
|
|
230
|
+
console.print(f"[red]Error: HTTP {response.status_code}[/red]")
|
|
231
|
+
console.print(f"[red]{response.text}[/red]")
|
|
232
|
+
raise typer.Exit(1)
|
|
233
|
+
|
|
234
|
+
data = response.json()
|
|
235
|
+
hardware_profiles = data.get("available_hardware_profiles", [])
|
|
236
|
+
pool_name = data.get("pool_name", "")
|
|
237
|
+
|
|
238
|
+
if not hardware_profiles:
|
|
239
|
+
console.print("[yellow]No hardware profiles available.[/yellow]")
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
console.print(f"\n[bold cyan]Available Hardware Profiles[/bold cyan] (Pool: {pool_name})\n")
|
|
243
|
+
|
|
244
|
+
# Create table
|
|
245
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
246
|
+
table.add_column("Hardware Profile", style="cyan")
|
|
247
|
+
table.add_column("Available", style="green", justify="right")
|
|
248
|
+
table.add_column("Total", style="yellow", justify="right")
|
|
249
|
+
table.add_column("GPU Type", style="blue")
|
|
250
|
+
table.add_column("vCPU", style="white", justify="right")
|
|
251
|
+
table.add_column("Memory (GB)", style="white", justify="right")
|
|
252
|
+
|
|
253
|
+
for profile in hardware_profiles:
|
|
254
|
+
table.add_row(
|
|
255
|
+
profile.get("hardware_profile", "-"),
|
|
256
|
+
str(profile.get("available", 0)),
|
|
257
|
+
str(profile.get("total", 0)),
|
|
258
|
+
profile.get("gpu_type", "-"),
|
|
259
|
+
str(profile.get("vcpu", "-")),
|
|
260
|
+
str(profile.get("memory_gb", "-")),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
console.print(table)
|
|
264
|
+
console.print(f"\n[dim]Use these hardware profile names with: lyceum vms start-instance --hardware-profile <name>[/dim]")
|
|
265
|
+
|
|
266
|
+
except httpx.TimeoutException:
|
|
267
|
+
console.print("[red]Error: Request timed out[/red]")
|
|
268
|
+
raise typer.Exit(1)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@vms_app.command("status")
|
|
275
|
+
def instance_status(
|
|
276
|
+
vm_id: str = typer.Argument(..., help="VM instance ID"),
|
|
277
|
+
):
|
|
278
|
+
"""Get detailed status of a VM instance"""
|
|
279
|
+
try:
|
|
280
|
+
# Ensure we have a valid token
|
|
281
|
+
config.get_client()
|
|
282
|
+
|
|
283
|
+
# Make API request
|
|
284
|
+
response = httpx.get(
|
|
285
|
+
f"{config.base_url}/api/v2/external/vms/{vm_id}/status",
|
|
286
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
287
|
+
timeout=30.0,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if response.status_code != 200:
|
|
291
|
+
console.print(f"[red]Error: HTTP {response.status_code}[/red]")
|
|
292
|
+
try:
|
|
293
|
+
error_data = response.json()
|
|
294
|
+
console.print(f"[red]{error_data.get('detail', response.text)}[/red]")
|
|
295
|
+
except Exception:
|
|
296
|
+
console.print(f"[red]{response.text}[/red]")
|
|
297
|
+
raise typer.Exit(1)
|
|
298
|
+
|
|
299
|
+
data = response.json()
|
|
300
|
+
|
|
301
|
+
console.print(f"[bold cyan]VM Instance: {vm_id}[/bold cyan]\n")
|
|
302
|
+
console.print(f"[bold]Status:[/bold] {data['status']}")
|
|
303
|
+
if data.get("name"):
|
|
304
|
+
console.print(f"[bold]Name:[/bold] {data['name']}")
|
|
305
|
+
if data.get("ip_address"):
|
|
306
|
+
console.print(f"[bold]IP Address:[/bold] {data['ip_address']}")
|
|
307
|
+
console.print(f"[bold]Hardware Profile:[/bold] {data.get('hardware_profile', '-')}")
|
|
308
|
+
if data.get("billed") is not None:
|
|
309
|
+
console.print(f"[bold]Total Billed:[/bold] ${data['billed']:.4f}")
|
|
310
|
+
if data.get("uptime_seconds") is not None:
|
|
311
|
+
hours = data["uptime_seconds"] / 3600
|
|
312
|
+
console.print(f"[bold]Uptime:[/bold] {hours:.2f} hours")
|
|
313
|
+
console.print(f"[dim]Created at: {data['created_at']}[/dim]")
|
|
314
|
+
|
|
315
|
+
if data.get("instance_specs"):
|
|
316
|
+
console.print("\n[bold]Instance Specs:[/bold]")
|
|
317
|
+
specs = data["instance_specs"]
|
|
318
|
+
console.print(f" CPU: {specs.get('cpu', '-')} cores")
|
|
319
|
+
console.print(f" Memory: {specs.get('memory', '-')} GB")
|
|
320
|
+
console.print(f" Disk: {specs.get('disk', '-')} GB")
|
|
321
|
+
console.print(f" GPU Count: {specs.get('gpu_count', '-')}")
|
|
322
|
+
|
|
323
|
+
if data.get("ip_address"):
|
|
324
|
+
ip_addr = data['ip_address']
|
|
325
|
+
# Handle IP:PORT format
|
|
326
|
+
if ':' in ip_addr:
|
|
327
|
+
ip, port = ip_addr.split(':', 1)
|
|
328
|
+
console.print(f"\n[cyan]SSH command:[/cyan] ssh -i <your-key> -p {port} ubuntu@{ip}")
|
|
329
|
+
else:
|
|
330
|
+
console.print(f"\n[cyan]SSH command:[/cyan] ssh -i <your-key> ubuntu@{ip_addr}")
|
|
331
|
+
|
|
332
|
+
except httpx.TimeoutException:
|
|
333
|
+
console.print("[red]Error: Request timed out[/red]")
|
|
334
|
+
raise typer.Exit(1)
|
|
335
|
+
except Exception as e:
|
|
336
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
337
|
+
raise typer.Exit(1)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@vms_app.command("terminate")
|
|
341
|
+
def terminate_instance(
|
|
342
|
+
vm_id: str = typer.Argument(..., help="VM instance ID to terminate"),
|
|
343
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
|
|
344
|
+
):
|
|
345
|
+
"""Terminate (delete) a VM instance"""
|
|
346
|
+
try:
|
|
347
|
+
# Ensure we have a valid token
|
|
348
|
+
config.get_client()
|
|
349
|
+
|
|
350
|
+
# Confirm termination unless --force is used
|
|
351
|
+
if not force:
|
|
352
|
+
confirm = typer.confirm(f"Are you sure you want to terminate VM {vm_id}?")
|
|
353
|
+
if not confirm:
|
|
354
|
+
console.print("[yellow]Termination cancelled.[/yellow]")
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
# Make API request
|
|
358
|
+
console.print("[dim]Terminating VM instance...[/dim]")
|
|
359
|
+
response = httpx.delete(
|
|
360
|
+
f"{config.base_url}/api/v2/external/vms/{vm_id}",
|
|
361
|
+
headers={"Authorization": f"Bearer {config.api_key}"},
|
|
362
|
+
timeout=30.0,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
if response.status_code != 200:
|
|
366
|
+
console.print(f"[red]Error: HTTP {response.status_code}[/red]")
|
|
367
|
+
try:
|
|
368
|
+
error_data = response.json()
|
|
369
|
+
console.print(f"[red]{error_data.get('detail', response.text)}[/red]")
|
|
370
|
+
except Exception:
|
|
371
|
+
console.print(f"[red]{response.text}[/red]")
|
|
372
|
+
raise typer.Exit(1)
|
|
373
|
+
|
|
374
|
+
data = response.json()
|
|
375
|
+
|
|
376
|
+
console.print(f"[green]✅ {data.get('message', 'VM instance terminated successfully')}[/green]")
|
|
377
|
+
|
|
378
|
+
except httpx.TimeoutException:
|
|
379
|
+
console.print("[red]Error: Request timed out[/red]")
|
|
380
|
+
raise typer.Exit(1)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
383
|
+
raise typer.Exit(1)
|