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,812 @@
1
+ """``impreza vps`` subcommand surface — Phases 2.5 + 3.2 + 3.3.
2
+
3
+ Read-only VPS commands over the smart-dispatch surface that ships
4
+ in 1.4b-i / 1.4b-ii:
5
+
6
+ * ``impreza vps list [--status STATUS] [--backend BACKEND]``
7
+ Wraps ``c.vps.list()`` (which walks ``/account/services`` and
8
+ keeps only entries with a non-null ``vps_backend``). Optional
9
+ filters run client-side — the underlying SDK call doesn't take
10
+ them, but the list is small enough that filtering after the
11
+ fetch is fine.
12
+
13
+ * ``impreza vps show <id>``
14
+ Wraps ``c.vps.get(id)`` and renders the resolved bound model's
15
+ underlying ``Service`` snapshot.
16
+
17
+ * ``impreza vps status <id>``
18
+ Wraps ``c.vps.get(id).status()`` and renders the
19
+ :class:`~impreza.VpsStatus` snapshot. Table mode formats memory
20
+ as MB / GB with units and uptime as a human-friendly duration;
21
+ JSON emits the raw bytes / seconds for piping into jq.
22
+
23
+ Power verbs added in Phase 3.2:
24
+
25
+ * ``impreza vps start <id>`` / ``reboot <id>`` / ``shutdown <id>``
26
+ Wrap ``c.vps.start/reboot/shutdown(id)`` from the smart-dispatch
27
+ surface. ``shutdown`` is the safe graceful ACPI path — no
28
+ confirmation by default. ``start`` and ``reboot`` are safe-ish
29
+ boot-state transitions.
30
+
31
+ * ``impreza vps stop <id>``
32
+ Force-stop (`/poweroff` on Cloud, `/stop` on Proxmox). May
33
+ corrupt unwritten data on the guest — gated by
34
+ ``confirm_or_exit``; pass ``--yes`` / ``-y`` to skip.
35
+
36
+ Management verbs added in Phase 3.3:
37
+
38
+ * ``impreza vps set-hostname <id> <hostname>`` (both backends)
39
+ * ``impreza vps set-password <id>`` — prompts hidden for the new
40
+ password unless ``--password`` is passed (both backends)
41
+ * ``impreza vps reinstall <id> --template TPL`` — destructive,
42
+ wipes the disk. ``--wait`` blocks on the Proxmox operation
43
+ queue (synchronous on Cloud — ``--wait`` is a silent no-op
44
+ there). Both backends.
45
+ * ``impreza vps migrate <id> --target TGT`` — Proxmox-only.
46
+ Returns an Operation; ``--wait`` blocks until the upstream
47
+ Proxmox queue settles.
48
+ * ``impreza vps cancel <id>`` — submits a cancellation request
49
+ via ``AddCancelRequest`` for staff approval (the customer does
50
+ not terminate the service directly; staff own the billing-cycle
51
+ close). Defaults to ``--type "End of Billing Period"`` to
52
+ discourage accidentally throwing away prepaid time. Both backends.
53
+
54
+ No ``suspend`` / ``unsuspend``: service suspension is a
55
+ billing-state operation owned by staff (overdue invoices auto-
56
+ resolve on payment, abuse holds resolve manually). The server
57
+ retired the underlying routes on 2026-05-11.
58
+
59
+ Backend-specific sub-resources (snapshots, backups, images,
60
+ rescue, etc.) get their own command groups in 3.4 / 3.5.
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ from typing import Any, Literal
66
+
67
+ import typer
68
+ from impreza import Vps, VpsStatus
69
+ from impreza.exceptions import (
70
+ ApiError,
71
+ BackendNotSupported,
72
+ InvalidRequest,
73
+ ResourceNotFound,
74
+ )
75
+
76
+ from ..output import OutputFormat, error, info, print_dict, print_table, success
77
+ from ..sdk import make_client_or_exit
78
+ from ..state import confirm_or_exit, from_typer_context, resolve_output
79
+ from ._helpers import (
80
+ exit_on_api_error as _exit_on_api_error,
81
+ )
82
+ from ._helpers import (
83
+ resolve_vps_or_exit as _resolve_vps_or_exit,
84
+ )
85
+ from ._helpers import (
86
+ wait_for_operation as _wait_for_operation,
87
+ )
88
+ from .vps_cloud import app as _cloud_app
89
+ from .vps_proxmox import app as _proxmox_app
90
+
91
+ app = typer.Typer(
92
+ name="vps",
93
+ help="Read VPS instances across both Proxmox and Cloud backends.",
94
+ no_args_is_help=True,
95
+ )
96
+ app.add_typer(_proxmox_app, name="proxmox")
97
+ app.add_typer(_cloud_app, name="cloud")
98
+
99
+
100
+ # ── render helpers ──────────────────────────────────────────────────
101
+
102
+
103
+ _MB = 1024 * 1024
104
+ _GB = 1024 * _MB
105
+
106
+
107
+ def _format_bytes(value: int | None) -> str:
108
+ """Render bytes as a human-friendly string for table output.
109
+ ``None`` → ``-``. Values ≥ 1 GB switch units."""
110
+ if value is None:
111
+ return "-"
112
+ if value >= _GB:
113
+ return f"{value / _GB:.2f} GB"
114
+ return f"{value / _MB:.0f} MB"
115
+
116
+
117
+ def _format_uptime(seconds: int | None) -> str:
118
+ """Render uptime in seconds as ``Xd HHh MMm`` for table output."""
119
+ if seconds is None:
120
+ return "-"
121
+ days, rem = divmod(seconds, 86400)
122
+ hours, rem = divmod(rem, 3600)
123
+ minutes, _ = divmod(rem, 60)
124
+ if days > 0:
125
+ return f"{days}d {hours:02d}h {minutes:02d}m"
126
+ if hours > 0:
127
+ return f"{hours}h {minutes:02d}m"
128
+ return f"{minutes}m"
129
+
130
+
131
+ def _format_cpu_usage(value: float | None) -> str:
132
+ """the Cloud backend doesn't report CPU usage; Proxmox returns it as a
133
+ fraction (0.05 = 5%). Multiply and tag the unit."""
134
+ if value is None:
135
+ return "-"
136
+ return f"{value * 100:.1f}%"
137
+
138
+
139
+ def _vps_to_row(vps: Vps) -> dict[str, Any]:
140
+ """Lift the underlying Service snapshot into a flat row for
141
+ `vps list` table mode."""
142
+ s = vps.service
143
+ return {
144
+ "id": s.id,
145
+ "domain": s.domain,
146
+ "backend": vps.backend,
147
+ "status": s.status,
148
+ "product": s.product,
149
+ "billing_cycle": s.billing_cycle,
150
+ "amount": f"{s.amount:.2f}",
151
+ "next_due": s.next_due,
152
+ }
153
+
154
+
155
+ _LIST_COLUMNS = [
156
+ "id",
157
+ "domain",
158
+ "backend",
159
+ "status",
160
+ "product",
161
+ "billing_cycle",
162
+ "amount",
163
+ "next_due",
164
+ ]
165
+
166
+
167
+ # ── vps list ────────────────────────────────────────────────────────
168
+
169
+
170
+ @app.command("list")
171
+ def list_vpss(
172
+ typer_ctx: typer.Context,
173
+ status: str | None = typer.Option(
174
+ None,
175
+ "--status",
176
+ help=(
177
+ "Filter by service status (Active, Pending, Suspended, "
178
+ "Cancelled, Terminated, Fraud). Case-insensitive substring "
179
+ "match — applied client-side after the API fetch."
180
+ ),
181
+ ),
182
+ backend: str | None = typer.Option(
183
+ None,
184
+ "--backend",
185
+ help="Filter by VPS backend: 'proxmox' or 'cloud'.",
186
+ case_sensitive=False,
187
+ ),
188
+ output: OutputFormat | None = typer.Option(
189
+ None,
190
+ "--output",
191
+ "-o",
192
+ help="Output format. Overrides the global --output flag.",
193
+ case_sensitive=False,
194
+ ),
195
+ ) -> None:
196
+ """List every VPS the authenticated client owns across all backends.
197
+
198
+ Wraps ``c.vps.list()``. The smart-dispatch list call returns one
199
+ bound model per VPS service regardless of backend, so Proxmox and
200
+ Cloud entries appear in the same table — the ``backend`` column
201
+ disambiguates.
202
+
203
+ Filters (``--status`` / ``--backend``) run client-side. The list
204
+ is small enough on real accounts (single-digit to low-double-
205
+ digit count) that the cost of pulling everything once and
206
+ filtering in Python is negligible.
207
+ """
208
+ state = from_typer_context(typer_ctx)
209
+ fmt = resolve_output(state, output)
210
+
211
+ if backend is not None and backend.lower() not in ("proxmox", "cloud"):
212
+ error(f"--backend must be 'proxmox' or 'cloud', got: {backend!r}")
213
+ raise typer.Exit(code=1)
214
+
215
+ with make_client_or_exit(state) as client:
216
+ try:
217
+ vpss = client.vps.list()
218
+ except ApiError as exc:
219
+ _exit_on_api_error(exc)
220
+
221
+ # Client-side filtering. Status match is case-insensitive substring
222
+ # so users can pass "act" / "active" / "ACTIVE" interchangeably.
223
+ if status is not None:
224
+ needle = status.lower()
225
+ vpss = [v for v in vpss if v.service.status.lower().find(needle) != -1]
226
+ if backend is not None:
227
+ wanted = backend.lower()
228
+ vpss = [v for v in vpss if v.backend == wanted]
229
+
230
+ if not vpss:
231
+ if status or backend:
232
+ filters = []
233
+ if status:
234
+ filters.append(f"status~={status!r}")
235
+ if backend:
236
+ filters.append(f"backend={backend!r}")
237
+ typer.echo(f"No VPS services match the filter: {', '.join(filters)}.")
238
+ else:
239
+ typer.echo("No VPS services on this account.")
240
+ return
241
+
242
+ rows = [_vps_to_row(v) for v in vpss]
243
+ title = f"VPS services ({len(rows)}"
244
+ if status or backend:
245
+ bits = []
246
+ if status:
247
+ bits.append(f"status~={status!r}")
248
+ if backend:
249
+ bits.append(f"backend={backend!r}")
250
+ title += f" matching {', '.join(bits)}"
251
+ title += ")"
252
+ print_table(title, rows, columns=_LIST_COLUMNS, fmt=fmt)
253
+
254
+
255
+ # ── vps show ────────────────────────────────────────────────────────
256
+
257
+
258
+ @app.command("show")
259
+ def show(
260
+ typer_ctx: typer.Context,
261
+ service_id: int = typer.Argument(
262
+ ...,
263
+ help="Service id (the same id returned by `impreza account services`).",
264
+ ),
265
+ output: OutputFormat | None = typer.Option(
266
+ None,
267
+ "--output",
268
+ "-o",
269
+ help="Output format. Overrides the global --output flag.",
270
+ case_sensitive=False,
271
+ ),
272
+ ) -> None:
273
+ """Show full detail for a single VPS service.
274
+
275
+ Wraps ``c.vps.get(id)`` and renders the underlying Service
276
+ snapshot — service id / domain / backend / status / product /
277
+ billing cycle / amount / dedicated IP / next due date.
278
+
279
+ For live power state / metrics, use ``impreza vps status <id>``.
280
+ """
281
+ state = from_typer_context(typer_ctx)
282
+ fmt = resolve_output(state, output)
283
+
284
+ with make_client_or_exit(state) as client:
285
+ try:
286
+ vps = client.vps.get(service_id)
287
+ except ResourceNotFound:
288
+ error(f"VPS service {service_id} not found on this account.")
289
+ raise typer.Exit(code=1) from None
290
+ except InvalidRequest as exc:
291
+ # InvalidRequest includes "service exists but is not a VPS"
292
+ # (NOT_A_VPS code) — pass the message through, the SDK
293
+ # generates a friendly hint already.
294
+ _exit_on_api_error(exc)
295
+ except ApiError as exc:
296
+ _exit_on_api_error(exc)
297
+
298
+ s = vps.service
299
+ data: dict[str, Any] = {
300
+ "id": s.id,
301
+ "domain": s.domain,
302
+ "backend": vps.backend,
303
+ "status": s.status,
304
+ "product": s.product,
305
+ "product_group": s.product_group,
306
+ "billing_cycle": s.billing_cycle,
307
+ "amount": (
308
+ f"{s.amount:.2f}"
309
+ if fmt is OutputFormat.TABLE
310
+ else s.amount
311
+ ),
312
+ "dedicated_ip": s.dedicated_ip,
313
+ "registered_at": s.registered_at,
314
+ "next_due": s.next_due,
315
+ }
316
+ print_dict(f"VPS {service_id}", data, fmt=fmt)
317
+
318
+
319
+ # ── vps status ──────────────────────────────────────────────────────
320
+
321
+
322
+ @app.command("status")
323
+ def status_cmd(
324
+ typer_ctx: typer.Context,
325
+ service_id: int = typer.Argument(..., help="Service id."),
326
+ output: OutputFormat | None = typer.Option(
327
+ None,
328
+ "--output",
329
+ "-o",
330
+ help="Output format. Overrides the global --output flag.",
331
+ case_sensitive=False,
332
+ ),
333
+ ) -> None:
334
+ """Show live power state and runtime metrics.
335
+
336
+ Wraps ``vps.status()`` after a single ``c.vps.get(id)`` lookup.
337
+ Proxmox returns the full set (power state, CPU, memory, uptime);
338
+ Cloud only populates ``power_state`` (CPU / memory / uptime stay
339
+ None). Table mode formats memory as MB / GB with units and
340
+ uptime as ``Xd HHh MMm``; JSON emits raw bytes / seconds for
341
+ piping.
342
+ """
343
+ state = from_typer_context(typer_ctx)
344
+ fmt = resolve_output(state, output)
345
+
346
+ with make_client_or_exit(state) as client:
347
+ try:
348
+ vps = client.vps.get(service_id)
349
+ st: VpsStatus = vps.status()
350
+ except ResourceNotFound:
351
+ error(f"VPS service {service_id} not found on this account.")
352
+ raise typer.Exit(code=1) from None
353
+ except InvalidRequest as exc:
354
+ _exit_on_api_error(exc)
355
+ except ApiError as exc:
356
+ _exit_on_api_error(exc)
357
+
358
+ if fmt is OutputFormat.TABLE:
359
+ # Compute memory percentage when both bytes are present.
360
+ mem_pct = (
361
+ f" ({st.memory_used / st.memory_total * 100:.1f}%)"
362
+ if st.memory_used is not None and st.memory_total
363
+ else ""
364
+ )
365
+ memory_str = (
366
+ f"{_format_bytes(st.memory_used)} / "
367
+ f"{_format_bytes(st.memory_total)}{mem_pct}"
368
+ )
369
+ data: dict[str, Any] = {
370
+ "service_id": service_id,
371
+ "backend": vps.backend,
372
+ "power_state": st.power_state,
373
+ "cpu_usage": _format_cpu_usage(st.cpu_usage),
374
+ "memory": memory_str,
375
+ "uptime": _format_uptime(st.uptime),
376
+ }
377
+ else:
378
+ # JSON / YAML: raw values, no formatting — consumers can
379
+ # transform as they wish.
380
+ data = {
381
+ "service_id": service_id,
382
+ "backend": vps.backend,
383
+ "power_state": st.power_state,
384
+ "cpu_usage": st.cpu_usage,
385
+ "memory_used": st.memory_used,
386
+ "memory_total": st.memory_total,
387
+ "uptime": st.uptime,
388
+ }
389
+
390
+ print_dict(f"VPS {service_id} — status", data, fmt=fmt)
391
+
392
+
393
+ # ── vps power (Phase 3.2) ───────────────────────────────────────────
394
+
395
+
396
+ # Power verbs all share the same plumbing: resolve service id →
397
+ # call the SDK dispatcher → print a tiny confirmation line. The
398
+ # only divergence is whether to confirm first (``stop`` only) and
399
+ # the human-readable verb in the success message.
400
+ _PowerAction = Literal["start", "stop", "reboot", "shutdown"]
401
+
402
+ _POWER_SUCCESS = {
403
+ "start": "Boot request sent for VPS {id}.",
404
+ "stop": "Force-stop request sent for VPS {id}.",
405
+ "reboot": "Reboot request sent for VPS {id}.",
406
+ "shutdown": "Graceful shutdown request sent for VPS {id}.",
407
+ }
408
+
409
+
410
+ def _run_power(state: Any, service_id: int, action: _PowerAction) -> None:
411
+ """Shared body for the four power verbs.
412
+
413
+ Resolves the service id via the SDK dispatcher (which performs
414
+ the backend lookup + URL normalisation), then prints a one-line
415
+ confirmation. Upstream may take a few seconds to actually
416
+ transition state — the SDK call returns when the API accepts
417
+ the request. Polling for the new ``power_state`` is up to the
418
+ caller (use ``impreza vps status <id>`` to check).
419
+ """
420
+ with make_client_or_exit(state) as client:
421
+ try:
422
+ getattr(client.vps, action)(service_id)
423
+ except ResourceNotFound:
424
+ error(f"VPS service {service_id} not found on this account.")
425
+ raise typer.Exit(code=1) from None
426
+ except InvalidRequest as exc:
427
+ # Service exists but isn't a VPS (NOT_A_VPS code) — the
428
+ # SDK message already explains. Pass through.
429
+ _exit_on_api_error(exc)
430
+ except ApiError as exc:
431
+ _exit_on_api_error(exc)
432
+ # Power request "sent", not "done" — the upstream may take a few
433
+ # seconds to actually transition state, so info() (cyan) reads
434
+ # more honestly than success() (green-bold).
435
+ info(_POWER_SUCCESS[action].format(id=service_id))
436
+
437
+
438
+ @app.command("start")
439
+ def start(
440
+ typer_ctx: typer.Context,
441
+ service_id: int = typer.Argument(..., help="Service id."),
442
+ ) -> None:
443
+ """Boot a stopped VPS.
444
+
445
+ Wraps ``c.vps.start(id)`` from the smart-dispatch surface.
446
+ Proxmox hits ``POST /vps/proxmox/{id}/start``; Cloud hits
447
+ ``POST /vps/cloud/{id}/boot`` (renamed upstream, hidden by the
448
+ SDK dispatcher). Fire-and-forget on the HTTP wire — the API
449
+ returns when the request is accepted, not when the guest is
450
+ fully up. Re-check with ``impreza vps status <id>``.
451
+ """
452
+ state = from_typer_context(typer_ctx)
453
+ _run_power(state, service_id, "start")
454
+
455
+
456
+ @app.command("reboot")
457
+ def reboot(
458
+ typer_ctx: typer.Context,
459
+ service_id: int = typer.Argument(..., help="Service id."),
460
+ ) -> None:
461
+ """Reboot a running VPS.
462
+
463
+ Wraps ``c.vps.reboot(id)``. Both backends hit
464
+ ``POST /vps/{backend}/{id}/reboot`` — no name divergence.
465
+ Behaves as a guest-OS reboot on Proxmox and Cloud alike
466
+ (graceful where supported, hard cycle if the guest doesn't
467
+ respond in time, upstream-dependent).
468
+ """
469
+ state = from_typer_context(typer_ctx)
470
+ _run_power(state, service_id, "reboot")
471
+
472
+
473
+ @app.command("shutdown")
474
+ def shutdown(
475
+ typer_ctx: typer.Context,
476
+ service_id: int = typer.Argument(..., help="Service id."),
477
+ ) -> None:
478
+ """Graceful ACPI shutdown.
479
+
480
+ Wraps ``c.vps.shutdown(id)``. The guest OS receives a power-
481
+ button signal and is expected to shut down cleanly. Safe to run
482
+ without confirmation — no data loss when the guest cooperates.
483
+ If the guest ignores the signal, use ``impreza vps stop`` (with
484
+ ``--yes`` to acknowledge the corruption risk).
485
+ """
486
+ state = from_typer_context(typer_ctx)
487
+ _run_power(state, service_id, "shutdown")
488
+
489
+
490
+ @app.command("stop")
491
+ def stop(
492
+ typer_ctx: typer.Context,
493
+ service_id: int = typer.Argument(..., help="Service id."),
494
+ yes: bool = typer.Option(
495
+ False, "--yes", "-y", help="Skip the corruption-risk confirmation prompt."
496
+ ),
497
+ ) -> None:
498
+ """Force-stop (hard power-off). Equivalent to pulling the plug —
499
+ unwritten guest data may be lost.
500
+
501
+ Wraps ``c.vps.stop(id)``. Proxmox hits
502
+ ``POST /vps/proxmox/{id}/stop``; Cloud hits the renamed
503
+ ``POST /vps/cloud/{id}/poweroff``. Prefer ``shutdown`` whenever
504
+ the guest is responsive — only reach for ``stop`` when the OS
505
+ has hung and an ACPI signal won't get through.
506
+ """
507
+ state = from_typer_context(typer_ctx)
508
+ confirm_or_exit(
509
+ f"Force-stopping VPS {service_id} cuts power immediately. "
510
+ "Unwritten guest data may be lost — prefer `impreza vps "
511
+ "shutdown` when the guest is responsive.",
512
+ yes=yes,
513
+ )
514
+ _run_power(state, service_id, "stop")
515
+
516
+
517
+ # ── vps set-hostname / set-password (Phase 3.3) ─────────────────────
518
+
519
+
520
+ @app.command("set-hostname")
521
+ def set_hostname(
522
+ typer_ctx: typer.Context,
523
+ service_id: int = typer.Argument(..., help="Service id."),
524
+ hostname: str = typer.Argument(..., help="New hostname for the VPS."),
525
+ ) -> None:
526
+ """Change the VPS hostname.
527
+
528
+ Wraps ``vps.set_hostname(hostname)``. Both backends accept this
529
+ surface. The change is applied immediately upstream — no
530
+ confirmation prompt; rerunning with the previous value rolls
531
+ back trivially.
532
+ """
533
+ state = from_typer_context(typer_ctx)
534
+ with make_client_or_exit(state) as client:
535
+ vps = _resolve_vps_or_exit(client, service_id)
536
+ try:
537
+ vps.set_hostname(hostname)
538
+ except ApiError as exc:
539
+ _exit_on_api_error(exc)
540
+ success(f"Hostname for VPS {service_id} set to {hostname!r}.")
541
+
542
+
543
+ @app.command("set-password")
544
+ def set_password(
545
+ typer_ctx: typer.Context,
546
+ service_id: int = typer.Argument(..., help="Service id."),
547
+ password: str = typer.Option(
548
+ ...,
549
+ "--password", "-p",
550
+ prompt="New root/admin password",
551
+ hide_input=True,
552
+ confirmation_prompt=True,
553
+ help=(
554
+ "New root/admin password. Prompts hidden (with "
555
+ "confirmation) when omitted. Passing on the command line "
556
+ "puts the password in shell history — prefer the prompt."
557
+ ),
558
+ ),
559
+ ) -> None:
560
+ """Reset the root/admin password.
561
+
562
+ Wraps ``vps.set_password(password)``. Both backends supported.
563
+ Active SSH sessions are not killed — the new password takes
564
+ effect on next login. If the new password fails to apply
565
+ (registrar-side complexity rules etc.), the upstream returns a
566
+ 400 and the SDK raises :class:`InvalidRequest`.
567
+ """
568
+ state = from_typer_context(typer_ctx)
569
+ with make_client_or_exit(state) as client:
570
+ vps = _resolve_vps_or_exit(client, service_id)
571
+ try:
572
+ vps.set_password(password)
573
+ except ApiError as exc:
574
+ _exit_on_api_error(exc)
575
+ success(f"Password updated for VPS {service_id}.")
576
+
577
+
578
+ # ── vps reinstall (Phase 3.3, with --wait) ──────────────────────────
579
+
580
+
581
+ @app.command("reinstall")
582
+ def reinstall(
583
+ typer_ctx: typer.Context,
584
+ service_id: int = typer.Argument(..., help="Service id."),
585
+ template: str = typer.Option(
586
+ ...,
587
+ "--template", "-t",
588
+ help=(
589
+ "OS template identifier. Proxmox: see "
590
+ "`vps.templates()` once 3.4 ships; for now use the value "
591
+ "displayed in your Impreza Account (e.g. 'debian-12'). Cloud: "
592
+ "image id from `vps cloud images list` (Phase 3.5)."
593
+ ),
594
+ ),
595
+ password: str = typer.Option(
596
+ ...,
597
+ "--password", "-p",
598
+ prompt="New root password for the reinstalled OS",
599
+ hide_input=True,
600
+ confirmation_prompt=True,
601
+ help="Root/admin password for the freshly reinstalled OS.",
602
+ ),
603
+ yes: bool = typer.Option(
604
+ False, "--yes", "-y", help="Skip the data-loss confirmation prompt."
605
+ ),
606
+ wait: bool = typer.Option(
607
+ False,
608
+ "--wait",
609
+ help=(
610
+ "Block until the Proxmox queue reports the reinstall as "
611
+ "complete. No-op on Cloud (upstream is synchronous)."
612
+ ),
613
+ ),
614
+ timeout: int = typer.Option(
615
+ 600,
616
+ "--timeout",
617
+ help="Max seconds to wait when --wait is set. Default 600 (10 min).",
618
+ ),
619
+ ) -> None:
620
+ """Reinstall the operating system. **Destructive** — wipes the
621
+ disk and any data on it.
622
+
623
+ Wraps ``vps.reinstall(template=..., password=..., confirm=True)``.
624
+ Behaviour diverges by backend:
625
+
626
+ * **Proxmox**: returns an :class:`Operation` future. Without
627
+ ``--wait`` the CLI prints the operation uuid and returns
628
+ immediately; with ``--wait`` it polls until the upstream
629
+ queue settles (or ``--timeout`` elapses).
630
+ * **Cloud**: synchronous at the Cloud backend. The SDK
631
+ returns ``None`` — the CLI prints a completion line and
632
+ ``--wait`` is silently a no-op.
633
+ """
634
+ state = from_typer_context(typer_ctx)
635
+ confirm_or_exit(
636
+ f"Reinstalling VPS {service_id} with template {template!r} "
637
+ "wipes the disk. All data on this VPS will be lost.",
638
+ yes=yes,
639
+ )
640
+ with make_client_or_exit(state) as client:
641
+ vps = _resolve_vps_or_exit(client, service_id)
642
+ try:
643
+ op = vps.reinstall(template=template, password=password, confirm=True)
644
+ except ApiError as exc:
645
+ _exit_on_api_error(exc)
646
+ return # unreachable
647
+
648
+ if op is None:
649
+ # Cloud — synchronous from the SDK's perspective.
650
+ success(
651
+ f"Reinstall completed synchronously for VPS {service_id} "
652
+ f"(template {template!r})."
653
+ )
654
+ return
655
+
656
+ # Proxmox — Operation future. Stay inside the `with` so the
657
+ # poll-loop's `op.refresh()` calls reuse the still-open HTTP
658
+ # client.
659
+ if not wait:
660
+ info(
661
+ f"Reinstall queued for VPS {service_id} (template {template!r}). "
662
+ f"Operation uuid: {op.uuid}"
663
+ )
664
+ return
665
+ _wait_for_operation(
666
+ op,
667
+ label=f"Reinstalling VPS {service_id}",
668
+ timeout=timeout,
669
+ )
670
+
671
+
672
+ # ── vps migrate (Phase 3.3, Proxmox-only, with --wait) ──────────────
673
+
674
+
675
+ @app.command("migrate")
676
+ def migrate(
677
+ typer_ctx: typer.Context,
678
+ service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
679
+ target: str = typer.Option(
680
+ ...,
681
+ "--target", "-t",
682
+ help=(
683
+ "Migration destination — a server_id or group_id string. "
684
+ "Available targets come from `vps.locations()` once 3.4 "
685
+ "ships; your Impreza Account also lists valid ids."
686
+ ),
687
+ ),
688
+ yes: bool = typer.Option(
689
+ False, "--yes", "-y", help="Skip the downtime confirmation prompt."
690
+ ),
691
+ wait: bool = typer.Option(
692
+ False,
693
+ "--wait",
694
+ help="Block until the Proxmox queue reports the migration as complete.",
695
+ ),
696
+ timeout: int = typer.Option(
697
+ 1800,
698
+ "--timeout",
699
+ help="Max seconds to wait when --wait is set. Default 1800 (30 min).",
700
+ ),
701
+ ) -> None:
702
+ """Migrate the VPS to a different physical host. **Proxmox-only.**
703
+
704
+ Wraps ``vps.migrate(target=...)`` on the Proxmox bound model.
705
+ Returns an :class:`Operation` future. Migration is a long
706
+ upstream job (typically minutes, occasionally tens of minutes
707
+ for large disks) and the VPS may experience downtime while the
708
+ transfer runs.
709
+ """
710
+ state = from_typer_context(typer_ctx)
711
+ confirm_or_exit(
712
+ f"Migrating VPS {service_id} to target {target!r} can cause "
713
+ "downtime while the disk is transferred (typically several "
714
+ "minutes for small VMs).",
715
+ yes=yes,
716
+ )
717
+ with make_client_or_exit(state) as client:
718
+ vps = _resolve_vps_or_exit(client, service_id)
719
+ try:
720
+ op = vps.migrate(target=target)
721
+ except BackendNotSupported:
722
+ error(
723
+ f"VPS {service_id} is on the Cloud backend — migrate is "
724
+ "Proxmox-only."
725
+ )
726
+ raise typer.Exit(code=1) from None
727
+ except ApiError as exc:
728
+ _exit_on_api_error(exc)
729
+ return # unreachable
730
+
731
+ if not wait:
732
+ info(
733
+ f"Migration queued for VPS {service_id} → {target!r}. "
734
+ f"Operation uuid: {op.uuid}"
735
+ )
736
+ return
737
+ _wait_for_operation(
738
+ op,
739
+ label=f"Migrating VPS {service_id} to {target!r}",
740
+ timeout=timeout,
741
+ )
742
+
743
+
744
+ # No `suspend` / `unsuspend` commands here. The server-side endpoints
745
+ # /vps/proxmox/{id}/suspend and /unsuspend were retired on 2026-05-11.
746
+ # Service suspension is a billing-state operation staff own — overdue
747
+ # invoices auto-resolve on payment, abuse holds resolve manually. To
748
+ # pause a guest, use `vps shutdown`; to wind down a service, use
749
+ # `vps cancel` (which submits an AddCancelRequest).
750
+
751
+
752
+ # ── vps cancel (Phase 3.3) ──────────────────────────────────────────
753
+
754
+
755
+ _CANCEL_TYPES = {"Immediate", "End of Billing Period"}
756
+
757
+
758
+ @app.command("cancel")
759
+ def cancel(
760
+ typer_ctx: typer.Context,
761
+ service_id: int = typer.Argument(..., help="Service id."),
762
+ cancel_type: str = typer.Option(
763
+ "End of Billing Period",
764
+ "--type", "-t",
765
+ help=(
766
+ "'Immediate' (terminate now, lose prepaid time) or "
767
+ "'End of Billing Period' (keep until next due date). "
768
+ "Default: 'End of Billing Period' so you don't accidentally "
769
+ "throw away prepaid days."
770
+ ),
771
+ ),
772
+ reason: str | None = typer.Option(
773
+ None, "--reason", "-r", help="Optional cancellation reason for billing."
774
+ ),
775
+ yes: bool = typer.Option(
776
+ False, "--yes", "-y", help="Skip the service-termination confirmation prompt."
777
+ ),
778
+ ) -> None:
779
+ """Submit a cancellation request for the service. **Permanent.**
780
+
781
+ Wraps ``vps.cancel(type=..., reason=...)``. Both backends. The
782
+ type defaults to ``"End of Billing Period"`` — that way the user
783
+ keeps the VPS up until the next renewal date instead of losing
784
+ prepaid time. Pass ``--type Immediate`` to terminate right away
785
+ (the prepaid balance is forfeit).
786
+ """
787
+ if cancel_type not in _CANCEL_TYPES:
788
+ error(
789
+ f"--type must be one of {sorted(_CANCEL_TYPES)!r}, "
790
+ f"got: {cancel_type!r}"
791
+ )
792
+ raise typer.Exit(code=1)
793
+
794
+ state = from_typer_context(typer_ctx)
795
+ blast = (
796
+ "immediately terminates the service (prepaid time is forfeit)"
797
+ if cancel_type == "Immediate"
798
+ else "schedules termination at the end of the current billing period"
799
+ )
800
+ confirm_or_exit(
801
+ f"Cancelling VPS {service_id} ({cancel_type!r}) {blast}.",
802
+ yes=yes,
803
+ )
804
+ with make_client_or_exit(state) as client:
805
+ vps = _resolve_vps_or_exit(client, service_id)
806
+ try:
807
+ vps.cancel(type=cancel_type, reason=reason)
808
+ except ApiError as exc:
809
+ _exit_on_api_error(exc)
810
+ success(
811
+ f"Cancellation submitted for VPS {service_id} ({cancel_type})."
812
+ )