impreza-cli 0.5.1__tar.gz → 0.6.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.6.0}/PKG-INFO +11 -2
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/README.md +9 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/_helpers.py +2 -3
- impreza_cli-0.6.0/impreza_cli/commands/config.py +220 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/dedicated.py +40 -6
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/vps_cloud.py +1 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/main.py +2 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/PKG-INFO +11 -2
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/SOURCES.txt +2 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/requires.txt +1 -1
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/pyproject.toml +1 -1
- impreza_cli-0.6.0/tests/test_config_commands.py +297 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_dedicated_commands.py +21 -9
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/__init__.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/__init__.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/account.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/catalog.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/context.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/doctor.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/domain.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/invoice.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/key.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/orders.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/services.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/vps.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/vps_proxmox.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/webhooks.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/config.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/output.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/sdk.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/state.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/dependency_links.txt +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/entry_points.txt +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/top_level.txt +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/setup.cfg +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/.gitkeep +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/__init__.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/conftest.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_account_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_account_topup_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_catalog_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_config.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_context_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_doctor_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_domain_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_invoice_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_key_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_orders_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_2_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_3_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_4_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_5_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_6_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_1_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_2_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_3_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_4_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_5_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_6_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_7_smoke.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_services_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_vps_cloud_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_vps_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_vps_proxmox_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_webhooks_commands.py +0 -0
- {impreza_cli-0.5.1 → impreza_cli-0.6.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.6.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.7,>=0.6.0
|
|
27
27
|
Requires-Dist: typer>=0.12
|
|
28
28
|
Requires-Dist: rich>=13.7
|
|
29
29
|
Requires-Dist: click>=8.0
|
|
@@ -259,6 +259,15 @@ Tracebacks never leak from expected failures (auth errors, missing
|
|
|
259
259
|
contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
|
|
260
260
|
the traceback isn't swallowed — that's intentional.
|
|
261
261
|
|
|
262
|
+
## Reviewed application configuration
|
|
263
|
+
|
|
264
|
+
Version 0.6.0 adds configuration export, review and apply. Documents are limited
|
|
265
|
+
to 64 KiB of UTF-8 and carry secret references instead of values. Applications
|
|
266
|
+
retain their current configuration until a reviewed plan is explicitly applied.
|
|
267
|
+
|
|
268
|
+
See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
|
|
269
|
+
for SDK and CLI examples, permissions, review expiry and replay behavior.
|
|
270
|
+
|
|
262
271
|
## Development
|
|
263
272
|
|
|
264
273
|
```bash
|
|
@@ -217,6 +217,15 @@ Tracebacks never leak from expected failures (auth errors, missing
|
|
|
217
217
|
contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
|
|
218
218
|
the traceback isn't swallowed — that's intentional.
|
|
219
219
|
|
|
220
|
+
## Reviewed application configuration
|
|
221
|
+
|
|
222
|
+
Version 0.6.0 adds configuration export, review and apply. Documents are limited
|
|
223
|
+
to 64 KiB of UTF-8 and carry secret references instead of values. Applications
|
|
224
|
+
retain their current configuration until a reviewed plan is explicitly applied.
|
|
225
|
+
|
|
226
|
+
See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
|
|
227
|
+
for SDK and CLI examples, permissions, review expiry and replay behavior.
|
|
228
|
+
|
|
220
229
|
## Development
|
|
221
230
|
|
|
222
231
|
```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}")
|
|
@@ -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
|
|
@@ -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
|
|
@@ -86,6 +87,7 @@ def _root(
|
|
|
86
87
|
# ``impreza --help`` output reads predictably.
|
|
87
88
|
app.add_typer(account_cmd.app, name="account")
|
|
88
89
|
app.add_typer(catalog_cmd.app, name="catalog")
|
|
90
|
+
app.add_typer(config_cmd.app, name="config")
|
|
89
91
|
app.add_typer(context_cmd.app, name="context")
|
|
90
92
|
app.add_typer(dedicated_cmd.app, name="dedicated")
|
|
91
93
|
app.add_typer(doctor_cmd.app, name="doctor")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: impreza-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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.7,>=0.6.0
|
|
27
27
|
Requires-Dist: typer>=0.12
|
|
28
28
|
Requires-Dist: rich>=13.7
|
|
29
29
|
Requires-Dist: click>=8.0
|
|
@@ -259,6 +259,15 @@ Tracebacks never leak from expected failures (auth errors, missing
|
|
|
259
259
|
contexts, 404s, 429s, etc.). Bugs in the CLI itself still raise so
|
|
260
260
|
the traceback isn't swallowed — that's intentional.
|
|
261
261
|
|
|
262
|
+
## Reviewed application configuration
|
|
263
|
+
|
|
264
|
+
Version 0.6.0 adds configuration export, review and apply. Documents are limited
|
|
265
|
+
to 64 KiB of UTF-8 and carry secret references instead of values. Applications
|
|
266
|
+
retain their current configuration until a reviewed plan is explicitly applied.
|
|
267
|
+
|
|
268
|
+
See [the configuration guide](https://docs.imprezahost.com/customer-workflows.html#config-cli)
|
|
269
|
+
for SDK and CLI examples, permissions, review expiry and replay behavior.
|
|
270
|
+
|
|
262
271
|
## Development
|
|
263
272
|
|
|
264
273
|
```bash
|
|
@@ -16,6 +16,7 @@ impreza_cli/commands/__init__.py
|
|
|
16
16
|
impreza_cli/commands/_helpers.py
|
|
17
17
|
impreza_cli/commands/account.py
|
|
18
18
|
impreza_cli/commands/catalog.py
|
|
19
|
+
impreza_cli/commands/config.py
|
|
19
20
|
impreza_cli/commands/context.py
|
|
20
21
|
impreza_cli/commands/dedicated.py
|
|
21
22
|
impreza_cli/commands/doctor.py
|
|
@@ -35,6 +36,7 @@ tests/test_account_commands.py
|
|
|
35
36
|
tests/test_account_topup_commands.py
|
|
36
37
|
tests/test_catalog_commands.py
|
|
37
38
|
tests/test_config.py
|
|
39
|
+
tests/test_config_commands.py
|
|
38
40
|
tests/test_context_commands.py
|
|
39
41
|
tests/test_dedicated_commands.py
|
|
40
42
|
tests/test_doctor_commands.py
|
|
@@ -41,7 +41,7 @@ dependencies = [
|
|
|
41
41
|
# breaking (see CHANGELOG). Raise BOTH bounds in the same commit that
|
|
42
42
|
# makes the CLI depend on a newer SDK feature, or this stops
|
|
43
43
|
# protecting anything.
|
|
44
|
-
"impreza-sdk>=0.
|
|
44
|
+
"impreza-sdk>=0.6.0,<0.7",
|
|
45
45
|
"typer>=0.12",
|
|
46
46
|
"rich>=13.7",
|
|
47
47
|
# Declared explicitly because `config.py` imports click directly, for
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Unit tests for ``impreza config`` commands (P4 config-as-code CLI).
|
|
2
|
+
|
|
3
|
+
respx mocks the SDK's HTTP layer end-to-end: export writes the exact
|
|
4
|
+
document text, apply runs the prepare → review → apply flow carrying the
|
|
5
|
+
exact digest, and plan renders a saved review. The confirmation path,
|
|
6
|
+
digest mismatch refusals and the no-changes rendering are all covered.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import pytest
|
|
16
|
+
import respx
|
|
17
|
+
from typer.testing import CliRunner
|
|
18
|
+
|
|
19
|
+
from impreza_cli.config import Config
|
|
20
|
+
from impreza_cli.main import app
|
|
21
|
+
|
|
22
|
+
runner = CliRunner()
|
|
23
|
+
|
|
24
|
+
BASE = "https://api.imprezahost.com/v1"
|
|
25
|
+
_FAKE_KEY = "imp_" + ("a" * 40)
|
|
26
|
+
_FAKE_SECRET = "0" * 64
|
|
27
|
+
|
|
28
|
+
_DEPLOYMENT = "dpl_" + "c" * 16
|
|
29
|
+
_PLAN = "cplan_" + "6" * 24
|
|
30
|
+
_DIGEST = "f" * 64
|
|
31
|
+
_DOCUMENT = {
|
|
32
|
+
"schema": 1,
|
|
33
|
+
"deployment": {"name": "web", "build_strategy": "static_files"},
|
|
34
|
+
"resources": {},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def seeded_config(isolated_config: Path) -> Path:
|
|
40
|
+
cfg = Config.load(isolated_config)
|
|
41
|
+
cfg.add_context("personal", api_key=_FAKE_KEY, api_secret=_FAKE_SECRET)
|
|
42
|
+
cfg.save()
|
|
43
|
+
return isolated_config
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _export_envelope() -> dict[str, object]:
|
|
47
|
+
return {
|
|
48
|
+
"success": True,
|
|
49
|
+
"data": {
|
|
50
|
+
"schema": 1,
|
|
51
|
+
"deployment_id": _DEPLOYMENT,
|
|
52
|
+
"name": "web",
|
|
53
|
+
"exported_at": "2026-09-21T00:00:00Z",
|
|
54
|
+
"document": _DOCUMENT,
|
|
55
|
+
"document_text": json.dumps(_DOCUMENT, indent=2) + "\n",
|
|
56
|
+
"secrets": "references_only",
|
|
57
|
+
},
|
|
58
|
+
"meta": {"request_id": "req_t"},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _prepare_envelope(changes: object, diff: str) -> dict[str, object]:
|
|
63
|
+
return {
|
|
64
|
+
"success": True,
|
|
65
|
+
"data": {
|
|
66
|
+
"plan_id": _PLAN,
|
|
67
|
+
"review_digest": _DIGEST,
|
|
68
|
+
"status": "ready_for_review",
|
|
69
|
+
"expires_at": "2026-09-21T00:15:00Z",
|
|
70
|
+
"changes": changes,
|
|
71
|
+
"no_changes": changes == [],
|
|
72
|
+
"diff_text": diff,
|
|
73
|
+
"operation": "config_apply",
|
|
74
|
+
"deployment_id": _DEPLOYMENT,
|
|
75
|
+
},
|
|
76
|
+
"meta": {"request_id": "req_t"},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── export ────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@respx.mock
|
|
84
|
+
def test_export_writes_document_text(seeded_config: Path, tmp_path: Path) -> None:
|
|
85
|
+
respx.get(f"{BASE}/platform/deployments/custom/{_DEPLOYMENT}/config").mock(
|
|
86
|
+
return_value=httpx.Response(200, json=_export_envelope()),
|
|
87
|
+
)
|
|
88
|
+
out = tmp_path / "config.json"
|
|
89
|
+
result = runner.invoke(app, ["config", "export", _DEPLOYMENT, "--out", str(out)])
|
|
90
|
+
assert result.exit_code == 0, result.stderr
|
|
91
|
+
assert _DEPLOYMENT in result.stdout
|
|
92
|
+
assert "Exported" in result.stdout
|
|
93
|
+
written = out.read_text(encoding="utf-8")
|
|
94
|
+
assert json.loads(written) == _DOCUMENT
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@respx.mock
|
|
98
|
+
def test_export_stdout_is_document_only(seeded_config: Path) -> None:
|
|
99
|
+
respx.get(f"{BASE}/platform/deployments/custom/{_DEPLOYMENT}/config").mock(
|
|
100
|
+
return_value=httpx.Response(200, json=_export_envelope()),
|
|
101
|
+
)
|
|
102
|
+
result = runner.invoke(app, ["config", "export", _DEPLOYMENT])
|
|
103
|
+
assert result.exit_code == 0, result.stderr
|
|
104
|
+
assert json.loads(result.stdout) == _DOCUMENT
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── apply ─────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@respx.mock
|
|
111
|
+
def test_apply_flow_carries_exact_digest(seeded_config: Path, tmp_path: Path) -> None:
|
|
112
|
+
doc = tmp_path / "config.json"
|
|
113
|
+
doc.write_text(json.dumps(_DOCUMENT), encoding="utf-8")
|
|
114
|
+
prepare = respx.post(
|
|
115
|
+
f"{BASE}/platform/deployments/custom/{_DEPLOYMENT}/prepare-config-apply"
|
|
116
|
+
).mock(
|
|
117
|
+
return_value=httpx.Response(
|
|
118
|
+
201,
|
|
119
|
+
json=_prepare_envelope(
|
|
120
|
+
["vars: two changes", "build: one change"],
|
|
121
|
+
"--- a\n+++ b\n@@\n- static_spa: false\n+ static_spa: true\n",
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
apply = respx.post(f"{BASE}/platform/config-plans/{_PLAN}/apply").mock(
|
|
126
|
+
return_value=httpx.Response(
|
|
127
|
+
202,
|
|
128
|
+
json={
|
|
129
|
+
"success": True,
|
|
130
|
+
"data": {
|
|
131
|
+
"command_id": "cmd_" + "a" * 16,
|
|
132
|
+
"receipt": {"command_id": "cmd_" + "a" * 16},
|
|
133
|
+
},
|
|
134
|
+
"meta": {"request_id": "req_t"},
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
)
|
|
138
|
+
result = runner.invoke(app, ["config", "apply", _DEPLOYMENT, "--file", str(doc), "--yes"])
|
|
139
|
+
assert result.exit_code == 0, result.stderr
|
|
140
|
+
assert "static_spa: true" in result.stdout
|
|
141
|
+
assert "cmd_" + "a" * 16 in result.stdout
|
|
142
|
+
assert "Applied" in result.stdout or "applied" in result.stdout
|
|
143
|
+
|
|
144
|
+
prepare_body = json.loads(prepare.calls.last.request.content)
|
|
145
|
+
assert prepare_body == {"document": json.dumps(_DOCUMENT)}
|
|
146
|
+
apply_body = json.loads(apply.calls.last.request.content)
|
|
147
|
+
assert apply_body == {"confirm": True, "review_digest": _DIGEST}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@respx.mock
|
|
151
|
+
def test_apply_without_yes_prompts_and_declines(seeded_config: Path, tmp_path: Path) -> None:
|
|
152
|
+
doc = tmp_path / "config.json"
|
|
153
|
+
doc.write_text(json.dumps(_DOCUMENT), encoding="utf-8")
|
|
154
|
+
respx.post(f"{BASE}/platform/deployments/custom/{_DEPLOYMENT}/prepare-config-apply").mock(
|
|
155
|
+
return_value=httpx.Response(201, json=_prepare_envelope([], ""))
|
|
156
|
+
)
|
|
157
|
+
result = runner.invoke(app, ["config", "apply", _DEPLOYMENT, "--file", str(doc)], input="n\n")
|
|
158
|
+
assert result.exit_code == 0, result.stderr
|
|
159
|
+
assert "Cancelled" in result.stdout
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@respx.mock
|
|
163
|
+
def test_apply_without_yes_prompts_and_confirms(seeded_config: Path, tmp_path: Path) -> None:
|
|
164
|
+
doc = tmp_path / "config.json"
|
|
165
|
+
doc.write_text(json.dumps(_DOCUMENT), encoding="utf-8")
|
|
166
|
+
respx.post(f"{BASE}/platform/deployments/custom/{_DEPLOYMENT}/prepare-config-apply").mock(
|
|
167
|
+
return_value=httpx.Response(201, json=_prepare_envelope([], ""))
|
|
168
|
+
)
|
|
169
|
+
respx.post(f"{BASE}/platform/config-plans/{_PLAN}/apply").mock(
|
|
170
|
+
return_value=httpx.Response(
|
|
171
|
+
202,
|
|
172
|
+
json={
|
|
173
|
+
"success": True,
|
|
174
|
+
"data": {"command_id": "cmd_b"},
|
|
175
|
+
"meta": {"request_id": "req_t"},
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
result = runner.invoke(app, ["config", "apply", _DEPLOYMENT, "--file", str(doc)], input="y\n")
|
|
180
|
+
assert result.exit_code == 0, result.stderr
|
|
181
|
+
assert "No changes detected" in result.stdout
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@respx.mock
|
|
185
|
+
def test_apply_digest_mismatch_is_refused(seeded_config: Path, tmp_path: Path) -> None:
|
|
186
|
+
doc = tmp_path / "config.json"
|
|
187
|
+
doc.write_text(json.dumps(_DOCUMENT), encoding="utf-8")
|
|
188
|
+
respx.post(f"{BASE}/platform/deployments/custom/{_DEPLOYMENT}/prepare-config-apply").mock(
|
|
189
|
+
return_value=httpx.Response(201, json=_prepare_envelope([], ""))
|
|
190
|
+
)
|
|
191
|
+
respx.post(f"{BASE}/platform/config-plans/{_PLAN}/apply").mock(
|
|
192
|
+
return_value=httpx.Response(
|
|
193
|
+
409,
|
|
194
|
+
json={
|
|
195
|
+
"success": False,
|
|
196
|
+
"error": {"code": "CONFLICT", "message": "Config review digest does not match."},
|
|
197
|
+
},
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
result = runner.invoke(app, ["config", "apply", _DEPLOYMENT, "--file", str(doc), "--yes"])
|
|
201
|
+
assert result.exit_code != 0
|
|
202
|
+
assert "digest" in result.stdout.lower() or "digest" in result.stderr.lower()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@respx.mock
|
|
206
|
+
def test_apply_replayed_plan_renders_receipt(seeded_config: Path, tmp_path: Path) -> None:
|
|
207
|
+
doc = tmp_path / "config.json"
|
|
208
|
+
doc.write_text(json.dumps(_DOCUMENT), encoding="utf-8")
|
|
209
|
+
respx.post(f"{BASE}/platform/deployments/custom/{_DEPLOYMENT}/prepare-config-apply").mock(
|
|
210
|
+
return_value=httpx.Response(201, json=_prepare_envelope([], ""))
|
|
211
|
+
)
|
|
212
|
+
respx.post(f"{BASE}/platform/config-plans/{_PLAN}/apply").mock(
|
|
213
|
+
return_value=httpx.Response(
|
|
214
|
+
202,
|
|
215
|
+
json={
|
|
216
|
+
"success": True,
|
|
217
|
+
"data": {"replayed": True, "receipt": {"command_id": "cmd_first"}},
|
|
218
|
+
"meta": {"request_id": "req_t"},
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
result = runner.invoke(app, ["config", "apply", _DEPLOYMENT, "--file", str(doc), "--yes"])
|
|
223
|
+
assert result.exit_code == 0, result.stderr
|
|
224
|
+
assert "already accepted" in result.stdout
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── plan ──────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@respx.mock
|
|
231
|
+
def test_plan_renders_status_and_digest(seeded_config: Path) -> None:
|
|
232
|
+
respx.get(f"{BASE}/platform/config-plans/{_PLAN}").mock(
|
|
233
|
+
return_value=httpx.Response(
|
|
234
|
+
200,
|
|
235
|
+
json={
|
|
236
|
+
"success": True,
|
|
237
|
+
"data": {
|
|
238
|
+
"plan_id": _PLAN,
|
|
239
|
+
"review_digest": _DIGEST,
|
|
240
|
+
"status": "accepted",
|
|
241
|
+
"receipt": {"command_id": "cmd_first"},
|
|
242
|
+
},
|
|
243
|
+
"meta": {"request_id": "req_t"},
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
result = runner.invoke(app, ["config", "plan", _PLAN])
|
|
248
|
+
assert result.exit_code == 0, result.stderr
|
|
249
|
+
assert "accepted" in result.stdout
|
|
250
|
+
assert _DIGEST in result.stdout
|
|
251
|
+
assert "cmd_first" in result.stdout
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@pytest.mark.parametrize(
|
|
255
|
+
"field,value",
|
|
256
|
+
[
|
|
257
|
+
("deployment_id", "dpl_" + "d" * 16),
|
|
258
|
+
("operation", "delete"),
|
|
259
|
+
("plan_id", "cplan_" + "6" * 24 + "/apply"),
|
|
260
|
+
("review_digest", ""),
|
|
261
|
+
("changes", None),
|
|
262
|
+
("changes", {}),
|
|
263
|
+
("changes", [123]),
|
|
264
|
+
("diff_text", None),
|
|
265
|
+
("no_changes", False),
|
|
266
|
+
],
|
|
267
|
+
)
|
|
268
|
+
@respx.mock
|
|
269
|
+
def test_incomplete_review_never_applies_even_with_yes(
|
|
270
|
+
seeded_config: Path, tmp_path: Path, field, value
|
|
271
|
+
) -> None:
|
|
272
|
+
doc = tmp_path / "config.json"
|
|
273
|
+
doc.write_text(json.dumps(_DOCUMENT), encoding="utf-8")
|
|
274
|
+
payload = _prepare_envelope([], "No changes")
|
|
275
|
+
payload["data"][field] = value
|
|
276
|
+
respx.post(f"{BASE}/platform/deployments/custom/{_DEPLOYMENT}/prepare-config-apply").respond(
|
|
277
|
+
201, json=payload
|
|
278
|
+
)
|
|
279
|
+
result = runner.invoke(app, ["config", "apply", _DEPLOYMENT, "--file", str(doc), "--yes"])
|
|
280
|
+
assert result.exit_code == 1
|
|
281
|
+
assert "nothing was applied" in result.stderr
|
|
282
|
+
assert len(respx.calls) == 1
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@respx.mock
|
|
286
|
+
def test_oversize_file_is_rejected_without_network(seeded_config: Path, tmp_path: Path) -> None:
|
|
287
|
+
doc = tmp_path / "large.json"
|
|
288
|
+
doc.write_bytes(b"x" * 65537)
|
|
289
|
+
result = runner.invoke(app, ["config", "apply", _DEPLOYMENT, "--file", str(doc), "--yes"])
|
|
290
|
+
assert result.exit_code == 1
|
|
291
|
+
assert len(respx.calls) == 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_review_terminal_control_is_visible() -> None:
|
|
295
|
+
from impreza_cli.commands.config import _terminal_text
|
|
296
|
+
|
|
297
|
+
assert _terminal_text("before\x1b[2J\r\u202eafter") == "before\\u001b[2J\\u000d\\u202eafter"
|
|
@@ -91,9 +91,7 @@ def test_list_renders_table(seeded_config: Path) -> None:
|
|
|
91
91
|
def test_list_json_echoes_raw_payload(seeded_config: Path) -> None:
|
|
92
92
|
"""JSON output passes the server payload through untouched, so
|
|
93
93
|
scripts see every field — not just the table projection."""
|
|
94
|
-
respx.get(f"{BASE}/dedicated").mock(
|
|
95
|
-
return_value=httpx.Response(200, json=_ok(_SERVERS))
|
|
96
|
-
)
|
|
94
|
+
respx.get(f"{BASE}/dedicated").mock(return_value=httpx.Response(200, json=_ok(_SERVERS)))
|
|
97
95
|
result = runner.invoke(app, ["--output", "json", "dedicated", "list"])
|
|
98
96
|
assert result.exit_code == 0, result.stdout
|
|
99
97
|
assert json.loads(result.stdout) == _SERVERS
|
|
@@ -166,9 +164,7 @@ def test_dict_verbs_render(
|
|
|
166
164
|
needle: str,
|
|
167
165
|
) -> None:
|
|
168
166
|
"""Every read verb that returns a dict renders a Field / Value table."""
|
|
169
|
-
route = respx.get(f"{BASE}{path}").mock(
|
|
170
|
-
return_value=httpx.Response(200, json=_ok(payload))
|
|
171
|
-
)
|
|
167
|
+
route = respx.get(f"{BASE}{path}").mock(return_value=httpx.Response(200, json=_ok(payload)))
|
|
172
168
|
result = runner.invoke(app, argv)
|
|
173
169
|
assert result.exit_code == 0, result.stdout
|
|
174
170
|
assert route.called
|
|
@@ -215,9 +211,7 @@ def test_set_rdns_puts_ip_in_path_and_hostname_in_body(seeded_config: Path) -> N
|
|
|
215
211
|
)
|
|
216
212
|
assert result.exit_code == 0, result.stdout
|
|
217
213
|
assert route.called
|
|
218
|
-
assert json.loads(route.calls.last.request.content) == {
|
|
219
|
-
"hostname": "srv1.example.com"
|
|
220
|
-
}
|
|
214
|
+
assert json.loads(route.calls.last.request.content) == {"hostname": "srv1.example.com"}
|
|
221
215
|
|
|
222
216
|
|
|
223
217
|
@respx.mock
|
|
@@ -294,3 +288,21 @@ def test_reinstall_sends_wipe_header(seeded_config: Path) -> None:
|
|
|
294
288
|
body = json.loads(route.calls.last.request.content)
|
|
295
289
|
assert body["confirm"] is True
|
|
296
290
|
assert body["os_id"] == "ubuntu-24.04"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@pytest.mark.parametrize(
|
|
294
|
+
"args",
|
|
295
|
+
[
|
|
296
|
+
["set-firewall", "101", "--ip", "203.0.113.10", "--state", "invalid"],
|
|
297
|
+
["set-firewall", "101", "--ip", "203.0.113.10", "--sensitivity", "invalid"],
|
|
298
|
+
["bandwidth", "101", "--type", "invalid"],
|
|
299
|
+
["bandwidth", "101", "--scale", "invalid"],
|
|
300
|
+
],
|
|
301
|
+
)
|
|
302
|
+
@respx.mock
|
|
303
|
+
def test_invalid_dedicated_options_never_reach_transport(
|
|
304
|
+
seeded_config: Path, args: list[str]
|
|
305
|
+
) -> None:
|
|
306
|
+
result = runner.invoke(app, ["dedicated"] + args)
|
|
307
|
+
assert result.exit_code == 2
|
|
308
|
+
assert len(respx.calls) == 0
|
|
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
|