impreza-cli 0.5.1__tar.gz → 0.7.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.5.1 → impreza_cli-0.7.0}/PKG-INFO +30 -11
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/README.md +28 -9
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/_helpers.py +2 -3
- impreza_cli-0.7.0/impreza_cli/commands/config.py +220 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/context.py +18 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/dedicated.py +40 -6
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/vps_cloud.py +1 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/config.py +6 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/main.py +12 -1
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/sdk.py +6 -3
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/state.py +5 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/PKG-INFO +30 -11
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/SOURCES.txt +2 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/requires.txt +1 -1
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/pyproject.toml +1 -1
- impreza_cli-0.7.0/tests/test_config_commands.py +297 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_context_commands.py +116 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_dedicated_commands.py +21 -9
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/__init__.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/__init__.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/account.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/catalog.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/doctor.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/domain.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/invoice.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/key.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/orders.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/services.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/vps.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/vps_proxmox.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/commands/webhooks.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli/output.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/dependency_links.txt +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/entry_points.txt +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/impreza_cli.egg-info/top_level.txt +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/setup.cfg +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/.gitkeep +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/__init__.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/conftest.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_account_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_account_topup_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_catalog_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_config.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_doctor_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_domain_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_invoice_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_key_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_orders_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_2_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_3_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_4_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_5_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_2_6_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_1_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_2_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_3_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_4_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_5_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_6_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_phase_3_7_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_services_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_vps_cloud_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_vps_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_vps_proxmox_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.0}/tests/test_webhooks_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.7.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.7.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
|
|
@@ -23,7 +23,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
23
23
|
Classifier: Topic :: Utilities
|
|
24
24
|
Requires-Python: >=3.10
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
|
-
Requires-Dist: impreza-sdk<0.
|
|
26
|
+
Requires-Dist: impreza-sdk<0.8,>=0.7.0
|
|
27
27
|
Requires-Dist: typer>=0.12
|
|
28
28
|
Requires-Dist: rich>=13.7
|
|
29
29
|
Requires-Dist: click>=8.0
|
|
@@ -227,23 +227,33 @@ equivalent) and `impreza <TAB>` should suggest resource groups,
|
|
|
227
227
|
|
|
228
228
|
## Tor
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
Four knobs, from the most persistent to the most one-off:
|
|
231
231
|
|
|
232
232
|
```bash
|
|
233
|
-
#
|
|
234
|
-
impreza context create offshore
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
# Persisted at context creation (writes [settings] use_tor=true)
|
|
234
|
+
impreza context create offshore --key imp_... --secret ... --via-tor
|
|
235
|
+
|
|
236
|
+
# Explicit SOCKS5 proxy instead (wins over use_tor; works for any
|
|
237
|
+
# SOCKS5, not just Tor)
|
|
238
|
+
impreza context create offshore --key imp_... --secret ... --proxy socks5://127.0.0.1:9050
|
|
239
|
+
|
|
240
|
+
# One-shot global flag on any command
|
|
241
|
+
impreza --via-tor account info
|
|
237
242
|
|
|
238
243
|
# Env var, picked up by the SDK transparently
|
|
239
244
|
IMPREZA_USE_TOR=1 impreza account info
|
|
240
|
-
|
|
241
|
-
# Programmatic via the SDK (Python users skip the CLI for this)
|
|
242
245
|
```
|
|
243
246
|
|
|
247
|
+
All four route through the local Tor daemon's SOCKS5 port with remote
|
|
248
|
+
DNS (no local lookup of the API hostname). Because exit nodes rotate,
|
|
249
|
+
set the API key's IP factor to `tofu` or `keyonly` in the client area —
|
|
250
|
+
`whitelist` cannot work without a stable egress IP. When the account
|
|
251
|
+
uses the API's `.onion` mirror, the IP factor sees a stable loopback
|
|
252
|
+
instead.
|
|
253
|
+
|
|
244
254
|
The SDK's `auto_tor=True` path (probe Tor, fall back to clearnet)
|
|
245
|
-
isn't surfaced through the CLI yet —
|
|
246
|
-
|
|
255
|
+
isn't surfaced through the CLI yet — it is a fail-open convenience,
|
|
256
|
+
which is the wrong default here.
|
|
247
257
|
|
|
248
258
|
## Error handling
|
|
249
259
|
|
|
@@ -259,6 +269,15 @@ Tracebacks never leak from expected failures (auth errors, missing
|
|
|
259
269
|
contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
|
|
260
270
|
the traceback isn't swallowed — that's intentional.
|
|
261
271
|
|
|
272
|
+
## Reviewed application configuration
|
|
273
|
+
|
|
274
|
+
Version 0.6.0 adds configuration export, review and apply. Documents are limited
|
|
275
|
+
to 64 KiB of UTF-8 and carry secret references instead of values. Applications
|
|
276
|
+
retain their current configuration until a reviewed plan is explicitly applied.
|
|
277
|
+
|
|
278
|
+
See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
|
|
279
|
+
for SDK and CLI examples, permissions, review expiry and replay behavior.
|
|
280
|
+
|
|
262
281
|
## Development
|
|
263
282
|
|
|
264
283
|
```bash
|
|
@@ -185,23 +185,33 @@ equivalent) and `impreza <TAB>` should suggest resource groups,
|
|
|
185
185
|
|
|
186
186
|
## Tor
|
|
187
187
|
|
|
188
|
-
|
|
188
|
+
Four knobs, from the most persistent to the most one-off:
|
|
189
189
|
|
|
190
190
|
```bash
|
|
191
|
-
#
|
|
192
|
-
impreza context create offshore
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
# Persisted at context creation (writes [settings] use_tor=true)
|
|
192
|
+
impreza context create offshore --key imp_... --secret ... --via-tor
|
|
193
|
+
|
|
194
|
+
# Explicit SOCKS5 proxy instead (wins over use_tor; works for any
|
|
195
|
+
# SOCKS5, not just Tor)
|
|
196
|
+
impreza context create offshore --key imp_... --secret ... --proxy socks5://127.0.0.1:9050
|
|
197
|
+
|
|
198
|
+
# One-shot global flag on any command
|
|
199
|
+
impreza --via-tor account info
|
|
195
200
|
|
|
196
201
|
# Env var, picked up by the SDK transparently
|
|
197
202
|
IMPREZA_USE_TOR=1 impreza account info
|
|
198
|
-
|
|
199
|
-
# Programmatic via the SDK (Python users skip the CLI for this)
|
|
200
203
|
```
|
|
201
204
|
|
|
205
|
+
All four route through the local Tor daemon's SOCKS5 port with remote
|
|
206
|
+
DNS (no local lookup of the API hostname). Because exit nodes rotate,
|
|
207
|
+
set the API key's IP factor to `tofu` or `keyonly` in the client area —
|
|
208
|
+
`whitelist` cannot work without a stable egress IP. When the account
|
|
209
|
+
uses the API's `.onion` mirror, the IP factor sees a stable loopback
|
|
210
|
+
instead.
|
|
211
|
+
|
|
202
212
|
The SDK's `auto_tor=True` path (probe Tor, fall back to clearnet)
|
|
203
|
-
isn't surfaced through the CLI yet —
|
|
204
|
-
|
|
213
|
+
isn't surfaced through the CLI yet — it is a fail-open convenience,
|
|
214
|
+
which is the wrong default here.
|
|
205
215
|
|
|
206
216
|
## Error handling
|
|
207
217
|
|
|
@@ -217,6 +227,15 @@ Tracebacks never leak from expected failures (auth errors, missing
|
|
|
217
227
|
contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
|
|
218
228
|
the traceback isn't swallowed — that's intentional.
|
|
219
229
|
|
|
230
|
+
## Reviewed application configuration
|
|
231
|
+
|
|
232
|
+
Version 0.6.0 adds configuration export, review and apply. Documents are limited
|
|
233
|
+
to 64 KiB of UTF-8 and carry secret references instead of values. Applications
|
|
234
|
+
retain their current configuration until a reviewed plan is explicitly applied.
|
|
235
|
+
|
|
236
|
+
See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
|
|
237
|
+
for SDK and CLI examples, permissions, review expiry and replay behavior.
|
|
238
|
+
|
|
220
239
|
## Development
|
|
221
240
|
|
|
222
241
|
```bash
|
|
@@ -28,11 +28,10 @@ Conventions kept consistent across every CLI command:
|
|
|
28
28
|
from __future__ import annotations
|
|
29
29
|
|
|
30
30
|
import time
|
|
31
|
-
from typing import Any
|
|
32
31
|
from urllib.parse import quote
|
|
33
32
|
|
|
34
33
|
import typer
|
|
35
|
-
from impreza import Operation, Vps
|
|
34
|
+
from impreza import Client, Operation, Vps
|
|
36
35
|
from impreza.exceptions import ApiError, InvalidRequest, ResourceNotFound
|
|
37
36
|
|
|
38
37
|
from ..output import error
|
|
@@ -85,7 +84,7 @@ def exit_on_api_error(exc: ApiError) -> None:
|
|
|
85
84
|
raise typer.Exit(code=1)
|
|
86
85
|
|
|
87
86
|
|
|
88
|
-
def resolve_vps_or_exit(client:
|
|
87
|
+
def resolve_vps_or_exit(client: Client, service_id: int) -> Vps:
|
|
89
88
|
"""Look up a VPS by id, mapping the standard SDK errors to the
|
|
90
89
|
same friendly stderr lines the 3.2 power verbs introduced.
|
|
91
90
|
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""``impreza config`` — configuration as code for custom deployments (P4).
|
|
2
|
+
|
|
3
|
+
Three commands over the reviewed config surface:
|
|
4
|
+
|
|
5
|
+
* ``impreza config export <deployment> [--out FILE]`` — write the versioned
|
|
6
|
+
document (secrets as references) to stdout or a file;
|
|
7
|
+
* ``impreza config apply <deployment> --file FILE [--yes]`` — prepare a
|
|
8
|
+
reviewed apply from the document, show the server-computed diff, and only
|
|
9
|
+
queue it after an explicit confirmation. The apply carries the exact
|
|
10
|
+
review digest, so what you confirmed is byte-for-byte what runs;
|
|
11
|
+
* ``impreza config plan <plan-id>`` — read a saved review's status or
|
|
12
|
+
replay its receipt.
|
|
13
|
+
|
|
14
|
+
The document is meant for version control: export it, commit it, edit it,
|
|
15
|
+
apply it. Secret values never appear in the document — applying resolves
|
|
16
|
+
the references server-side.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import stat
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
from impreza.exceptions import ApiError
|
|
28
|
+
|
|
29
|
+
from ..output import error, success
|
|
30
|
+
from ..sdk import make_client_or_exit
|
|
31
|
+
from ..state import confirm_or_exit, from_typer_context
|
|
32
|
+
from ._helpers import exit_on_api_error
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="config",
|
|
36
|
+
help="Export, review and apply versioned application configuration.",
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _terminal_text(value: str) -> str:
|
|
42
|
+
# Escape terminal control characters and bidi overrides in server text.
|
|
43
|
+
return "".join(
|
|
44
|
+
ch
|
|
45
|
+
if ch in "\n\t"
|
|
46
|
+
or (
|
|
47
|
+
ord(ch) >= 32
|
|
48
|
+
and not 127 <= ord(ch) <= 159
|
|
49
|
+
and not 0x202A <= ord(ch) <= 0x202E
|
|
50
|
+
and not 0x2066 <= ord(ch) <= 0x2069
|
|
51
|
+
)
|
|
52
|
+
else f"\\u{ord(ch):04x}"
|
|
53
|
+
for ch in value
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("export")
|
|
58
|
+
def export(
|
|
59
|
+
typer_ctx: typer.Context,
|
|
60
|
+
deployment_id: str = typer.Argument(..., help="Custom deployment id (dpl_…)."),
|
|
61
|
+
out: Path | None = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--out",
|
|
64
|
+
"-o",
|
|
65
|
+
help="Write the document to this file instead of stdout.",
|
|
66
|
+
),
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Export the versioned config document (secrets as references)."""
|
|
69
|
+
client = make_client_or_exit(from_typer_context(typer_ctx))
|
|
70
|
+
try:
|
|
71
|
+
payload = client.config.export(deployment_id)
|
|
72
|
+
except ApiError as exc:
|
|
73
|
+
exit_on_api_error(exc)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
error(str(exc))
|
|
76
|
+
raise typer.Exit(1) from exc
|
|
77
|
+
document_text = payload.get("document_text")
|
|
78
|
+
if not isinstance(document_text, str) or not document_text.strip():
|
|
79
|
+
error("The export carried no document text.")
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
if out is not None:
|
|
82
|
+
out.write_text(document_text, encoding="utf-8")
|
|
83
|
+
success(
|
|
84
|
+
f"Exported config for {payload.get('deployment_id', deployment_id)} "
|
|
85
|
+
f"(schema {payload.get('schema', '?')}) to {out}."
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
typer.echo(document_text, nl=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command("apply")
|
|
92
|
+
def apply(
|
|
93
|
+
typer_ctx: typer.Context,
|
|
94
|
+
deployment_id: str = typer.Argument(..., help="Custom deployment id (dpl_…)."),
|
|
95
|
+
file: Path = typer.Option(
|
|
96
|
+
...,
|
|
97
|
+
"--file",
|
|
98
|
+
"-f",
|
|
99
|
+
help="Config document to apply (as written by `impreza config export`).",
|
|
100
|
+
exists=True,
|
|
101
|
+
readable=True,
|
|
102
|
+
dir_okay=False,
|
|
103
|
+
),
|
|
104
|
+
yes: bool = typer.Option(
|
|
105
|
+
False,
|
|
106
|
+
"--yes",
|
|
107
|
+
"-y",
|
|
108
|
+
help="Apply without the interactive confirmation prompt.",
|
|
109
|
+
),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Prepare a reviewed apply from a document, show its diff, confirm, apply."""
|
|
112
|
+
try:
|
|
113
|
+
if not file.is_file():
|
|
114
|
+
raise ValueError("Config input must be a regular file.")
|
|
115
|
+
# Nonblocking open also prevents a raced replacement with a FIFO from
|
|
116
|
+
# hanging automation. Validate the opened descriptor, then bound reads.
|
|
117
|
+
fd = os.open(file, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0))
|
|
118
|
+
with os.fdopen(fd, "rb") as stream:
|
|
119
|
+
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
120
|
+
raise ValueError("Config input must be a regular file.")
|
|
121
|
+
raw = stream.read(65537)
|
|
122
|
+
if not raw or len(raw) > 65536:
|
|
123
|
+
raise ValueError("Config document must contain 1 to 65536 UTF-8 bytes.")
|
|
124
|
+
document = raw.decode("utf-8")
|
|
125
|
+
except (OSError, ValueError) as exc:
|
|
126
|
+
error("Cannot read config document: " + str(exc))
|
|
127
|
+
raise typer.Exit(1) from exc
|
|
128
|
+
client = make_client_or_exit(from_typer_context(typer_ctx))
|
|
129
|
+
try:
|
|
130
|
+
review = client.config.prepare(deployment_id, document)
|
|
131
|
+
except ApiError as exc:
|
|
132
|
+
exit_on_api_error(exc)
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
error(str(exc))
|
|
135
|
+
raise typer.Exit(1) from exc
|
|
136
|
+
plan_id = review.get("plan_id")
|
|
137
|
+
review_digest = review.get("review_digest")
|
|
138
|
+
changes = review.get("changes")
|
|
139
|
+
diff_text = review.get("diff_text")
|
|
140
|
+
if (
|
|
141
|
+
not isinstance(plan_id, str)
|
|
142
|
+
or re.fullmatch(r"cplan_[a-f0-9]{24}", plan_id) is None
|
|
143
|
+
or not isinstance(review_digest, str)
|
|
144
|
+
or re.fullmatch(r"[a-f0-9]{64}", review_digest) is None
|
|
145
|
+
or review.get("deployment_id") != deployment_id
|
|
146
|
+
or review.get("operation") != "config_apply"
|
|
147
|
+
or not isinstance(changes, list)
|
|
148
|
+
or not all(isinstance(item, str) for item in changes)
|
|
149
|
+
or not isinstance(diff_text, str)
|
|
150
|
+
or review.get("no_changes") is not (changes == [])
|
|
151
|
+
):
|
|
152
|
+
error(
|
|
153
|
+
"The review response is incomplete or does not match this deployment; "
|
|
154
|
+
"nothing was applied."
|
|
155
|
+
)
|
|
156
|
+
raise typer.Exit(1)
|
|
157
|
+
|
|
158
|
+
if isinstance(changes, (list, dict)) and changes:
|
|
159
|
+
typer.secho("Planned changes:", fg=typer.colors.YELLOW, bold=True)
|
|
160
|
+
if isinstance(changes, dict):
|
|
161
|
+
for key, value in changes.items():
|
|
162
|
+
typer.echo(f" {key}: {value}")
|
|
163
|
+
else:
|
|
164
|
+
for line in changes:
|
|
165
|
+
typer.echo(_terminal_text(f" {line}"))
|
|
166
|
+
else:
|
|
167
|
+
typer.echo("No changes detected — the apply is idempotent for this document.")
|
|
168
|
+
if isinstance(diff_text, str) and diff_text.strip():
|
|
169
|
+
typer.echo("")
|
|
170
|
+
typer.secho("Diff:", fg=typer.colors.YELLOW, bold=True)
|
|
171
|
+
typer.echo(_terminal_text(diff_text.rstrip()))
|
|
172
|
+
|
|
173
|
+
typer.echo("")
|
|
174
|
+
confirm_or_exit(f"Apply this configuration to {deployment_id}.", yes=yes)
|
|
175
|
+
try:
|
|
176
|
+
result = client.config.apply(plan_id, review_digest)
|
|
177
|
+
except ApiError as exc:
|
|
178
|
+
exit_on_api_error(exc)
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
error(str(exc))
|
|
181
|
+
raise typer.Exit(1) from exc
|
|
182
|
+
receipt = result.get("receipt")
|
|
183
|
+
if not isinstance(receipt, dict):
|
|
184
|
+
receipt = result
|
|
185
|
+
command_id = result.get("command_id") or receipt.get("command_id")
|
|
186
|
+
replayed = bool(result.get("replayed"))
|
|
187
|
+
if replayed:
|
|
188
|
+
success(f"Plan {plan_id} was already accepted — replayed its receipt.")
|
|
189
|
+
else:
|
|
190
|
+
success(f"Configuration applied (plan {plan_id}).")
|
|
191
|
+
if isinstance(command_id, str) and command_id:
|
|
192
|
+
typer.echo(_terminal_text(f"Deploy command: {command_id}"))
|
|
193
|
+
if receipt.get("no_changes") is True:
|
|
194
|
+
typer.echo("No deployment was queued because the configuration already matches.")
|
|
195
|
+
else:
|
|
196
|
+
typer.echo("The change is queued; watch the deployment's status or the command above.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.command("plan")
|
|
200
|
+
def plan(
|
|
201
|
+
typer_ctx: typer.Context,
|
|
202
|
+
plan_id: str = typer.Argument(..., help="Config plan id (cplan_…)."),
|
|
203
|
+
) -> None:
|
|
204
|
+
"""Read one saved config review: status, digest and receipt."""
|
|
205
|
+
client = make_client_or_exit(from_typer_context(typer_ctx))
|
|
206
|
+
try:
|
|
207
|
+
payload = client.config.get_plan(plan_id)
|
|
208
|
+
except ApiError as exc:
|
|
209
|
+
exit_on_api_error(exc)
|
|
210
|
+
except ValueError as exc:
|
|
211
|
+
error(str(exc))
|
|
212
|
+
raise typer.Exit(1) from exc
|
|
213
|
+
status = payload.get("status", "unknown")
|
|
214
|
+
typer.echo(_terminal_text(f"Plan {plan_id}: {status}"))
|
|
215
|
+
digest = payload.get("review_digest")
|
|
216
|
+
if isinstance(digest, str) and digest:
|
|
217
|
+
typer.echo(_terminal_text(f"Review digest: {digest}"))
|
|
218
|
+
receipt = payload.get("receipt")
|
|
219
|
+
if isinstance(receipt, dict):
|
|
220
|
+
typer.echo(f"Receipt: {receipt}")
|
|
@@ -88,6 +88,18 @@ def create(
|
|
|
88
88
|
help="Optional default output format for this context.",
|
|
89
89
|
case_sensitive=False,
|
|
90
90
|
),
|
|
91
|
+
via_tor: bool = typer.Option(
|
|
92
|
+
False,
|
|
93
|
+
"--via-tor",
|
|
94
|
+
help="Route API calls through the local Tor daemon "
|
|
95
|
+
"(sets [settings] use_tor=true, CLI-wide).",
|
|
96
|
+
),
|
|
97
|
+
proxy: str | None = typer.Option(
|
|
98
|
+
None,
|
|
99
|
+
"--proxy",
|
|
100
|
+
help="Explicit SOCKS5 proxy, e.g. socks5://127.0.0.1:9050 "
|
|
101
|
+
"(sets [settings] proxy, CLI-wide; wins over use_tor).",
|
|
102
|
+
),
|
|
91
103
|
overwrite: bool = typer.Option(
|
|
92
104
|
False,
|
|
93
105
|
"--overwrite",
|
|
@@ -108,6 +120,12 @@ def create(
|
|
|
108
120
|
)
|
|
109
121
|
except (ContextAlreadyExists, InvalidContextName) as exc:
|
|
110
122
|
_exit_on_config_error(exc)
|
|
123
|
+
|
|
124
|
+
if via_tor or proxy:
|
|
125
|
+
if via_tor:
|
|
126
|
+
cfg.settings.use_tor = True
|
|
127
|
+
if proxy:
|
|
128
|
+
cfg.settings.proxy = proxy
|
|
111
129
|
cfg.save()
|
|
112
130
|
|
|
113
131
|
is_default = cfg.default_context == name
|
|
@@ -45,7 +45,7 @@ Reinstall (destructive — wipes ALL data):
|
|
|
45
45
|
|
|
46
46
|
from __future__ import annotations
|
|
47
47
|
|
|
48
|
-
from typing import Any
|
|
48
|
+
from typing import Any, Literal, cast
|
|
49
49
|
|
|
50
50
|
import typer
|
|
51
51
|
from impreza.exceptions import ApiError
|
|
@@ -76,9 +76,11 @@ def _emit(value: Any, *, ctx: typer.Context, title: str) -> None:
|
|
|
76
76
|
"""
|
|
77
77
|
fmt = resolve_output(from_typer_context(ctx), None)
|
|
78
78
|
if isinstance(value, list):
|
|
79
|
-
rows =
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
rows = (
|
|
80
|
+
value
|
|
81
|
+
if all(isinstance(row, dict) for row in value)
|
|
82
|
+
else [{"value": item} for item in value]
|
|
83
|
+
)
|
|
82
84
|
print_table(title, rows, fmt=fmt)
|
|
83
85
|
elif isinstance(value, dict):
|
|
84
86
|
print_dict(title, value, fmt=fmt)
|
|
@@ -356,8 +358,15 @@ def cmd_set_firewall(
|
|
|
356
358
|
"""Update DDoS firewall state/sensitivity for an IP. Requires the ``firewall`` capability."""
|
|
357
359
|
client = make_client_or_exit(from_typer_context(ctx))
|
|
358
360
|
try:
|
|
361
|
+
if state not in (None, "always_on", "redirect_on_attack"):
|
|
362
|
+
raise typer.BadParameter("Use always_on or redirect_on_attack.", param_hint="--state")
|
|
363
|
+
if sensitivity not in (None, "low", "normal", "medium", "high"):
|
|
364
|
+
raise typer.BadParameter("Use low, normal, medium or high.", param_hint="--sensitivity")
|
|
359
365
|
data = client.dedicated.set_firewall(
|
|
360
|
-
service_id,
|
|
366
|
+
service_id,
|
|
367
|
+
ip=ip,
|
|
368
|
+
state=cast(Literal["always_on", "redirect_on_attack"] | None, state),
|
|
369
|
+
sensitivity=cast(Literal["low", "normal", "medium", "high"] | None, sensitivity),
|
|
361
370
|
)
|
|
362
371
|
except ApiError as exc:
|
|
363
372
|
exit_on_api_error(exc)
|
|
@@ -382,7 +391,32 @@ def cmd_bandwidth(
|
|
|
382
391
|
"""Bandwidth graph (PNG base64). Requires the ``bandwidth`` capability."""
|
|
383
392
|
client = make_client_or_exit(from_typer_context(ctx))
|
|
384
393
|
try:
|
|
385
|
-
|
|
394
|
+
if type not in (
|
|
395
|
+
"port_bits",
|
|
396
|
+
"port_upkts",
|
|
397
|
+
"port_percent",
|
|
398
|
+
"port_errors",
|
|
399
|
+
"port_pktsize",
|
|
400
|
+
"port_discards",
|
|
401
|
+
):
|
|
402
|
+
raise typer.BadParameter("Unsupported bandwidth type.", param_hint="--type")
|
|
403
|
+
if scale not in ("day", "week", "month"):
|
|
404
|
+
raise typer.BadParameter("Use day, week or month.", param_hint="--scale")
|
|
405
|
+
data = client.dedicated.bandwidth(
|
|
406
|
+
service_id,
|
|
407
|
+
type=cast(
|
|
408
|
+
Literal[
|
|
409
|
+
"port_bits",
|
|
410
|
+
"port_upkts",
|
|
411
|
+
"port_percent",
|
|
412
|
+
"port_errors",
|
|
413
|
+
"port_pktsize",
|
|
414
|
+
"port_discards",
|
|
415
|
+
],
|
|
416
|
+
type,
|
|
417
|
+
),
|
|
418
|
+
scale=cast(Literal["day", "week", "month"], scale),
|
|
419
|
+
)
|
|
386
420
|
except ApiError as exc:
|
|
387
421
|
exit_on_api_error(exc)
|
|
388
422
|
return
|
|
@@ -167,6 +167,7 @@ class _Settings:
|
|
|
167
167
|
|
|
168
168
|
poll_interval: float | None = None
|
|
169
169
|
use_tor: bool | None = None
|
|
170
|
+
proxy: str | None = None
|
|
170
171
|
|
|
171
172
|
def to_toml_dict(self) -> dict[str, Any]:
|
|
172
173
|
body: dict[str, Any] = {}
|
|
@@ -174,6 +175,8 @@ class _Settings:
|
|
|
174
175
|
body["poll_interval"] = self.poll_interval
|
|
175
176
|
if self.use_tor is not None:
|
|
176
177
|
body["use_tor"] = self.use_tor
|
|
178
|
+
if self.proxy is not None:
|
|
179
|
+
body["proxy"] = self.proxy
|
|
177
180
|
return body
|
|
178
181
|
|
|
179
182
|
|
|
@@ -267,6 +270,9 @@ class Config:
|
|
|
267
270
|
tor = settings_raw.get("use_tor")
|
|
268
271
|
if isinstance(tor, bool):
|
|
269
272
|
settings.use_tor = tor
|
|
273
|
+
proxy = settings_raw.get("proxy")
|
|
274
|
+
if isinstance(proxy, str) and proxy:
|
|
275
|
+
settings.proxy = proxy
|
|
270
276
|
|
|
271
277
|
default_context = raw.get("default_context")
|
|
272
278
|
if not isinstance(default_context, str):
|
|
@@ -18,6 +18,7 @@ import typer
|
|
|
18
18
|
from . import __version__
|
|
19
19
|
from .commands import account as account_cmd
|
|
20
20
|
from .commands import catalog as catalog_cmd
|
|
21
|
+
from .commands import config as config_cmd
|
|
21
22
|
from .commands import context as context_cmd
|
|
22
23
|
from .commands import dedicated as dedicated_cmd
|
|
23
24
|
from .commands import doctor as doctor_cmd
|
|
@@ -61,6 +62,15 @@ def _root(
|
|
|
61
62
|
"-c",
|
|
62
63
|
help="Override the default context for this invocation.",
|
|
63
64
|
),
|
|
65
|
+
via_tor: bool = typer.Option(
|
|
66
|
+
False,
|
|
67
|
+
"--via-tor",
|
|
68
|
+
help=(
|
|
69
|
+
"Route this invocation through the local Tor daemon "
|
|
70
|
+
"(SOCKS5 127.0.0.1:9050). Overrides [settings] use_tor once; "
|
|
71
|
+
"persist it with `impreza context create --via-tor`."
|
|
72
|
+
),
|
|
73
|
+
),
|
|
64
74
|
output: OutputFormat | None = typer.Option(
|
|
65
75
|
None,
|
|
66
76
|
"--output",
|
|
@@ -77,7 +87,7 @@ def _root(
|
|
|
77
87
|
Global flags ``--context`` and ``--output`` apply to every
|
|
78
88
|
subcommand and are inherited via Typer's context object.
|
|
79
89
|
"""
|
|
80
|
-
ctx.obj = GlobalState(context_override=context, output=output)
|
|
90
|
+
ctx.obj = GlobalState(context_override=context, output=output, via_tor=via_tor)
|
|
81
91
|
|
|
82
92
|
|
|
83
93
|
# ── Subcommand mounts ────────────────────────────────────────────────
|
|
@@ -86,6 +96,7 @@ def _root(
|
|
|
86
96
|
# ``impreza --help`` output reads predictably.
|
|
87
97
|
app.add_typer(account_cmd.app, name="account")
|
|
88
98
|
app.add_typer(catalog_cmd.app, name="catalog")
|
|
99
|
+
app.add_typer(config_cmd.app, name="config")
|
|
89
100
|
app.add_typer(context_cmd.app, name="context")
|
|
90
101
|
app.add_typer(dedicated_cmd.app, name="dedicated")
|
|
91
102
|
app.add_typer(doctor_cmd.app, name="doctor")
|
|
@@ -69,10 +69,13 @@ def make_client(
|
|
|
69
69
|
if ctx.base_url:
|
|
70
70
|
kwargs["base_url"] = ctx.base_url
|
|
71
71
|
|
|
72
|
-
# Tor preference
|
|
73
|
-
#
|
|
72
|
+
# Tor/proxy preference: an explicit [settings] proxy wins; then the
|
|
73
|
+
# one-shot global --via-tor flag; then the CLI-wide use_tor setting.
|
|
74
|
+
# A future per-context override (one VPN context, one clearnet, etc.)
|
|
74
75
|
# would slot in here without touching callers.
|
|
75
|
-
if cfg.settings.
|
|
76
|
+
if cfg.settings.proxy:
|
|
77
|
+
kwargs["proxy"] = cfg.settings.proxy
|
|
78
|
+
elif state.via_tor or cfg.settings.use_tor:
|
|
76
79
|
kwargs["use_tor"] = True
|
|
77
80
|
|
|
78
81
|
return Client(**kwargs)
|
|
@@ -31,6 +31,11 @@ class GlobalState:
|
|
|
31
31
|
#: ``None`` means "use the config file's ``default_context``".
|
|
32
32
|
context_override: str | None = None
|
|
33
33
|
|
|
34
|
+
#: One-shot Tor routing selected via the global ``--via-tor`` flag.
|
|
35
|
+
#: Overrides the config file's ``[settings] use_tor`` for this
|
|
36
|
+
#: invocation only; ``None``/False means "follow the config".
|
|
37
|
+
via_tor: bool = False
|
|
38
|
+
|
|
34
39
|
#: Default output format selected via the global ``--output`` flag.
|
|
35
40
|
#: Per-command ``--output`` flags can override this on a single
|
|
36
41
|
#: invocation; ``None`` here means "no preference, fall back to
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: impreza-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -23,7 +23,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
|
23
23
|
Classifier: Topic :: Utilities
|
|
24
24
|
Requires-Python: >=3.10
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
|
-
Requires-Dist: impreza-sdk<0.
|
|
26
|
+
Requires-Dist: impreza-sdk<0.8,>=0.7.0
|
|
27
27
|
Requires-Dist: typer>=0.12
|
|
28
28
|
Requires-Dist: rich>=13.7
|
|
29
29
|
Requires-Dist: click>=8.0
|
|
@@ -227,23 +227,33 @@ equivalent) and `impreza <TAB>` should suggest resource groups,
|
|
|
227
227
|
|
|
228
228
|
## Tor
|
|
229
229
|
|
|
230
|
-
|
|
230
|
+
Four knobs, from the most persistent to the most one-off:
|
|
231
231
|
|
|
232
232
|
```bash
|
|
233
|
-
#
|
|
234
|
-
impreza context create offshore
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
# Persisted at context creation (writes [settings] use_tor=true)
|
|
234
|
+
impreza context create offshore --key imp_... --secret ... --via-tor
|
|
235
|
+
|
|
236
|
+
# Explicit SOCKS5 proxy instead (wins over use_tor; works for any
|
|
237
|
+
# SOCKS5, not just Tor)
|
|
238
|
+
impreza context create offshore --key imp_... --secret ... --proxy socks5://127.0.0.1:9050
|
|
239
|
+
|
|
240
|
+
# One-shot global flag on any command
|
|
241
|
+
impreza --via-tor account info
|
|
237
242
|
|
|
238
243
|
# Env var, picked up by the SDK transparently
|
|
239
244
|
IMPREZA_USE_TOR=1 impreza account info
|
|
240
|
-
|
|
241
|
-
# Programmatic via the SDK (Python users skip the CLI for this)
|
|
242
245
|
```
|
|
243
246
|
|
|
247
|
+
All four route through the local Tor daemon's SOCKS5 port with remote
|
|
248
|
+
DNS (no local lookup of the API hostname). Because exit nodes rotate,
|
|
249
|
+
set the API key's IP factor to `tofu` or `keyonly` in the client area —
|
|
250
|
+
`whitelist` cannot work without a stable egress IP. When the account
|
|
251
|
+
uses the API's `.onion` mirror, the IP factor sees a stable loopback
|
|
252
|
+
instead.
|
|
253
|
+
|
|
244
254
|
The SDK's `auto_tor=True` path (probe Tor, fall back to clearnet)
|
|
245
|
-
isn't surfaced through the CLI yet —
|
|
246
|
-
|
|
255
|
+
isn't surfaced through the CLI yet — it is a fail-open convenience,
|
|
256
|
+
which is the wrong default here.
|
|
247
257
|
|
|
248
258
|
## Error handling
|
|
249
259
|
|
|
@@ -259,6 +269,15 @@ Tracebacks never leak from expected failures (auth errors, missing
|
|
|
259
269
|
contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
|
|
260
270
|
the traceback isn't swallowed — that's intentional.
|
|
261
271
|
|
|
272
|
+
## Reviewed application configuration
|
|
273
|
+
|
|
274
|
+
Version 0.6.0 adds configuration export, review and apply. Documents are limited
|
|
275
|
+
to 64 KiB of UTF-8 and carry secret references instead of values. Applications
|
|
276
|
+
retain their current configuration until a reviewed plan is explicitly applied.
|
|
277
|
+
|
|
278
|
+
See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
|
|
279
|
+
for SDK and CLI examples, permissions, review expiry and replay behavior.
|
|
280
|
+
|
|
262
281
|
## Development
|
|
263
282
|
|
|
264
283
|
```bash
|