compshare-cli 0.1.0__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.
@@ -0,0 +1,1493 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple, TypeVar
8
+
9
+ import typer
10
+
11
+ from compshare_cli.api import call, invoke
12
+ from compshare_cli.commands.common import confirm, confirm_details, request, runtime
13
+ from compshare_cli.errors import UsageError
14
+ from compshare_cli.i18n import tr
15
+ from compshare_cli.location import locate_instance, region_from_zone, supported_regions
16
+ from compshare_cli.output import Renderer
17
+ from compshare_cli.parsing import compact, disk_gib, encode_password, memory_mib, timestamp
18
+ from compshare_cli.runtime import Runtime
19
+
20
+ app = typer.Typer(help="Manage GPU instances.", no_args_is_help=True)
21
+ ports_app = typer.Typer(help="Manage container port mappings.", no_args_is_help=True)
22
+ schedule_app = typer.Typer(help="Manage scheduled shutdowns.", no_args_is_help=True)
23
+ software_app = typer.Typer(help="Discover software exposed by instances.", no_args_is_help=True)
24
+ app.add_typer(ports_app, name="ports")
25
+ app.add_typer(schedule_app, name="schedule")
26
+ app.add_typer(software_app, name="software")
27
+
28
+ INSTANCE_COLUMNS = (
29
+ ("UHostId", "ID"),
30
+ ("Name", "NAME"),
31
+ ("State", "STATE"),
32
+ ("GpuType", "GPU"),
33
+ ("GPU", "COUNT"),
34
+ ("CPU", "CPU"),
35
+ ("MemoryDisplay", "MEMORY"),
36
+ ("Region", "REGION"),
37
+ ("Zone", "ZONE"),
38
+ ("ChargeType", "CHARGE"),
39
+ ("InstancePrice", "PRICE/H"),
40
+ )
41
+
42
+ Choice = TypeVar("Choice")
43
+
44
+
45
+ def _instance_rows(response: Dict[str, Any]) -> Iterable[Dict[str, Any]]:
46
+ for raw in response.get("UHostSet", []):
47
+ row = dict(raw)
48
+ memory = row.get("Memory")
49
+ row["MemoryDisplay"] = f"{memory // 1024}GiB" if isinstance(memory, int) else memory
50
+ yield row
51
+
52
+
53
+ def _search_rows(
54
+ response: Dict[str, Any],
55
+ inventory: Optional[Dict[str, Dict[tuple, bool]]] = None,
56
+ available_only: bool = False,
57
+ ) -> Iterable[Dict[str, Any]]:
58
+ inventory = inventory or {}
59
+ for machine in response.get("AvailableInstanceTypes", []):
60
+ gpu_memory = machine.get("GraphicsMemory", {}).get("Value")
61
+ for size in machine.get("MachineSizes", []):
62
+ for collection in size.get("Collection", []):
63
+ for memory in collection.get("Memory", []):
64
+ stock = inventory.get(machine.get("Name"), {}).get(
65
+ (size.get("Gpu"), collection.get("Cpu"), memory)
66
+ )
67
+ if available_only and stock is not True:
68
+ continue
69
+ yield {
70
+ "GpuType": machine.get("Name"),
71
+ "GPU": size.get("Gpu"),
72
+ "CPU": collection.get("Cpu"),
73
+ "Memory": f"{memory}GiB",
74
+ "VRAM": f"{gpu_memory}GiB" if gpu_memory else None,
75
+ "Zone": machine.get("Zone"),
76
+ "Stock": stock,
77
+ "Platforms": collection.get("MinimalCpuPlatform", []),
78
+ }
79
+
80
+
81
+ def _disk_list(
82
+ boot_disk: str,
83
+ boot_type: str,
84
+ data_disks: Optional[List[str]],
85
+ ) -> List[Dict[str, Any]]:
86
+ disks: List[Dict[str, Any]] = [{"IsBoot": True, "Type": boot_type, "Size": disk_gib(boot_disk)}]
87
+ for specification in data_disks or []:
88
+ parts = specification.split(":", 1)
89
+ size = disk_gib(parts[0])
90
+ disk_type = parts[1] if len(parts) == 2 else boot_type
91
+ disks.append({"IsBoot": False, "Type": disk_type, "Size": size})
92
+ return disks
93
+
94
+
95
+ def _volume_list(volumes: Optional[List[str]]) -> Optional[List[Dict[str, Any]]]:
96
+ if not volumes:
97
+ return None
98
+ result: List[Dict[str, Any]] = []
99
+ for specification in volumes:
100
+ parts = specification.split(":", 1)
101
+ size = disk_gib(parts[0])
102
+ volume_type = parts[1] if len(parts) == 2 else "UDisk"
103
+ result.append({"Type": volume_type, "Size": size})
104
+ return result
105
+
106
+
107
+ def _choose(
108
+ title: str,
109
+ choices: Sequence[Choice],
110
+ label: Callable[[Choice], str],
111
+ *,
112
+ default: int = 1,
113
+ ) -> Choice:
114
+ if not choices:
115
+ raise UsageError(f"{tr(title)}: {tr('No selectable options')}")
116
+ typer.echo(f"\n{tr(title)}")
117
+ for index, choice in enumerate(choices, start=1):
118
+ typer.echo(f" {index}. {label(choice)}")
119
+ if len(choices) == 1:
120
+ typer.echo(f" {tr('Automatically selected the only option.')}")
121
+ return choices[0]
122
+ while True:
123
+ selected = typer.prompt(tr("Select"), default=default, type=int)
124
+ if 1 <= selected <= len(choices):
125
+ return choices[selected - 1]
126
+ typer.echo(tr("Please enter a number from 1 to {count}.", count=len(choices)), err=True)
127
+
128
+
129
+ def _wait_enabled(state: Runtime, value: Optional[bool]) -> bool:
130
+ if value is not None:
131
+ return value
132
+ return not state.json_output and sys.stdout.isatty()
133
+
134
+
135
+ def _wait_for_instance(
136
+ state: Runtime,
137
+ instance: str,
138
+ *,
139
+ region: str,
140
+ desired: Optional[set[str]] = None,
141
+ absent: bool = False,
142
+ timeout: int = 600,
143
+ ) -> Dict[str, Any]:
144
+ started = time.monotonic()
145
+ previous: Optional[str] = None
146
+ while True:
147
+ response = call(
148
+ state,
149
+ "DescribeCompShareInstance",
150
+ {"Region": region, "UHostIds": [instance]},
151
+ )
152
+ hosts = response.get("UHostSet", [])
153
+ if absent and not hosts:
154
+ return response
155
+ current = str(hosts[0].get("State", "Unknown")) if hosts else "NotFound"
156
+ if hosts and desired and current in desired:
157
+ return response
158
+ if (
159
+ hosts
160
+ and desired is None
161
+ and current
162
+ not in {
163
+ "Initializing",
164
+ "Pending",
165
+ "Starting",
166
+ "Stopping",
167
+ "Rebooting",
168
+ "Reinstalling",
169
+ "Resizing",
170
+ }
171
+ ):
172
+ return response
173
+ if current != previous and not state.json_output:
174
+ typer.echo(tr("Waiting for {instance}: {state}", instance=instance, state=current))
175
+ previous = current
176
+ if time.monotonic() - started >= timeout:
177
+ raise UsageError(
178
+ tr(
179
+ "Timed out after {timeout}s while waiting for {instance}.",
180
+ timeout=timeout,
181
+ instance=instance,
182
+ )
183
+ )
184
+ time.sleep(3)
185
+
186
+
187
+ def _project_id(state: Runtime, explicit: Optional[str]) -> str:
188
+ if explicit:
189
+ return explicit
190
+ response = call(state, "GetProjectList", {})
191
+ projects = response.get("ProjectSet", [])
192
+ selected = next((item for item in projects if item.get("IsDefault")), None)
193
+ selected = selected or (projects[0] if projects else None)
194
+ if not selected or not selected.get("ProjectId"):
195
+ raise UsageError(
196
+ tr("No project was returned by GetProjectList; pass --project-id explicitly.")
197
+ )
198
+ return str(selected["ProjectId"])
199
+
200
+
201
+ def _create_location(
202
+ state: Runtime,
203
+ zone: Optional[str],
204
+ *,
205
+ interactive: bool,
206
+ ) -> Tuple[str, str]:
207
+ selected_zone = zone or state.zone
208
+ selected_region = region_from_zone(selected_zone)
209
+ if not interactive or zone is not None:
210
+ return selected_region, selected_zone
211
+
212
+ response = call(
213
+ state,
214
+ "DescribeCompShareSupportZone",
215
+ {"Region": state.region},
216
+ )
217
+ zones = response.get("ZoneInfo", [])
218
+ if not zones:
219
+ return selected_region, selected_zone
220
+ default = next(
221
+ (index for index, item in enumerate(zones, start=1) if item.get("Zone") == selected_zone),
222
+ 1,
223
+ )
224
+ selected = _choose(
225
+ "Availability zone",
226
+ zones,
227
+ lambda item: (
228
+ f"{item.get('Describe') or item.get('Zone')} "
229
+ f"({item.get('Region')} / {item.get('Zone')})"
230
+ ),
231
+ default=default,
232
+ )
233
+ return selected.get("Region") or selected_region, selected.get("Zone") or selected_zone
234
+
235
+
236
+ def _create_gpu(
237
+ state: Runtime,
238
+ region: str,
239
+ zone: str,
240
+ gpu: Optional[str],
241
+ ) -> str:
242
+ if gpu is not None:
243
+ return gpu
244
+ response = call(
245
+ state,
246
+ "DescribeAvailableCompShareInstanceTypes",
247
+ {"Region": region, "Zone": zone, "InstanceType": "uhost"},
248
+ )
249
+ machines = response.get("AvailableInstanceTypes", [])
250
+
251
+ def label(machine: Dict[str, Any]) -> str:
252
+ memory = machine.get("GraphicsMemory", {}).get("Value")
253
+ suffix = f" · {memory}GiB VRAM" if memory else ""
254
+ return f"{machine.get('Name')}{suffix}"
255
+
256
+ selected = _choose("GPU type", machines, label)
257
+ value = selected.get("Name")
258
+ if not value:
259
+ raise UsageError(tr("The machine type API returned an unnamed GPU."))
260
+ return str(value)
261
+
262
+
263
+ def _create_images(
264
+ state: Runtime,
265
+ region: str,
266
+ zone: str,
267
+ source: Optional[str],
268
+ ) -> List[Dict[str, Any]]:
269
+ sources = ["platform", "custom", "community", "shared"]
270
+ selected_source = (
271
+ source.lower()
272
+ if source
273
+ else _choose(
274
+ "Image source",
275
+ sources,
276
+ lambda value: value,
277
+ )
278
+ )
279
+ mapping = {
280
+ "platform": "DescribeCompShareImages",
281
+ "custom": "DescribeCompShareCustomImages",
282
+ "community": "DescribeCommunityImages",
283
+ "shared": "DescribeCompShareSharingImages",
284
+ }
285
+ if selected_source not in mapping:
286
+ raise UsageError(tr("--image-source must be platform, custom, community, or shared."))
287
+ params: Dict[str, Any] = {"Region": region, "Limit": 100, "Offset": 0}
288
+ if selected_source == "platform":
289
+ params["Zone"] = zone
290
+ response = call(state, mapping[selected_source], params)
291
+ if selected_source == "community":
292
+ images = []
293
+ for group in response.get("CompshareImageGroup", []):
294
+ for raw in group.get("Data", []):
295
+ image = dict(raw)
296
+ image.setdefault("Name", group.get("ImageName"))
297
+ images.append(image)
298
+ else:
299
+ images = list(response.get("ImageSet", []))
300
+ return [
301
+ image
302
+ for image in images
303
+ if image.get("CompShareImageId") and image.get("Status", "Available") == "Available"
304
+ ]
305
+
306
+
307
+ def _create_image(
308
+ state: Runtime,
309
+ region: str,
310
+ zone: str,
311
+ image: Optional[str],
312
+ source: Optional[str],
313
+ ) -> str:
314
+ if image is not None:
315
+ return image
316
+ images = _create_images(state, region, zone, source)
317
+ if len(images) > 1:
318
+ query = typer.prompt(
319
+ tr("Filter images by name or ID (blank shows all)"),
320
+ default="",
321
+ show_default=False,
322
+ ).strip()
323
+ if query:
324
+ normalized = query.casefold()
325
+ images = [
326
+ item
327
+ for item in images
328
+ if normalized in str(item.get("Name", "")).casefold()
329
+ or normalized in str(item.get("CompShareImageId", "")).casefold()
330
+ ]
331
+ if len(images) > 50:
332
+ raise UsageError(tr("More than 50 images matched; use a more specific filter."))
333
+
334
+ def label(item: Dict[str, Any]) -> str:
335
+ author = f" · {item.get('Author')}" if item.get("Author") else ""
336
+ return f"{item.get('Name') or tr('Unnamed')}{author} · {item.get('CompShareImageId')}"
337
+
338
+ selected = _choose("Image", images, label)
339
+ return str(selected["CompShareImageId"])
340
+
341
+
342
+ def _create_charge(charge: Optional[str]) -> str:
343
+ if charge is not None:
344
+ return charge
345
+ return _choose(
346
+ "Billing type",
347
+ ["Postpay", "Spot", "Day", "Month"],
348
+ lambda value: value,
349
+ )
350
+
351
+
352
+ @app.command("search")
353
+ def search(
354
+ ctx: typer.Context,
355
+ gpu: Optional[List[str]] = typer.Option(None, "--gpu", help="GPU type; repeatable."),
356
+ spot: bool = typer.Option(False, "--spot", help="Search interruptible instances."),
357
+ image: Optional[str] = typer.Option(
358
+ None,
359
+ "--image",
360
+ help="Image ID. When set, check real inventory for every matched GPU type.",
361
+ ),
362
+ available: bool = typer.Option(False, "--available", help="Show only in-stock specs."),
363
+ zone: Optional[str] = typer.Option(None, "--zone", help="Availability zone."),
364
+ platform: str = typer.Option("Auto", "--platform", help="CPU platform for stock checks."),
365
+ charge: Optional[str] = typer.Option(None, "--charge", help="Billing type for stock checks."),
366
+ disk: str = typer.Option("100GiB", "--disk", help="Boot disk size for stock checks."),
367
+ disk_type: str = typer.Option(
368
+ "CLOUD_SSD", "--disk-type", help="Boot disk type used for stock checks."
369
+ ),
370
+ ) -> None:
371
+ """Search legal specifications and, with --image, real inventory."""
372
+ if available and image is None:
373
+ raise UsageError(
374
+ tr("--available requires --image because inventory depends on the image and disks.")
375
+ )
376
+ state = runtime(ctx)
377
+ selected_zone = zone or state.zone
378
+ selected_region = region_from_zone(selected_zone)
379
+ params = request(ctx, region_value=selected_region)
380
+ params.update(
381
+ compact(
382
+ {
383
+ "Zone": selected_zone,
384
+ "MachineTypes": gpu,
385
+ "InstanceType": "spot" if spot else "uhost",
386
+ }
387
+ )
388
+ )
389
+ legal = call(state, "DescribeAvailableCompShareInstanceTypes", params)
390
+ inventory: Dict[str, Dict[tuple, bool]] = {}
391
+ inventory_response: Dict[str, List[Dict[str, Any]]] = {}
392
+ if image is not None:
393
+ machine_types = {
394
+ machine.get("Name")
395
+ for machine in legal.get("AvailableInstanceTypes", [])
396
+ if machine.get("Name")
397
+ }
398
+ for machine_type in sorted(machine_types):
399
+ capacity_params = request(ctx, region_value=selected_region)
400
+ capacity_params.update(
401
+ {
402
+ "Zone": selected_zone,
403
+ "GpuType": machine_type,
404
+ "MachineType": "G",
405
+ "MinimalCpuPlatform": platform,
406
+ "CompShareImageId": image,
407
+ "ChargeType": charge or ("Spot" if spot else "Postpay"),
408
+ "Disks": [{"IsBoot": True, "Type": disk_type, "Size": disk_gib(disk)}],
409
+ }
410
+ )
411
+ capacity = call(state, "CheckCompShareResourceCapacity", capacity_params)
412
+ inventory_response[machine_type] = capacity.get("Specs", [])
413
+ inventory[machine_type] = {
414
+ (spec.get("Gpu"), spec.get("Cpu"), spec.get("Mem")): bool(
415
+ spec.get("ResourceEnough")
416
+ )
417
+ for spec in capacity.get("Specs", [])
418
+ }
419
+
420
+ response = dict(legal)
421
+ if image is not None:
422
+ response["Inventory"] = inventory_response
423
+ Renderer(state.json_output).data(
424
+ response,
425
+ rows=_search_rows(legal, inventory, available),
426
+ columns=(
427
+ ("GpuType", "GPU"),
428
+ ("GPU", "COUNT"),
429
+ ("VRAM", "VRAM"),
430
+ ("CPU", "CPU"),
431
+ ("Memory", "MEMORY"),
432
+ ("Zone", "ZONE"),
433
+ ("Stock", "IN STOCK"),
434
+ ("Platforms", "CPU PLATFORM"),
435
+ ),
436
+ )
437
+
438
+
439
+ @app.command("zones")
440
+ def zones(ctx: typer.Context) -> None:
441
+ """List supported regions and availability zones."""
442
+ invoke(
443
+ runtime(ctx),
444
+ "DescribeCompShareSupportZone",
445
+ request(ctx),
446
+ list_key="ZoneInfo",
447
+ columns=(("Region", "REGION"), ("Zone", "ZONE"), ("Describe", "NAME")),
448
+ )
449
+
450
+
451
+ @app.command("families")
452
+ def families(ctx: typer.Context) -> None:
453
+ """List GPU machine families."""
454
+ invoke(
455
+ runtime(ctx),
456
+ "DescribeCompShareMachineTypeFamilies",
457
+ request(ctx),
458
+ list_key="MachineTypes",
459
+ columns=(("Name", "NAME"), ("Description", "DESCRIPTION")),
460
+ )
461
+
462
+
463
+ @app.command("list")
464
+ def list_instances(
465
+ ctx: typer.Context,
466
+ ids: Optional[List[str]] = typer.Option(None, "--id", help="Instance ID; repeatable."),
467
+ region: Optional[str] = typer.Option(None, "--region", help="Filter by region."),
468
+ zone: Optional[str] = typer.Option(None, "--zone", help="Filter by availability zone."),
469
+ limit: int = typer.Option(20, min=1, max=100, help="Maximum number of results."),
470
+ offset: int = typer.Option(0, min=0, help="Number of results to skip."),
471
+ tag: Optional[str] = typer.Option(None, help="Filter by instance tag."),
472
+ vpc: Optional[str] = typer.Option(None, "--vpc", help="Filter by VPC ID."),
473
+ subnet: Optional[str] = typer.Option(None, "--subnet", help="Filter by subnet ID."),
474
+ disk: Optional[str] = typer.Option(None, "--disk", help="Filter hosts compatible with a disk."),
475
+ project_id: Optional[str] = typer.Option(
476
+ None,
477
+ "--project-id",
478
+ help="Project ID for this request.",
479
+ ),
480
+ without_gpu: bool = typer.Option(False, "--without-gpu", help="List no-GPU instances."),
481
+ name: Optional[str] = typer.Option(None, "--name", help="Filter by instance name."),
482
+ status: Optional[str] = typer.Option(None, "--status", help="Filter by instance state."),
483
+ gpu: Optional[str] = typer.Option(None, "--gpu", help="Filter by GPU type."),
484
+ billing: Optional[str] = typer.Option(None, "--billing", help="Filter by billing type."),
485
+ ) -> None:
486
+ """List instances."""
487
+ state = runtime(ctx)
488
+ selected_region = region_from_zone(zone) if zone else region
489
+ params = request(ctx, region_value=selected_region)
490
+ params.update(
491
+ compact(
492
+ {
493
+ "Zone": zone,
494
+ "ProjectId": project_id,
495
+ "UHostIds": ids,
496
+ "Limit": limit,
497
+ "Offset": offset,
498
+ "Tag": tag,
499
+ "VPCId": vpc,
500
+ "SubnetId": subnet,
501
+ "UDiskIdForAttachment": disk,
502
+ "WithoutGpu": without_gpu if without_gpu else None,
503
+ }
504
+ )
505
+ )
506
+ regions = [selected_region] if selected_region else supported_regions(state)
507
+ response: Dict[str, Any] = {"UHostSet": [], "RegionSet": regions}
508
+ for current_region in regions:
509
+ current = call(
510
+ state,
511
+ "DescribeCompShareInstance",
512
+ {**params, "Region": current_region},
513
+ )
514
+ for host in current.get("UHostSet", []):
515
+ item = dict(host)
516
+ item.setdefault("Region", current_region)
517
+ response["UHostSet"].append(item)
518
+ response["TotalCount"] = len(response["UHostSet"])
519
+ filters = {
520
+ "Name": name.casefold() if name else None,
521
+ "State": status.casefold() if status else None,
522
+ "GpuType": gpu.casefold() if gpu else None,
523
+ "ChargeType": billing.casefold() if billing else None,
524
+ }
525
+ filtered = []
526
+ for host in response.get("UHostSet", []):
527
+ if all(
528
+ expected is None or expected in str(host.get(field, "")).casefold()
529
+ for field, expected in filters.items()
530
+ ):
531
+ filtered.append(host)
532
+ result = dict(response)
533
+ result["UHostSet"] = filtered
534
+ result["FilteredCount"] = len(filtered)
535
+ Renderer(state.json_output).data(
536
+ result,
537
+ rows=_instance_rows(result),
538
+ columns=INSTANCE_COLUMNS,
539
+ )
540
+
541
+
542
+ @app.command("show")
543
+ def show(ctx: typer.Context, instance: str = typer.Argument(..., help="Instance ID.")) -> None:
544
+ """Show full instance details."""
545
+ state = runtime(ctx)
546
+ region, zone, host = locate_instance(state, instance)
547
+ response = {"UHostSet": [host], "ResolvedRegion": region, "ResolvedZone": zone}
548
+ Renderer(state.json_output).details(
549
+ "Instance details",
550
+ [
551
+ ("ID", host.get("UHostId")),
552
+ ("NAME", host.get("Name")),
553
+ ("STATE", host.get("State")),
554
+ ("GPU", f"{host.get('GpuType', '-')} × {host.get('GPU', '-')}"),
555
+ ("CPU", host.get("CPU")),
556
+ (
557
+ "MEMORY",
558
+ f"{host.get('Memory', 0) // 1024}GiB"
559
+ if isinstance(host.get("Memory"), int)
560
+ else host.get("Memory"),
561
+ ),
562
+ ("REGION", region),
563
+ ("ZONE", host.get("Zone")),
564
+ ("CHARGE", host.get("ChargeType")),
565
+ ("IMAGE", host.get("CompShareImageId") or host.get("ImageId")),
566
+ ("Password", host.get("Password")),
567
+ ("SSH", host.get("SshLoginCommand")),
568
+ ("DATA DISKS", host.get("DiskSet") or host.get("UDiskSet")),
569
+ ],
570
+ response=response,
571
+ )
572
+
573
+
574
+ @app.command("create")
575
+ def create(
576
+ ctx: typer.Context,
577
+ gpu: Optional[str] = typer.Option(None, "--gpu", help="GPU type, for example 4090."),
578
+ count: Optional[int] = typer.Option(None, "--count", min=1, help="GPU count."),
579
+ cpu: Optional[int] = typer.Option(None, "--cpu", min=1, help="CPU core count."),
580
+ memory: Optional[str] = typer.Option(None, "--memory", help="Memory, for example 64GiB."),
581
+ image: Optional[str] = typer.Option(None, "--image", help="CompShare image ID."),
582
+ image_source: Optional[str] = typer.Option(
583
+ None,
584
+ "--image-source",
585
+ help="Image source: platform, custom, community or shared.",
586
+ ),
587
+ zone: Optional[str] = typer.Option(
588
+ None,
589
+ "--zone",
590
+ help="Availability zone for this request.",
591
+ ),
592
+ boot_disk: Optional[str] = typer.Option(None, "--disk", help="Boot disk size."),
593
+ boot_type: Optional[str] = typer.Option(None, "--disk-type", help="Boot disk type."),
594
+ data_disk: Optional[List[str]] = typer.Option(
595
+ None,
596
+ "--data-disk",
597
+ help="Data disk as SIZE[:TYPE]; repeatable.",
598
+ ),
599
+ charge: Optional[str] = typer.Option(None, "--charge", help="Billing type."),
600
+ quantity: int = typer.Option(1, min=1, help="Billing duration for prepaid modes."),
601
+ name: Optional[str] = typer.Option(None, help="Instance name."),
602
+ platform: str = typer.Option("Auto", "--platform", help="Minimum CPU platform."),
603
+ remark: Optional[str] = typer.Option(None, help="Instance remark."),
604
+ firewall: Optional[str] = typer.Option(None, "--firewall", help="Security group ID."),
605
+ max_count: int = typer.Option(1, "--max-count", min=1, help="Number of instances."),
606
+ us3: bool = typer.Option(False, "--us3", help="Attach US3 during container creation."),
607
+ dry_run: bool = typer.Option(
608
+ False,
609
+ "--dry-run",
610
+ help="Validate and show the request without changing resources.",
611
+ ),
612
+ wait: Optional[bool] = typer.Option(
613
+ None,
614
+ "--wait/--no-wait",
615
+ help="Wait for the operation to reach a stable state.",
616
+ ),
617
+ timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
618
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
619
+ ) -> None:
620
+ """Create instances interactively, or use explicit options for automation."""
621
+ state = runtime(ctx)
622
+ interactive = any(value is None for value in (gpu, count, cpu, memory, image))
623
+ if interactive and state.json_output:
624
+ raise UsageError(
625
+ tr(
626
+ "JSON mode cannot start the interactive wizard; pass --gpu, --count, --cpu, "
627
+ "--memory, and --image."
628
+ )
629
+ )
630
+
631
+ region, selected_zone = _create_location(state, zone, interactive=interactive)
632
+ resolved_gpu = _create_gpu(state, region, selected_zone, gpu)
633
+ resolved_image = _create_image(
634
+ state,
635
+ region,
636
+ selected_zone,
637
+ image,
638
+ image_source,
639
+ )
640
+ resolved_charge = _create_charge(charge) if interactive else charge or "Postpay"
641
+ resolved_boot_disk = boot_disk
642
+ resolved_boot_type = boot_type
643
+ if interactive:
644
+ resolved_boot_disk = resolved_boot_disk or typer.prompt(
645
+ tr("Boot disk size"),
646
+ default="100GiB",
647
+ )
648
+ resolved_boot_type = resolved_boot_type or typer.prompt(
649
+ tr("Boot disk type"),
650
+ default="CLOUD_SSD",
651
+ )
652
+ else:
653
+ resolved_boot_disk = resolved_boot_disk or "100GiB"
654
+ resolved_boot_type = resolved_boot_type or "CLOUD_SSD"
655
+
656
+ disks = _disk_list(resolved_boot_disk, resolved_boot_type, data_disk)
657
+ common = {"Region": region, "Zone": selected_zone}
658
+ capacity_params = dict(common)
659
+ capacity_params.update(
660
+ {
661
+ "GpuType": resolved_gpu,
662
+ "MachineType": "G",
663
+ "MinimalCpuPlatform": platform,
664
+ "CompShareImageId": resolved_image,
665
+ "ChargeType": resolved_charge,
666
+ "Disks": disks,
667
+ }
668
+ )
669
+ capacity = call(state, "CheckCompShareResourceCapacity", capacity_params)
670
+ requested_memory = memory_mib(memory) // 1024 if memory is not None else None
671
+ matching = [
672
+ spec
673
+ for spec in capacity.get("Specs", [])
674
+ if spec.get("ResourceEnough")
675
+ and (count is None or spec.get("Gpu") == count)
676
+ and (cpu is None or spec.get("Cpu") == cpu)
677
+ and (requested_memory is None or spec.get("Mem") == requested_memory)
678
+ ]
679
+ if not matching:
680
+ Renderer(state.json_output).error(
681
+ tr(
682
+ "No inventory is available for the selected GPU, CPU, memory, image, billing, "
683
+ "and disk combination."
684
+ ),
685
+ details={"capacity": capacity},
686
+ )
687
+ raise typer.Exit(2)
688
+
689
+ selected_spec = matching[0]
690
+ if interactive and any(value is None for value in (count, cpu, memory)):
691
+ selected_spec = _choose(
692
+ "Available specification",
693
+ matching,
694
+ lambda spec: (
695
+ f"GPU ×{spec.get('Gpu')} · {spec.get('Cpu')} CPU · {spec.get('Mem')}GiB memory"
696
+ ),
697
+ )
698
+ resolved_count = int(count if count is not None else selected_spec["Gpu"])
699
+ resolved_cpu = int(cpu if cpu is not None else selected_spec["Cpu"])
700
+ resolved_memory_gib = int(
701
+ requested_memory if requested_memory is not None else selected_spec["Mem"]
702
+ )
703
+ memory_mb = resolved_memory_gib * 1024
704
+
705
+ price_params = dict(common)
706
+ price_params.update(
707
+ {
708
+ "GpuType": resolved_gpu,
709
+ "Gpu": resolved_count,
710
+ "Cpu": resolved_cpu,
711
+ "Memory": memory_mb,
712
+ "ChargeType": resolved_charge,
713
+ "Disks": disks,
714
+ "CompShareImageId": resolved_image,
715
+ "Quantity": quantity,
716
+ }
717
+ )
718
+ price = call(state, "GetCompShareInstancePrice", price_params)
719
+ price_details = price.get("PriceDetails", [])
720
+ amount = None
721
+ if price_details:
722
+ values = [
723
+ price_details[0].get(key)
724
+ for key in ("Instance", "Disks", "SystemDisks", "CompShareImage")
725
+ ]
726
+ numbers = [value for value in values if isinstance(value, (int, float))]
727
+ amount = sum(numbers) if numbers else None
728
+ price_text = "unknown" if amount is None else str(round(amount * max_count, 4))
729
+ create_params = dict(common)
730
+ create_params.update(
731
+ compact(
732
+ {
733
+ "GpuType": resolved_gpu,
734
+ "GPU": resolved_count,
735
+ "CPU": resolved_cpu,
736
+ "Memory": memory_mb,
737
+ "MachineType": "G",
738
+ "MinimalCpuPlatform": platform,
739
+ "CompShareImageId": resolved_image,
740
+ "Disks": disks,
741
+ "ChargeType": resolved_charge,
742
+ "Quantity": quantity,
743
+ "Name": name,
744
+ "Remark": remark,
745
+ "SecurityGroupId": firewall,
746
+ "MaxCount": max_count,
747
+ "EnableUS3": us3 if us3 else None,
748
+ }
749
+ )
750
+ )
751
+ selection = {
752
+ "Region": region,
753
+ "Zone": selected_zone,
754
+ "GpuType": resolved_gpu,
755
+ "GPU": resolved_count,
756
+ "CPU": resolved_cpu,
757
+ "Memory": memory_mb,
758
+ "CompShareImageId": resolved_image,
759
+ "Disks": disks,
760
+ "ChargeType": resolved_charge,
761
+ "MaxCount": max_count,
762
+ }
763
+ plan = {
764
+ "dry_run": dry_run,
765
+ "selection": selection,
766
+ "capacity": selected_spec,
767
+ "price": price,
768
+ "request": create_params,
769
+ }
770
+ if dry_run:
771
+ Renderer(state.json_output).details(
772
+ "Create plan",
773
+ [
774
+ ("ZONE", selected_zone),
775
+ ("GPU", f"{resolved_gpu} × {resolved_count}"),
776
+ ("CPU", resolved_cpu),
777
+ ("MEMORY", f"{resolved_memory_gib}GiB"),
778
+ ("IMAGE", resolved_image),
779
+ ("SYSTEM DISK", f"{resolved_boot_disk}:{resolved_boot_type}"),
780
+ ("CHARGE", resolved_charge),
781
+ ("COUNT", max_count),
782
+ ("PRICE", price_text),
783
+ ],
784
+ response=plan,
785
+ )
786
+ return
787
+ confirm_details(
788
+ state,
789
+ "Create plan",
790
+ [
791
+ ("ZONE", selected_zone),
792
+ ("GPU", f"{resolved_gpu} × {resolved_count}"),
793
+ ("CPU", resolved_cpu),
794
+ ("MEMORY", f"{resolved_memory_gib}GiB"),
795
+ ("IMAGE", resolved_image),
796
+ ("SYSTEM DISK", f"{resolved_boot_disk}:{resolved_boot_type}"),
797
+ ("CHARGE", resolved_charge),
798
+ ("COUNT", max_count),
799
+ ("PRICE", price_text),
800
+ ],
801
+ "Confirm this operation?",
802
+ yes,
803
+ )
804
+ created = call(state, "CreateCompShareInstance", create_params)
805
+ result = {
806
+ "selection": selection,
807
+ "capacity": selected_spec,
808
+ "price": price,
809
+ "instance": created,
810
+ }
811
+ ids = created.get("UHostIds") or created.get("UHostId") or []
812
+ if isinstance(ids, str):
813
+ ids = [ids]
814
+ if _wait_enabled(state, wait):
815
+ result["final"] = [
816
+ _wait_for_instance(state, item, region=region, timeout=timeout) for item in ids
817
+ ]
818
+ Renderer(state.json_output).details(
819
+ "Operation completed",
820
+ [
821
+ ("INSTANCE", ids),
822
+ ("Password", created.get("Password")),
823
+ ("ZONE", selected_zone),
824
+ ("GPU", f"{resolved_gpu} × {resolved_count}"),
825
+ ("PRICE", price_text),
826
+ ],
827
+ response=result,
828
+ )
829
+
830
+
831
+ def _lifecycle(
832
+ ctx: typer.Context,
833
+ action: str,
834
+ instance: str,
835
+ message: str,
836
+ *,
837
+ yes: bool,
838
+ extra: Optional[Dict[str, Any]] = None,
839
+ wait: Optional[bool] = None,
840
+ timeout: int = 600,
841
+ desired: Optional[set[str]] = None,
842
+ absent: bool = False,
843
+ ) -> None:
844
+ state = runtime(ctx)
845
+ region, zone, _ = locate_instance(state, instance)
846
+ confirm_details(
847
+ state,
848
+ "Operation plan",
849
+ [("INSTANCE", instance), ("ACTION", tr(message.rstrip("?")))],
850
+ "Confirm this operation?",
851
+ yes,
852
+ )
853
+ params = request(ctx, zone=True, region_value=region, zone_value=zone)
854
+ params["UHostId"] = instance
855
+ params.update(extra or {})
856
+ submitted = call(state, action, params)
857
+ result: Dict[str, Any] = {"operation": submitted}
858
+ if _wait_enabled(state, wait):
859
+ result["final"] = _wait_for_instance(
860
+ state,
861
+ instance,
862
+ region=region,
863
+ desired=desired,
864
+ absent=absent,
865
+ timeout=timeout,
866
+ )
867
+ Renderer(state.json_output).success(tr("Operation completed"), result)
868
+
869
+
870
+ @app.command("start", help="Start an instance.")
871
+ def start(
872
+ ctx: typer.Context,
873
+ instance: str,
874
+ without_gpu: Optional[str] = typer.Option(
875
+ None, "--without-gpu", help="No-GPU specification: A or B."
876
+ ),
877
+ wait: Optional[bool] = typer.Option(
878
+ None, "--wait/--no-wait", help="Wait for the operation to reach a stable state."
879
+ ),
880
+ timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
881
+ ) -> None:
882
+ state = runtime(ctx)
883
+ region, zone, _ = locate_instance(state, instance)
884
+ params = request(ctx, zone=True, region_value=region, zone_value=zone)
885
+ params.update(compact({"UHostId": instance, "WithoutGpuSpec": without_gpu}))
886
+ submitted = call(state, "StartCompShareInstance", params)
887
+ result: Dict[str, Any] = {"operation": submitted}
888
+ if _wait_enabled(state, wait):
889
+ result["final"] = _wait_for_instance(
890
+ state,
891
+ instance,
892
+ region=region,
893
+ desired={"Running"},
894
+ timeout=timeout,
895
+ )
896
+ Renderer(state.json_output).success(tr("Operation completed"), result)
897
+
898
+
899
+ @app.command("stop", help="Stop an instance.")
900
+ def stop(
901
+ ctx: typer.Context,
902
+ instance: str,
903
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
904
+ wait: Optional[bool] = typer.Option(
905
+ None, "--wait/--no-wait", help="Wait for the operation to reach a stable state."
906
+ ),
907
+ timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
908
+ ) -> None:
909
+ _lifecycle(
910
+ ctx,
911
+ "StopCompShareInstance",
912
+ instance,
913
+ "Stop instance",
914
+ yes=yes,
915
+ wait=wait,
916
+ timeout=timeout,
917
+ desired={"Stopped"},
918
+ )
919
+
920
+
921
+ @app.command("reboot", help="Reboot an instance.")
922
+ def reboot(
923
+ ctx: typer.Context,
924
+ instance: str,
925
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
926
+ wait: Optional[bool] = typer.Option(
927
+ None, "--wait/--no-wait", help="Wait for the operation to reach a stable state."
928
+ ),
929
+ timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
930
+ ) -> None:
931
+ _lifecycle(
932
+ ctx,
933
+ "RebootCompShareInstance",
934
+ instance,
935
+ "Reboot instance",
936
+ yes=yes,
937
+ wait=wait,
938
+ timeout=timeout,
939
+ desired={"Running"},
940
+ )
941
+
942
+
943
+ @app.command("delete", help="Permanently delete an instance.")
944
+ def delete(
945
+ ctx: typer.Context,
946
+ instance: str,
947
+ release_disk: bool = typer.Option(
948
+ False, "--release-disk", help="Delete attached data disks with the instance."
949
+ ),
950
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
951
+ wait: Optional[bool] = typer.Option(
952
+ None, "--wait/--no-wait", help="Wait for the operation to reach a stable state."
953
+ ),
954
+ timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
955
+ ) -> None:
956
+ _lifecycle(
957
+ ctx,
958
+ "TerminateCompShareInstance",
959
+ instance,
960
+ "Permanently delete instance and attached data disks"
961
+ if release_disk
962
+ else "Permanently delete instance",
963
+ yes=yes,
964
+ extra={"ReleaseUDisk": release_disk},
965
+ wait=wait,
966
+ timeout=timeout,
967
+ absent=True,
968
+ )
969
+
970
+
971
+ @app.command("rename", help="Rename an instance.")
972
+ def rename(ctx: typer.Context, instance: str, name: str) -> None:
973
+ state = runtime(ctx)
974
+ region, zone, _ = locate_instance(state, instance)
975
+ params = request(ctx, zone=True, region_value=region, zone_value=zone)
976
+ params.update({"UHostId": instance, "Name": name})
977
+ invoke(
978
+ state,
979
+ "ModifyCompShareInstanceName",
980
+ params,
981
+ success=tr("Renamed {instance}", instance=instance),
982
+ )
983
+
984
+
985
+ @app.command("password")
986
+ def password(
987
+ ctx: typer.Context,
988
+ instance: str,
989
+ value: Optional[str] = typer.Option(None, "--password", hidden=True),
990
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
991
+ ) -> None:
992
+ """Reset an instance password."""
993
+ secret = value or typer.prompt(tr("New password"), hide_input=True, confirmation_prompt=True)
994
+ _lifecycle(
995
+ ctx,
996
+ "ResetCompShareInstancePassword",
997
+ instance,
998
+ "Reset instance password",
999
+ yes=yes,
1000
+ extra={"Password": encode_password(secret)},
1001
+ )
1002
+
1003
+
1004
+ @app.command("reinstall", help="Reinstall an instance from an image.")
1005
+ def reinstall(
1006
+ ctx: typer.Context,
1007
+ instance: str,
1008
+ image: str = typer.Option(..., "--image", help="Replacement image ID."),
1009
+ password: Optional[str] = typer.Option(None, "--password", hidden=True),
1010
+ coupon: Optional[str] = typer.Option(None, "--coupon", help="Coupon ID."),
1011
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
1012
+ wait: Optional[bool] = typer.Option(
1013
+ None, "--wait/--no-wait", help="Wait for the operation to reach a stable state."
1014
+ ),
1015
+ timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
1016
+ ) -> None:
1017
+ extra = compact(
1018
+ {
1019
+ "CompShareImageId": image,
1020
+ "Password": encode_password(password) if password else None,
1021
+ "CouponId": coupon,
1022
+ }
1023
+ )
1024
+ _lifecycle(
1025
+ ctx,
1026
+ "ReinstallCompShareInstance",
1027
+ instance,
1028
+ "Reinstall instance; all system disk data will be lost",
1029
+ yes=yes,
1030
+ extra=extra,
1031
+ wait=wait,
1032
+ timeout=timeout,
1033
+ )
1034
+
1035
+
1036
+ @app.command("resize", help="Change instance CPU, memory, GPU or disk size.")
1037
+ def resize(
1038
+ ctx: typer.Context,
1039
+ instance: str,
1040
+ cpu: Optional[int] = typer.Option(None, min=1, help="Target CPU core count."),
1041
+ memory: Optional[str] = typer.Option(None, help="Target memory, for example 64GiB."),
1042
+ gpu: Optional[int] = typer.Option(None, min=0, help="Target GPU count."),
1043
+ without_gpu: Optional[str] = typer.Option(
1044
+ None, "--without-gpu", help="Target no-GPU specification: A or B."
1045
+ ),
1046
+ disk: Optional[str] = typer.Option(None, "--disk", help="Disk ID to resize."),
1047
+ disk_size: Optional[str] = typer.Option(
1048
+ None, "--disk-size", help="Target disk size, for example 200GiB."
1049
+ ),
1050
+ coupon: Optional[str] = typer.Option(None, "--coupon", help="Coupon ID."),
1051
+ dry_run: bool = typer.Option(
1052
+ False,
1053
+ "--dry-run",
1054
+ help="Validate and show the request without changing resources.",
1055
+ ),
1056
+ wait: Optional[bool] = typer.Option(
1057
+ None, "--wait/--no-wait", help="Wait for the operation to reach a stable state."
1058
+ ),
1059
+ timeout: int = typer.Option(600, "--timeout", min=1, help="Maximum wait time in seconds."),
1060
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
1061
+ ) -> None:
1062
+ compute_values = (cpu, memory, gpu)
1063
+ compute_resize = any(value is not None for value in compute_values) or without_gpu is not None
1064
+ disk_resize = disk is not None or disk_size is not None
1065
+ if not compute_resize and not disk_resize:
1066
+ raise UsageError(tr("Specify a compute target or both --disk and --disk-size."))
1067
+ if (disk is None) != (disk_size is None):
1068
+ raise UsageError(tr("--disk and --disk-size must be used together."))
1069
+ if compute_resize and disk_resize:
1070
+ raise UsageError(tr("Compute resizing and disk resizing must be separate operations."))
1071
+ if without_gpu is not None and any(value is not None for value in compute_values):
1072
+ raise UsageError(tr("--without-gpu cannot be combined with --cpu, --memory, or --gpu."))
1073
+ if (
1074
+ without_gpu is None
1075
+ and compute_resize
1076
+ and not all(value is not None for value in compute_values)
1077
+ ):
1078
+ raise UsageError(tr("Compute resizing requires --cpu, --memory, and --gpu together."))
1079
+ params = compact(
1080
+ {
1081
+ "Cpu": cpu,
1082
+ "Memory": memory_mib(memory) if memory else None,
1083
+ "Gpu": gpu,
1084
+ "WithoutGpuSpec": without_gpu,
1085
+ "DiskId": disk,
1086
+ "DiskSpace": disk_gib(disk_size) if disk_size else None,
1087
+ "CouponId": coupon,
1088
+ }
1089
+ )
1090
+ if dry_run:
1091
+ quote: Dict[str, Any] = {}
1092
+ if compute_resize and without_gpu is None:
1093
+ state = runtime(ctx)
1094
+ region, zone, _ = locate_instance(state, instance)
1095
+ quote_params = request(
1096
+ ctx,
1097
+ zone=True,
1098
+ region_value=region,
1099
+ zone_value=zone,
1100
+ )
1101
+ quote_params.update(
1102
+ {"UHostId": instance, "CPU": cpu, "Memory": memory_mib(memory or ""), "GPU": gpu}
1103
+ )
1104
+ quote = call(runtime(ctx), "GetCompShareInstanceUpgradePrice", quote_params)
1105
+ Renderer(runtime(ctx).json_output).details(
1106
+ "Operation plan",
1107
+ [("INSTANCE", instance), ("ACTION", "Resize"), ("REQUEST", params), ("PRICE", quote)],
1108
+ response={"dry_run": True, "instance": instance, "request": params, "price": quote},
1109
+ )
1110
+ return
1111
+ _lifecycle(
1112
+ ctx,
1113
+ "ResizeCompShareInstance",
1114
+ instance,
1115
+ "Resize instance",
1116
+ yes=yes,
1117
+ extra=params,
1118
+ wait=wait,
1119
+ timeout=timeout,
1120
+ )
1121
+
1122
+
1123
+ @app.command("price", help="Query a new instance price.")
1124
+ def price(
1125
+ ctx: typer.Context,
1126
+ gpu: str = typer.Option(..., "--gpu", help="GPU type."),
1127
+ count: int = typer.Option(1, "--count", min=1, help="GPU count."),
1128
+ cpu: int = typer.Option(..., min=1, help="CPU core count."),
1129
+ memory: str = typer.Option(..., help="Memory, for example 64GiB."),
1130
+ charge: Optional[str] = typer.Option(None, help="Billing type."),
1131
+ disk: str = typer.Option("100GiB", help="Boot disk size."),
1132
+ disk_type: str = typer.Option("CLOUD_SSD", "--disk-type", help="Boot disk type."),
1133
+ volume: Optional[List[str]] = typer.Option(
1134
+ None,
1135
+ "--volume",
1136
+ help="Shared storage as SIZE[:TYPE]; repeatable.",
1137
+ ),
1138
+ image: Optional[str] = typer.Option(None, help="Image ID for image pricing."),
1139
+ quantity: int = typer.Option(1, min=1, help="Billing duration for prepaid modes."),
1140
+ zone: Optional[str] = typer.Option(None, "--zone", help="Availability zone."),
1141
+ ) -> None:
1142
+ selected_zone = zone or runtime(ctx).zone
1143
+ params = request(ctx, zone=True, zone_value=selected_zone)
1144
+ params.update(
1145
+ compact(
1146
+ {
1147
+ "GpuType": gpu,
1148
+ "Gpu": count,
1149
+ "Cpu": cpu,
1150
+ "Memory": memory_mib(memory),
1151
+ "ChargeType": charge,
1152
+ "Disks": [{"IsBoot": True, "Type": disk_type, "Size": disk_gib(disk)}],
1153
+ "Volumes": _volume_list(volume),
1154
+ "CompShareImageId": image,
1155
+ "Quantity": quantity,
1156
+ }
1157
+ )
1158
+ )
1159
+ invoke(
1160
+ runtime(ctx),
1161
+ "GetCompShareInstancePrice",
1162
+ params,
1163
+ list_key="PriceDetails",
1164
+ columns=(
1165
+ ("ChargeType", "CHARGE"),
1166
+ ("Instance", "INSTANCE"),
1167
+ ("SystemDisks", "SYSTEM DISK"),
1168
+ ("Disks", "DATA DISKS"),
1169
+ ("CompShareImage", "IMAGE"),
1170
+ ),
1171
+ )
1172
+
1173
+
1174
+ @app.command("upgrade-price", help="Query the price of an instance upgrade.", hidden=True)
1175
+ @app.command("resize-price", help="Query the price of an instance upgrade.")
1176
+ def upgrade_price(
1177
+ ctx: typer.Context,
1178
+ instance: str,
1179
+ cpu: Optional[int] = typer.Option(None, help="Target CPU core count."),
1180
+ memory: Optional[str] = typer.Option(None, help="Target memory, for example 64GiB."),
1181
+ gpu: Optional[int] = typer.Option(None, help="Target GPU count."),
1182
+ ) -> None:
1183
+ if cpu is None and memory is None and gpu is None:
1184
+ raise UsageError(tr("Specify at least one of --cpu, --memory, or --gpu."))
1185
+ state = runtime(ctx)
1186
+ region, zone, _ = locate_instance(state, instance)
1187
+ params = request(ctx, zone=True, region_value=region, zone_value=zone)
1188
+ params.update(
1189
+ compact(
1190
+ {
1191
+ "UHostId": instance,
1192
+ "CPU": cpu,
1193
+ "Memory": memory_mib(memory) if memory else None,
1194
+ "GPU": gpu,
1195
+ }
1196
+ )
1197
+ )
1198
+ invoke(state, "GetCompShareInstanceUpgradePrice", params)
1199
+
1200
+
1201
+ @app.command("billing", help="Query current instance pricing.")
1202
+ def billing(
1203
+ ctx: typer.Context,
1204
+ gpu: str = typer.Option(..., "--gpu", help="GPU type."),
1205
+ count: int = typer.Option(1, "--count", help="GPU count."),
1206
+ cpu: int = typer.Option(..., help="CPU core count."),
1207
+ memory: str = typer.Option(..., help="Memory, for example 64GiB."),
1208
+ charge: Optional[str] = typer.Option(None, help="Billing type."),
1209
+ zone: Optional[str] = typer.Option(None, "--zone", help="Availability zone."),
1210
+ ) -> None:
1211
+ selected_zone = zone or runtime(ctx).zone
1212
+ params = request(ctx, zone=True, zone_value=selected_zone)
1213
+ params.update(
1214
+ compact(
1215
+ {
1216
+ "GpuType": gpu,
1217
+ "GPU": count,
1218
+ "CPU": cpu,
1219
+ "Memory": memory_mib(memory),
1220
+ "ChargeType": charge,
1221
+ }
1222
+ )
1223
+ )
1224
+ invoke(
1225
+ runtime(ctx),
1226
+ "GetCompShareInstanceUserPrice",
1227
+ params,
1228
+ list_key="PriceDetails",
1229
+ columns=(
1230
+ ("ChargeType", "CHARGE"),
1231
+ ("Instance", "INSTANCE"),
1232
+ ("SystemDisks", "SYSTEM DISK"),
1233
+ ("Disks", "DATA DISKS"),
1234
+ ("CompShareImage", "IMAGE"),
1235
+ ),
1236
+ )
1237
+
1238
+
1239
+ @app.command("refund", help="Query instance refund amounts.")
1240
+ def refund(ctx: typer.Context, instances: List[str] = typer.Argument(...)) -> None:
1241
+ state = runtime(ctx)
1242
+ groups: Dict[Tuple[str, str], List[str]] = {}
1243
+ for instance in instances:
1244
+ region, zone, _ = locate_instance(state, instance)
1245
+ groups.setdefault((region, zone), []).append(instance)
1246
+ response: Dict[str, Any] = {"RefundPriceSet": []}
1247
+ for (region, zone), ids in groups.items():
1248
+ current = call(
1249
+ state,
1250
+ "GetCompShareRefundPrice",
1251
+ {"Region": region, "Zone": zone, "UHostIds": ids},
1252
+ )
1253
+ response["RefundPriceSet"].extend(current.get("RefundPriceSet", []))
1254
+ Renderer(state.json_output).data(
1255
+ response,
1256
+ rows=response["RefundPriceSet"],
1257
+ columns=(
1258
+ ("UHostId", "INSTANCE"),
1259
+ ("Code", "CODE"),
1260
+ ("RefundPrice", "REFUND"),
1261
+ ("Message", "MESSAGE"),
1262
+ ),
1263
+ )
1264
+
1265
+
1266
+ @app.command(
1267
+ "monitor",
1268
+ help="Get instance monitoring data (currently unavailable in production).",
1269
+ )
1270
+ def monitor(
1271
+ ctx: typer.Context,
1272
+ instances: Optional[List[str]] = typer.Argument(None),
1273
+ region: Optional[str] = typer.Option(None, "--region", help="Region for this request."),
1274
+ ) -> None:
1275
+ state = runtime(ctx)
1276
+ if not region and instances:
1277
+ region, _, _ = locate_instance(state, instances[0])
1278
+ params = request(ctx, region_value=region)
1279
+ params.update(compact({"UHostIds": instances}))
1280
+ invoke(state, "GetCompShareInstanceMonitor", params)
1281
+
1282
+
1283
+ @app.command("charge", help="Change an instance billing type.")
1284
+ def charge(
1285
+ ctx: typer.Context,
1286
+ instance: str,
1287
+ destination: str = typer.Option(..., "--to", help="Month, Day, Dynamic or Postpay."),
1288
+ dry_run: bool = typer.Option(
1289
+ False,
1290
+ "--dry-run",
1291
+ help="Validate and show the request without changing resources.",
1292
+ ),
1293
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
1294
+ ) -> None:
1295
+ if dry_run:
1296
+ Renderer(runtime(ctx).json_output).details(
1297
+ "Operation plan",
1298
+ [("INSTANCE", instance), ("CHARGE", destination)],
1299
+ response={"dry_run": True, "instance": instance, "destination": destination},
1300
+ )
1301
+ return
1302
+ _lifecycle(
1303
+ ctx,
1304
+ "SwitchChargeType",
1305
+ instance,
1306
+ "Change instance billing type",
1307
+ yes=yes,
1308
+ extra={"DestChargeType": destination},
1309
+ )
1310
+
1311
+
1312
+ @app.command("network", help="Check network accelerator status.")
1313
+ def network(
1314
+ ctx: typer.Context,
1315
+ region: Optional[str] = typer.Option(None, "--region", help="Region for this request."),
1316
+ ) -> None:
1317
+ invoke(
1318
+ runtime(ctx),
1319
+ "CheckCompShareNetOptimizer",
1320
+ request(ctx, region_value=region),
1321
+ )
1322
+
1323
+
1324
+ @app.command("models", help="List models in the model repository.")
1325
+ def models(
1326
+ ctx: typer.Context,
1327
+ name: Optional[str] = typer.Option(None, help="Filter by model name."),
1328
+ tags: Optional[str] = typer.Option(None, help="Filter by model tags."),
1329
+ region: Optional[str] = typer.Option(None, "--region", help="Region for this request."),
1330
+ ) -> None:
1331
+ params = request(ctx, region_value=region)
1332
+ params.update(compact({"name": name, "tags": tags}))
1333
+ invoke(
1334
+ runtime(ctx),
1335
+ "DescribeModelRepositoryModels",
1336
+ params,
1337
+ list_key="Models",
1338
+ columns=(
1339
+ ("Name", "NAME"),
1340
+ ("Path", "PATH"),
1341
+ ("Tag", "TAG"),
1342
+ ("Size", "SIZE"),
1343
+ ("CreateTime", "CREATED"),
1344
+ ),
1345
+ )
1346
+
1347
+
1348
+ @ports_app.command("list", help="List supported software ports.")
1349
+ def list_ports(
1350
+ ctx: typer.Context,
1351
+ region: Optional[str] = typer.Option(None, "--region", help="Region for this request."),
1352
+ ) -> None:
1353
+ invoke(
1354
+ runtime(ctx),
1355
+ "DescribeCompShareSoftwarePort",
1356
+ request(ctx, region_value=region),
1357
+ list_key="SoftwarePort",
1358
+ columns=(("Software", "SOFTWARE"), ("Port", "PORT")),
1359
+ )
1360
+
1361
+
1362
+ @ports_app.command("update", help="Replace an instance's container port mappings.")
1363
+ def update_ports(
1364
+ ctx: typer.Context,
1365
+ instance: str,
1366
+ http: Optional[List[int]] = typer.Option(
1367
+ None, "--http", help="Complete HTTP port list; repeatable."
1368
+ ),
1369
+ tcp: Optional[List[int]] = typer.Option(
1370
+ None, "--tcp", help="Complete TCP port list; repeatable."
1371
+ ),
1372
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
1373
+ ) -> None:
1374
+ if http is None and tcp is None:
1375
+ raise UsageError(tr("Specify at least one --http or --tcp port."))
1376
+ confirm(tr("Replace port mappings for {instance}?", instance=instance), yes)
1377
+ state = runtime(ctx)
1378
+ region, zone, _ = locate_instance(state, instance)
1379
+ params = request(ctx, zone=True, region_value=region, zone_value=zone)
1380
+ params.update(compact({"UHostId": instance, "HttpPorts": http, "TcpPorts": tcp}))
1381
+ invoke(
1382
+ state,
1383
+ "UpdateCompShareInstancePorts",
1384
+ params,
1385
+ success=tr("Updated port mappings"),
1386
+ )
1387
+
1388
+
1389
+ @schedule_app.command("set", help="Schedule an instance shutdown.")
1390
+ def set_schedule(
1391
+ ctx: typer.Context,
1392
+ instance: str,
1393
+ at: str = typer.Option(..., "--at", help="Unix timestamp, ISO 8601, or relative time."),
1394
+ project_id: Optional[str] = typer.Option(
1395
+ None, "--project-id", help="Override the automatically detected project ID."
1396
+ ),
1397
+ ) -> None:
1398
+ stop_time = timestamp(at)
1399
+ if stop_time < int(time.time()) + 300:
1400
+ raise UsageError(tr("Scheduled shutdown must be at least five minutes from now."))
1401
+ state = runtime(ctx)
1402
+ region, zone, _ = locate_instance(state, instance)
1403
+ params = request(
1404
+ ctx,
1405
+ zone=True,
1406
+ project_id=_project_id(state, project_id),
1407
+ region_value=region,
1408
+ zone_value=zone,
1409
+ )
1410
+ params.update({"UHostId": instance, "SchedulerStopTime": stop_time})
1411
+ invoke(state, "UpdateCompShareStopScheduler", params, success=tr("Scheduled shutdown"))
1412
+
1413
+
1414
+ @schedule_app.command("cancel", help="Cancel an instance scheduled shutdown.")
1415
+ def cancel_schedule(
1416
+ ctx: typer.Context,
1417
+ instance: str,
1418
+ project_id: Optional[str] = typer.Option(
1419
+ None, "--project-id", help="Override the automatically detected project ID."
1420
+ ),
1421
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
1422
+ ) -> None:
1423
+ confirm(tr("Cancel scheduled shutdown for {instance}?", instance=instance), yes)
1424
+ state = runtime(ctx)
1425
+ region, _, _ = locate_instance(state, instance)
1426
+ params = request(
1427
+ ctx,
1428
+ project_id=_project_id(state, project_id),
1429
+ region_value=region,
1430
+ )
1431
+ params["UHostId"] = instance
1432
+ invoke(state, "DeleteCompShareStopScheduler", params, success=tr("Cancelled shutdown"))
1433
+
1434
+
1435
+ @software_app.command("list", help="List supported instance software.")
1436
+ def list_software(
1437
+ ctx: typer.Context,
1438
+ region: Optional[str] = typer.Option(None, "--region", help="Region for this request."),
1439
+ ) -> None:
1440
+ invoke(
1441
+ runtime(ctx),
1442
+ "DescribeCompShareSoftwarePort",
1443
+ request(ctx, region_value=region),
1444
+ list_key="SoftwarePort",
1445
+ columns=(("Software", "SOFTWARE"), ("Port", "PORT")),
1446
+ )
1447
+
1448
+
1449
+ @software_app.command(
1450
+ "url",
1451
+ help="Get an instance software access URL (currently unavailable in production).",
1452
+ )
1453
+ def software_url(ctx: typer.Context, instance: str, software: str) -> None:
1454
+ state = runtime(ctx)
1455
+ region, zone, _ = locate_instance(state, instance)
1456
+ params = request(ctx, zone=True, region_value=region, zone_value=zone)
1457
+ params.update({"UHostId": instance, "Software": software})
1458
+ invoke(state, "GetSoftwareURL", params)
1459
+
1460
+
1461
+ @app.command("ssh", help="Open or print an instance SSH command.")
1462
+ def ssh(
1463
+ ctx: typer.Context,
1464
+ instance: str,
1465
+ print_only: bool = typer.Option(False, "--print", help="Print instead of executing SSH."),
1466
+ ) -> None:
1467
+ state = runtime(ctx)
1468
+ region, zone, host = locate_instance(state, instance)
1469
+ command = host.get("SshLoginCommand")
1470
+ password = host.get("Password")
1471
+ if not command:
1472
+ raise UsageError(tr("Instance {instance} has no SSH login command.", instance=instance))
1473
+ if print_only or state.json_output:
1474
+ Renderer(state.json_output).data(
1475
+ {
1476
+ "instance": instance,
1477
+ "command": command,
1478
+ "password": password,
1479
+ }
1480
+ )
1481
+ return
1482
+ if password:
1483
+ typer.echo(f"Password: {password}")
1484
+ else:
1485
+ typer.echo(
1486
+ tr(
1487
+ "The API did not return a password. Run `compshare instance password {instance}` "
1488
+ "to set one.",
1489
+ instance=instance,
1490
+ )
1491
+ )
1492
+ argv = shlex.split(command)
1493
+ raise typer.Exit(subprocess.call(argv))