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 ADDED
@@ -0,0 +1,3 @@
1
+ """pmox - a friendly, AI-friendly CLI for exploring and managing a Proxmox VE cluster."""
2
+
3
+ __version__ = "0.1.0"
pmox/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__": # pragma: no cover
4
+ main()
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()