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.
@@ -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
+ )