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,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}.")
|