impreza-cli 0.3.1__tar.gz → 0.4.0__tar.gz
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-0.3.1 → impreza_cli-0.4.0}/PKG-INFO +3 -3
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/README.md +2 -2
- impreza_cli-0.4.0/impreza_cli/commands/dedicated.py +466 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/vps_cloud.py +30 -123
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/main.py +2 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli.egg-info/PKG-INFO +3 -3
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli.egg-info/SOURCES.txt +1 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_3_5_smoke.py +18 -68
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_vps_cloud_commands.py +14 -57
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/__init__.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/__init__.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/_helpers.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/account.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/catalog.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/context.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/doctor.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/domain.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/invoice.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/key.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/orders.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/services.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/vps.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/vps_proxmox.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/commands/webhooks.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/config.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/output.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/sdk.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli/state.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli.egg-info/dependency_links.txt +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli.egg-info/entry_points.txt +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli.egg-info/requires.txt +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/impreza_cli.egg-info/top_level.txt +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/pyproject.toml +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/setup.cfg +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/.gitkeep +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/__init__.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/conftest.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_account_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_account_topup_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_catalog_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_config.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_context_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_doctor_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_domain_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_invoice_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_key_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_orders_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_2_2_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_2_3_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_2_4_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_2_5_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_2_6_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_3_1_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_3_2_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_3_3_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_3_4_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_3_6_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_phase_3_7_smoke.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_services_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_vps_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_vps_proxmox_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_webhooks_commands.py +0 -0
- {impreza_cli-0.3.1 → impreza_cli-0.4.0}/tests/test_yaml_output.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: impreza-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Official command-line interface for the Impreza Host public REST API
|
|
5
5
|
Author-email: Impreza Host <support@imprezahost.com>
|
|
6
6
|
License: MIT
|
|
@@ -142,10 +142,10 @@ to see the full subcommand list, or `impreza <group> <command>
|
|
|
142
142
|
| `account` | `info / balance / services / topup / topup-status` | Profile + balance + services + crypto top-up |
|
|
143
143
|
| `catalog` | `products / product / product-groups / tlds` | Pre-purchase discovery |
|
|
144
144
|
| `domain` | `show / check / pricing / register / transfer / set-nameservers / lock / unlock / id-protection / raa-verify / gdpr-auth / transfer-approval` + `domain dns list / add / update / delete / activate` | Domain registrations + full DNS CRUD |
|
|
145
|
-
| `vps` | `list / show / status / start / stop / reboot / shutdown / set-hostname / set-password / reinstall / migrate / cancel` + `vps proxmox snapshots / backups / backup-schedules / network` + `vps cloud images / rescue / iso /
|
|
145
|
+
| `vps` | `list / show / status / start / stop / reboot / shutdown / set-hostname / set-password / reinstall / migrate / cancel` + `vps proxmox snapshots / backups / backup-schedules / network` + `vps cloud images / rescue / iso / ssh-keys / vnc / vnc-password / resize / boot-order / ipv6` | Cross-backend (Proxmox + Cloud) VPS with smart dispatch |
|
|
146
146
|
| `order` | `list / show / create / upgrade` | Submit / browse product orders |
|
|
147
147
|
| `service` | `cancel` | Submit cancellation request (any service) |
|
|
148
|
-
| `webhook` | `list / show / create / update / delete / rotate-secret / deliveries / event-types` | Webhook subscription management + delivery history |
|
|
148
|
+
| `webhook` | `list / show / create / update / delete / rotate-secret / deliveries / event-types` | Webhook subscription management + delivery history. Event-payload contract documented in [`../openapi/asyncapi.yaml`](../openapi/asyncapi.yaml). |
|
|
149
149
|
| `invoice` | `list / show` | Invoices with line items + transactions |
|
|
150
150
|
| `key` | `whoami` | Active API key identity + IP whitelist |
|
|
151
151
|
|
|
@@ -101,10 +101,10 @@ to see the full subcommand list, or `impreza <group> <command>
|
|
|
101
101
|
| `account` | `info / balance / services / topup / topup-status` | Profile + balance + services + crypto top-up |
|
|
102
102
|
| `catalog` | `products / product / product-groups / tlds` | Pre-purchase discovery |
|
|
103
103
|
| `domain` | `show / check / pricing / register / transfer / set-nameservers / lock / unlock / id-protection / raa-verify / gdpr-auth / transfer-approval` + `domain dns list / add / update / delete / activate` | Domain registrations + full DNS CRUD |
|
|
104
|
-
| `vps` | `list / show / status / start / stop / reboot / shutdown / set-hostname / set-password / reinstall / migrate / cancel` + `vps proxmox snapshots / backups / backup-schedules / network` + `vps cloud images / rescue / iso /
|
|
104
|
+
| `vps` | `list / show / status / start / stop / reboot / shutdown / set-hostname / set-password / reinstall / migrate / cancel` + `vps proxmox snapshots / backups / backup-schedules / network` + `vps cloud images / rescue / iso / ssh-keys / vnc / vnc-password / resize / boot-order / ipv6` | Cross-backend (Proxmox + Cloud) VPS with smart dispatch |
|
|
105
105
|
| `order` | `list / show / create / upgrade` | Submit / browse product orders |
|
|
106
106
|
| `service` | `cancel` | Submit cancellation request (any service) |
|
|
107
|
-
| `webhook` | `list / show / create / update / delete / rotate-secret / deliveries / event-types` | Webhook subscription management + delivery history |
|
|
107
|
+
| `webhook` | `list / show / create / update / delete / rotate-secret / deliveries / event-types` | Webhook subscription management + delivery history. Event-payload contract documented in [`../openapi/asyncapi.yaml`](../openapi/asyncapi.yaml). |
|
|
108
108
|
| `invoice` | `list / show` | Invoices with line items + transactions |
|
|
109
109
|
| `key` | `whoami` | Active API key identity + IP whitelist |
|
|
110
110
|
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""``impreza dedicated`` subcommand surface — Phase 8.
|
|
2
|
+
|
|
3
|
+
Twenty verbs over the :class:`~impreza.DedicatedResource` shipped in
|
|
4
|
+
the SDK alongside this CLI. The public ``/dedicated/*`` namespace is
|
|
5
|
+
vendor-agnostic: operations are gated by per-service capabilities,
|
|
6
|
+
and capability-gated endpoints return ``NOT_SUPPORTED`` when the
|
|
7
|
+
underlying service doesn't advertise the feature. Always inspect
|
|
8
|
+
``dedicated capabilities <id>`` first when scripting.
|
|
9
|
+
|
|
10
|
+
Discovery & state:
|
|
11
|
+
* ``list`` / ``show <id>`` / ``capabilities <id>`` / ``status <id>``
|
|
12
|
+
* ``ips <id>`` — IPs with current PTR
|
|
13
|
+
* ``os-images <id>`` — OS images available for reinstall
|
|
14
|
+
|
|
15
|
+
Power:
|
|
16
|
+
* ``start <id>`` / ``shutdown <id>`` / ``reboot <id>``
|
|
17
|
+
|
|
18
|
+
rDNS:
|
|
19
|
+
* ``set-rdns <id> --ip --hostname``
|
|
20
|
+
* ``reset-rdns <id>``
|
|
21
|
+
|
|
22
|
+
KVM / IPMI:
|
|
23
|
+
* ``kvm <id>`` — current access info
|
|
24
|
+
* ``enable-kvm <id>`` — the SDK / server inject the caller's public
|
|
25
|
+
IP as the session-binding IP when the service needs it.
|
|
26
|
+
* ``disable-kvm <id>``
|
|
27
|
+
|
|
28
|
+
Firewall (requires ``firewall`` capability):
|
|
29
|
+
* ``firewall <id>`` / ``ddos-logs <id>``
|
|
30
|
+
* ``set-firewall <id> --ip --state --sensitivity``
|
|
31
|
+
|
|
32
|
+
Bandwidth (requires ``bandwidth`` capability):
|
|
33
|
+
* ``bandwidth <id> --type --scale``
|
|
34
|
+
|
|
35
|
+
VPN credentials (requires ``vpn`` capability):
|
|
36
|
+
* ``vpn <id>``
|
|
37
|
+
|
|
38
|
+
Reinstall (destructive — wipes ALL data):
|
|
39
|
+
* ``reinstall <id> --os-id --password --confirm [--yes]``
|
|
40
|
+
Requires both the SDK's ``confirm=True`` flag and the
|
|
41
|
+
``X-Impreza-Confirm: WIPE`` header (the SDK injects the header
|
|
42
|
+
when ``confirm=True``). The CLI also prompts on a TTY unless
|
|
43
|
+
``--yes`` is passed.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from typing import Any
|
|
49
|
+
|
|
50
|
+
import typer
|
|
51
|
+
from impreza.exceptions import ApiError
|
|
52
|
+
|
|
53
|
+
from ..output import OutputFormat, error, info, print_dict, print_table, success
|
|
54
|
+
from ..sdk import make_client_or_exit
|
|
55
|
+
from ..state import confirm_or_exit, from_typer_context, resolve_output
|
|
56
|
+
from ._helpers import exit_on_api_error
|
|
57
|
+
|
|
58
|
+
app = typer.Typer(
|
|
59
|
+
name="dedicated",
|
|
60
|
+
help="Manage dedicated servers. Operations gated by per-service capabilities.",
|
|
61
|
+
no_args_is_help=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── helpers ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _emit(value: Any, *, ctx: typer.Context) -> None:
|
|
69
|
+
"""Render a heterogeneous payload (dict, list[dict], or scalar).
|
|
70
|
+
|
|
71
|
+
The /dedicated/* payloads vary by service — there's no single
|
|
72
|
+
typed projection that fits every endpoint. Tables turn into flat
|
|
73
|
+
key=value dumps; JSON / YAML pass the structure through.
|
|
74
|
+
"""
|
|
75
|
+
state = from_typer_context(ctx)
|
|
76
|
+
fmt = resolve_output(state, override=None)
|
|
77
|
+
if fmt is OutputFormat.TABLE and isinstance(value, dict):
|
|
78
|
+
print_dict(value)
|
|
79
|
+
elif fmt is OutputFormat.TABLE and isinstance(value, list) and value and isinstance(value[0], dict):
|
|
80
|
+
# Use the first row's keys as the column order so the table
|
|
81
|
+
# is stable across runs.
|
|
82
|
+
columns = list(value[0].keys())
|
|
83
|
+
print_table(columns, value)
|
|
84
|
+
else:
|
|
85
|
+
print_dict(value) if isinstance(value, dict) else typer.echo(_serialize(value, fmt))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _serialize(value: Any, fmt: OutputFormat) -> str:
|
|
89
|
+
"""Format a non-dict value for JSON / YAML output."""
|
|
90
|
+
import json
|
|
91
|
+
|
|
92
|
+
if fmt is OutputFormat.YAML:
|
|
93
|
+
try:
|
|
94
|
+
import yaml # type: ignore[import-not-found]
|
|
95
|
+
|
|
96
|
+
return yaml.safe_dump(value, sort_keys=False).rstrip()
|
|
97
|
+
except ImportError: # pragma: no cover
|
|
98
|
+
return json.dumps(value, indent=2, ensure_ascii=False)
|
|
99
|
+
return json.dumps(value, indent=2, ensure_ascii=False)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── discovery ──────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("list")
|
|
106
|
+
def cmd_list(ctx: typer.Context) -> None:
|
|
107
|
+
"""List every dedicated server on the account."""
|
|
108
|
+
client = make_client_or_exit(ctx)
|
|
109
|
+
try:
|
|
110
|
+
items = client.dedicated.list()
|
|
111
|
+
except ApiError as exc:
|
|
112
|
+
exit_on_api_error(exc)
|
|
113
|
+
return
|
|
114
|
+
if not items:
|
|
115
|
+
info("No dedicated servers on this account.")
|
|
116
|
+
return
|
|
117
|
+
fmt = resolve_output(from_typer_context(ctx), override=None)
|
|
118
|
+
if fmt is OutputFormat.TABLE:
|
|
119
|
+
columns = ["service_id", "domain", "ip", "status", "capabilities"]
|
|
120
|
+
rows = [
|
|
121
|
+
{
|
|
122
|
+
"service_id": item.get("service_id"),
|
|
123
|
+
"domain": item.get("domain", ""),
|
|
124
|
+
"ip": item.get("ip", ""),
|
|
125
|
+
"status": item.get("status", ""),
|
|
126
|
+
"capabilities": ",".join(item.get("capabilities") or []),
|
|
127
|
+
}
|
|
128
|
+
for item in items
|
|
129
|
+
]
|
|
130
|
+
print_table(columns, rows)
|
|
131
|
+
else:
|
|
132
|
+
_emit(items, ctx=ctx)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command("show")
|
|
136
|
+
def cmd_show(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
137
|
+
"""Show full details for a dedicated server."""
|
|
138
|
+
client = make_client_or_exit(ctx)
|
|
139
|
+
try:
|
|
140
|
+
data = client.dedicated.info(service_id)
|
|
141
|
+
except ApiError as exc:
|
|
142
|
+
exit_on_api_error(exc)
|
|
143
|
+
return
|
|
144
|
+
_emit(data, ctx=ctx)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("capabilities")
|
|
148
|
+
def cmd_capabilities(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
149
|
+
"""Show the capability list advertised by a dedicated server.
|
|
150
|
+
|
|
151
|
+
Call this before any capability-gated sub-command — firewall,
|
|
152
|
+
bandwidth, vpn, kvm, reinstall, power — so you don't waste a
|
|
153
|
+
round-trip on a NOT_SUPPORTED response.
|
|
154
|
+
"""
|
|
155
|
+
client = make_client_or_exit(ctx)
|
|
156
|
+
try:
|
|
157
|
+
data = client.dedicated.capabilities(service_id)
|
|
158
|
+
except ApiError as exc:
|
|
159
|
+
exit_on_api_error(exc)
|
|
160
|
+
return
|
|
161
|
+
_emit(data, ctx=ctx)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command("status")
|
|
165
|
+
def cmd_status(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
166
|
+
"""Show current power / provisioning state."""
|
|
167
|
+
client = make_client_or_exit(ctx)
|
|
168
|
+
try:
|
|
169
|
+
data = client.dedicated.status(service_id)
|
|
170
|
+
except ApiError as exc:
|
|
171
|
+
exit_on_api_error(exc)
|
|
172
|
+
return
|
|
173
|
+
_emit(data, ctx=ctx)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@app.command("ips")
|
|
177
|
+
def cmd_ips(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
178
|
+
"""List the IPs assigned to the server with current PTR."""
|
|
179
|
+
client = make_client_or_exit(ctx)
|
|
180
|
+
try:
|
|
181
|
+
data = client.dedicated.ips(service_id)
|
|
182
|
+
except ApiError as exc:
|
|
183
|
+
exit_on_api_error(exc)
|
|
184
|
+
return
|
|
185
|
+
_emit(data, ctx=ctx)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@app.command("os-images")
|
|
189
|
+
def cmd_os_images(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
190
|
+
"""List OS images available for reinstall."""
|
|
191
|
+
client = make_client_or_exit(ctx)
|
|
192
|
+
try:
|
|
193
|
+
data = client.dedicated.os_images(service_id)
|
|
194
|
+
except ApiError as exc:
|
|
195
|
+
exit_on_api_error(exc)
|
|
196
|
+
return
|
|
197
|
+
_emit(data, ctx=ctx)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── power ──────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command("start")
|
|
204
|
+
def cmd_start(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
205
|
+
"""Power on a dedicated server."""
|
|
206
|
+
client = make_client_or_exit(ctx)
|
|
207
|
+
try:
|
|
208
|
+
client.dedicated.start(service_id)
|
|
209
|
+
except ApiError as exc:
|
|
210
|
+
exit_on_api_error(exc)
|
|
211
|
+
return
|
|
212
|
+
success(f"Dedicated server {service_id} start signal sent.")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command("shutdown")
|
|
216
|
+
def cmd_shutdown(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
217
|
+
"""Graceful shutdown."""
|
|
218
|
+
client = make_client_or_exit(ctx)
|
|
219
|
+
try:
|
|
220
|
+
client.dedicated.shutdown(service_id)
|
|
221
|
+
except ApiError as exc:
|
|
222
|
+
exit_on_api_error(exc)
|
|
223
|
+
return
|
|
224
|
+
success(f"Dedicated server {service_id} shutdown signal sent.")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command("reboot")
|
|
228
|
+
def cmd_reboot(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
229
|
+
"""Reboot."""
|
|
230
|
+
client = make_client_or_exit(ctx)
|
|
231
|
+
try:
|
|
232
|
+
client.dedicated.reboot(service_id)
|
|
233
|
+
except ApiError as exc:
|
|
234
|
+
exit_on_api_error(exc)
|
|
235
|
+
return
|
|
236
|
+
success(f"Dedicated server {service_id} reboot signal sent.")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ── rDNS ───────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command("set-rdns")
|
|
243
|
+
def cmd_set_rdns(
|
|
244
|
+
ctx: typer.Context,
|
|
245
|
+
service_id: int = typer.Argument(...),
|
|
246
|
+
ip: str = typer.Option(..., "--ip", help="IP whose PTR you want to set."),
|
|
247
|
+
hostname: str = typer.Option(..., "--hostname", help="New PTR value."),
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Set the PTR for a single IP on a dedicated server.
|
|
250
|
+
|
|
251
|
+
Applied synchronously on most services. On services without an
|
|
252
|
+
automated rDNS path the response is ``{status: queued, ...}`` and
|
|
253
|
+
an operator on our side completes it within a few hours.
|
|
254
|
+
"""
|
|
255
|
+
client = make_client_or_exit(ctx)
|
|
256
|
+
try:
|
|
257
|
+
data = client.dedicated.set_rdns(service_id, ip, hostname)
|
|
258
|
+
except ApiError as exc:
|
|
259
|
+
exit_on_api_error(exc)
|
|
260
|
+
return
|
|
261
|
+
_emit(data, ctx=ctx)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@app.command("reset-rdns")
|
|
265
|
+
def cmd_reset_rdns(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
266
|
+
"""Reset every PTR back to the Impreza default (impreza.host pattern)."""
|
|
267
|
+
client = make_client_or_exit(ctx)
|
|
268
|
+
try:
|
|
269
|
+
data = client.dedicated.reset_rdns(service_id)
|
|
270
|
+
except ApiError as exc:
|
|
271
|
+
exit_on_api_error(exc)
|
|
272
|
+
return
|
|
273
|
+
_emit(data, ctx=ctx)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ── KVM ────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.command("kvm")
|
|
280
|
+
def cmd_kvm(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
281
|
+
"""Show current KVM / IPMI access info."""
|
|
282
|
+
client = make_client_or_exit(ctx)
|
|
283
|
+
try:
|
|
284
|
+
data = client.dedicated.kvm(service_id)
|
|
285
|
+
except ApiError as exc:
|
|
286
|
+
exit_on_api_error(exc)
|
|
287
|
+
return
|
|
288
|
+
_emit(data, ctx=ctx)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command("enable-kvm")
|
|
292
|
+
def cmd_enable_kvm(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
293
|
+
"""Open a KVM/IPMI session.
|
|
294
|
+
|
|
295
|
+
The caller's public IP is injected as the session-binding IP
|
|
296
|
+
automatically when the service needs it — nothing for you to
|
|
297
|
+
pass.
|
|
298
|
+
"""
|
|
299
|
+
client = make_client_or_exit(ctx)
|
|
300
|
+
try:
|
|
301
|
+
data = client.dedicated.enable_kvm(service_id)
|
|
302
|
+
except ApiError as exc:
|
|
303
|
+
exit_on_api_error(exc)
|
|
304
|
+
return
|
|
305
|
+
_emit(data, ctx=ctx)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@app.command("disable-kvm")
|
|
309
|
+
def cmd_disable_kvm(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
310
|
+
"""Close the active KVM/IPMI session."""
|
|
311
|
+
client = make_client_or_exit(ctx)
|
|
312
|
+
try:
|
|
313
|
+
client.dedicated.disable_kvm(service_id)
|
|
314
|
+
except ApiError as exc:
|
|
315
|
+
exit_on_api_error(exc)
|
|
316
|
+
return
|
|
317
|
+
success(f"KVM session for service {service_id} disabled.")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ── Firewall (capability-gated) ────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@app.command("firewall")
|
|
324
|
+
def cmd_firewall(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
325
|
+
"""Show DDoS firewall state. Requires the ``firewall`` capability."""
|
|
326
|
+
client = make_client_or_exit(ctx)
|
|
327
|
+
try:
|
|
328
|
+
data = client.dedicated.firewall(service_id)
|
|
329
|
+
except ApiError as exc:
|
|
330
|
+
exit_on_api_error(exc)
|
|
331
|
+
return
|
|
332
|
+
_emit(data, ctx=ctx)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@app.command("ddos-logs")
|
|
336
|
+
def cmd_ddos_logs(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
337
|
+
"""Show DDoS attack logs. Requires the ``firewall`` capability."""
|
|
338
|
+
client = make_client_or_exit(ctx)
|
|
339
|
+
try:
|
|
340
|
+
data = client.dedicated.ddos_logs(service_id)
|
|
341
|
+
except ApiError as exc:
|
|
342
|
+
exit_on_api_error(exc)
|
|
343
|
+
return
|
|
344
|
+
_emit(data, ctx=ctx)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@app.command("set-firewall")
|
|
348
|
+
def cmd_set_firewall(
|
|
349
|
+
ctx: typer.Context,
|
|
350
|
+
service_id: int = typer.Argument(...),
|
|
351
|
+
ip: str = typer.Option(..., "--ip", help="IP to update."),
|
|
352
|
+
state: str | None = typer.Option(
|
|
353
|
+
None,
|
|
354
|
+
"--state",
|
|
355
|
+
help="always_on | redirect_on_attack (omit to leave unchanged).",
|
|
356
|
+
),
|
|
357
|
+
sensitivity: str | None = typer.Option(
|
|
358
|
+
None,
|
|
359
|
+
"--sensitivity",
|
|
360
|
+
help="low | normal | medium | high (omit to leave unchanged).",
|
|
361
|
+
),
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Update DDoS firewall state/sensitivity for an IP. Requires the ``firewall`` capability."""
|
|
364
|
+
client = make_client_or_exit(ctx)
|
|
365
|
+
try:
|
|
366
|
+
data = client.dedicated.set_firewall(
|
|
367
|
+
service_id, ip=ip, state=state, sensitivity=sensitivity
|
|
368
|
+
)
|
|
369
|
+
except ApiError as exc:
|
|
370
|
+
exit_on_api_error(exc)
|
|
371
|
+
return
|
|
372
|
+
_emit(data, ctx=ctx)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ── Bandwidth (capability-gated) ───────────────────────────────────
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@app.command("bandwidth")
|
|
379
|
+
def cmd_bandwidth(
|
|
380
|
+
ctx: typer.Context,
|
|
381
|
+
service_id: int = typer.Argument(...),
|
|
382
|
+
type: str = typer.Option(
|
|
383
|
+
"port_bits",
|
|
384
|
+
"--type",
|
|
385
|
+
help="port_bits | port_upkts | port_percent | port_errors | port_pktsize | port_discards",
|
|
386
|
+
),
|
|
387
|
+
scale: str = typer.Option("month", "--scale", help="day | week | month"),
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Bandwidth graph (PNG base64). Requires the ``bandwidth`` capability."""
|
|
390
|
+
client = make_client_or_exit(ctx)
|
|
391
|
+
try:
|
|
392
|
+
data = client.dedicated.bandwidth(service_id, type=type, scale=scale)
|
|
393
|
+
except ApiError as exc:
|
|
394
|
+
exit_on_api_error(exc)
|
|
395
|
+
return
|
|
396
|
+
_emit(data, ctx=ctx)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ── VPN (capability-gated) ─────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@app.command("vpn")
|
|
403
|
+
def cmd_vpn(ctx: typer.Context, service_id: int = typer.Argument(...)) -> None:
|
|
404
|
+
"""Show rotating VPN credentials. Requires the ``vpn`` capability."""
|
|
405
|
+
client = make_client_or_exit(ctx)
|
|
406
|
+
try:
|
|
407
|
+
data = client.dedicated.vpn(service_id)
|
|
408
|
+
except ApiError as exc:
|
|
409
|
+
exit_on_api_error(exc)
|
|
410
|
+
return
|
|
411
|
+
_emit(data, ctx=ctx)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# ── Reinstall (destructive) ────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@app.command("reinstall")
|
|
418
|
+
def cmd_reinstall(
|
|
419
|
+
ctx: typer.Context,
|
|
420
|
+
service_id: int = typer.Argument(...),
|
|
421
|
+
os_id: str = typer.Option(..., "--os-id", help="OS id from `dedicated os-images <id>`."),
|
|
422
|
+
password: str = typer.Option(
|
|
423
|
+
..., "--password", help="New root password (min 8 chars).", prompt=True, hide_input=True
|
|
424
|
+
),
|
|
425
|
+
os_label: str | None = typer.Option(
|
|
426
|
+
None, "--os-label", help="Optional OS label — auto-resolved from os-id if absent."
|
|
427
|
+
),
|
|
428
|
+
confirm: bool = typer.Option(
|
|
429
|
+
False,
|
|
430
|
+
"--confirm",
|
|
431
|
+
help="Acknowledge that ALL DATA will be wiped.",
|
|
432
|
+
),
|
|
433
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the interactive prompt."),
|
|
434
|
+
) -> None:
|
|
435
|
+
"""Reinstall the OS. Destructive — wipes ALL data.
|
|
436
|
+
|
|
437
|
+
On services that support a synchronous reinstall path, the result
|
|
438
|
+
returns immediately and includes the new root password. On services
|
|
439
|
+
where the reinstall has to be applied manually, the response is
|
|
440
|
+
``{status: queued, message: ...}`` and our team executes the
|
|
441
|
+
reinstall within a few hours.
|
|
442
|
+
|
|
443
|
+
Both ``--confirm`` AND the prompt (or ``--yes``) must pass. The
|
|
444
|
+
required ``X-Impreza-Confirm: WIPE`` header is injected by the SDK
|
|
445
|
+
automatically.
|
|
446
|
+
"""
|
|
447
|
+
if not confirm:
|
|
448
|
+
error("Reinstall is destructive — pass --confirm to acknowledge data loss.")
|
|
449
|
+
raise typer.Exit(code=1)
|
|
450
|
+
confirm_or_exit(
|
|
451
|
+
f"Reinstall service {service_id} to os-id={os_id}? ALL DATA WILL BE LOST.",
|
|
452
|
+
yes=yes,
|
|
453
|
+
)
|
|
454
|
+
client = make_client_or_exit(ctx)
|
|
455
|
+
try:
|
|
456
|
+
data = client.dedicated.reinstall(
|
|
457
|
+
service_id,
|
|
458
|
+
os_id=os_id,
|
|
459
|
+
password=password,
|
|
460
|
+
confirm=True,
|
|
461
|
+
os_label=os_label,
|
|
462
|
+
)
|
|
463
|
+
except ApiError as exc:
|
|
464
|
+
exit_on_api_error(exc)
|
|
465
|
+
return
|
|
466
|
+
_emit(data, ctx=ctx)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Cloud sub-resources mounted on the main ``vps`` Typer
|
|
4
4
|
app as the ``cloud`` namespace. Mirror of the 3.4 Proxmox layout.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Four nested sub-apps + five inline verbs at the cloud root:
|
|
7
7
|
|
|
8
8
|
* ``vps cloud images`` — ``list / create / restore / delete``
|
|
9
9
|
Account-scoped on the wire (the Cloud image catalog is
|
|
@@ -15,15 +15,22 @@ Five nested sub-apps + five inline verbs at the cloud root:
|
|
|
15
15
|
|
|
16
16
|
* ``vps cloud iso`` — ``mount / unmount``
|
|
17
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
18
|
* ``vps cloud ssh-keys`` — ``list / assign``
|
|
24
19
|
``list`` returns every key on the Cloud account; ``assign``
|
|
25
20
|
attaches one or more existing keys to the bound VPS.
|
|
26
21
|
|
|
22
|
+
* ``vps cloud rdns`` (``get / set / delete``) was previously here
|
|
23
|
+
but is **removed from the CLI surface in 7.7**. The
|
|
24
|
+
``/vps/cloud/rdns/{ip}`` endpoint hits a WAF/mod_security rule
|
|
25
|
+
on the public edge (dotted-IPv4 path segments are flagged as
|
|
26
|
+
suspicious) and returns the WAF maintenance HTML page instead
|
|
27
|
+
of a JSON response. Server-side fix is owned by whoever
|
|
28
|
+
configures the WAF rules; until that lands, the CLI hides the
|
|
29
|
+
broken verbs rather than ship a known-broken surface. The SDK
|
|
30
|
+
resource (``vps.rdns.get/set/delete``) stays intact so library
|
|
31
|
+
consumers can still call it and handle the WAF response
|
|
32
|
+
themselves.
|
|
33
|
+
|
|
27
34
|
Inline verbs (no sub-app):
|
|
28
35
|
|
|
29
36
|
* ``vps cloud vnc <id>`` — read the VNC client triple (host, port,
|
|
@@ -65,7 +72,7 @@ from ._helpers import exit_on_api_error, resolve_vps_or_exit
|
|
|
65
72
|
|
|
66
73
|
app = typer.Typer(
|
|
67
74
|
name="cloud",
|
|
68
|
-
help="Cloud-only sub-resources: images, rescue, iso,
|
|
75
|
+
help="Cloud-only sub-resources: images, rescue, iso, ssh-keys, etc.",
|
|
69
76
|
no_args_is_help=True,
|
|
70
77
|
)
|
|
71
78
|
|
|
@@ -84,11 +91,6 @@ iso_app = typer.Typer(
|
|
|
84
91
|
help="Mount / unmount an ISO on a Cloud VPS.",
|
|
85
92
|
no_args_is_help=True,
|
|
86
93
|
)
|
|
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
94
|
ssh_keys_app = typer.Typer(
|
|
93
95
|
name="ssh-keys",
|
|
94
96
|
help="List account-level SSH keys and assign to a Cloud VPS.",
|
|
@@ -100,10 +102,11 @@ ipv6_app = typer.Typer(
|
|
|
100
102
|
no_args_is_help=True,
|
|
101
103
|
)
|
|
102
104
|
|
|
105
|
+
# rdns sub-app removed in 7.7 (WAF-blocked endpoint). SDK resource
|
|
106
|
+
# `vps.rdns.get/set/delete` stays available for library consumers.
|
|
103
107
|
app.add_typer(images_app)
|
|
104
108
|
app.add_typer(rescue_app)
|
|
105
109
|
app.add_typer(iso_app)
|
|
106
|
-
app.add_typer(rdns_app)
|
|
107
110
|
app.add_typer(ssh_keys_app)
|
|
108
111
|
app.add_typer(ipv6_app)
|
|
109
112
|
|
|
@@ -448,117 +451,21 @@ def iso_unmount(
|
|
|
448
451
|
|
|
449
452
|
|
|
450
453
|
# ══════════════════════════════════════════════════════════════════════
|
|
451
|
-
# rDNS
|
|
454
|
+
# rDNS — removed from CLI surface in 7.7
|
|
452
455
|
# ══════════════════════════════════════════════════════════════════════
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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}.")
|
|
456
|
+
#
|
|
457
|
+
# `rdns_app` + the get/set/delete commands lived here through 0.3.1.
|
|
458
|
+
# The /vps/cloud/rdns/{ip} endpoint hits a WAF rule on the public
|
|
459
|
+
# edge (dotted-IPv4 path segments) and returns HTML instead of JSON,
|
|
460
|
+
# so the verbs are unusable end-to-end. SDK methods stay available
|
|
461
|
+
# (`client.vps.get(id).rdns.get/set/delete`) for library consumers
|
|
462
|
+
# who want to handle the WAF response themselves.
|
|
463
|
+
#
|
|
464
|
+
# Re-add the typer app + commands here when the server-side WAF rule
|
|
465
|
+
# is fixed. The shape was three commands matching `vps.rdns.{get,
|
|
466
|
+
# set, delete}` with a service-id positional + `<ip>` (+ `<hostname>`
|
|
467
|
+
# for set) — see git history at tag `cli-v0.3.1` for the original
|
|
468
|
+
# code if you need to restore it.
|
|
562
469
|
|
|
563
470
|
|
|
564
471
|
# ══════════════════════════════════════════════════════════════════════
|
|
@@ -19,6 +19,7 @@ from . import __version__
|
|
|
19
19
|
from .commands import account as account_cmd
|
|
20
20
|
from .commands import catalog as catalog_cmd
|
|
21
21
|
from .commands import context as context_cmd
|
|
22
|
+
from .commands import dedicated as dedicated_cmd
|
|
22
23
|
from .commands import doctor as doctor_cmd
|
|
23
24
|
from .commands import domain as domain_cmd
|
|
24
25
|
from .commands import invoice as invoice_cmd
|
|
@@ -86,6 +87,7 @@ def _root(
|
|
|
86
87
|
app.add_typer(account_cmd.app, name="account")
|
|
87
88
|
app.add_typer(catalog_cmd.app, name="catalog")
|
|
88
89
|
app.add_typer(context_cmd.app, name="context")
|
|
90
|
+
app.add_typer(dedicated_cmd.app, name="dedicated")
|
|
89
91
|
app.add_typer(doctor_cmd.app, name="doctor")
|
|
90
92
|
app.add_typer(domain_cmd.app, name="domain")
|
|
91
93
|
app.add_typer(invoice_cmd.app, name="invoice")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: impreza-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Official command-line interface for the Impreza Host public REST API
|
|
5
5
|
Author-email: Impreza Host <support@imprezahost.com>
|
|
6
6
|
License: MIT
|
|
@@ -142,10 +142,10 @@ to see the full subcommand list, or `impreza <group> <command>
|
|
|
142
142
|
| `account` | `info / balance / services / topup / topup-status` | Profile + balance + services + crypto top-up |
|
|
143
143
|
| `catalog` | `products / product / product-groups / tlds` | Pre-purchase discovery |
|
|
144
144
|
| `domain` | `show / check / pricing / register / transfer / set-nameservers / lock / unlock / id-protection / raa-verify / gdpr-auth / transfer-approval` + `domain dns list / add / update / delete / activate` | Domain registrations + full DNS CRUD |
|
|
145
|
-
| `vps` | `list / show / status / start / stop / reboot / shutdown / set-hostname / set-password / reinstall / migrate / cancel` + `vps proxmox snapshots / backups / backup-schedules / network` + `vps cloud images / rescue / iso /
|
|
145
|
+
| `vps` | `list / show / status / start / stop / reboot / shutdown / set-hostname / set-password / reinstall / migrate / cancel` + `vps proxmox snapshots / backups / backup-schedules / network` + `vps cloud images / rescue / iso / ssh-keys / vnc / vnc-password / resize / boot-order / ipv6` | Cross-backend (Proxmox + Cloud) VPS with smart dispatch |
|
|
146
146
|
| `order` | `list / show / create / upgrade` | Submit / browse product orders |
|
|
147
147
|
| `service` | `cancel` | Submit cancellation request (any service) |
|
|
148
|
-
| `webhook` | `list / show / create / update / delete / rotate-secret / deliveries / event-types` | Webhook subscription management + delivery history |
|
|
148
|
+
| `webhook` | `list / show / create / update / delete / rotate-secret / deliveries / event-types` | Webhook subscription management + delivery history. Event-payload contract documented in [`../openapi/asyncapi.yaml`](../openapi/asyncapi.yaml). |
|
|
149
149
|
| `invoice` | `list / show` | Invoices with line items + transactions |
|
|
150
150
|
| `key` | `whoami` | Active API key identity + IP whitelist |
|
|
151
151
|
|
|
@@ -17,6 +17,7 @@ impreza_cli/commands/_helpers.py
|
|
|
17
17
|
impreza_cli/commands/account.py
|
|
18
18
|
impreza_cli/commands/catalog.py
|
|
19
19
|
impreza_cli/commands/context.py
|
|
20
|
+
impreza_cli/commands/dedicated.py
|
|
20
21
|
impreza_cli/commands/doctor.py
|
|
21
22
|
impreza_cli/commands/domain.py
|
|
22
23
|
impreza_cli/commands/invoice.py
|
|
@@ -8,24 +8,25 @@ Bucketed by reversibility, the same way 3.3 / 3.4 are:
|
|
|
8
8
|
smokes so a casual ``pytest`` run never touches the live API.
|
|
9
9
|
* **Catastrophic** (images create/restore/delete, resize,
|
|
10
10
|
boot-order, vnc-password rotate, rescue enable, iso mount,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
ipv6 enable): real state changes — some reversible
|
|
12
|
+
(boot-order, rescue, iso) and some that consume storage quota
|
|
13
|
+
(images create) or have billing impact (resize). Mock-only by
|
|
14
|
+
design.
|
|
15
15
|
|
|
16
16
|
The cheap round-trips that 3.3's set-hostname / 3.4's snapshots
|
|
17
|
-
create+delete enabled don't have direct
|
|
18
|
-
|
|
19
|
-
- rdns get is safe to read but requires knowing a valid IP
|
|
20
|
-
(the VPS's dedicated_ip from `vps show`). Could be done as
|
|
21
|
-
a round-trip (`set sentinel → get → delete-or-restore`)
|
|
22
|
-
but the upstream may reject hostnames that don't resolve
|
|
23
|
-
forward, so the test would need to construct a resolvable
|
|
24
|
-
sentinel. Deferred for now.
|
|
17
|
+
create+delete enabled don't have a direct equivalent here:
|
|
18
|
+
|
|
25
19
|
- vnc read returns short-lived credentials; testing them
|
|
26
20
|
would require an actual VNC client. Just exercise the
|
|
27
21
|
request-response.
|
|
28
22
|
|
|
23
|
+
The earlier ``vps cloud rdns`` smoke (read for the VPS's
|
|
24
|
+
dedicated_ip) was removed in 7.7 along with the CLI verbs —
|
|
25
|
+
the public-edge WAF rejects /vps/cloud/rdns/{ip} paths and
|
|
26
|
+
returns HTML instead of JSON. SDK-level unit tests for the
|
|
27
|
+
underlying ``vps.rdns`` resource still live in
|
|
28
|
+
sdk-python/tests/test_vps_specific_unit.py.
|
|
29
|
+
|
|
29
30
|
If we add reversible round-trips later, gate them behind
|
|
30
31
|
extra opt-in flags (``IMPREZA_TEST_ALLOW_RDNS_CHANGE=1`` etc.),
|
|
31
32
|
following the 3.3 ``IMPREZA_TEST_ALLOW_PASSWORD_RESET`` pattern.
|
|
@@ -175,59 +176,8 @@ def test_smoke_vnc_read(live_seeded_config: Path) -> None:
|
|
|
175
176
|
)
|
|
176
177
|
|
|
177
178
|
|
|
178
|
-
#
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
reason="reads from a real account",
|
|
184
|
-
)
|
|
185
|
-
def test_smoke_rdns_get_dedicated_ip(live_seeded_config: Path) -> None:
|
|
186
|
-
"""Best-effort: look up the VPS's dedicated_ip via `vps show`,
|
|
187
|
-
then read its rDNS. Skips cleanly if the VPS doesn't have a
|
|
188
|
-
dedicated_ip exposed (some flavours don't).
|
|
189
|
-
"""
|
|
190
|
-
vps_id = _test_cloud_vps_id()
|
|
191
|
-
|
|
192
|
-
show_result = runner.invoke(
|
|
193
|
-
app, ["vps", "show", str(vps_id), "--output", "json"]
|
|
194
|
-
)
|
|
195
|
-
if show_result.exit_code != 0:
|
|
196
|
-
pytest.fail(f"vps show failed: {show_result.stderr.strip()!r}")
|
|
197
|
-
info = json.loads(show_result.stdout)
|
|
198
|
-
dedicated_ip = info.get("dedicated_ip")
|
|
199
|
-
if not dedicated_ip:
|
|
200
|
-
pytest.skip(
|
|
201
|
-
f"VPS {vps_id} has no dedicated_ip in its Service record "
|
|
202
|
-
"— rDNS read needs a specific IP target"
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
result = runner.invoke(
|
|
206
|
-
app,
|
|
207
|
-
["vps", "cloud", "rdns", "get", str(vps_id), dedicated_ip,
|
|
208
|
-
"--output", "json"],
|
|
209
|
-
)
|
|
210
|
-
if result.exit_code != 0:
|
|
211
|
-
if "Proxmox backend" in result.stderr:
|
|
212
|
-
pytest.skip(f"VPS {vps_id} is on Proxmox — rdns is Cloud-only")
|
|
213
|
-
# rDNS-not-set is a legitimate state; the SDK returns an empty
|
|
214
|
-
# dict and the CLI prints "No rDNS record" on stdout. Treat
|
|
215
|
-
# any non-zero exit other than that as a failure.
|
|
216
|
-
if "No rDNS record" in result.stdout:
|
|
217
|
-
print(f"\n VPS {vps_id}: no rDNS record for {dedicated_ip}")
|
|
218
|
-
return
|
|
219
|
-
# the Cloud backend sometimes returns 502 instead of 404 when the
|
|
220
|
-
# rDNS record isn't set or the IP isn't in their managed pool.
|
|
221
|
-
# Treat as skip rather than failure — the integration plumbing
|
|
222
|
-
# is covered by unit tests with respx mocks.
|
|
223
|
-
if "502" in result.stderr or "UPSTREAM_ERROR" in result.stderr:
|
|
224
|
-
pytest.skip(
|
|
225
|
-
f"Cloud backend rejected rDNS read for {dedicated_ip} "
|
|
226
|
-
f"(upstream 502): {result.stderr.strip()!r}"
|
|
227
|
-
)
|
|
228
|
-
pytest.fail(f"rdns get failed: {result.stderr.strip()!r}")
|
|
229
|
-
if result.stdout.strip().startswith("{"):
|
|
230
|
-
parsed = json.loads(result.stdout)
|
|
231
|
-
print(f"\n VPS {vps_id}: rDNS for {dedicated_ip} = {parsed}")
|
|
232
|
-
else:
|
|
233
|
-
print(f"\n VPS {vps_id}: rDNS for {dedicated_ip} read OK (empty)")
|
|
179
|
+
# rdns smoke test removed in 7.7. The `vps cloud rdns get` verb is
|
|
180
|
+
# no longer exposed by the CLI (WAF-blocked endpoint — the public
|
|
181
|
+
# edge returns the maintenance HTML page for /vps/cloud/rdns/{ip}
|
|
182
|
+
# paths with dotted-IPv4 segments). Re-add when the server-side WAF
|
|
183
|
+
# rule is fixed and `rdns_app` returns to commands/vps_cloud.py.
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
"""Unit tests for ``impreza vps cloud`` (Phase 3.5).
|
|
1
|
+
"""Unit tests for ``impreza vps cloud`` (Phase 3.5; 7.7 trimmed rdns).
|
|
2
2
|
|
|
3
|
-
Mirrors the 3.4 Proxmox unit suite — covers the
|
|
4
|
-
sub-apps (images, rescue, iso,
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Mirrors the 3.4 Proxmox unit suite — covers the four nested
|
|
4
|
+
sub-apps (images, rescue, iso, ssh-keys) plus the five inline
|
|
5
|
+
verbs at the cloud root (vnc, vnc-password, resize, boot-order,
|
|
6
|
+
ipv6 enable). The fifth sub-app (rdns) was removed from the CLI
|
|
7
|
+
surface in 7.7 (WAF-blocked endpoint); SDK-level tests for
|
|
8
|
+
`vps.rdns.get/set/delete` stay in
|
|
9
|
+
sdk-python/tests/test_vps_specific_unit.py.
|
|
7
10
|
|
|
8
11
|
For each verb:
|
|
9
12
|
|
|
10
13
|
* happy-path test pins the URL + body
|
|
11
14
|
* a Proxmox-backend test asserts the friendly "Cloud-only" stderr
|
|
12
15
|
line (the inverse of 3.4's BackendNotSupported handling)
|
|
13
|
-
* destructive verbs (delete, restore
|
|
14
|
-
decline-at-prompt test
|
|
16
|
+
* destructive verbs (delete, restore) get a decline-at-prompt test
|
|
15
17
|
* boot-order has a client-side validation test (unknown --order
|
|
16
18
|
rejected before any HTTP)
|
|
17
19
|
"""
|
|
@@ -299,56 +301,11 @@ def test_iso_unmount(seeded_config: Path) -> None:
|
|
|
299
301
|
# ══════════════════════════════════════════════════════════════════════
|
|
300
302
|
|
|
301
303
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
route = respx.get(f"{BASE}/vps/cloud/rdns/1.2.3.4").mock(
|
|
308
|
-
return_value=httpx.Response(
|
|
309
|
-
200, json=_ok({"ip": "1.2.3.4", "hostname": "mail.example.com"})
|
|
310
|
-
)
|
|
311
|
-
)
|
|
312
|
-
result = runner.invoke(
|
|
313
|
-
app, ["vps", "cloud", "rdns", "get", "17987", "1.2.3.4"]
|
|
314
|
-
)
|
|
315
|
-
assert result.exit_code == 0, result.stderr
|
|
316
|
-
assert route.called
|
|
317
|
-
assert "mail.example.com" in result.stdout
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
@respx.mock
|
|
321
|
-
def test_rdns_set(seeded_config: Path) -> None:
|
|
322
|
-
respx.get(f"{BASE}/account/services/17987").mock(
|
|
323
|
-
return_value=httpx.Response(200, json=_service_envelope(_cloud_service()))
|
|
324
|
-
)
|
|
325
|
-
route = respx.put(f"{BASE}/vps/cloud/rdns/1.2.3.4").mock(
|
|
326
|
-
return_value=httpx.Response(200, json=_ok())
|
|
327
|
-
)
|
|
328
|
-
result = runner.invoke(
|
|
329
|
-
app,
|
|
330
|
-
["vps", "cloud", "rdns", "set", "17987", "1.2.3.4", "mail.example.com"],
|
|
331
|
-
)
|
|
332
|
-
assert result.exit_code == 0, result.stderr
|
|
333
|
-
assert route.called
|
|
334
|
-
body = json.loads(route.calls.last.request.content)
|
|
335
|
-
assert body == {"hostname": "mail.example.com"}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
@respx.mock
|
|
339
|
-
def test_rdns_delete_with_yes(seeded_config: Path) -> None:
|
|
340
|
-
respx.get(f"{BASE}/account/services/17987").mock(
|
|
341
|
-
return_value=httpx.Response(200, json=_service_envelope(_cloud_service()))
|
|
342
|
-
)
|
|
343
|
-
route = respx.delete(f"{BASE}/vps/cloud/rdns/1.2.3.4").mock(
|
|
344
|
-
return_value=httpx.Response(200, json=_ok())
|
|
345
|
-
)
|
|
346
|
-
result = runner.invoke(
|
|
347
|
-
app,
|
|
348
|
-
["vps", "cloud", "rdns", "delete", "17987", "1.2.3.4", "--yes"],
|
|
349
|
-
)
|
|
350
|
-
assert result.exit_code == 0, result.stderr
|
|
351
|
-
assert route.called
|
|
304
|
+
# rdns CLI tests removed in 7.7. The `vps cloud rdns` sub-app is no
|
|
305
|
+
# longer registered (WAF-blocked endpoint); SDK-level tests for
|
|
306
|
+
# `vps.rdns.get/set/delete` still live in
|
|
307
|
+
# sdk-python/tests/test_vps_specific_unit.py for library consumers
|
|
308
|
+
# who route around the CLI surface.
|
|
352
309
|
|
|
353
310
|
|
|
354
311
|
# ══════════════════════════════════════════════════════════════════════
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|