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.
- impreza_cli/__init__.py +14 -0
- impreza_cli/commands/__init__.py +7 -0
- impreza_cli/commands/_helpers.py +128 -0
- impreza_cli/commands/account.py +576 -0
- impreza_cli/commands/catalog.py +270 -0
- impreza_cli/commands/context.py +232 -0
- impreza_cli/commands/doctor.py +427 -0
- impreza_cli/commands/domain.py +858 -0
- impreza_cli/commands/invoice.py +198 -0
- impreza_cli/commands/key.py +104 -0
- impreza_cli/commands/orders.py +478 -0
- impreza_cli/commands/services.py +100 -0
- impreza_cli/commands/vps.py +812 -0
- impreza_cli/commands/vps_cloud.py +865 -0
- impreza_cli/commands/vps_proxmox.py +727 -0
- impreza_cli/commands/webhooks.py +483 -0
- impreza_cli/config.py +405 -0
- impreza_cli/main.py +100 -0
- impreza_cli/output.py +207 -0
- impreza_cli/sdk.py +97 -0
- impreza_cli/state.py +94 -0
- impreza_cli-0.3.0.dist-info/METADATA +296 -0
- impreza_cli-0.3.0.dist-info/RECORD +26 -0
- impreza_cli-0.3.0.dist-info/WHEEL +5 -0
- impreza_cli-0.3.0.dist-info/entry_points.txt +2 -0
- impreza_cli-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|