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.
Files changed (66) hide show
  1. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/PKG-INFO +11 -2
  2. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/README.md +9 -0
  3. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/_helpers.py +2 -3
  4. impreza_cli-0.6.0/impreza_cli/commands/config.py +220 -0
  5. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/dedicated.py +40 -6
  6. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/vps_cloud.py +1 -0
  7. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/main.py +2 -0
  8. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/PKG-INFO +11 -2
  9. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/SOURCES.txt +2 -0
  10. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/requires.txt +1 -1
  11. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/pyproject.toml +1 -1
  12. impreza_cli-0.6.0/tests/test_config_commands.py +297 -0
  13. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_dedicated_commands.py +21 -9
  14. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/__init__.py +0 -0
  15. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/__init__.py +0 -0
  16. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/account.py +0 -0
  17. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/catalog.py +0 -0
  18. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/context.py +0 -0
  19. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/doctor.py +0 -0
  20. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/domain.py +0 -0
  21. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/invoice.py +0 -0
  22. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/key.py +0 -0
  23. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/orders.py +0 -0
  24. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/services.py +0 -0
  25. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/vps.py +0 -0
  26. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/vps_proxmox.py +0 -0
  27. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/commands/webhooks.py +0 -0
  28. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/config.py +0 -0
  29. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/output.py +0 -0
  30. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/sdk.py +0 -0
  31. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli/state.py +0 -0
  32. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/dependency_links.txt +0 -0
  33. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/entry_points.txt +0 -0
  34. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/impreza_cli.egg-info/top_level.txt +0 -0
  35. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/setup.cfg +0 -0
  36. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/.gitkeep +0 -0
  37. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/__init__.py +0 -0
  38. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/conftest.py +0 -0
  39. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_account_commands.py +0 -0
  40. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_account_topup_commands.py +0 -0
  41. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_catalog_commands.py +0 -0
  42. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_config.py +0 -0
  43. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_context_commands.py +0 -0
  44. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_doctor_commands.py +0 -0
  45. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_domain_commands.py +0 -0
  46. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_invoice_commands.py +0 -0
  47. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_key_commands.py +0 -0
  48. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_orders_commands.py +0 -0
  49. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_2_smoke.py +0 -0
  50. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_3_smoke.py +0 -0
  51. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_4_smoke.py +0 -0
  52. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_5_smoke.py +0 -0
  53. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_2_6_smoke.py +0 -0
  54. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_1_smoke.py +0 -0
  55. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_2_smoke.py +0 -0
  56. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_3_smoke.py +0 -0
  57. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_4_smoke.py +0 -0
  58. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_5_smoke.py +0 -0
  59. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_6_smoke.py +0 -0
  60. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_phase_3_7_smoke.py +0 -0
  61. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_services_commands.py +0 -0
  62. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_vps_cloud_commands.py +0 -0
  63. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_vps_commands.py +0 -0
  64. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_vps_proxmox_commands.py +0 -0
  65. {impreza_cli-0.5.1 → impreza_cli-0.6.0}/tests/test_webhooks_commands.py +0 -0
  66. {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.5.1
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.6,>=0.5.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: Any, service_id: int) -> Vps:
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 = value if all(isinstance(row, dict) for row in value) else [
80
- {"value": item} for item in value
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, ip=ip, state=state, sensitivity=sensitivity
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
- data = client.dedicated.bandwidth(service_id, type=type, scale=scale)
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
@@ -178,6 +178,7 @@ def images_list(
178
178
  if not images:
179
179
  typer.echo("No images saved on this account.")
180
180
  return
181
+ rows: list[dict[str, Any]]
181
182
  if fmt is OutputFormat.TABLE:
182
183
  rows = [
183
184
  {
@@ -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.5.1
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.6,>=0.5.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
@@ -1,4 +1,4 @@
1
- impreza-sdk<0.6,>=0.5.0
1
+ impreza-sdk<0.7,>=0.6.0
2
2
  typer>=0.12
3
3
  rich>=13.7
4
4
  click>=8.0
@@ -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.5.0,<0.6",
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