impreza-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- impreza_cli/__init__.py +14 -0
- impreza_cli/commands/__init__.py +7 -0
- impreza_cli/commands/_helpers.py +128 -0
- impreza_cli/commands/account.py +576 -0
- impreza_cli/commands/catalog.py +270 -0
- impreza_cli/commands/context.py +232 -0
- impreza_cli/commands/doctor.py +427 -0
- impreza_cli/commands/domain.py +858 -0
- impreza_cli/commands/invoice.py +198 -0
- impreza_cli/commands/key.py +104 -0
- impreza_cli/commands/orders.py +478 -0
- impreza_cli/commands/services.py +100 -0
- impreza_cli/commands/vps.py +812 -0
- impreza_cli/commands/vps_cloud.py +865 -0
- impreza_cli/commands/vps_proxmox.py +727 -0
- impreza_cli/commands/webhooks.py +483 -0
- impreza_cli/config.py +405 -0
- impreza_cli/main.py +100 -0
- impreza_cli/output.py +207 -0
- impreza_cli/sdk.py +97 -0
- impreza_cli/state.py +94 -0
- impreza_cli-0.3.0.dist-info/METADATA +296 -0
- impreza_cli-0.3.0.dist-info/RECORD +26 -0
- impreza_cli-0.3.0.dist-info/WHEEL +5 -0
- impreza_cli-0.3.0.dist-info/entry_points.txt +2 -0
- impreza_cli-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""``impreza vps proxmox`` sub-command surface — Phase 3.4.
|
|
2
|
+
|
|
3
|
+
Proxmox-only sub-resources mounted on the main ``vps`` Typer app
|
|
4
|
+
as the ``proxmox`` namespace. Four groups:
|
|
5
|
+
|
|
6
|
+
* ``vps proxmox snapshots``
|
|
7
|
+
- ``list <id>`` / ``create <id> <name>`` / ``delete <id> <name>``
|
|
8
|
+
/ ``rollback <id> <name>`` (rollback wraps Operation polling)
|
|
9
|
+
|
|
10
|
+
* ``vps proxmox backups``
|
|
11
|
+
- ``list <id>`` / ``create <id>`` / ``restore <id> <backup-id>``
|
|
12
|
+
/ ``delete <id> <backup-id>`` (create + restore wrap Operation
|
|
13
|
+
polling)
|
|
14
|
+
|
|
15
|
+
* ``vps proxmox backup-schedules``
|
|
16
|
+
- ``list <id>`` / ``create <id> --dow ... --hour ... --minute ...``
|
|
17
|
+
/ ``delete <id> <schedule-id>``
|
|
18
|
+
|
|
19
|
+
* ``vps proxmox network``
|
|
20
|
+
- ``reconfigure <id>`` — apply pending Proxmox network config
|
|
21
|
+
|
|
22
|
+
All commands here go through the ``Vps`` bound model
|
|
23
|
+
(``client.vps.get(id).snapshots`` / ``.backups`` / etc.) so a Cloud
|
|
24
|
+
VPS gets ``BackendNotSupported`` mapped to a friendly "Proxmox-only"
|
|
25
|
+
stderr line (mirroring the 3.3 pattern used for migrate / suspend).
|
|
26
|
+
|
|
27
|
+
The shared helpers (``resolve_vps_or_exit``, ``wait_for_operation``,
|
|
28
|
+
``exit_on_api_error``) live in ``commands/_helpers.py`` so this
|
|
29
|
+
module doesn't import from ``commands.vps`` and we avoid a cycle.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import typer
|
|
37
|
+
from impreza.exceptions import ApiError, BackendNotSupported
|
|
38
|
+
|
|
39
|
+
from ..output import OutputFormat, error, info, print_dict, print_table, success
|
|
40
|
+
from ..sdk import make_client_or_exit
|
|
41
|
+
from ..state import confirm_or_exit, from_typer_context, resolve_output
|
|
42
|
+
from ._helpers import (
|
|
43
|
+
exit_on_api_error,
|
|
44
|
+
resolve_vps_or_exit,
|
|
45
|
+
wait_for_operation,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# ── parent Typer app + nested sub-apps ──────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
app = typer.Typer(
|
|
52
|
+
name="proxmox",
|
|
53
|
+
help="Proxmox-only sub-resources: snapshots, backups, schedules, network.",
|
|
54
|
+
no_args_is_help=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
snapshots_app = typer.Typer(
|
|
58
|
+
name="snapshots",
|
|
59
|
+
help="Manage Proxmox VM snapshots.",
|
|
60
|
+
no_args_is_help=True,
|
|
61
|
+
)
|
|
62
|
+
backups_app = typer.Typer(
|
|
63
|
+
name="backups",
|
|
64
|
+
help="Manage Proxmox VM backups.",
|
|
65
|
+
no_args_is_help=True,
|
|
66
|
+
)
|
|
67
|
+
schedules_app = typer.Typer(
|
|
68
|
+
name="backup-schedules",
|
|
69
|
+
help="Manage scheduled-backup jobs on a Proxmox VM.",
|
|
70
|
+
no_args_is_help=True,
|
|
71
|
+
)
|
|
72
|
+
network_app = typer.Typer(
|
|
73
|
+
name="network",
|
|
74
|
+
help="Proxmox network operations (apply pending config).",
|
|
75
|
+
no_args_is_help=True,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
app.add_typer(snapshots_app)
|
|
79
|
+
app.add_typer(backups_app)
|
|
80
|
+
app.add_typer(schedules_app)
|
|
81
|
+
app.add_typer(network_app)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── helpers ─────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _proxmox_only_exit(op: str) -> None:
|
|
88
|
+
"""Print the standard "Proxmox-only" stderr line and exit 1.
|
|
89
|
+
Called from every ``except BackendNotSupported:`` branch on the
|
|
90
|
+
sub-resource verbs (mirroring the 3.3 migrate / suspend pattern).
|
|
91
|
+
"""
|
|
92
|
+
error(f"This VPS is on the Cloud backend — {op} is Proxmox-only.")
|
|
93
|
+
raise typer.Exit(code=1) from None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _format_bytes(value: int | None) -> str:
|
|
97
|
+
"""Human-readable byte count for backup sizes. ``None`` → ``-``.
|
|
98
|
+
Matches the table-mode renderer in :mod:`commands.vps`."""
|
|
99
|
+
if value is None:
|
|
100
|
+
return "-"
|
|
101
|
+
mb = 1024 * 1024
|
|
102
|
+
gb = 1024 * mb
|
|
103
|
+
if value >= gb:
|
|
104
|
+
return f"{value / gb:.2f} GB"
|
|
105
|
+
if value >= mb:
|
|
106
|
+
return f"{value / mb:.0f} MB"
|
|
107
|
+
return f"{value} B"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
111
|
+
# Snapshots
|
|
112
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@snapshots_app.command("list")
|
|
116
|
+
def snapshots_list(
|
|
117
|
+
typer_ctx: typer.Context,
|
|
118
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
119
|
+
output: OutputFormat | None = typer.Option(
|
|
120
|
+
None, "--output", "-o",
|
|
121
|
+
help="Output format. Overrides the global --output flag.",
|
|
122
|
+
case_sensitive=False,
|
|
123
|
+
),
|
|
124
|
+
) -> None:
|
|
125
|
+
"""List Proxmox snapshots on the VPS.
|
|
126
|
+
|
|
127
|
+
Wraps ``vps.snapshots.list()``. Renders ``name``, ``description``,
|
|
128
|
+
and ``created_at`` in table mode; JSON / YAML emit the full
|
|
129
|
+
:class:`~impreza.models.vps_extras.Snapshot` payload.
|
|
130
|
+
"""
|
|
131
|
+
state = from_typer_context(typer_ctx)
|
|
132
|
+
fmt = resolve_output(state, output)
|
|
133
|
+
with make_client_or_exit(state) as client:
|
|
134
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
135
|
+
try:
|
|
136
|
+
snapshots = vps.snapshots.list()
|
|
137
|
+
except BackendNotSupported:
|
|
138
|
+
_proxmox_only_exit("snapshots")
|
|
139
|
+
return
|
|
140
|
+
except ApiError as exc:
|
|
141
|
+
exit_on_api_error(exc)
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
if not snapshots:
|
|
145
|
+
typer.echo(f"No snapshots on VPS {service_id}.")
|
|
146
|
+
return
|
|
147
|
+
rows = [
|
|
148
|
+
{
|
|
149
|
+
"name": s.name,
|
|
150
|
+
"description": s.description or "",
|
|
151
|
+
"created_at": s.created_at or "",
|
|
152
|
+
}
|
|
153
|
+
for s in snapshots
|
|
154
|
+
]
|
|
155
|
+
print_table(
|
|
156
|
+
f"Snapshots — VPS {service_id} ({len(rows)})",
|
|
157
|
+
rows,
|
|
158
|
+
columns=["name", "description", "created_at"],
|
|
159
|
+
fmt=fmt,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@snapshots_app.command("create")
|
|
164
|
+
def snapshots_create(
|
|
165
|
+
typer_ctx: typer.Context,
|
|
166
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
167
|
+
name: str = typer.Argument(
|
|
168
|
+
...,
|
|
169
|
+
help=(
|
|
170
|
+
"Snapshot name. Proxmox restricts to letters/digits/dashes/"
|
|
171
|
+
"underscores; passing a name with other characters may be "
|
|
172
|
+
"rejected upstream."
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
description: str | None = typer.Option(
|
|
176
|
+
None, "--description", "-d", help="Optional human-readable note."
|
|
177
|
+
),
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Take a new Proxmox snapshot.
|
|
180
|
+
|
|
181
|
+
Wraps ``vps.snapshots.create(name, description=...)``. Synchronous
|
|
182
|
+
on the SDK side — returns the snapshot model directly.
|
|
183
|
+
"""
|
|
184
|
+
state = from_typer_context(typer_ctx)
|
|
185
|
+
with make_client_or_exit(state) as client:
|
|
186
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
187
|
+
try:
|
|
188
|
+
snapshot = vps.snapshots.create(name, description=description)
|
|
189
|
+
except BackendNotSupported:
|
|
190
|
+
_proxmox_only_exit("snapshots")
|
|
191
|
+
return
|
|
192
|
+
except ApiError as exc:
|
|
193
|
+
exit_on_api_error(exc)
|
|
194
|
+
return
|
|
195
|
+
success(f"Snapshot {snapshot.name!r} created on VPS {service_id}.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@snapshots_app.command("delete")
|
|
199
|
+
def snapshots_delete(
|
|
200
|
+
typer_ctx: typer.Context,
|
|
201
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
202
|
+
name: str = typer.Argument(..., help="Snapshot name to delete."),
|
|
203
|
+
yes: bool = typer.Option(
|
|
204
|
+
False, "--yes", "-y", help="Skip the deletion confirmation prompt."
|
|
205
|
+
),
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Delete a Proxmox snapshot. **Irreversible** — there's no
|
|
208
|
+
undo; the saved VM state is freed at the storage layer.
|
|
209
|
+
"""
|
|
210
|
+
state = from_typer_context(typer_ctx)
|
|
211
|
+
confirm_or_exit(
|
|
212
|
+
f"Deleting snapshot {name!r} on VPS {service_id} is irreversible — "
|
|
213
|
+
"the saved VM state is freed and cannot be recovered.",
|
|
214
|
+
yes=yes,
|
|
215
|
+
)
|
|
216
|
+
with make_client_or_exit(state) as client:
|
|
217
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
218
|
+
try:
|
|
219
|
+
vps.snapshots.delete(name)
|
|
220
|
+
except BackendNotSupported:
|
|
221
|
+
_proxmox_only_exit("snapshots")
|
|
222
|
+
return
|
|
223
|
+
except ApiError as exc:
|
|
224
|
+
exit_on_api_error(exc)
|
|
225
|
+
return
|
|
226
|
+
success(f"Snapshot {name!r} deleted from VPS {service_id}.")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@snapshots_app.command("rollback")
|
|
230
|
+
def snapshots_rollback(
|
|
231
|
+
typer_ctx: typer.Context,
|
|
232
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
233
|
+
name: str = typer.Argument(..., help="Snapshot name to roll back to."),
|
|
234
|
+
yes: bool = typer.Option(
|
|
235
|
+
False, "--yes", "-y", help="Skip the data-loss confirmation prompt."
|
|
236
|
+
),
|
|
237
|
+
wait: bool = typer.Option(
|
|
238
|
+
False,
|
|
239
|
+
"--wait",
|
|
240
|
+
help="Block until the Proxmox queue reports the rollback as complete.",
|
|
241
|
+
),
|
|
242
|
+
timeout: int = typer.Option(
|
|
243
|
+
600,
|
|
244
|
+
"--timeout",
|
|
245
|
+
help="Max seconds to wait when --wait is set. Default 600 (10 min).",
|
|
246
|
+
),
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Roll the VM back to a snapshot. **Destructive** — any disk
|
|
249
|
+
changes since the snapshot was taken are lost. The VM is
|
|
250
|
+
stopped during rollback.
|
|
251
|
+
|
|
252
|
+
Returns an :class:`Operation` future. Without ``--wait`` the CLI
|
|
253
|
+
surfaces the operation uuid and returns immediately; with
|
|
254
|
+
``--wait`` it polls until the upstream Proxmox queue settles
|
|
255
|
+
(or ``--timeout`` elapses).
|
|
256
|
+
"""
|
|
257
|
+
state = from_typer_context(typer_ctx)
|
|
258
|
+
confirm_or_exit(
|
|
259
|
+
f"Rolling back VPS {service_id} to snapshot {name!r} discards "
|
|
260
|
+
"every disk change made after the snapshot was taken. The VM "
|
|
261
|
+
"is stopped during rollback.",
|
|
262
|
+
yes=yes,
|
|
263
|
+
)
|
|
264
|
+
with make_client_or_exit(state) as client:
|
|
265
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
266
|
+
try:
|
|
267
|
+
op = vps.snapshots.rollback(name)
|
|
268
|
+
except BackendNotSupported:
|
|
269
|
+
_proxmox_only_exit("snapshots")
|
|
270
|
+
return
|
|
271
|
+
except ApiError as exc:
|
|
272
|
+
exit_on_api_error(exc)
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
if not wait:
|
|
276
|
+
info(
|
|
277
|
+
f"Rollback queued for VPS {service_id} → snapshot {name!r}. "
|
|
278
|
+
f"Operation uuid: {op.uuid}"
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
wait_for_operation(
|
|
282
|
+
op,
|
|
283
|
+
label=f"Rolling back VPS {service_id} to {name!r}",
|
|
284
|
+
timeout=timeout,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
289
|
+
# Backups
|
|
290
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _backup_row(b: Any) -> dict[str, Any]:
|
|
294
|
+
"""Lift a :class:`Backup` model into a flat row for table mode."""
|
|
295
|
+
return {
|
|
296
|
+
"id": b.id,
|
|
297
|
+
"date": b.date or "",
|
|
298
|
+
"size": _format_bytes(b.size),
|
|
299
|
+
"mode": b.mode or "",
|
|
300
|
+
"compress": b.compress or "",
|
|
301
|
+
"protected": "yes" if b.protected else "",
|
|
302
|
+
"notes": b.notes or "",
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@backups_app.command("list")
|
|
307
|
+
def backups_list(
|
|
308
|
+
typer_ctx: typer.Context,
|
|
309
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
310
|
+
output: OutputFormat | None = typer.Option(
|
|
311
|
+
None, "--output", "-o",
|
|
312
|
+
help="Output format. Overrides the global --output flag.",
|
|
313
|
+
case_sensitive=False,
|
|
314
|
+
),
|
|
315
|
+
) -> None:
|
|
316
|
+
"""List Proxmox backups on the VPS.
|
|
317
|
+
|
|
318
|
+
Wraps ``vps.backups.list()``. Table mode renders ``size`` as
|
|
319
|
+
MB/GB; JSON/YAML emit raw bytes for piping.
|
|
320
|
+
"""
|
|
321
|
+
state = from_typer_context(typer_ctx)
|
|
322
|
+
fmt = resolve_output(state, output)
|
|
323
|
+
with make_client_or_exit(state) as client:
|
|
324
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
325
|
+
try:
|
|
326
|
+
backups = vps.backups.list()
|
|
327
|
+
except BackendNotSupported:
|
|
328
|
+
_proxmox_only_exit("backups")
|
|
329
|
+
return
|
|
330
|
+
except ApiError as exc:
|
|
331
|
+
exit_on_api_error(exc)
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
if not backups:
|
|
335
|
+
typer.echo(f"No backups on VPS {service_id}.")
|
|
336
|
+
return
|
|
337
|
+
if fmt is OutputFormat.TABLE:
|
|
338
|
+
rows = [_backup_row(b) for b in backups]
|
|
339
|
+
else:
|
|
340
|
+
rows = [
|
|
341
|
+
{
|
|
342
|
+
"id": b.id,
|
|
343
|
+
"date": b.date,
|
|
344
|
+
"size": b.size,
|
|
345
|
+
"mode": b.mode,
|
|
346
|
+
"compress": b.compress,
|
|
347
|
+
"protected": b.protected,
|
|
348
|
+
"notes": b.notes,
|
|
349
|
+
}
|
|
350
|
+
for b in backups
|
|
351
|
+
]
|
|
352
|
+
print_table(
|
|
353
|
+
f"Backups — VPS {service_id} ({len(rows)})",
|
|
354
|
+
rows,
|
|
355
|
+
columns=["id", "date", "size", "mode", "compress", "protected", "notes"],
|
|
356
|
+
fmt=fmt,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@backups_app.command("create")
|
|
361
|
+
def backups_create(
|
|
362
|
+
typer_ctx: typer.Context,
|
|
363
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
364
|
+
wait: bool = typer.Option(
|
|
365
|
+
False,
|
|
366
|
+
"--wait",
|
|
367
|
+
help="Block until the Proxmox queue reports the backup as complete.",
|
|
368
|
+
),
|
|
369
|
+
timeout: int = typer.Option(
|
|
370
|
+
1800,
|
|
371
|
+
"--timeout",
|
|
372
|
+
help="Max seconds to wait when --wait is set. Default 1800 (30 min).",
|
|
373
|
+
),
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Trigger a new Proxmox backup.
|
|
376
|
+
|
|
377
|
+
Wraps ``vps.backups.create()``. Returns an :class:`Operation`
|
|
378
|
+
future — without ``--wait`` the CLI prints the uuid and exits;
|
|
379
|
+
with ``--wait`` it polls (default 30-minute timeout since full
|
|
380
|
+
disk dumps can run long on large VMs).
|
|
381
|
+
|
|
382
|
+
Subject to the per-VM backup limit configured upstream — if you
|
|
383
|
+
hit the limit, the API returns a 4xx and the SDK raises
|
|
384
|
+
:class:`InvalidRequest`.
|
|
385
|
+
"""
|
|
386
|
+
state = from_typer_context(typer_ctx)
|
|
387
|
+
with make_client_or_exit(state) as client:
|
|
388
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
389
|
+
try:
|
|
390
|
+
op = vps.backups.create()
|
|
391
|
+
except BackendNotSupported:
|
|
392
|
+
_proxmox_only_exit("backups")
|
|
393
|
+
return
|
|
394
|
+
except ApiError as exc:
|
|
395
|
+
exit_on_api_error(exc)
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
if not wait:
|
|
399
|
+
info(
|
|
400
|
+
f"Backup queued for VPS {service_id}. "
|
|
401
|
+
f"Operation uuid: {op.uuid}"
|
|
402
|
+
)
|
|
403
|
+
return
|
|
404
|
+
wait_for_operation(
|
|
405
|
+
op,
|
|
406
|
+
label=f"Creating backup for VPS {service_id}",
|
|
407
|
+
timeout=timeout,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@backups_app.command("restore")
|
|
412
|
+
def backups_restore(
|
|
413
|
+
typer_ctx: typer.Context,
|
|
414
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
415
|
+
backup_id: str = typer.Argument(
|
|
416
|
+
...,
|
|
417
|
+
help=(
|
|
418
|
+
"Backup id from `vps proxmox backups list`. Accepts the "
|
|
419
|
+
"registrar's id verbatim — typically a string but may be "
|
|
420
|
+
"numeric depending on the storage backend."
|
|
421
|
+
),
|
|
422
|
+
),
|
|
423
|
+
yes: bool = typer.Option(
|
|
424
|
+
False, "--yes", "-y", help="Skip the data-loss confirmation prompt."
|
|
425
|
+
),
|
|
426
|
+
wait: bool = typer.Option(
|
|
427
|
+
False,
|
|
428
|
+
"--wait",
|
|
429
|
+
help="Block until the Proxmox queue reports the restore as complete.",
|
|
430
|
+
),
|
|
431
|
+
timeout: int = typer.Option(
|
|
432
|
+
1800,
|
|
433
|
+
"--timeout",
|
|
434
|
+
help="Max seconds to wait when --wait is set. Default 1800 (30 min).",
|
|
435
|
+
),
|
|
436
|
+
) -> None:
|
|
437
|
+
"""Restore a backup, overwriting the current disk. **Destructive.**
|
|
438
|
+
|
|
439
|
+
Wraps ``vps.backups.restore(backup_id)``. The VM is stopped
|
|
440
|
+
during restore, the disk is overwritten with the backup's
|
|
441
|
+
contents, and the VM is left in whichever power state the
|
|
442
|
+
backup recorded.
|
|
443
|
+
"""
|
|
444
|
+
state = from_typer_context(typer_ctx)
|
|
445
|
+
confirm_or_exit(
|
|
446
|
+
f"Restoring backup {backup_id!r} onto VPS {service_id} overwrites "
|
|
447
|
+
"the current disk. Any changes made since the backup was taken "
|
|
448
|
+
"will be lost.",
|
|
449
|
+
yes=yes,
|
|
450
|
+
)
|
|
451
|
+
with make_client_or_exit(state) as client:
|
|
452
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
453
|
+
try:
|
|
454
|
+
op = vps.backups.restore(backup_id)
|
|
455
|
+
except BackendNotSupported:
|
|
456
|
+
_proxmox_only_exit("backups")
|
|
457
|
+
return
|
|
458
|
+
except ApiError as exc:
|
|
459
|
+
exit_on_api_error(exc)
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
if not wait:
|
|
463
|
+
info(
|
|
464
|
+
f"Restore queued for VPS {service_id} ← backup {backup_id!r}. "
|
|
465
|
+
f"Operation uuid: {op.uuid}"
|
|
466
|
+
)
|
|
467
|
+
return
|
|
468
|
+
wait_for_operation(
|
|
469
|
+
op,
|
|
470
|
+
label=f"Restoring VPS {service_id} from backup {backup_id!r}",
|
|
471
|
+
timeout=timeout,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@backups_app.command("delete")
|
|
476
|
+
def backups_delete(
|
|
477
|
+
typer_ctx: typer.Context,
|
|
478
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
479
|
+
backup_id: str = typer.Argument(..., help="Backup id to delete."),
|
|
480
|
+
yes: bool = typer.Option(
|
|
481
|
+
False, "--yes", "-y", help="Skip the deletion confirmation prompt."
|
|
482
|
+
),
|
|
483
|
+
) -> None:
|
|
484
|
+
"""Delete a Proxmox backup. **Irreversible.**
|
|
485
|
+
|
|
486
|
+
Wraps ``vps.backups.delete(backup_id)``. Protected backups
|
|
487
|
+
return a 4xx from the upstream — the SDK surfaces that as
|
|
488
|
+
:class:`InvalidRequest` with a clear message.
|
|
489
|
+
"""
|
|
490
|
+
state = from_typer_context(typer_ctx)
|
|
491
|
+
confirm_or_exit(
|
|
492
|
+
f"Deleting backup {backup_id!r} on VPS {service_id} is irreversible — "
|
|
493
|
+
"the saved VM state is freed and cannot be recovered.",
|
|
494
|
+
yes=yes,
|
|
495
|
+
)
|
|
496
|
+
with make_client_or_exit(state) as client:
|
|
497
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
498
|
+
try:
|
|
499
|
+
vps.backups.delete(backup_id)
|
|
500
|
+
except BackendNotSupported:
|
|
501
|
+
_proxmox_only_exit("backups")
|
|
502
|
+
return
|
|
503
|
+
except ApiError as exc:
|
|
504
|
+
exit_on_api_error(exc)
|
|
505
|
+
return
|
|
506
|
+
success(f"Backup {backup_id!r} deleted from VPS {service_id}.")
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
510
|
+
# Backup schedules
|
|
511
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
_BACKUP_MODES = {"snapshot", "suspend", "stop"}
|
|
515
|
+
_BACKUP_COMPRESS = {"zstd", "lzo", "gzip", "none"}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@schedules_app.command("list")
|
|
519
|
+
def schedules_list(
|
|
520
|
+
typer_ctx: typer.Context,
|
|
521
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
522
|
+
output: OutputFormat | None = typer.Option(
|
|
523
|
+
None, "--output", "-o",
|
|
524
|
+
help="Output format. Overrides the global --output flag.",
|
|
525
|
+
case_sensitive=False,
|
|
526
|
+
),
|
|
527
|
+
) -> None:
|
|
528
|
+
"""List scheduled-backup jobs on the VPS.
|
|
529
|
+
|
|
530
|
+
Wraps ``vps.backup_schedules.list()``. Day-of-week is rendered
|
|
531
|
+
verbatim (the registrar accepts comma-separated tokens like
|
|
532
|
+
``mon,wed,fri``).
|
|
533
|
+
"""
|
|
534
|
+
state = from_typer_context(typer_ctx)
|
|
535
|
+
fmt = resolve_output(state, output)
|
|
536
|
+
with make_client_or_exit(state) as client:
|
|
537
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
538
|
+
try:
|
|
539
|
+
schedules = vps.backup_schedules.list()
|
|
540
|
+
except BackendNotSupported:
|
|
541
|
+
_proxmox_only_exit("backup-schedules")
|
|
542
|
+
return
|
|
543
|
+
except ApiError as exc:
|
|
544
|
+
exit_on_api_error(exc)
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
if not schedules:
|
|
548
|
+
typer.echo(f"No backup schedules on VPS {service_id}.")
|
|
549
|
+
return
|
|
550
|
+
rows = [
|
|
551
|
+
{
|
|
552
|
+
"id": s.id,
|
|
553
|
+
"dow": s.dow or "",
|
|
554
|
+
"hour": s.hour if s.hour is not None else "",
|
|
555
|
+
"minute": s.minute if s.minute is not None else "",
|
|
556
|
+
"mode": s.mode or "",
|
|
557
|
+
"compress": s.compress or "",
|
|
558
|
+
}
|
|
559
|
+
for s in schedules
|
|
560
|
+
]
|
|
561
|
+
print_table(
|
|
562
|
+
f"Backup schedules — VPS {service_id} ({len(rows)})",
|
|
563
|
+
rows,
|
|
564
|
+
columns=["id", "dow", "hour", "minute", "mode", "compress"],
|
|
565
|
+
fmt=fmt,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@schedules_app.command("create")
|
|
570
|
+
def schedules_create(
|
|
571
|
+
typer_ctx: typer.Context,
|
|
572
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
573
|
+
dow: str = typer.Option(
|
|
574
|
+
...,
|
|
575
|
+
"--dow",
|
|
576
|
+
help=(
|
|
577
|
+
"Day-of-week selector: comma-separated tokens "
|
|
578
|
+
"(`mon,wed,fri`). Tokens are passed through to Proxmox "
|
|
579
|
+
"verbatim — typo handling is upstream."
|
|
580
|
+
),
|
|
581
|
+
),
|
|
582
|
+
hour: int = typer.Option(
|
|
583
|
+
..., "--hour", help="Hour of the day to run (0–23).", min=0, max=23
|
|
584
|
+
),
|
|
585
|
+
minute: int = typer.Option(
|
|
586
|
+
..., "--minute", help="Minute past the hour (0–59).", min=0, max=59
|
|
587
|
+
),
|
|
588
|
+
mode: str | None = typer.Option(
|
|
589
|
+
None,
|
|
590
|
+
"--mode",
|
|
591
|
+
help=(
|
|
592
|
+
"Backup mode: one of snapshot / suspend / stop. Default "
|
|
593
|
+
"(upstream): snapshot. 'suspend' freezes the VM during "
|
|
594
|
+
"backup; 'stop' powers it off."
|
|
595
|
+
),
|
|
596
|
+
),
|
|
597
|
+
compress: str | None = typer.Option(
|
|
598
|
+
None,
|
|
599
|
+
"--compress",
|
|
600
|
+
help=(
|
|
601
|
+
"Compression algorithm: zstd / lzo / gzip / none. Default "
|
|
602
|
+
"(upstream): zstd."
|
|
603
|
+
),
|
|
604
|
+
),
|
|
605
|
+
) -> None:
|
|
606
|
+
"""Create a scheduled backup job.
|
|
607
|
+
|
|
608
|
+
Wraps ``vps.backup_schedules.create(...)``. ``--mode`` and
|
|
609
|
+
``--compress`` are validated client-side against the known
|
|
610
|
+
Proxmox vocabularies; unknown values exit 1 before any HTTP
|
|
611
|
+
call.
|
|
612
|
+
"""
|
|
613
|
+
if mode is not None and mode not in _BACKUP_MODES:
|
|
614
|
+
error(
|
|
615
|
+
f"--mode must be one of {sorted(_BACKUP_MODES)!r}, "
|
|
616
|
+
f"got: {mode!r}"
|
|
617
|
+
)
|
|
618
|
+
raise typer.Exit(code=1)
|
|
619
|
+
if compress is not None and compress not in _BACKUP_COMPRESS:
|
|
620
|
+
error(
|
|
621
|
+
f"--compress must be one of {sorted(_BACKUP_COMPRESS)!r}, "
|
|
622
|
+
f"got: {compress!r}"
|
|
623
|
+
)
|
|
624
|
+
raise typer.Exit(code=1)
|
|
625
|
+
|
|
626
|
+
state = from_typer_context(typer_ctx)
|
|
627
|
+
with make_client_or_exit(state) as client:
|
|
628
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
629
|
+
try:
|
|
630
|
+
schedule = vps.backup_schedules.create(
|
|
631
|
+
dow=dow, hour=hour, minute=minute, mode=mode, compress=compress
|
|
632
|
+
)
|
|
633
|
+
except BackendNotSupported:
|
|
634
|
+
_proxmox_only_exit("backup-schedules")
|
|
635
|
+
return
|
|
636
|
+
except ApiError as exc:
|
|
637
|
+
exit_on_api_error(exc)
|
|
638
|
+
return
|
|
639
|
+
success(
|
|
640
|
+
f"Backup schedule {schedule.id!r} created on VPS {service_id} "
|
|
641
|
+
f"(dow={dow!r}, {hour:02d}:{minute:02d})."
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@schedules_app.command("delete")
|
|
646
|
+
def schedules_delete(
|
|
647
|
+
typer_ctx: typer.Context,
|
|
648
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
649
|
+
schedule_id: str = typer.Argument(..., help="Schedule id to delete."),
|
|
650
|
+
yes: bool = typer.Option(
|
|
651
|
+
False, "--yes", "-y", help="Skip the deletion confirmation prompt."
|
|
652
|
+
),
|
|
653
|
+
) -> None:
|
|
654
|
+
"""Delete a scheduled backup job.
|
|
655
|
+
|
|
656
|
+
Wraps ``vps.backup_schedules.delete(schedule_id)``. The job is
|
|
657
|
+
removed from the upstream cron; in-flight runs are not
|
|
658
|
+
interrupted (let them finish or use ``vps proxmox operations``
|
|
659
|
+
to inspect once 3.4+ ships the operations command).
|
|
660
|
+
"""
|
|
661
|
+
state = from_typer_context(typer_ctx)
|
|
662
|
+
confirm_or_exit(
|
|
663
|
+
f"Deleting backup schedule {schedule_id!r} on VPS {service_id} "
|
|
664
|
+
"removes the recurring job — existing backups stay intact.",
|
|
665
|
+
yes=yes,
|
|
666
|
+
)
|
|
667
|
+
with make_client_or_exit(state) as client:
|
|
668
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
669
|
+
try:
|
|
670
|
+
vps.backup_schedules.delete(schedule_id)
|
|
671
|
+
except BackendNotSupported:
|
|
672
|
+
_proxmox_only_exit("backup-schedules")
|
|
673
|
+
return
|
|
674
|
+
except ApiError as exc:
|
|
675
|
+
exit_on_api_error(exc)
|
|
676
|
+
return
|
|
677
|
+
success(f"Backup schedule {schedule_id!r} deleted from VPS {service_id}.")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
681
|
+
# Network reconfigure
|
|
682
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
@network_app.command("reconfigure")
|
|
686
|
+
def network_reconfigure(
|
|
687
|
+
typer_ctx: typer.Context,
|
|
688
|
+
service_id: int = typer.Argument(..., help="Service id (Proxmox VPS)."),
|
|
689
|
+
output: OutputFormat | None = typer.Option(
|
|
690
|
+
None, "--output", "-o",
|
|
691
|
+
help="Output format. Overrides the global --output flag.",
|
|
692
|
+
case_sensitive=False,
|
|
693
|
+
),
|
|
694
|
+
) -> None:
|
|
695
|
+
"""Apply pending Proxmox network config.
|
|
696
|
+
|
|
697
|
+
Wraps ``vps.network_reconfigure()``. Some changes (IP add/remove,
|
|
698
|
+
DNS rotation) require either the Proxmox Guest Agent inside the
|
|
699
|
+
VM **or** a reboot to take effect — this command pokes the
|
|
700
|
+
agent. If the agent isn't installed, fall back to
|
|
701
|
+
``impreza vps reboot``.
|
|
702
|
+
|
|
703
|
+
Returns whatever ack payload the registrar emits — typically
|
|
704
|
+
``{"applied": True}`` on success, or a more detailed payload
|
|
705
|
+
when the registrar surfaces partial-apply state.
|
|
706
|
+
"""
|
|
707
|
+
state = from_typer_context(typer_ctx)
|
|
708
|
+
fmt = resolve_output(state, output)
|
|
709
|
+
with make_client_or_exit(state) as client:
|
|
710
|
+
vps = resolve_vps_or_exit(client, service_id)
|
|
711
|
+
try:
|
|
712
|
+
result = vps.network_reconfigure()
|
|
713
|
+
except BackendNotSupported:
|
|
714
|
+
_proxmox_only_exit("network reconfigure")
|
|
715
|
+
return
|
|
716
|
+
except ApiError as exc:
|
|
717
|
+
exit_on_api_error(exc)
|
|
718
|
+
return
|
|
719
|
+
|
|
720
|
+
if not result:
|
|
721
|
+
success(f"Network reconfigure requested on VPS {service_id}.")
|
|
722
|
+
return
|
|
723
|
+
print_dict(
|
|
724
|
+
f"Network reconfigure — VPS {service_id}",
|
|
725
|
+
{str(k): v for k, v in result.items()},
|
|
726
|
+
fmt=fmt,
|
|
727
|
+
)
|