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.
Files changed (34) hide show
  1. lyceum/external/auth/login.py +18 -18
  2. lyceum/external/compute/execution/docker.py +4 -2
  3. lyceum/external/compute/execution/docker_compose.py +263 -0
  4. lyceum/external/compute/execution/notebook.py +0 -2
  5. lyceum/external/compute/execution/python.py +2 -1
  6. lyceum/external/compute/inference/batch.py +8 -10
  7. lyceum/external/vms/instances.py +301 -0
  8. lyceum/external/vms/management.py +383 -0
  9. lyceum/main.py +3 -0
  10. lyceum/shared/config.py +19 -24
  11. lyceum/shared/display.py +12 -31
  12. lyceum/shared/streaming.py +17 -45
  13. {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/METADATA +1 -1
  14. lyceum_cli-1.0.27.dist-info/RECORD +34 -0
  15. {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/WHEEL +1 -1
  16. {lyceum_cli-1.0.25.dist-info → lyceum_cli-1.0.27.dist-info}/top_level.txt +0 -1
  17. lyceum/external/compute/execution/docker_config.py +0 -123
  18. lyceum/external/storage/files.py +0 -273
  19. lyceum_cli-1.0.25.dist-info/RECORD +0 -46
  20. tests/__init__.py +0 -1
  21. tests/conftest.py +0 -200
  22. tests/unit/__init__.py +0 -1
  23. tests/unit/external/__init__.py +0 -1
  24. tests/unit/external/compute/__init__.py +0 -1
  25. tests/unit/external/compute/execution/__init__.py +0 -1
  26. tests/unit/external/compute/execution/test_data.py +0 -33
  27. tests/unit/external/compute/execution/test_dependency_resolver.py +0 -257
  28. tests/unit/external/compute/execution/test_python_helpers.py +0 -406
  29. tests/unit/external/compute/execution/test_python_run.py +0 -289
  30. tests/unit/shared/__init__.py +0 -1
  31. tests/unit/shared/test_config.py +0 -341
  32. tests/unit/shared/test_streaming.py +0 -259
  33. /lyceum/external/{storage → vms}/__init__.py +0 -0
  34. {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)