impreza-cli 0.3.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,865 @@
1
+ """``impreza vps cloud`` sub-command surface — Phase 3.5.
2
+
3
+ Cloud sub-resources mounted on the main ``vps`` Typer
4
+ app as the ``cloud`` namespace. Mirror of the 3.4 Proxmox layout.
5
+
6
+ Five nested sub-apps + five inline verbs at the cloud root:
7
+
8
+ * ``vps cloud images`` — ``list / create / restore / delete``
9
+ Account-scoped on the wire (the Cloud image catalog is
10
+ per-account, not per-VM); ``create`` snapshots the bound VM and
11
+ ``restore`` brings an image back onto the bound VM.
12
+
13
+ * ``vps cloud rescue`` — ``enable / disable``
14
+ Reboot the VM after ``enable`` to actually enter rescue mode.
15
+
16
+ * ``vps cloud iso`` — ``mount / unmount``
17
+
18
+ * ``vps cloud rdns`` — ``get / set / delete``
19
+ Account-scoped on the API side (``/vps/cloud/rdns/{ip}``); the
20
+ SDK exposes it via the bound VPS for ergonomics. ``<ip>`` selects
21
+ the record within the account.
22
+
23
+ * ``vps cloud ssh-keys`` — ``list / assign``
24
+ ``list`` returns every key on the Cloud account; ``assign``
25
+ attaches one or more existing keys to the bound VPS.
26
+
27
+ Inline verbs (no sub-app):
28
+
29
+ * ``vps cloud vnc <id>`` — read the VNC client triple (host, port,
30
+ password).
31
+ * ``vps cloud vnc-password <id>`` — rotate the VNC password
32
+ (prompted hidden+confirmed by default).
33
+ * ``vps cloud resize <id> --size SIZE`` — change the Cloud backend
34
+ instance size. Reboot required to apply.
35
+ * ``vps cloud boot-order <id> --order ORDER`` — set boot order;
36
+ client-side validated against ``{"cda", "dca"}``.
37
+ * ``vps cloud ipv6 <id> enable`` — enable IPv6 on the VM.
38
+
39
+ Every verb maps ``BackendNotSupported`` (raised by the SDK
40
+ sub-resource property or the ``_require_backend("cloud", ...)``
41
+ check on a Proxmox VPS) to a friendly "This VPS is on the Proxmox
42
+ backend — <op> is Cloud-only." stderr line, mirroring the 3.4
43
+ Proxmox-only pattern in reverse.
44
+
45
+ Catalog reads (``catalog vps-cloud-sizes`` / ``vps-cloud-locations``)
46
+ deferred again — the SDK still hasn't shipped wrappers as of 3.5
47
+ kickoff. Pushed to a future fase; add a small SDK task before
48
+ re-attempting.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ from typing import Any
54
+
55
+ import typer
56
+ from impreza.exceptions import ApiError, BackendNotSupported
57
+
58
+ from ..output import OutputFormat, error, info, print_dict, print_table, success
59
+ from ..sdk import make_client_or_exit
60
+ from ..state import confirm_or_exit, from_typer_context, resolve_output
61
+ from ._helpers import exit_on_api_error, resolve_vps_or_exit
62
+
63
+ # ── parent Typer app + nested sub-apps ──────────────────────────────
64
+
65
+
66
+ app = typer.Typer(
67
+ name="cloud",
68
+ help="Cloud-only sub-resources: images, rescue, iso, rdns, ssh-keys, etc.",
69
+ no_args_is_help=True,
70
+ )
71
+
72
+ images_app = typer.Typer(
73
+ name="images",
74
+ help="Manage saved Cloud VM images (account-scoped on the wire).",
75
+ no_args_is_help=True,
76
+ )
77
+ rescue_app = typer.Typer(
78
+ name="rescue",
79
+ help="Enable / disable rescue mode on a Cloud VPS.",
80
+ no_args_is_help=True,
81
+ )
82
+ iso_app = typer.Typer(
83
+ name="iso",
84
+ help="Mount / unmount an ISO on a Cloud VPS.",
85
+ no_args_is_help=True,
86
+ )
87
+ rdns_app = typer.Typer(
88
+ name="rdns",
89
+ help="Reverse-DNS records for Cloud account IPs.",
90
+ no_args_is_help=True,
91
+ )
92
+ ssh_keys_app = typer.Typer(
93
+ name="ssh-keys",
94
+ help="List account-level SSH keys and assign to a Cloud VPS.",
95
+ no_args_is_help=True,
96
+ )
97
+ ipv6_app = typer.Typer(
98
+ name="ipv6",
99
+ help="IPv6 operations on a Cloud VPS.",
100
+ no_args_is_help=True,
101
+ )
102
+
103
+ app.add_typer(images_app)
104
+ app.add_typer(rescue_app)
105
+ app.add_typer(iso_app)
106
+ app.add_typer(rdns_app)
107
+ app.add_typer(ssh_keys_app)
108
+ app.add_typer(ipv6_app)
109
+
110
+
111
+ # ── helpers ─────────────────────────────────────────────────────────
112
+
113
+
114
+ def _cloud_only_exit(op: str) -> None:
115
+ """Print the standard "Cloud-only" stderr line and exit 1.
116
+ Mirrors :func:`commands.vps_proxmox._proxmox_only_exit` — every
117
+ sub-resource property on a Proxmox VPS raises
118
+ :class:`BackendNotSupported`.
119
+ """
120
+ error(f"This VPS is on the Proxmox backend — {op} is Cloud-only.")
121
+ raise typer.Exit(code=1) from None
122
+
123
+
124
+ def _format_bytes(value: int | None) -> str:
125
+ """Human-readable byte count for image sizes. ``None`` → ``-``."""
126
+ if value is None:
127
+ return "-"
128
+ mb = 1024 * 1024
129
+ gb = 1024 * mb
130
+ if value >= gb:
131
+ return f"{value / gb:.2f} GB"
132
+ if value >= mb:
133
+ return f"{value / mb:.0f} MB"
134
+ return f"{value} B"
135
+
136
+
137
+ _VALID_BOOT_ORDER = {"cda", "dca"}
138
+
139
+
140
+ # ══════════════════════════════════════════════════════════════════════
141
+ # Images
142
+ # ══════════════════════════════════════════════════════════════════════
143
+
144
+
145
+ @images_app.command("list")
146
+ def images_list(
147
+ typer_ctx: typer.Context,
148
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
149
+ output: OutputFormat | None = typer.Option(
150
+ None, "--output", "-o",
151
+ help="Output format. Overrides the global --output flag.",
152
+ case_sensitive=False,
153
+ ),
154
+ ) -> None:
155
+ """List saved images on the Cloud account.
156
+
157
+ Wraps ``vps.images.list()``. The Cloud image catalog is
158
+ account-scoped — this returns every image, regardless of which
159
+ Cloud VPS originally created it. Use the ``vm_id`` column to
160
+ spot which VPS each image came from.
161
+ """
162
+ state = from_typer_context(typer_ctx)
163
+ fmt = resolve_output(state, output)
164
+ with make_client_or_exit(state) as client:
165
+ vps = resolve_vps_or_exit(client, service_id)
166
+ try:
167
+ images = vps.images.list()
168
+ except BackendNotSupported:
169
+ _cloud_only_exit("images")
170
+ return
171
+ except ApiError as exc:
172
+ exit_on_api_error(exc)
173
+ return
174
+
175
+ if not images:
176
+ typer.echo("No images saved on this account.")
177
+ return
178
+ if fmt is OutputFormat.TABLE:
179
+ rows = [
180
+ {
181
+ "id": img.id,
182
+ "name": img.name or "",
183
+ "vm_id": img.vm_id if img.vm_id is not None else "",
184
+ "size": _format_bytes(img.size),
185
+ "status": img.status or "",
186
+ "created_at": img.created_at or "",
187
+ }
188
+ for img in images
189
+ ]
190
+ else:
191
+ rows = [
192
+ {
193
+ "id": img.id,
194
+ "name": img.name,
195
+ "vm_id": img.vm_id,
196
+ "size": img.size,
197
+ "status": img.status,
198
+ "created_at": img.created_at,
199
+ }
200
+ for img in images
201
+ ]
202
+ print_table(
203
+ f"Cloud images ({len(rows)})",
204
+ rows,
205
+ columns=["id", "name", "vm_id", "size", "status", "created_at"],
206
+ fmt=fmt,
207
+ )
208
+
209
+
210
+ @images_app.command("create")
211
+ def images_create(
212
+ typer_ctx: typer.Context,
213
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
214
+ ) -> None:
215
+ """Snapshot the bound VM's current state into a saved image.
216
+
217
+ Wraps ``vps.images.create()``. Synchronous — returns the
218
+ :class:`Image` model. The new image is added to the account-
219
+ level catalog and can be restored to any Cloud VPS the account
220
+ owns via ``vps cloud images restore``.
221
+ """
222
+ state = from_typer_context(typer_ctx)
223
+ with make_client_or_exit(state) as client:
224
+ vps = resolve_vps_or_exit(client, service_id)
225
+ try:
226
+ image = vps.images.create()
227
+ except BackendNotSupported:
228
+ _cloud_only_exit("images")
229
+ return
230
+ except ApiError as exc:
231
+ exit_on_api_error(exc)
232
+ return
233
+ success(
234
+ f"Image created from VPS {service_id}: id={image.id!r}"
235
+ + (f", name={image.name!r}" if image.name else "")
236
+ )
237
+
238
+
239
+ @images_app.command("restore")
240
+ def images_restore(
241
+ typer_ctx: typer.Context,
242
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
243
+ image_id: str = typer.Argument(..., help="Image id from `vps cloud images list`."),
244
+ yes: bool = typer.Option(
245
+ False, "--yes", "-y", help="Skip the data-loss confirmation prompt."
246
+ ),
247
+ ) -> None:
248
+ """Restore an image onto the bound Cloud VPS. **Destructive** —
249
+ overwrites the current disk with the image contents.
250
+
251
+ Wraps ``vps.images.restore(image_id)``. Synchronous on the
252
+ Cloud backend side.
253
+ """
254
+ state = from_typer_context(typer_ctx)
255
+ confirm_or_exit(
256
+ f"Restoring image {image_id!r} onto VPS {service_id} overwrites "
257
+ "the current disk. Any changes made since the image was created "
258
+ "will be lost.",
259
+ yes=yes,
260
+ )
261
+ with make_client_or_exit(state) as client:
262
+ vps = resolve_vps_or_exit(client, service_id)
263
+ try:
264
+ vps.images.restore(image_id)
265
+ except BackendNotSupported:
266
+ _cloud_only_exit("images")
267
+ return
268
+ except ApiError as exc:
269
+ exit_on_api_error(exc)
270
+ return
271
+ success(f"Image {image_id!r} restored onto VPS {service_id}.")
272
+
273
+
274
+ @images_app.command("delete")
275
+ def images_delete(
276
+ typer_ctx: typer.Context,
277
+ service_id: int = typer.Argument(
278
+ ...,
279
+ help=(
280
+ "Service id of any Cloud VPS on the account (the image "
281
+ "catalog is account-scoped; this id is only used to resolve "
282
+ "the backend and confirm the account)."
283
+ ),
284
+ ),
285
+ image_id: str = typer.Argument(..., help="Image id to delete."),
286
+ yes: bool = typer.Option(
287
+ False, "--yes", "-y", help="Skip the deletion confirmation prompt."
288
+ ),
289
+ ) -> None:
290
+ """Delete an image from the Cloud account. **Irreversible.**
291
+
292
+ Wraps ``vps.images.delete(image_id)``. The API path
293
+ ``DELETE /vps/cloud/images/{id}`` is account-scoped — passing
294
+ any Cloud VPS id on the account is enough to reach it.
295
+ """
296
+ state = from_typer_context(typer_ctx)
297
+ confirm_or_exit(
298
+ f"Deleting image {image_id!r} from the account is irreversible.",
299
+ yes=yes,
300
+ )
301
+ with make_client_or_exit(state) as client:
302
+ vps = resolve_vps_or_exit(client, service_id)
303
+ try:
304
+ vps.images.delete(image_id)
305
+ except BackendNotSupported:
306
+ _cloud_only_exit("images")
307
+ return
308
+ except ApiError as exc:
309
+ exit_on_api_error(exc)
310
+ return
311
+ success(f"Image {image_id!r} deleted from the account.")
312
+
313
+
314
+ # ══════════════════════════════════════════════════════════════════════
315
+ # Rescue
316
+ # ══════════════════════════════════════════════════════════════════════
317
+
318
+
319
+ @rescue_app.command("enable")
320
+ def rescue_enable(
321
+ typer_ctx: typer.Context,
322
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
323
+ password: str = typer.Option(
324
+ ...,
325
+ "--password", "-p",
326
+ prompt="Rescue root password",
327
+ hide_input=True,
328
+ confirmation_prompt=True,
329
+ help=(
330
+ "Root password for the rescue environment. Prompts hidden "
331
+ "(with confirmation) when omitted. Passing on the command "
332
+ "line puts the password in shell history — prefer the prompt."
333
+ ),
334
+ ),
335
+ yes: bool = typer.Option(
336
+ False, "--yes", "-y", help="Skip the reboot-required confirmation prompt."
337
+ ),
338
+ ) -> None:
339
+ """Enable rescue mode on a Cloud VPS. A reboot is required to
340
+ actually enter rescue — the upstream marks the VM for rescue on
341
+ the next boot.
342
+
343
+ Wraps ``vps.rescue.enable(password=...)``.
344
+ """
345
+ state = from_typer_context(typer_ctx)
346
+ confirm_or_exit(
347
+ f"Enabling rescue on VPS {service_id} marks the VM for rescue. "
348
+ "You must reboot for it to take effect (`impreza vps reboot`).",
349
+ yes=yes,
350
+ )
351
+ with make_client_or_exit(state) as client:
352
+ vps = resolve_vps_or_exit(client, service_id)
353
+ try:
354
+ vps.rescue.enable(password=password)
355
+ except BackendNotSupported:
356
+ _cloud_only_exit("rescue")
357
+ return
358
+ except ApiError as exc:
359
+ exit_on_api_error(exc)
360
+ return
361
+ info(
362
+ f"Rescue armed on VPS {service_id}. Reboot to enter rescue mode."
363
+ )
364
+
365
+
366
+ @rescue_app.command("disable")
367
+ def rescue_disable(
368
+ typer_ctx: typer.Context,
369
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
370
+ ) -> None:
371
+ """Disarm rescue mode. The VM will boot normally on next reboot.
372
+
373
+ Wraps ``vps.rescue.disable()``. No confirmation prompt —
374
+ disabling rescue is a recovery action with no data impact.
375
+ """
376
+ state = from_typer_context(typer_ctx)
377
+ with make_client_or_exit(state) as client:
378
+ vps = resolve_vps_or_exit(client, service_id)
379
+ try:
380
+ vps.rescue.disable()
381
+ except BackendNotSupported:
382
+ _cloud_only_exit("rescue")
383
+ return
384
+ except ApiError as exc:
385
+ exit_on_api_error(exc)
386
+ return
387
+ success(f"Rescue disabled on VPS {service_id}.")
388
+
389
+
390
+ # ══════════════════════════════════════════════════════════════════════
391
+ # ISO
392
+ # ══════════════════════════════════════════════════════════════════════
393
+
394
+
395
+ @iso_app.command("mount")
396
+ def iso_mount(
397
+ typer_ctx: typer.Context,
398
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
399
+ iso: str = typer.Argument(
400
+ ...,
401
+ help=(
402
+ "ISO identifier (typically the file name as registered with "
403
+ "Cloud backend). Available ISOs vary per location — check the "
404
+ "Impreza Account control panel for the catalog."
405
+ ),
406
+ ),
407
+ ) -> None:
408
+ """Mount an ISO on a Cloud VPS. Reboot required for the VM to
409
+ actually boot from it.
410
+
411
+ Wraps ``vps.iso.mount(iso)``.
412
+ """
413
+ state = from_typer_context(typer_ctx)
414
+ with make_client_or_exit(state) as client:
415
+ vps = resolve_vps_or_exit(client, service_id)
416
+ try:
417
+ vps.iso.mount(iso)
418
+ except BackendNotSupported:
419
+ _cloud_only_exit("iso")
420
+ return
421
+ except ApiError as exc:
422
+ exit_on_api_error(exc)
423
+ return
424
+ info(f"ISO {iso!r} mounted on VPS {service_id}. Reboot to boot from it.")
425
+
426
+
427
+ @iso_app.command("unmount")
428
+ def iso_unmount(
429
+ typer_ctx: typer.Context,
430
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
431
+ ) -> None:
432
+ """Unmount the ISO. Takes effect on next reboot.
433
+
434
+ Wraps ``vps.iso.unmount()``.
435
+ """
436
+ state = from_typer_context(typer_ctx)
437
+ with make_client_or_exit(state) as client:
438
+ vps = resolve_vps_or_exit(client, service_id)
439
+ try:
440
+ vps.iso.unmount()
441
+ except BackendNotSupported:
442
+ _cloud_only_exit("iso")
443
+ return
444
+ except ApiError as exc:
445
+ exit_on_api_error(exc)
446
+ return
447
+ success(f"ISO unmounted on VPS {service_id}.")
448
+
449
+
450
+ # ══════════════════════════════════════════════════════════════════════
451
+ # rDNS
452
+ # ══════════════════════════════════════════════════════════════════════
453
+
454
+
455
+ @rdns_app.command("get")
456
+ def rdns_get(
457
+ typer_ctx: typer.Context,
458
+ service_id: int = typer.Argument(
459
+ ...,
460
+ help=(
461
+ "Service id of any Cloud VPS on the account (rDNS is "
462
+ "account-scoped; this id is only used to confirm the account)."
463
+ ),
464
+ ),
465
+ ip: str = typer.Argument(..., help="IP address whose rDNS to read."),
466
+ output: OutputFormat | None = typer.Option(
467
+ None, "--output", "-o",
468
+ help="Output format. Overrides the global --output flag.",
469
+ case_sensitive=False,
470
+ ),
471
+ ) -> None:
472
+ """Read the rDNS record for an IP.
473
+
474
+ Wraps ``vps.rdns.get(ip)``. The API path is account-scoped
475
+ (``GET /vps/cloud/rdns/{ip}``).
476
+ """
477
+ state = from_typer_context(typer_ctx)
478
+ fmt = resolve_output(state, output)
479
+ with make_client_or_exit(state) as client:
480
+ vps = resolve_vps_or_exit(client, service_id)
481
+ try:
482
+ result = vps.rdns.get(ip)
483
+ except BackendNotSupported:
484
+ _cloud_only_exit("rdns")
485
+ return
486
+ except ApiError as exc:
487
+ exit_on_api_error(exc)
488
+ return
489
+
490
+ if not result:
491
+ typer.echo(f"No rDNS record for {ip}.")
492
+ return
493
+ print_dict(f"rDNS — {ip}", {str(k): v for k, v in result.items()}, fmt=fmt)
494
+
495
+
496
+ @rdns_app.command("set")
497
+ def rdns_set(
498
+ typer_ctx: typer.Context,
499
+ service_id: int = typer.Argument(
500
+ ...,
501
+ help="Service id of any Cloud VPS on the account (see `rdns get`).",
502
+ ),
503
+ ip: str = typer.Argument(..., help="IP address to set rDNS on."),
504
+ hostname: str = typer.Argument(..., help="Hostname to point the rDNS PTR at."),
505
+ ) -> None:
506
+ """Set the rDNS PTR record for an IP.
507
+
508
+ Wraps ``vps.rdns.set(ip, hostname)``. Some upstreams reject
509
+ hostnames that don't resolve forward to the IP — those return
510
+ a 4xx and the SDK surfaces it as :class:`InvalidRequest`.
511
+ """
512
+ state = from_typer_context(typer_ctx)
513
+ with make_client_or_exit(state) as client:
514
+ vps = resolve_vps_or_exit(client, service_id)
515
+ try:
516
+ vps.rdns.set(ip, hostname)
517
+ except BackendNotSupported:
518
+ _cloud_only_exit("rdns")
519
+ return
520
+ except ApiError as exc:
521
+ exit_on_api_error(exc)
522
+ return
523
+ success(f"rDNS for {ip} set to {hostname!r}.")
524
+
525
+
526
+ @rdns_app.command("delete")
527
+ def rdns_delete(
528
+ typer_ctx: typer.Context,
529
+ service_id: int = typer.Argument(
530
+ ...,
531
+ help="Service id of any Cloud VPS on the account (see `rdns get`).",
532
+ ),
533
+ ip: str = typer.Argument(..., help="IP address whose rDNS to clear."),
534
+ yes: bool = typer.Option(
535
+ False, "--yes", "-y", help="Skip the deletion confirmation prompt."
536
+ ),
537
+ ) -> None:
538
+ """Delete the rDNS record for an IP.
539
+
540
+ Wraps ``vps.rdns.delete(ip)``. After deletion, reverse-DNS
541
+ lookups on the IP return whatever default the upstream
542
+ configures.
543
+ """
544
+ state = from_typer_context(typer_ctx)
545
+ confirm_or_exit(
546
+ f"Removing rDNS for {ip} reverts reverse-DNS lookups to the "
547
+ "upstream default. Mail servers and rate-limiters relying on "
548
+ "the PTR may behave differently.",
549
+ yes=yes,
550
+ )
551
+ with make_client_or_exit(state) as client:
552
+ vps = resolve_vps_or_exit(client, service_id)
553
+ try:
554
+ vps.rdns.delete(ip)
555
+ except BackendNotSupported:
556
+ _cloud_only_exit("rdns")
557
+ return
558
+ except ApiError as exc:
559
+ exit_on_api_error(exc)
560
+ return
561
+ success(f"rDNS removed for {ip}.")
562
+
563
+
564
+ # ══════════════════════════════════════════════════════════════════════
565
+ # SSH keys
566
+ # ══════════════════════════════════════════════════════════════════════
567
+
568
+
569
+ @ssh_keys_app.command("list")
570
+ def ssh_keys_list(
571
+ typer_ctx: typer.Context,
572
+ service_id: int = typer.Argument(
573
+ ...,
574
+ help=(
575
+ "Service id of any Cloud VPS on the account (SSH key "
576
+ "catalog is account-scoped; this id is only used to confirm "
577
+ "the account)."
578
+ ),
579
+ ),
580
+ output: OutputFormat | None = typer.Option(
581
+ None, "--output", "-o",
582
+ help="Output format. Overrides the global --output flag.",
583
+ case_sensitive=False,
584
+ ),
585
+ ) -> None:
586
+ """List SSH keys registered on the Cloud account.
587
+
588
+ Wraps ``vps.ssh_keys.list()``. The catalog is account-scoped —
589
+ every key returned is available to assign to any Cloud VPS on
590
+ the account via ``vps cloud ssh-keys assign``.
591
+ """
592
+ state = from_typer_context(typer_ctx)
593
+ fmt = resolve_output(state, output)
594
+ with make_client_or_exit(state) as client:
595
+ vps = resolve_vps_or_exit(client, service_id)
596
+ try:
597
+ keys = vps.ssh_keys.list()
598
+ except BackendNotSupported:
599
+ _cloud_only_exit("ssh-keys")
600
+ return
601
+ except ApiError as exc:
602
+ exit_on_api_error(exc)
603
+ return
604
+
605
+ if not keys:
606
+ typer.echo("No SSH keys registered on this account.")
607
+ return
608
+ rows = [
609
+ {"id": k.id, "name": k.name, "fingerprint": k.fingerprint or ""}
610
+ for k in keys
611
+ ]
612
+ print_table(
613
+ f"SSH keys ({len(rows)})",
614
+ rows,
615
+ columns=["id", "name", "fingerprint"],
616
+ fmt=fmt,
617
+ )
618
+
619
+
620
+ @ssh_keys_app.command("assign")
621
+ def ssh_keys_assign(
622
+ typer_ctx: typer.Context,
623
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
624
+ key_ids: list[str] = typer.Argument(
625
+ ...,
626
+ help=(
627
+ "One or more SSH key ids from `vps cloud ssh-keys list`. "
628
+ "Pass each id as a separate argument: `... assign 17987 1 2 3`."
629
+ ),
630
+ ),
631
+ ) -> None:
632
+ """Assign one or more account-level SSH keys to a Cloud VPS.
633
+
634
+ Wraps ``vps.ssh_keys.assign([...])``. The assigned keys are
635
+ injected into ``~/.ssh/authorized_keys`` for the root user on
636
+ the next provisioning event (rebuild, reinstall, rescue).
637
+ Existing instances may need a reboot for the keys to be picked
638
+ up — depends on the Cloud image template.
639
+ """
640
+ state = from_typer_context(typer_ctx)
641
+ with make_client_or_exit(state) as client:
642
+ vps = resolve_vps_or_exit(client, service_id)
643
+ try:
644
+ # The SDK accepts list[str | int]; pass strings verbatim and
645
+ # let the upstream coerce. Typer would lose the type either
646
+ # way (CLI args are always strings).
647
+ vps.ssh_keys.assign(list(key_ids))
648
+ except BackendNotSupported:
649
+ _cloud_only_exit("ssh-keys")
650
+ return
651
+ except ApiError as exc:
652
+ exit_on_api_error(exc)
653
+ return
654
+ success(
655
+ f"Assigned {len(key_ids)} SSH key(s) to VPS {service_id}: "
656
+ + ", ".join(key_ids)
657
+ )
658
+
659
+
660
+ # ══════════════════════════════════════════════════════════════════════
661
+ # VNC (inline at cloud root)
662
+ # ══════════════════════════════════════════════════════════════════════
663
+
664
+
665
+ @app.command("vnc")
666
+ def vnc(
667
+ typer_ctx: typer.Context,
668
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
669
+ output: OutputFormat | None = typer.Option(
670
+ None, "--output", "-o",
671
+ help="Output format. Overrides the global --output flag.",
672
+ case_sensitive=False,
673
+ ),
674
+ ) -> None:
675
+ """Read VNC client credentials (host, port, password).
676
+
677
+ Wraps ``vps.vnc()``. Connect with a desktop VNC client
678
+ (TigerVNC, RealVNC, etc.). The credentials are short-lived;
679
+ re-fetch when you reconnect after extended idle.
680
+ """
681
+ state = from_typer_context(typer_ctx)
682
+ fmt = resolve_output(state, output)
683
+ with make_client_or_exit(state) as client:
684
+ vps_obj = resolve_vps_or_exit(client, service_id)
685
+ try:
686
+ creds = vps_obj.vnc()
687
+ except BackendNotSupported:
688
+ _cloud_only_exit("vnc")
689
+ return
690
+ except ApiError as exc:
691
+ exit_on_api_error(exc)
692
+ return
693
+
694
+ data: dict[str, Any] = {
695
+ "ip": creds.ip,
696
+ "port": creds.port,
697
+ "password": creds.password,
698
+ }
699
+ print_dict(f"VNC — VPS {service_id}", data, fmt=fmt)
700
+
701
+
702
+ @app.command("vnc-password")
703
+ def vnc_password(
704
+ typer_ctx: typer.Context,
705
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
706
+ password: str = typer.Option(
707
+ ...,
708
+ "--password", "-p",
709
+ prompt="New VNC password",
710
+ hide_input=True,
711
+ confirmation_prompt=True,
712
+ help=(
713
+ "New VNC password. Prompts hidden (with confirmation) when "
714
+ "omitted. Passing on the command line puts the password in "
715
+ "shell history — prefer the prompt."
716
+ ),
717
+ ),
718
+ ) -> None:
719
+ """Rotate the VNC password.
720
+
721
+ Wraps ``vps.vnc_password(password)``. The next ``vps cloud vnc``
722
+ call returns the new password.
723
+ """
724
+ state = from_typer_context(typer_ctx)
725
+ with make_client_or_exit(state) as client:
726
+ vps_obj = resolve_vps_or_exit(client, service_id)
727
+ try:
728
+ vps_obj.vnc_password(password)
729
+ except BackendNotSupported:
730
+ _cloud_only_exit("vnc-password")
731
+ return
732
+ except ApiError as exc:
733
+ exit_on_api_error(exc)
734
+ return
735
+ success(f"VNC password rotated for VPS {service_id}.")
736
+
737
+
738
+ # ══════════════════════════════════════════════════════════════════════
739
+ # Resize (inline)
740
+ # ══════════════════════════════════════════════════════════════════════
741
+
742
+
743
+ @app.command("resize")
744
+ def resize(
745
+ typer_ctx: typer.Context,
746
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
747
+ size: str = typer.Option(
748
+ ...,
749
+ "--size", "-s",
750
+ help=(
751
+ "New Cloud instance size identifier. Available sizes "
752
+ "depend on the VPS's location — once 3.5+ ships the SDK "
753
+ "wrapper, `impreza catalog vps-cloud-sizes` will list them. "
754
+ "Until then, your Impreza Account is authoritative."
755
+ ),
756
+ ),
757
+ yes: bool = typer.Option(
758
+ False, "--yes", "-y", help="Skip the resize confirmation prompt."
759
+ ),
760
+ ) -> None:
761
+ """Resize the Cloud VPS to a new Cloud instance size.
762
+ **Reboot required** for the resize to apply on the guest.
763
+
764
+ Wraps ``vps.resize(instance_size=size)``. Billing adjusts on
765
+ the next invoice — Cloud charges the difference between
766
+ the old and new size pro-rated for the remaining billing cycle.
767
+ """
768
+ state = from_typer_context(typer_ctx)
769
+ confirm_or_exit(
770
+ f"Resizing VPS {service_id} to size {size!r} requires a reboot "
771
+ "to take effect and adjusts billing on the next invoice.",
772
+ yes=yes,
773
+ )
774
+ with make_client_or_exit(state) as client:
775
+ vps_obj = resolve_vps_or_exit(client, service_id)
776
+ try:
777
+ vps_obj.resize(instance_size=size)
778
+ except BackendNotSupported:
779
+ _cloud_only_exit("resize")
780
+ return
781
+ except ApiError as exc:
782
+ exit_on_api_error(exc)
783
+ return
784
+ info(
785
+ f"VPS {service_id} resized to {size!r}. Reboot for the change to apply."
786
+ )
787
+
788
+
789
+ # ══════════════════════════════════════════════════════════════════════
790
+ # Boot order (inline)
791
+ # ══════════════════════════════════════════════════════════════════════
792
+
793
+
794
+ @app.command("boot-order")
795
+ def boot_order(
796
+ typer_ctx: typer.Context,
797
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
798
+ order: str = typer.Option(
799
+ ...,
800
+ "--order", "-o",
801
+ help=(
802
+ "Boot order: 'cda' (disk → CD-ROM → network) or 'dca' "
803
+ "(CD-ROM → disk → network). Other strings are rejected "
804
+ "client-side."
805
+ ),
806
+ ),
807
+ ) -> None:
808
+ """Set the BIOS boot order on a Cloud VPS.
809
+
810
+ Wraps ``vps.boot_order(order)``. ``--order`` is validated
811
+ client-side against ``{"cda", "dca"}`` so unknown values exit 1
812
+ before any HTTP call.
813
+ """
814
+ if order not in _VALID_BOOT_ORDER:
815
+ error(
816
+ f"--order must be one of {sorted(_VALID_BOOT_ORDER)!r}, "
817
+ f"got: {order!r}"
818
+ )
819
+ raise typer.Exit(code=1)
820
+
821
+ state = from_typer_context(typer_ctx)
822
+ with make_client_or_exit(state) as client:
823
+ vps_obj = resolve_vps_or_exit(client, service_id)
824
+ try:
825
+ # mypy needs the Literal here; the runtime check above
826
+ # already narrows it, but a cast keeps strict happy.
827
+ from typing import Literal, cast
828
+ vps_obj.boot_order(cast(Literal["cda", "dca"], order))
829
+ except BackendNotSupported:
830
+ _cloud_only_exit("boot-order")
831
+ return
832
+ except ApiError as exc:
833
+ exit_on_api_error(exc)
834
+ return
835
+ success(f"Boot order on VPS {service_id} set to {order!r}.")
836
+
837
+
838
+ # ══════════════════════════════════════════════════════════════════════
839
+ # IPv6 (sub-app with `enable` verb)
840
+ # ══════════════════════════════════════════════════════════════════════
841
+
842
+
843
+ @ipv6_app.command("enable")
844
+ def ipv6_enable(
845
+ typer_ctx: typer.Context,
846
+ service_id: int = typer.Argument(..., help="Service id (Cloud VPS)."),
847
+ ) -> None:
848
+ """Enable IPv6 on the VM.
849
+
850
+ Wraps ``vps.ipv6_enable()``. Reboot may be required for the
851
+ interface to come up inside the guest (depends on the
852
+ Cloud image template).
853
+ """
854
+ state = from_typer_context(typer_ctx)
855
+ with make_client_or_exit(state) as client:
856
+ vps_obj = resolve_vps_or_exit(client, service_id)
857
+ try:
858
+ vps_obj.ipv6_enable()
859
+ except BackendNotSupported:
860
+ _cloud_only_exit("ipv6")
861
+ return
862
+ except ApiError as exc:
863
+ exit_on_api_error(exc)
864
+ return
865
+ success(f"IPv6 enabled on VPS {service_id}.")