pmox 0.1.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.
- pmox/__init__.py +3 -0
- pmox/__main__.py +4 -0
- pmox/cli.py +644 -0
- pmox/client.py +139 -0
- pmox/config.py +167 -0
- pmox/output.py +170 -0
- pmox/safety.py +86 -0
- pmox-0.1.0.dist-info/METADATA +228 -0
- pmox-0.1.0.dist-info/RECORD +13 -0
- pmox-0.1.0.dist-info/WHEEL +5 -0
- pmox-0.1.0.dist-info/entry_points.txt +2 -0
- pmox-0.1.0.dist-info/licenses/LICENSE +21 -0
- pmox-0.1.0.dist-info/top_level.txt +1 -0
pmox/__init__.py
ADDED
pmox/__main__.py
ADDED
pmox/cli.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""The pmox command-line interface.
|
|
2
|
+
|
|
3
|
+
Command groups: ``nodes``, ``vm``, ``ct``, ``storage``, ``cluster``, ``task``
|
|
4
|
+
(with a nested ``snapshot`` group under ``vm``/``ct``), plus a top-level
|
|
5
|
+
``version``.
|
|
6
|
+
|
|
7
|
+
Global options live on the root callback and must precede the subcommand, e.g.::
|
|
8
|
+
|
|
9
|
+
pmox --json vm list
|
|
10
|
+
pmox --host 10.0.0.2 nodes list
|
|
11
|
+
|
|
12
|
+
Output format auto-detects: when stdout is piped or captured (e.g. an AI driving
|
|
13
|
+
the CLI) pmox emits JSON; at an interactive terminal it prints tables. Override
|
|
14
|
+
per-command with ``--json`` / ``--no-json``, or globally with ``PMOX_JSON``
|
|
15
|
+
(``1``/``0``/``auto``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from contextlib import contextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import List, Optional
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
from .client import ProxmoxClient
|
|
31
|
+
from .config import ConfigError, Settings, _parse_bool, load_settings
|
|
32
|
+
from .output import (
|
|
33
|
+
Column,
|
|
34
|
+
console,
|
|
35
|
+
emit,
|
|
36
|
+
err_console,
|
|
37
|
+
fmt_epoch,
|
|
38
|
+
human_bytes,
|
|
39
|
+
human_uptime,
|
|
40
|
+
percent,
|
|
41
|
+
status_fmt,
|
|
42
|
+
)
|
|
43
|
+
from .safety import ConfirmationRequired, DangerousNotEnabled, confirm, require_dangerous
|
|
44
|
+
|
|
45
|
+
# Indirection so tests can inject a fake client factory.
|
|
46
|
+
_client_factory = ProxmoxClient.from_settings
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _stream_isatty(stream) -> bool:
|
|
50
|
+
"""True if ``stream`` is an interactive terminal; False if unknown or it errors."""
|
|
51
|
+
try:
|
|
52
|
+
return bool(stream.isatty())
|
|
53
|
+
except Exception: # noqa: BLE001 - a stream that can't answer is treated as non-TTY
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_json_output(flag: Optional[bool], env_value: Optional[str], stdout_isatty: bool) -> bool:
|
|
58
|
+
"""Decide whether to emit JSON, in precedence order (highest first):
|
|
59
|
+
|
|
60
|
+
1. an explicit ``--json`` / ``--no-json`` flag,
|
|
61
|
+
2. the ``PMOX_JSON`` env var (``1``/``0``/``true``/``false``, or ``auto``),
|
|
62
|
+
3. auto-detect: when stdout is **not** a TTY (piped or captured, e.g. by an AI
|
|
63
|
+
driving the CLI) default to JSON; at an interactive terminal, tables.
|
|
64
|
+
"""
|
|
65
|
+
if flag is not None:
|
|
66
|
+
return flag
|
|
67
|
+
if env_value is not None and env_value.strip() != "":
|
|
68
|
+
token = env_value.strip().lower()
|
|
69
|
+
if token == "auto":
|
|
70
|
+
return not stdout_isatty
|
|
71
|
+
return _parse_bool(token)
|
|
72
|
+
return not stdout_isatty
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class State:
|
|
76
|
+
def __init__(self, settings: Settings, json_output: bool = False, dangerous: bool = False):
|
|
77
|
+
self.settings = settings
|
|
78
|
+
self.json = json_output
|
|
79
|
+
self.dangerous = dangerous
|
|
80
|
+
self.client: Optional[ProxmoxClient] = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _get_client(ctx: typer.Context) -> ProxmoxClient:
|
|
84
|
+
state: State = ctx.obj
|
|
85
|
+
if state.client is None:
|
|
86
|
+
state.settings.validate()
|
|
87
|
+
state.client = _client_factory(state.settings)
|
|
88
|
+
return state.client
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _require_dangerous(ctx: typer.Context) -> None:
|
|
92
|
+
"""Refuse a mutating command unless dangerous (write) mode is enabled."""
|
|
93
|
+
require_dangerous(ctx.obj.dangerous)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _resolve_node_or_die(client: ProxmoxClient, vmid: int) -> str:
|
|
97
|
+
node = client.resolve_node(vmid)
|
|
98
|
+
if not node:
|
|
99
|
+
err_console.print(
|
|
100
|
+
f"[red]Error:[/red] Could not locate guest {vmid} in the cluster. "
|
|
101
|
+
"Pass --node to specify it explicitly."
|
|
102
|
+
)
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
return node
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _ok(ctx: typer.Context, message: str, result=None) -> None:
|
|
108
|
+
state: State = ctx.obj
|
|
109
|
+
if state.json:
|
|
110
|
+
print(json.dumps({"ok": True, "message": message, "result": result}, default=str, indent=2))
|
|
111
|
+
else:
|
|
112
|
+
console.print(f"[green]✓[/green] {message}")
|
|
113
|
+
if result:
|
|
114
|
+
console.print(f" [dim]{result}[/dim]")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@contextmanager
|
|
118
|
+
def error_boundary():
|
|
119
|
+
"""Translate exceptions into friendly messages and distinct exit codes."""
|
|
120
|
+
try:
|
|
121
|
+
yield
|
|
122
|
+
except typer.Exit:
|
|
123
|
+
raise
|
|
124
|
+
except DangerousNotEnabled as exc:
|
|
125
|
+
err_console.print(f"[yellow]Read-only:[/yellow] {exc}")
|
|
126
|
+
raise typer.Exit(4)
|
|
127
|
+
except ConfirmationRequired as exc:
|
|
128
|
+
err_console.print(f"[yellow]Aborted:[/yellow] {exc}")
|
|
129
|
+
raise typer.Exit(3)
|
|
130
|
+
except ConfigError as exc:
|
|
131
|
+
err_console.print(f"[red]Config error:[/red] {exc}")
|
|
132
|
+
raise typer.Exit(2)
|
|
133
|
+
except Exception as exc: # noqa: BLE001 - top-level CLI guard
|
|
134
|
+
err_console.print(f"[red]Error:[/red] {exc}")
|
|
135
|
+
raise typer.Exit(1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---- shared option / argument definitions ----
|
|
139
|
+
vmid_arg = typer.Argument(..., metavar="VMID", help="Numeric VM/CT id.")
|
|
140
|
+
node_opt = typer.Option(None, "--node", "-n", help="Node name (auto-resolved from the cluster if omitted).")
|
|
141
|
+
yes_opt = typer.Option(False, "--yes", "-y", help="Confirm a destructive operation (required when non-interactive).")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---- column specifications ----
|
|
145
|
+
NODE_COLUMNS = [
|
|
146
|
+
Column("Node", "node"),
|
|
147
|
+
Column("Status", "status", status_fmt),
|
|
148
|
+
Column("CPU", "cpu", percent),
|
|
149
|
+
Column("Cores", "maxcpu"),
|
|
150
|
+
Column("Mem", "mem", human_bytes),
|
|
151
|
+
Column("Max Mem", "maxmem", human_bytes),
|
|
152
|
+
Column("Uptime", "uptime", human_uptime),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
GUEST_COLUMNS = [
|
|
156
|
+
Column("VMID", "vmid"),
|
|
157
|
+
Column("Name", "name"),
|
|
158
|
+
Column("Type", "type"),
|
|
159
|
+
Column("Status", "status", status_fmt),
|
|
160
|
+
Column("Node", "node"),
|
|
161
|
+
Column("CPU", "cpu", percent),
|
|
162
|
+
Column("Mem", "mem", human_bytes),
|
|
163
|
+
Column("Max Mem", "maxmem", human_bytes),
|
|
164
|
+
Column("Disk", "maxdisk", human_bytes),
|
|
165
|
+
Column("Uptime", "uptime", human_uptime),
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
STORAGE_COLUMNS = [
|
|
169
|
+
Column("Storage", "storage"),
|
|
170
|
+
Column("Node", "node"),
|
|
171
|
+
Column("Status", "status", status_fmt),
|
|
172
|
+
Column("Backend", "plugintype"),
|
|
173
|
+
Column("Used", "disk", human_bytes),
|
|
174
|
+
Column("Total", "maxdisk", human_bytes),
|
|
175
|
+
Column("Use%", row_formatter=lambda r: percent(r.get("disk"), r.get("maxdisk"))),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
CONTENT_COLUMNS = [
|
|
179
|
+
Column("Volid", "volid"),
|
|
180
|
+
Column("Content", "content"),
|
|
181
|
+
Column("Format", "format"),
|
|
182
|
+
Column("Size", "size", human_bytes),
|
|
183
|
+
Column("VMID", "vmid"),
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
SNAPSHOT_COLUMNS = [
|
|
187
|
+
Column("Name", "name"),
|
|
188
|
+
Column("Description", "description"),
|
|
189
|
+
Column("Created", "snaptime", fmt_epoch),
|
|
190
|
+
Column("Parent", "parent"),
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
TASK_COLUMNS = [
|
|
194
|
+
Column("Type", "type"),
|
|
195
|
+
Column("Status", "status", status_fmt),
|
|
196
|
+
Column("Start", "starttime", fmt_epoch),
|
|
197
|
+
Column("User", "user"),
|
|
198
|
+
Column("ID", "id"),
|
|
199
|
+
Column("UPID", row_formatter=lambda r: (r.get("upid", "") or "")[:48]),
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
CLUSTER_NODE_COLUMNS = [
|
|
203
|
+
Column("Name", "name"),
|
|
204
|
+
Column("Online", row_formatter=lambda r: status_fmt("online" if r.get("online") else "offline")),
|
|
205
|
+
Column("IP", "ip"),
|
|
206
|
+
Column("Level", "level"),
|
|
207
|
+
Column("Node ID", "nodeid"),
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
RESOURCE_COLUMNS = [
|
|
211
|
+
Column("Type", "type"),
|
|
212
|
+
Column("Name", row_formatter=lambda r: str(r.get("name") or r.get("storage") or r.get("id") or "-")),
|
|
213
|
+
Column("Node", "node"),
|
|
214
|
+
Column("Status", "status", status_fmt),
|
|
215
|
+
Column("CPU", "cpu", percent),
|
|
216
|
+
Column("Mem", "mem", human_bytes),
|
|
217
|
+
Column("Max Mem", "maxmem", human_bytes),
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ---- root app ----
|
|
222
|
+
def _version_callback(value: bool):
|
|
223
|
+
if value:
|
|
224
|
+
print(f"pmox {__version__}")
|
|
225
|
+
raise typer.Exit()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
app = typer.Typer(
|
|
229
|
+
help="Explore and manage a Proxmox VE cluster from the command line (AI-friendly).",
|
|
230
|
+
no_args_is_help=True,
|
|
231
|
+
add_completion=False,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.callback()
|
|
236
|
+
def main_callback(
|
|
237
|
+
ctx: typer.Context,
|
|
238
|
+
json_output: Optional[bool] = typer.Option(
|
|
239
|
+
None,
|
|
240
|
+
"--json/--no-json",
|
|
241
|
+
help="Force JSON or human tables. Default: auto — JSON when output is piped/captured "
|
|
242
|
+
"(e.g. an AI driving the CLI), tables at an interactive terminal.",
|
|
243
|
+
),
|
|
244
|
+
dangerous: bool = typer.Option(
|
|
245
|
+
False, "--dangerous", help="Enable dangerous (write/management) mode. Default is read-only (safe for AI exploration)."
|
|
246
|
+
),
|
|
247
|
+
host: Optional[str] = typer.Option(None, "--host", help="Proxmox host or IP."),
|
|
248
|
+
port: Optional[int] = typer.Option(None, "--port", help="API port (default 8006)."),
|
|
249
|
+
token_id: Optional[str] = typer.Option(None, "--token-id", help="API token id: user@realm!tokenname."),
|
|
250
|
+
token_secret: Optional[str] = typer.Option(None, "--token-secret", help="API token secret."),
|
|
251
|
+
verify_ssl: Optional[bool] = typer.Option(None, "--verify-ssl/--no-verify-ssl", help="Verify TLS cert (default: no)."),
|
|
252
|
+
config: Optional[str] = typer.Option(None, "--config", help="Path to a TOML config file."),
|
|
253
|
+
version: Optional[bool] = typer.Option(
|
|
254
|
+
None, "--version", callback=_version_callback, is_eager=True, help="Show pmox version and exit."
|
|
255
|
+
),
|
|
256
|
+
):
|
|
257
|
+
# Load a local .env if python-dotenv is available (never fatal).
|
|
258
|
+
try:
|
|
259
|
+
from dotenv import load_dotenv
|
|
260
|
+
|
|
261
|
+
load_dotenv()
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
overrides = {
|
|
266
|
+
"host": host,
|
|
267
|
+
"port": port,
|
|
268
|
+
"token_id": token_id,
|
|
269
|
+
"token_secret": token_secret,
|
|
270
|
+
"verify_ssl": verify_ssl,
|
|
271
|
+
}
|
|
272
|
+
config_path = Path(config) if config else None
|
|
273
|
+
try:
|
|
274
|
+
settings = load_settings(config_path=config_path, overrides=overrides)
|
|
275
|
+
except ConfigError as exc:
|
|
276
|
+
err_console.print(f"[red]Config error:[/red] {exc}")
|
|
277
|
+
raise typer.Exit(2)
|
|
278
|
+
|
|
279
|
+
json_on = resolve_json_output(json_output, os.environ.get("PMOX_JSON"), _stream_isatty(sys.stdout))
|
|
280
|
+
dangerous_on = dangerous or _parse_bool(os.environ.get("PMOX_DANGEROUS"))
|
|
281
|
+
ctx.obj = State(settings=settings, json_output=json_on, dangerous=dangerous_on)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.command("version")
|
|
285
|
+
def server_version(ctx: typer.Context):
|
|
286
|
+
"""Show the Proxmox VE version of the connected node."""
|
|
287
|
+
with error_boundary():
|
|
288
|
+
client = _get_client(ctx)
|
|
289
|
+
emit(client.version(), json_output=ctx.obj.json, title="Proxmox version")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---- nodes ----
|
|
293
|
+
nodes_app = typer.Typer(help="Inspect cluster nodes.", no_args_is_help=True)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@nodes_app.command("list")
|
|
297
|
+
def nodes_list(ctx: typer.Context):
|
|
298
|
+
"""List all nodes and their resource usage."""
|
|
299
|
+
with error_boundary():
|
|
300
|
+
client = _get_client(ctx)
|
|
301
|
+
emit(client.list_nodes(), columns=NODE_COLUMNS, json_output=ctx.obj.json, title="Nodes")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@nodes_app.command("status")
|
|
305
|
+
def nodes_status(ctx: typer.Context, node: str = typer.Argument(..., help="Node name.")):
|
|
306
|
+
"""Show detailed status for a node."""
|
|
307
|
+
with error_boundary():
|
|
308
|
+
client = _get_client(ctx)
|
|
309
|
+
emit(client.node_status(node), json_output=ctx.obj.json, title=f"Node {node}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---- guest (vm / ct) app factory ----
|
|
313
|
+
def _make_power_command(group, kind, label, action, destructive, description):
|
|
314
|
+
@group.command(action, help=f"{description} a {label}.")
|
|
315
|
+
def _cmd(ctx: typer.Context, vmid: int = vmid_arg, node: Optional[str] = node_opt, yes: bool = yes_opt):
|
|
316
|
+
with error_boundary():
|
|
317
|
+
_require_dangerous(ctx)
|
|
318
|
+
client = _get_client(ctx)
|
|
319
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
320
|
+
if destructive:
|
|
321
|
+
confirm(f"{action} {label.lower()} {vmid} on {resolved}", assume_yes=yes)
|
|
322
|
+
result = client.guest_power(resolved, kind, vmid, action)
|
|
323
|
+
_ok(ctx, f"{description}: {label.lower()} {vmid} on {resolved}", result)
|
|
324
|
+
|
|
325
|
+
return _cmd
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def build_guest_app(kind: str, label: str) -> typer.Typer:
|
|
329
|
+
descr = "QEMU VMs" if kind == "qemu" else "LXC containers"
|
|
330
|
+
group = typer.Typer(help=f"Manage {label}s ({descr}).", no_args_is_help=True)
|
|
331
|
+
|
|
332
|
+
@group.command("list")
|
|
333
|
+
def _list(ctx: typer.Context, node: Optional[str] = node_opt):
|
|
334
|
+
with error_boundary():
|
|
335
|
+
client = _get_client(ctx)
|
|
336
|
+
emit(
|
|
337
|
+
client.list_guests(kind, node=node),
|
|
338
|
+
columns=GUEST_COLUMNS,
|
|
339
|
+
json_output=ctx.obj.json,
|
|
340
|
+
title=f"{label}s",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@group.command("status")
|
|
344
|
+
def _status(ctx: typer.Context, vmid: int = vmid_arg, node: Optional[str] = node_opt):
|
|
345
|
+
with error_boundary():
|
|
346
|
+
client = _get_client(ctx)
|
|
347
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
348
|
+
emit(client.guest_status(resolved, kind, vmid), json_output=ctx.obj.json, title=f"{label} {vmid} status")
|
|
349
|
+
|
|
350
|
+
@group.command("config")
|
|
351
|
+
def _config(ctx: typer.Context, vmid: int = vmid_arg, node: Optional[str] = node_opt):
|
|
352
|
+
with error_boundary():
|
|
353
|
+
client = _get_client(ctx)
|
|
354
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
355
|
+
emit(client.guest_config(resolved, kind, vmid), json_output=ctx.obj.json, title=f"{label} {vmid} config")
|
|
356
|
+
|
|
357
|
+
for action, destructive, description in [
|
|
358
|
+
("start", False, "Start"),
|
|
359
|
+
("shutdown", False, "Gracefully shut down"),
|
|
360
|
+
("reboot", False, "Reboot"),
|
|
361
|
+
("suspend", False, "Suspend"),
|
|
362
|
+
("resume", False, "Resume"),
|
|
363
|
+
("stop", True, "Hard-stop"),
|
|
364
|
+
("reset", True, "Hard-reset"),
|
|
365
|
+
]:
|
|
366
|
+
_make_power_command(group, kind, label, action, destructive, description)
|
|
367
|
+
|
|
368
|
+
@group.command("create")
|
|
369
|
+
def _create(
|
|
370
|
+
ctx: typer.Context,
|
|
371
|
+
vmid: int = vmid_arg,
|
|
372
|
+
node: str = typer.Option(..., "--node", "-n", help="Node to create the guest on."),
|
|
373
|
+
name: Optional[str] = typer.Option(None, "--name", help="Name (VM) / hostname (CT)."),
|
|
374
|
+
option: Optional[List[str]] = typer.Option(
|
|
375
|
+
None, "--option", "-o", help="Extra API parameter key=value (repeatable)."
|
|
376
|
+
),
|
|
377
|
+
):
|
|
378
|
+
with error_boundary():
|
|
379
|
+
_require_dangerous(ctx)
|
|
380
|
+
client = _get_client(ctx)
|
|
381
|
+
params = {}
|
|
382
|
+
if name:
|
|
383
|
+
params["name" if kind == "qemu" else "hostname"] = name
|
|
384
|
+
for item in option or []:
|
|
385
|
+
if "=" not in item:
|
|
386
|
+
raise ValueError(f"--option must be key=value (got {item!r}).")
|
|
387
|
+
key, value = item.split("=", 1)
|
|
388
|
+
params[key] = value
|
|
389
|
+
result = client.create_guest(node, kind, vmid, **params)
|
|
390
|
+
_ok(ctx, f"Creating {label.lower()} {vmid} on {node}", result)
|
|
391
|
+
|
|
392
|
+
@group.command("clone")
|
|
393
|
+
def _clone(
|
|
394
|
+
ctx: typer.Context,
|
|
395
|
+
vmid: int = vmid_arg,
|
|
396
|
+
newid: int = typer.Option(..., "--newid", help="VMID for the new clone."),
|
|
397
|
+
name: Optional[str] = typer.Option(None, "--name", help="Name for the clone."),
|
|
398
|
+
full: bool = typer.Option(False, "--full", help="Full clone (default: linked)."),
|
|
399
|
+
target: Optional[str] = typer.Option(None, "--target", help="Target node for the clone."),
|
|
400
|
+
node: Optional[str] = node_opt,
|
|
401
|
+
):
|
|
402
|
+
with error_boundary():
|
|
403
|
+
_require_dangerous(ctx)
|
|
404
|
+
client = _get_client(ctx)
|
|
405
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
406
|
+
params = {}
|
|
407
|
+
if name:
|
|
408
|
+
params["name"] = name
|
|
409
|
+
if full:
|
|
410
|
+
params["full"] = 1
|
|
411
|
+
if target:
|
|
412
|
+
params["target"] = target
|
|
413
|
+
result = client.clone_guest(resolved, kind, vmid, newid, **params)
|
|
414
|
+
_ok(ctx, f"Cloning {label.lower()} {vmid} → {newid}", result)
|
|
415
|
+
|
|
416
|
+
@group.command("migrate")
|
|
417
|
+
def _migrate(
|
|
418
|
+
ctx: typer.Context,
|
|
419
|
+
vmid: int = vmid_arg,
|
|
420
|
+
target: str = typer.Option(..., "--target", help="Destination node."),
|
|
421
|
+
online: bool = typer.Option(False, "--online", help="Online/live migration."),
|
|
422
|
+
node: Optional[str] = node_opt,
|
|
423
|
+
yes: bool = yes_opt,
|
|
424
|
+
):
|
|
425
|
+
with error_boundary():
|
|
426
|
+
_require_dangerous(ctx)
|
|
427
|
+
client = _get_client(ctx)
|
|
428
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
429
|
+
confirm(f"migrate {label.lower()} {vmid} from {resolved} to {target}", assume_yes=yes)
|
|
430
|
+
params = {}
|
|
431
|
+
if online:
|
|
432
|
+
params["online"] = 1
|
|
433
|
+
result = client.migrate_guest(resolved, kind, vmid, target, **params)
|
|
434
|
+
_ok(ctx, f"Migrating {label.lower()} {vmid} → {target}", result)
|
|
435
|
+
|
|
436
|
+
@group.command("delete")
|
|
437
|
+
def _delete(
|
|
438
|
+
ctx: typer.Context,
|
|
439
|
+
vmid: int = vmid_arg,
|
|
440
|
+
node: Optional[str] = node_opt,
|
|
441
|
+
purge: bool = typer.Option(False, "--purge", help="Also remove from backup jobs / HA."),
|
|
442
|
+
yes: bool = yes_opt,
|
|
443
|
+
):
|
|
444
|
+
with error_boundary():
|
|
445
|
+
_require_dangerous(ctx)
|
|
446
|
+
client = _get_client(ctx)
|
|
447
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
448
|
+
confirm(f"DELETE {label.lower()} {vmid} on {resolved} (irreversible)", assume_yes=yes)
|
|
449
|
+
result = client.delete_guest(resolved, kind, vmid, purge=purge)
|
|
450
|
+
_ok(ctx, f"Deleted {label.lower()} {vmid} on {resolved}", result)
|
|
451
|
+
|
|
452
|
+
# snapshots (nested under the guest group)
|
|
453
|
+
snap = typer.Typer(help=f"Manage {label} snapshots.", no_args_is_help=True)
|
|
454
|
+
|
|
455
|
+
@snap.command("list")
|
|
456
|
+
def _snap_list(ctx: typer.Context, vmid: int = vmid_arg, node: Optional[str] = node_opt):
|
|
457
|
+
with error_boundary():
|
|
458
|
+
client = _get_client(ctx)
|
|
459
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
460
|
+
emit(
|
|
461
|
+
client.list_snapshots(resolved, kind, vmid),
|
|
462
|
+
columns=SNAPSHOT_COLUMNS,
|
|
463
|
+
json_output=ctx.obj.json,
|
|
464
|
+
title=f"{label} {vmid} snapshots",
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
@snap.command("create")
|
|
468
|
+
def _snap_create(
|
|
469
|
+
ctx: typer.Context,
|
|
470
|
+
vmid: int = vmid_arg,
|
|
471
|
+
name: str = typer.Argument(..., help="Snapshot name."),
|
|
472
|
+
description: Optional[str] = typer.Option(None, "--description", "-d"),
|
|
473
|
+
vmstate: bool = typer.Option(False, "--vmstate", help="Include RAM state."),
|
|
474
|
+
node: Optional[str] = node_opt,
|
|
475
|
+
):
|
|
476
|
+
with error_boundary():
|
|
477
|
+
_require_dangerous(ctx)
|
|
478
|
+
client = _get_client(ctx)
|
|
479
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
480
|
+
params = {}
|
|
481
|
+
if description:
|
|
482
|
+
params["description"] = description
|
|
483
|
+
if vmstate:
|
|
484
|
+
params["vmstate"] = 1
|
|
485
|
+
result = client.create_snapshot(resolved, kind, vmid, name, **params)
|
|
486
|
+
_ok(ctx, f"Creating snapshot {name!r} of {label.lower()} {vmid}", result)
|
|
487
|
+
|
|
488
|
+
@snap.command("delete")
|
|
489
|
+
def _snap_delete(
|
|
490
|
+
ctx: typer.Context,
|
|
491
|
+
vmid: int = vmid_arg,
|
|
492
|
+
name: str = typer.Argument(..., help="Snapshot name."),
|
|
493
|
+
node: Optional[str] = node_opt,
|
|
494
|
+
yes: bool = yes_opt,
|
|
495
|
+
):
|
|
496
|
+
with error_boundary():
|
|
497
|
+
_require_dangerous(ctx)
|
|
498
|
+
client = _get_client(ctx)
|
|
499
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
500
|
+
confirm(f"delete snapshot {name!r} of {label.lower()} {vmid}", assume_yes=yes)
|
|
501
|
+
result = client.delete_snapshot(resolved, kind, vmid, name)
|
|
502
|
+
_ok(ctx, f"Deleted snapshot {name!r} of {label.lower()} {vmid}", result)
|
|
503
|
+
|
|
504
|
+
@snap.command("rollback")
|
|
505
|
+
def _snap_rollback(
|
|
506
|
+
ctx: typer.Context,
|
|
507
|
+
vmid: int = vmid_arg,
|
|
508
|
+
name: str = typer.Argument(..., help="Snapshot name."),
|
|
509
|
+
node: Optional[str] = node_opt,
|
|
510
|
+
yes: bool = yes_opt,
|
|
511
|
+
):
|
|
512
|
+
with error_boundary():
|
|
513
|
+
_require_dangerous(ctx)
|
|
514
|
+
client = _get_client(ctx)
|
|
515
|
+
resolved = node or _resolve_node_or_die(client, vmid)
|
|
516
|
+
confirm(f"ROLLBACK {label.lower()} {vmid} to snapshot {name!r} (loses current state)", assume_yes=yes)
|
|
517
|
+
result = client.rollback_snapshot(resolved, kind, vmid, name)
|
|
518
|
+
_ok(ctx, f"Rolling back {label.lower()} {vmid} → {name!r}", result)
|
|
519
|
+
|
|
520
|
+
group.add_typer(snap, name="snapshot")
|
|
521
|
+
return group
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
vm_app = build_guest_app("qemu", "VM")
|
|
525
|
+
ct_app = build_guest_app("lxc", "CT")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ---- storage ----
|
|
529
|
+
storage_app = typer.Typer(help="Inspect storage.", no_args_is_help=True)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@storage_app.command("list")
|
|
533
|
+
def storage_list(ctx: typer.Context, node: Optional[str] = node_opt):
|
|
534
|
+
with error_boundary():
|
|
535
|
+
client = _get_client(ctx)
|
|
536
|
+
rows = client.cluster_resources(type="storage")
|
|
537
|
+
if node:
|
|
538
|
+
rows = [r for r in rows if r.get("node") == node]
|
|
539
|
+
emit(rows, columns=STORAGE_COLUMNS, json_output=ctx.obj.json, title="Storage")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@storage_app.command("content")
|
|
543
|
+
def storage_content(
|
|
544
|
+
ctx: typer.Context,
|
|
545
|
+
storage: str = typer.Argument(..., help="Storage id."),
|
|
546
|
+
node: str = typer.Option(..., "--node", "-n", help="Node name."),
|
|
547
|
+
):
|
|
548
|
+
with error_boundary():
|
|
549
|
+
client = _get_client(ctx)
|
|
550
|
+
emit(
|
|
551
|
+
client.storage_content(node, storage),
|
|
552
|
+
columns=CONTENT_COLUMNS,
|
|
553
|
+
json_output=ctx.obj.json,
|
|
554
|
+
title=f"{storage} content",
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
# ---- cluster ----
|
|
559
|
+
cluster_app = typer.Typer(help="Cluster-wide views.", no_args_is_help=True)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
@cluster_app.command("status")
|
|
563
|
+
def cluster_status(ctx: typer.Context):
|
|
564
|
+
with error_boundary():
|
|
565
|
+
client = _get_client(ctx)
|
|
566
|
+
data = client.cluster_status()
|
|
567
|
+
if ctx.obj.json:
|
|
568
|
+
emit(data, json_output=True)
|
|
569
|
+
return
|
|
570
|
+
nodes = [d for d in data if d.get("type") == "node"]
|
|
571
|
+
emit(nodes, columns=CLUSTER_NODE_COLUMNS, json_output=False, title="Cluster nodes")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@cluster_app.command("resources")
|
|
575
|
+
def cluster_resources(
|
|
576
|
+
ctx: typer.Context,
|
|
577
|
+
type: Optional[str] = typer.Option(None, "--type", help="Filter: vm | node | storage | sdn | pool."),
|
|
578
|
+
):
|
|
579
|
+
with error_boundary():
|
|
580
|
+
client = _get_client(ctx)
|
|
581
|
+
emit(
|
|
582
|
+
client.cluster_resources(type=type),
|
|
583
|
+
columns=RESOURCE_COLUMNS,
|
|
584
|
+
json_output=ctx.obj.json,
|
|
585
|
+
title="Cluster resources",
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ---- tasks ----
|
|
590
|
+
task_app = typer.Typer(help="Inspect node tasks.", no_args_is_help=True)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@task_app.command("list")
|
|
594
|
+
def task_list(
|
|
595
|
+
ctx: typer.Context,
|
|
596
|
+
node: str = typer.Option(..., "--node", "-n", help="Node name."),
|
|
597
|
+
limit: int = typer.Option(50, "--limit", help="Max tasks to show."),
|
|
598
|
+
):
|
|
599
|
+
with error_boundary():
|
|
600
|
+
client = _get_client(ctx)
|
|
601
|
+
emit(client.list_tasks(node, limit=limit), columns=TASK_COLUMNS, json_output=ctx.obj.json, title=f"{node} tasks")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@task_app.command("status")
|
|
605
|
+
def task_status(
|
|
606
|
+
ctx: typer.Context,
|
|
607
|
+
upid: str = typer.Argument(..., help="Task UPID."),
|
|
608
|
+
node: str = typer.Option(..., "--node", "-n", help="Node name."),
|
|
609
|
+
):
|
|
610
|
+
with error_boundary():
|
|
611
|
+
client = _get_client(ctx)
|
|
612
|
+
emit(client.task_status(node, upid), json_output=ctx.obj.json, title="Task status")
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
@task_app.command("log")
|
|
616
|
+
def task_log(
|
|
617
|
+
ctx: typer.Context,
|
|
618
|
+
upid: str = typer.Argument(..., help="Task UPID."),
|
|
619
|
+
node: str = typer.Option(..., "--node", "-n", help="Node name."),
|
|
620
|
+
):
|
|
621
|
+
with error_boundary():
|
|
622
|
+
client = _get_client(ctx)
|
|
623
|
+
data = client.task_log(node, upid)
|
|
624
|
+
if ctx.obj.json:
|
|
625
|
+
emit(data, json_output=True)
|
|
626
|
+
return
|
|
627
|
+
for line in data:
|
|
628
|
+
console.print(line.get("t", "") if isinstance(line, dict) else str(line))
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
app.add_typer(nodes_app, name="nodes")
|
|
632
|
+
app.add_typer(vm_app, name="vm")
|
|
633
|
+
app.add_typer(ct_app, name="ct")
|
|
634
|
+
app.add_typer(storage_app, name="storage")
|
|
635
|
+
app.add_typer(cluster_app, name="cluster")
|
|
636
|
+
app.add_typer(task_app, name="task")
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def main():
|
|
640
|
+
app()
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
if __name__ == "__main__": # pragma: no cover
|
|
644
|
+
main()
|