lyceum-cli 1.2.1__tar.gz → 1.2.2__tar.gz
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_cli-1.2.1 → lyceum_cli-1.2.2}/PKG-INFO +1 -1
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/_version.py +1 -1
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/vms/management.py +58 -3
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum_cli.egg-info/PKG-INFO +1 -1
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/tests/test_block_storage.py +86 -1
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/README.md +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/__main__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/auth/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/auth/environment.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/auth/keys.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/auth/login.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/execution/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/execution/config.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/execution/docker.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/execution/docker_compose.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/execution/gpu_selection.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/execution/jobs.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/execution/notebook.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/execution/python.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/inference/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/inference/chat.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/inference/claude_code.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/compute/inference/infer.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/orgs/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/orgs/management.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/storage/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/storage/block.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/storage/files.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/external/vms/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/main.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/shared/__init__.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/shared/config.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/shared/http.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/shared/imports.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/shared/orgs.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/shared/run_options.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/shared/streaming.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum/shared/ui.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum_cli.egg-info/SOURCES.txt +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum_cli.egg-info/dependency_links.txt +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum_cli.egg-info/entry_points.txt +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum_cli.egg-info/requires.txt +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/lyceum_cli.egg-info/top_level.txt +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/pyproject.toml +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/setup.cfg +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/tests/test_dedicated_deploy.py +0 -0
- {lyceum_cli-1.2.1 → lyceum_cli-1.2.2}/tests/test_orgs.py +0 -0
|
@@ -47,6 +47,54 @@ def _print_ssh_command(ip_addr: str):
|
|
|
47
47
|
console.print(f"\n[cyan]SSH command:[/cyan] ssh -i <your-key> lyceum@{ip_addr}")
|
|
48
48
|
|
|
49
49
|
|
|
50
|
+
def _attached_volumes_of(vm_id: str) -> list[dict[str, Any]]:
|
|
51
|
+
try:
|
|
52
|
+
data = api_request("GET", f"/api/v2/external/vms/{vm_id}/status", silent=True)
|
|
53
|
+
except typer.Exit:
|
|
54
|
+
return []
|
|
55
|
+
return (data or {}).get("volumes") or []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _print_volumes_with_ids(volumes: list[dict[str, Any]]):
|
|
59
|
+
for volume in volumes:
|
|
60
|
+
size = volume.get("size_gb")
|
|
61
|
+
mount_path = volume.get("mount_path")
|
|
62
|
+
details = ", ".join(part for part in (f"{size} GB" if size else "", mount_path or "") if part)
|
|
63
|
+
console.print(f" [cyan]{volume.get('volume_id', '-')}[/cyan]" + (f" [dim]{details}[/dim]" if details else ""))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _warn_about_attached_storage_being_not_deleted(volumes: list[dict[str, Any]]):
|
|
67
|
+
if not volumes:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
one = len(volumes) == 1
|
|
71
|
+
noun, them, keep = ("volume", "it", "keeps") if one else ("volumes", "them", "keep")
|
|
72
|
+
console.print(f"\n[yellow]This VM is holding {len(volumes)} block storage {noun}:[/yellow]")
|
|
73
|
+
_print_volumes_with_ids(volumes)
|
|
74
|
+
console.print(
|
|
75
|
+
f"[yellow]Stopping the VM detaches {them} but does not delete {them}.[/yellow]\n"
|
|
76
|
+
f"[dim]The {noun} {keep} your data and {keep} being billed until you delete {them} separately.[/dim]\n"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _report_detached_storage_with_usage_hints(volumes: list[dict[str, Any]]):
|
|
81
|
+
if not volumes:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
one = len(volumes) == 1
|
|
85
|
+
noun = "volume" if one else "volumes"
|
|
86
|
+
verb = "is" if one else "are"
|
|
87
|
+
console.print(f"\n[yellow]{len(volumes)} block storage {noun} outlived the VM and {verb} still billed:[/yellow]")
|
|
88
|
+
_print_volumes_with_ids(volumes)
|
|
89
|
+
console.print(f"\n[dim]Delete {'it' if one else 'each one'} with:[/dim]")
|
|
90
|
+
for volume in volumes:
|
|
91
|
+
console.print(f" [cyan]lyceum storage block delete {volume.get('volume_id', '<volume-id>')}[/cyan]")
|
|
92
|
+
console.print(
|
|
93
|
+
f"[dim]Or keep {'it' if one else 'them'} for the next VM: "
|
|
94
|
+
"lyceum vm start --attach-storage <volume-id> ...[/dim]"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
50
98
|
def _validate_storage_options(
|
|
51
99
|
attach_storage: list[str] | None,
|
|
52
100
|
create_block_storage: int | None,
|
|
@@ -73,11 +121,14 @@ def _print_storage_result(data: dict[str, Any]):
|
|
|
73
121
|
"""Report the block storage a VM was created with, if any."""
|
|
74
122
|
volume = data.get("volume")
|
|
75
123
|
if volume:
|
|
124
|
+
name = volume.get("name", "-")
|
|
76
125
|
console.print(
|
|
77
|
-
f"[bold]Block storage:[/bold] {
|
|
126
|
+
f"[bold]Block storage:[/bold] {name} "
|
|
78
127
|
f"({volume['size_gb']} GB, {_status_style(volume.get('status', 'pending'))})"
|
|
79
128
|
)
|
|
80
|
-
|
|
129
|
+
# The volume is named here rather than identified, because the provider
|
|
130
|
+
# has issued no ID yet. `storage block list` carries it once it has.
|
|
131
|
+
console.print("[dim]The volume outlives this VM; see it with: lyceum storage block list[/dim]")
|
|
81
132
|
|
|
82
133
|
notice = data.get("volume_notice")
|
|
83
134
|
if notice:
|
|
@@ -575,11 +626,15 @@ def terminate_instance(
|
|
|
575
626
|
):
|
|
576
627
|
"""Stop and delete a VM instance"""
|
|
577
628
|
if not force:
|
|
629
|
+
_warn_about_attached_storage_being_not_deleted(_attached_volumes_of(vm_id))
|
|
578
630
|
confirm = typer.confirm(f"Are you sure you want to terminate VM {vm_id}?")
|
|
579
631
|
if not confirm:
|
|
580
632
|
console.print("[yellow]Termination cancelled.[/yellow]")
|
|
581
633
|
return
|
|
582
634
|
|
|
583
635
|
console.print("[dim]Terminating VM instance...[/dim]")
|
|
584
|
-
data = api_request("DELETE", f"/api/v2/external/vms/{vm_id}")
|
|
636
|
+
data = api_request("DELETE", f"/api/v2/external/vms/{vm_id}") or {}
|
|
585
637
|
console.print(f"[green]✓ {data.get('message', 'VM instance terminated successfully')}[/green]")
|
|
638
|
+
# Taken from the delete rather than the lookup above, so --force hears about
|
|
639
|
+
# the storage too.
|
|
640
|
+
_report_detached_storage_with_usage_hints(data.get("detached_volumes") or [])
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""The CLI surface for block storage: `storage block` and `vm start`'s options."""
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
|
+
import typer
|
|
4
5
|
from typer.testing import CliRunner
|
|
5
6
|
|
|
6
7
|
from lyceum.external.storage import block
|
|
@@ -271,7 +272,7 @@ def test_a_copy_can_be_switched_off(gateway):
|
|
|
271
272
|
|
|
272
273
|
def test_a_new_volume_is_asked_for_by_size(gateway):
|
|
273
274
|
gateway.bodies["/api/v2/external/vms/create"] = _vm_body(
|
|
274
|
-
volume={"
|
|
275
|
+
volume={"name": "hyd-devel-vol-new", "size_gb": 100, "status": "pending"}
|
|
275
276
|
)
|
|
276
277
|
|
|
277
278
|
result = runner.invoke(app, [*_VM_ARGS, "--create-block-storage", "100"])
|
|
@@ -312,3 +313,87 @@ def test_a_volume_being_copied_is_reported_instead_of_polled(gateway):
|
|
|
312
313
|
assert result.exit_code == 0
|
|
313
314
|
assert "being copied" in result.output
|
|
314
315
|
assert "lyceum storage block list" in result.output
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# --- lyceum vm stop ---------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _stop_bodies(gateway, volumes: list[dict], detached: list[dict] | None = None):
|
|
322
|
+
"""What the gateway answers a stop with: the VM's storage, then the delete."""
|
|
323
|
+
gateway.bodies["/api/v2/external/vms/vm-1/status"] = {"vm_id": "vm-1", "status": "ready", "volumes": volumes}
|
|
324
|
+
gateway.bodies["/api/v2/external/vms/vm-1"] = {
|
|
325
|
+
"vm_id": "vm-1",
|
|
326
|
+
"status": "terminated",
|
|
327
|
+
"message": "VM vm-1 has been terminated",
|
|
328
|
+
"detached_volumes": volumes if detached is None else detached,
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def test_stopping_a_vm_names_its_storage_before_asking(gateway):
|
|
333
|
+
"""The prompt is the last point at which knowing costs the customer nothing."""
|
|
334
|
+
_stop_bodies(gateway, [_VOLUME])
|
|
335
|
+
|
|
336
|
+
result = runner.invoke(app, ["vm", "stop", "vm-1"], input="y\n")
|
|
337
|
+
|
|
338
|
+
assert result.exit_code == 0
|
|
339
|
+
assert "vol-abc" in result.output
|
|
340
|
+
assert "does not delete" in result.output
|
|
341
|
+
# Being billed on is the part no other command would have told them.
|
|
342
|
+
assert "billed" in result.output
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_a_vm_without_storage_is_not_warned_about_any(gateway):
|
|
346
|
+
_stop_bodies(gateway, [])
|
|
347
|
+
|
|
348
|
+
result = runner.invoke(app, ["vm", "stop", "vm-1"], input="y\n")
|
|
349
|
+
|
|
350
|
+
assert result.exit_code == 0
|
|
351
|
+
assert "block storage" not in result.output.lower()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_declining_the_prompt_stops_nothing(gateway):
|
|
355
|
+
_stop_bodies(gateway, [_VOLUME])
|
|
356
|
+
|
|
357
|
+
result = runner.invoke(app, ["vm", "stop", "vm-1"], input="n\n")
|
|
358
|
+
|
|
359
|
+
assert result.exit_code == 0
|
|
360
|
+
assert "cancelled" in result.output.lower()
|
|
361
|
+
assert not any(method == "DELETE" for method, _path, _kwargs in gateway.calls)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_the_storage_left_behind_comes_with_the_command_that_deletes_it(gateway):
|
|
365
|
+
_stop_bodies(gateway, [_VOLUME])
|
|
366
|
+
|
|
367
|
+
result = runner.invoke(app, ["vm", "stop", "vm-1"], input="y\n")
|
|
368
|
+
|
|
369
|
+
assert result.exit_code == 0
|
|
370
|
+
assert "lyceum storage block delete vol-abc" in result.output
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def test_force_hears_about_the_storage_even_though_it_skips_the_prompt(gateway):
|
|
374
|
+
"""--force waives the question, not the answer: the volumes are still billed."""
|
|
375
|
+
_stop_bodies(gateway, [_VOLUME])
|
|
376
|
+
|
|
377
|
+
result = runner.invoke(app, ["vm", "stop", "vm-1", "--force"])
|
|
378
|
+
|
|
379
|
+
assert result.exit_code == 0
|
|
380
|
+
# Nothing was asked, so the status lookup behind the prompt was not made.
|
|
381
|
+
assert [path for _method, path, _kwargs in gateway.calls] == ["/api/v2/external/vms/vm-1"]
|
|
382
|
+
assert "lyceum storage block delete vol-abc" in result.output
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def test_a_status_lookup_that_fails_does_not_block_the_stop(gateway, monkeypatch):
|
|
386
|
+
"""Not knowing about the storage is no reason to refuse to stop the VM."""
|
|
387
|
+
_stop_bodies(gateway, [_VOLUME])
|
|
388
|
+
|
|
389
|
+
def refuse_the_status(method, path, **kwargs):
|
|
390
|
+
if path.endswith("/status"):
|
|
391
|
+
raise typer.Exit(1)
|
|
392
|
+
return gateway(method, path, **kwargs)
|
|
393
|
+
|
|
394
|
+
monkeypatch.setattr(management, "api_request", refuse_the_status)
|
|
395
|
+
|
|
396
|
+
result = runner.invoke(app, ["vm", "stop", "vm-1"], input="y\n")
|
|
397
|
+
|
|
398
|
+
assert result.exit_code == 0
|
|
399
|
+
assert "terminated" in result.output
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|