pxinv 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.
pxinv/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """pxinv — Proxmox VM & container inventory CLI."""
2
+
3
+ __version__ = "0.3.0"
pxinv/cli.py ADDED
@@ -0,0 +1,308 @@
1
+ import time
2
+ import warnings
3
+
4
+ warnings.filterwarnings("ignore", category=UserWarning, module="urllib3")
5
+
6
+ import click # noqa: E402
7
+ from rich.console import Console # noqa: E402
8
+
9
+ from .client import ProxmoxClient, PxinvAuthError, PxinvConnectionError, PxinvNotFoundError # noqa: E402
10
+ from .config import load_config # noqa: E402
11
+ from .output import print_summary, print_table, build_watch_panel # noqa: E402
12
+
13
+
14
+ @click.group()
15
+ @click.version_option(package_name="pxinv")
16
+ @click.option("--host", envvar="PXINV_HOST", help="Proxmox host (e.g. 192.168.1.10)")
17
+ @click.option("--user", envvar="PXINV_USER", default="root@pam", show_default=True)
18
+ @click.option("--token-name", envvar="PXINV_TOKEN_NAME", help="API token name")
19
+ @click.option("--token-value", envvar="PXINV_TOKEN_VALUE", help="API token value")
20
+ @click.option("--config", "config_path", default=None, help="Path to config file")
21
+ @click.option("--verify-ssl/--no-verify-ssl", default=True, envvar="PXINV_VERIFY_SSL")
22
+ @click.pass_context
23
+ def cli(ctx, host, user, token_name, token_value, config_path, verify_ssl):
24
+ """pxinv — Proxmox VM & container inventory CLI."""
25
+ ctx.ensure_object(dict)
26
+
27
+ cfg = load_config(config_path)
28
+
29
+ ctx.obj["host"] = host or cfg.get("host")
30
+ ctx.obj["user"] = user or cfg.get("user", "root@pam")
31
+ ctx.obj["token_name"] = token_name or cfg.get("token_name")
32
+ ctx.obj["token_value"] = token_value or cfg.get("token_value")
33
+ ctx.obj["verify_ssl"] = verify_ssl
34
+
35
+ if not ctx.obj["host"]:
36
+ raise click.UsageError(
37
+ "Proxmox host is required. Use --host, PXINV_HOST env var, or config file."
38
+ )
39
+
40
+
41
+ def _get_client(ctx):
42
+ obj = ctx.obj
43
+ missing = [k for k in ("host", "token_name", "token_value") if not obj.get(k)]
44
+ if missing:
45
+ raise click.UsageError(
46
+ f"Missing required config: {', '.join(missing)}. "
47
+ "Use CLI flags, env vars (PXINV_*), or a config file."
48
+ )
49
+ try:
50
+ return ProxmoxClient(
51
+ host=obj["host"],
52
+ user=obj["user"],
53
+ token_name=obj["token_name"],
54
+ token_value=obj["token_value"],
55
+ verify_ssl=obj["verify_ssl"],
56
+ )
57
+ except (PxinvConnectionError, PxinvAuthError) as e:
58
+ raise click.ClickException(str(e))
59
+
60
+
61
+ def _catch(exc):
62
+ """Re-raise pxinv domain errors as clean ClickExceptions."""
63
+ if isinstance(exc, (PxinvConnectionError, PxinvAuthError, PxinvNotFoundError)):
64
+ raise click.ClickException(str(exc))
65
+ raise exc
66
+
67
+
68
+ def _wait_for_status(client, vmid, target_status, timeout):
69
+ """Poll until VM reaches target_status or timeout."""
70
+ console = Console()
71
+ deadline = time.time() + timeout
72
+ with console.status(f"Waiting for {target_status}..."):
73
+ while time.time() < deadline:
74
+ current = client.get_vm_status(vmid)
75
+ if current == target_status:
76
+ return
77
+ time.sleep(2)
78
+ raise click.ClickException(
79
+ f"Timeout after {timeout}s — last status: {client.get_vm_status(vmid)}"
80
+ )
81
+
82
+
83
+ @cli.command()
84
+ @click.option("--node", default=None, help="Filter by node name")
85
+ @click.option(
86
+ "--type", "vm_type", default=None,
87
+ type=click.Choice(["vm", "ct"]),
88
+ help="Filter by type: vm or ct",
89
+ )
90
+ @click.option(
91
+ "--status", default=None,
92
+ type=click.Choice(["running", "stopped", "paused"]),
93
+ help="Filter by status",
94
+ )
95
+ @click.option(
96
+ "--output", "-o", default="table",
97
+ type=click.Choice(["table", "json", "yaml"]),
98
+ show_default=True, help="Output format",
99
+ )
100
+ @click.option("--tags", default=None, help="Filter by tag (e.g. --tags homelab)")
101
+ @click.pass_context
102
+ def list(ctx, node, vm_type, status, output, tags):
103
+ """List all VMs and containers."""
104
+ try:
105
+ client = _get_client(ctx)
106
+ resources = client.get_resources(node=node, vm_type=vm_type, status=status)
107
+ except (PxinvConnectionError, PxinvAuthError, PxinvNotFoundError) as e:
108
+ _catch(e)
109
+
110
+ if tags:
111
+ resources = [
112
+ r for r in resources
113
+ if tags.lower() in [t.strip().lower() for t in r.get("tags", "").split(";") if t.strip()]
114
+ ]
115
+
116
+ if output == "table":
117
+ print_table(resources)
118
+ elif output == "json":
119
+ import json
120
+ click.echo(json.dumps(resources, indent=2))
121
+ elif output == "yaml":
122
+ import yaml
123
+ click.echo(yaml.dump(resources, default_flow_style=False))
124
+
125
+
126
+ @cli.command()
127
+ @click.option(
128
+ "--output", "-o", default="table",
129
+ type=click.Choice(["table", "json", "yaml"]),
130
+ show_default=True,
131
+ )
132
+ @click.pass_context
133
+ def summary(ctx, output):
134
+ """Show cluster resource summary."""
135
+ try:
136
+ client = _get_client(ctx)
137
+ resources = client.get_resources()
138
+ nodes = client.get_nodes()
139
+ except (PxinvConnectionError, PxinvAuthError) as e:
140
+ _catch(e)
141
+
142
+ if output == "table":
143
+ print_summary(resources, nodes)
144
+ elif output == "json":
145
+ import json
146
+ click.echo(json.dumps({"resources": resources, "nodes": nodes}, indent=2))
147
+ elif output == "yaml":
148
+ import yaml
149
+ click.echo(yaml.dump({"resources": resources, "nodes": nodes}, default_flow_style=False))
150
+
151
+
152
+ @cli.command()
153
+ @click.option("--interval", "-i", default=5, show_default=True, help="Refresh interval in seconds")
154
+ @click.option("--node", default=None, help="Filter by node name")
155
+ @click.option(
156
+ "--type", "vm_type", default=None,
157
+ type=click.Choice(["vm", "ct"]),
158
+ help="Filter by type: vm or ct",
159
+ )
160
+ @click.option(
161
+ "--status", default=None,
162
+ type=click.Choice(["running", "stopped", "paused"]),
163
+ help="Filter by status",
164
+ )
165
+ @click.option("--tags", default=None, help="Filter by tag")
166
+ @click.pass_context
167
+ def watch(ctx, interval, node, vm_type, status, tags):
168
+ """Live-refresh the VM/container list. Press Ctrl+C to exit."""
169
+ from rich.live import Live
170
+ client = _get_client(ctx)
171
+
172
+ def fetch():
173
+ try:
174
+ resources = client.get_resources(node=node, vm_type=vm_type, status=status)
175
+ if tags:
176
+ resources = [
177
+ r for r in resources
178
+ if tags.lower() in [t.strip().lower() for t in r.get("tags", "").split(";") if t.strip()]
179
+ ]
180
+ return resources
181
+ except (PxinvConnectionError, PxinvAuthError) as e:
182
+ raise click.ClickException(str(e))
183
+
184
+ resources = fetch()
185
+ try:
186
+ with Live(build_watch_panel(resources, interval), refresh_per_second=1, screen=True) as live:
187
+ while True:
188
+ time.sleep(interval)
189
+ resources = fetch()
190
+ live.update(build_watch_panel(resources, interval))
191
+ except KeyboardInterrupt:
192
+ pass
193
+
194
+
195
+
196
+ @cli.command()
197
+ @click.argument("vmid", type=int)
198
+ @click.option("--wait", is_flag=True, default=False, help="Wait until VM is running")
199
+ @click.option("--timeout", default=60, show_default=True, help="Timeout in seconds when using --wait")
200
+ @click.pass_context
201
+ def start(ctx, vmid, wait, timeout):
202
+ """Start a VM or container by VMID."""
203
+ client = _get_client(ctx)
204
+ try:
205
+ _, vm = client.start_vm(vmid)
206
+ click.echo(f"Starting {vm['name']} ({vmid})...")
207
+ if wait:
208
+ _wait_for_status(client, vmid, "running", timeout)
209
+ click.echo(f"{vm['name']} is running.")
210
+ else:
211
+ click.echo("Task sent. Use 'pxinv list' to check status.")
212
+ except (PxinvConnectionError, PxinvAuthError, PxinvNotFoundError, ValueError) as e:
213
+ if isinstance(e, ValueError):
214
+ raise click.ClickException(str(e))
215
+ _catch(e)
216
+
217
+
218
+ @cli.command()
219
+ @click.argument("vmid", type=int)
220
+ @click.option("--wait", is_flag=True, default=False, help="Wait until VM is stopped")
221
+ @click.option("--timeout", default=60, show_default=True, help="Timeout in seconds when using --wait")
222
+ @click.pass_context
223
+ def stop(ctx, vmid, wait, timeout):
224
+ """Gracefully shut down a VM or container by VMID."""
225
+ client = _get_client(ctx)
226
+ try:
227
+ _, vm = client.stop_vm(vmid)
228
+ click.echo(f"Stopping {vm['name']} ({vmid})...")
229
+ if wait:
230
+ _wait_for_status(client, vmid, "stopped", timeout)
231
+ click.echo(f"{vm['name']} is stopped.")
232
+ else:
233
+ click.echo("Task sent. Use 'pxinv list' to check status.")
234
+ except (PxinvConnectionError, PxinvAuthError, PxinvNotFoundError, ValueError) as e:
235
+ if isinstance(e, ValueError):
236
+ raise click.ClickException(str(e))
237
+ _catch(e)
238
+
239
+
240
+ def main():
241
+ cli()
242
+
243
+
244
+ @cli.command("ansible-inventory")
245
+ @click.option("--with-ips", is_flag=True, default=False,
246
+ help="Fetch IPs via QEMU guest agent (requires qemu-guest-agent installed in VMs)")
247
+ @click.option("--running-only", is_flag=True, default=False,
248
+ help="Only include running VMs and containers")
249
+ @click.pass_context
250
+ def ansible_inventory(ctx, with_ips, running_only):
251
+ """Export inventory in Ansible dynamic inventory JSON format."""
252
+ import json
253
+
254
+ client = _get_client(ctx)
255
+ try:
256
+ status_filter = "running" if running_only else None
257
+ resources = client.get_resources(status=status_filter)
258
+ except (PxinvConnectionError, PxinvAuthError) as e:
259
+ _catch(e)
260
+
261
+ hostvars = {}
262
+ groups = {"all": {"hosts": [], "children": []}, "_meta": {"hostvars": {}}}
263
+
264
+ # Group by status and by tag
265
+ status_groups = {}
266
+ tag_groups = {}
267
+
268
+ for r in resources:
269
+ hostname = r["name"]
270
+ groups["all"]["hosts"].append(hostname)
271
+
272
+ # Build hostvars
273
+ hvars = {
274
+ "proxmox_vmid": r["vmid"],
275
+ "proxmox_node": r["node"],
276
+ "proxmox_type": r["type"],
277
+ "proxmox_status": r["status"],
278
+ "proxmox_tags": [t.strip() for t in r.get("tags", "").split(";") if t.strip()],
279
+ }
280
+
281
+ if with_ips and r["status"] == "running":
282
+ ip = client.get_vm_ip(r["vmid"], r["node"], r["type"])
283
+ if ip:
284
+ hvars["ansible_host"] = ip
285
+
286
+ hostvars[hostname] = hvars
287
+
288
+ # Group by status
289
+ s = r["status"]
290
+ status_groups.setdefault(s, []).append(hostname)
291
+
292
+ # Group by tag
293
+ for tag in hvars["proxmox_tags"]:
294
+ tag_groups.setdefault(tag, []).append(hostname)
295
+
296
+ # Build final structure
297
+ for s, hosts in status_groups.items():
298
+ groups[s] = {"hosts": hosts}
299
+ groups["all"]["children"].append(s)
300
+
301
+ for tag, hosts in tag_groups.items():
302
+ groups[f"tag_{tag}"] = {"hosts": hosts}
303
+ groups["all"]["children"].append(f"tag_{tag}")
304
+
305
+ groups["_meta"]["hostvars"] = hostvars
306
+ groups["all"]["children"] = sorted(set(groups["all"]["children"]))
307
+
308
+ click.echo(json.dumps(groups, indent=2))
pxinv/client.py ADDED
@@ -0,0 +1,175 @@
1
+ from proxmoxer import ProxmoxAPI
2
+ from proxmoxer.backends.https import AuthenticationError
3
+ from requests.exceptions import ConnectionError, SSLError
4
+
5
+
6
+ class PxinvConnectionError(Exception):
7
+ pass
8
+
9
+
10
+ class PxinvAuthError(Exception):
11
+ pass
12
+
13
+
14
+ class PxinvNotFoundError(Exception):
15
+ pass
16
+
17
+
18
+ def _wrap_api_call(func):
19
+ """Decorator that converts proxmoxer/requests exceptions into clean pxinv errors."""
20
+ def wrapper(*args, **kwargs):
21
+ try:
22
+ return func(*args, **kwargs)
23
+ except AuthenticationError:
24
+ raise PxinvAuthError(
25
+ "Authentication failed. Check your token name and token value."
26
+ )
27
+ except SSLError:
28
+ raise PxinvConnectionError(
29
+ "SSL verification failed. Use --no-verify-ssl if your Proxmox uses a self-signed certificate."
30
+ )
31
+ except ConnectionError as e:
32
+ if "No route to host" in str(e) or "refused" in str(e).lower():
33
+ raise PxinvConnectionError(
34
+ "Cannot connect to Proxmox. Check that the host is reachable and port 8006 is open."
35
+ )
36
+ raise PxinvConnectionError(f"Connection error: {e}")
37
+ return wrapper
38
+
39
+
40
+ class ProxmoxClient:
41
+ def __init__(self, host, user, token_name, token_value, verify_ssl=True):
42
+ try:
43
+ self._px = ProxmoxAPI(
44
+ host,
45
+ user=user,
46
+ token_name=token_name,
47
+ token_value=token_value,
48
+ verify_ssl=verify_ssl,
49
+ )
50
+ except SSLError:
51
+ raise PxinvConnectionError(
52
+ "SSL verification failed. Use --no-verify-ssl if your Proxmox uses a self-signed certificate."
53
+ )
54
+ except ConnectionError as e:
55
+ raise PxinvConnectionError(
56
+ f"Cannot connect to {host}:8006 — {e}"
57
+ )
58
+
59
+ @_wrap_api_call
60
+ def get_nodes(self):
61
+ """Return list of nodes with basic stats."""
62
+ nodes = []
63
+ for node in self._px.nodes.get():
64
+ nodes.append(
65
+ {
66
+ "name": node["node"],
67
+ "status": node.get("status", "unknown"),
68
+ "cpu": round(node.get("cpu", 0) * 100, 1),
69
+ "mem_used": node.get("mem", 0),
70
+ "mem_total": node.get("maxmem", 0),
71
+ "uptime": node.get("uptime", 0),
72
+ }
73
+ )
74
+ return nodes
75
+
76
+ @_wrap_api_call
77
+ def get_vm(self, vmid):
78
+ """Find a VM/CT by VMID and return its node and type."""
79
+ for item in self._px.cluster.resources.get(type="vm"):
80
+ if item.get("vmid") == vmid:
81
+ return {
82
+ "vmid": vmid,
83
+ "node": item["node"],
84
+ "type": "qemu" if item["type"] == "qemu" else "lxc",
85
+ "status": item["status"],
86
+ "name": item.get("name", f"vm-{vmid}"),
87
+ }
88
+ return None
89
+
90
+ @_wrap_api_call
91
+ def start_vm(self, vmid):
92
+ """Start a VM or container."""
93
+ vm = self.get_vm(vmid)
94
+ if not vm:
95
+ raise PxinvNotFoundError(f"VMID {vmid} not found")
96
+ if vm["status"] == "running":
97
+ raise ValueError(f"{vm['name']} is already running")
98
+ node = self._px.nodes(vm["node"])
99
+ if vm["type"] == "qemu":
100
+ return node.qemu(vmid).status.start.post(), vm
101
+ return node.lxc(vmid).status.start.post(), vm
102
+
103
+ @_wrap_api_call
104
+ def stop_vm(self, vmid):
105
+ """Gracefully shutdown a VM or container."""
106
+ vm = self.get_vm(vmid)
107
+ if not vm:
108
+ raise PxinvNotFoundError(f"VMID {vmid} not found")
109
+ if vm["status"] == "stopped":
110
+ raise ValueError(f"{vm['name']} is already stopped")
111
+ node = self._px.nodes(vm["node"])
112
+ if vm["type"] == "qemu":
113
+ return node.qemu(vmid).status.shutdown.post(), vm
114
+ return node.lxc(vmid).status.shutdown.post(), vm
115
+
116
+ @_wrap_api_call
117
+ def get_vm_status(self, vmid):
118
+ """Return current status of a VM/CT."""
119
+ vm = self.get_vm(vmid)
120
+ return vm["status"] if vm else None
121
+
122
+ @_wrap_api_call
123
+ def get_resources(self, node=None, vm_type=None, status=None):
124
+ """Return VMs and containers, optionally filtered."""
125
+ resources = []
126
+
127
+ for item in self._px.cluster.resources.get(type="vm"):
128
+ vm_type_raw = item.get("type", "") # "qemu" or "lxc"
129
+ inferred_type = "vm" if vm_type_raw == "qemu" else "ct"
130
+
131
+ if vm_type and vm_type != inferred_type:
132
+ continue
133
+ if node and item.get("node") != node:
134
+ continue
135
+ if status and item.get("status") != status:
136
+ continue
137
+
138
+ resources.append(
139
+ {
140
+ "vmid": item.get("vmid"),
141
+ "name": item.get("name", f"vm-{item.get('vmid')}"),
142
+ "node": item.get("node"),
143
+ "type": inferred_type,
144
+ "status": item.get("status", "unknown"),
145
+ "cpu_usage": round(item.get("cpu", 0) * 100, 1),
146
+ "mem_used": item.get("mem", 0),
147
+ "mem_total": item.get("maxmem", 0),
148
+ "disk_used": item.get("disk", 0),
149
+ "disk_total": item.get("maxdisk", 0),
150
+ "uptime": item.get("uptime", 0),
151
+ "tags": item.get("tags", ""),
152
+ }
153
+ )
154
+
155
+ return sorted(resources, key=lambda r: (r["node"], r["name"]))
156
+
157
+ @_wrap_api_call
158
+ def get_vm_ip(self, vmid, node, vm_type):
159
+ """Try to get the primary IP of a running VM via QEMU guest agent.
160
+ Returns None if the agent is not available or the VM is stopped.
161
+ Only works for VMs (qemu), not containers (lxc).
162
+ """
163
+ if vm_type != "vm":
164
+ return None
165
+ try:
166
+ ifaces = self._px.nodes(node).qemu(vmid).agent("network-get-interfaces").get()
167
+ for iface in ifaces.get("result", []):
168
+ if iface.get("name") == "lo":
169
+ continue
170
+ for addr in iface.get("ip-addresses", []):
171
+ if addr.get("ip-address-type") == "ipv4":
172
+ return addr["ip-address"]
173
+ except Exception:
174
+ return None
175
+ return None
pxinv/config.py ADDED
@@ -0,0 +1,22 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+
6
+ DEFAULT_CONFIG_PATHS = [
7
+ Path.home() / ".config" / "pxinv" / "config.yaml",
8
+ Path.home() / ".pxinv.yaml",
9
+ Path("pxinv.yaml"),
10
+ ]
11
+
12
+
13
+ def load_config(path=None):
14
+ """Load config from file. Returns empty dict if not found."""
15
+ candidates = [Path(path)] if path else DEFAULT_CONFIG_PATHS
16
+
17
+ for candidate in candidates:
18
+ if candidate.exists():
19
+ with open(candidate) as f:
20
+ return yaml.safe_load(f) or {}
21
+
22
+ return {}
pxinv/output.py ADDED
@@ -0,0 +1,173 @@
1
+ from datetime import datetime
2
+
3
+ from rich import box
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+ from rich.text import Text
8
+
9
+ console = Console()
10
+
11
+
12
+ def _fmt_bytes(b):
13
+ if b == 0:
14
+ return "—"
15
+ for unit in ("B", "KB", "MB", "GB", "TB"):
16
+ if b < 1024:
17
+ return f"{b:.1f}{unit}"
18
+ b /= 1024
19
+ return f"{b:.1f}PB"
20
+
21
+
22
+ def _fmt_uptime(seconds):
23
+ if not seconds:
24
+ return "—"
25
+ days, rem = divmod(int(seconds), 86400)
26
+ hours, rem = divmod(rem, 3600)
27
+ mins = rem // 60
28
+ parts = []
29
+ if days:
30
+ parts.append(f"{days}d")
31
+ if hours:
32
+ parts.append(f"{hours}h")
33
+ if mins and not days:
34
+ parts.append(f"{mins}m")
35
+ return " ".join(parts) or "< 1m"
36
+
37
+
38
+ def _status_style(status):
39
+ return {
40
+ "running": "[green]running[/green]",
41
+ "stopped": "[red]stopped[/red]",
42
+ "paused": "[yellow]paused[/yellow]",
43
+ }.get(status, status)
44
+
45
+
46
+ def _type_style(t):
47
+ return "[cyan]VM[/cyan]" if t == "vm" else "[magenta]CT[/magenta]"
48
+
49
+
50
+ def build_table(resources):
51
+ """Build and return a Rich Table from a list of resources."""
52
+ table = Table(box=box.SIMPLE_HEAD, show_header=True, header_style="bold")
53
+ table.add_column("VMID", style="dim", width=6)
54
+ table.add_column("NAME", min_width=20)
55
+ table.add_column("NODE", style="dim")
56
+ table.add_column("TYPE", width=4)
57
+ table.add_column("STATUS", width=9)
58
+ table.add_column("CPU", justify="right", width=6)
59
+ table.add_column("RAM", justify="right", width=14)
60
+ table.add_column("DISK", justify="right", width=14)
61
+ table.add_column("UPTIME", justify="right", width=10)
62
+ table.add_column("TAGS", style="dim")
63
+
64
+ for r in resources:
65
+ mem = f"{_fmt_bytes(r['mem_used'])}/{_fmt_bytes(r['mem_total'])}"
66
+ disk = f"{_fmt_bytes(r['disk_used'])}/{_fmt_bytes(r['disk_total'])}"
67
+ cpu = f"{r['cpu_usage']}%" if r["status"] == "running" else "—"
68
+ tags = r.get("tags", "").replace(";", " ") if r.get("tags") else ""
69
+
70
+ table.add_row(
71
+ str(r["vmid"]),
72
+ r["name"],
73
+ r["node"],
74
+ _type_style(r["type"]),
75
+ _status_style(r["status"]),
76
+ cpu,
77
+ mem,
78
+ disk,
79
+ _fmt_uptime(r["uptime"]),
80
+ tags,
81
+ )
82
+
83
+ return table
84
+
85
+
86
+ def build_footer(resources):
87
+ """Return a footer Text with running/stopped counts."""
88
+ running = sum(1 for r in resources if r["status"] == "running")
89
+ stopped = sum(1 for r in resources if r["status"] == "stopped")
90
+ return Text(
91
+ f"{len(resources)} resource(s) — {running} running, {stopped} stopped",
92
+ style="dim",
93
+ )
94
+
95
+
96
+ def print_table(resources):
97
+ if not resources:
98
+ console.print("[yellow]No resources found.[/yellow]")
99
+ return
100
+ console.print(build_table(resources))
101
+ console.print(build_footer(resources))
102
+
103
+
104
+ def build_watch_panel(resources, interval):
105
+ """Build a Panel containing the table + footer for use with rich.Live."""
106
+ if not resources:
107
+ content = Text("No resources found.", style="yellow")
108
+ else:
109
+ from rich.console import Group
110
+ content = Group(build_table(resources), build_footer(resources))
111
+
112
+ timestamp = datetime.now().strftime("%H:%M:%S")
113
+ return Panel(
114
+ content,
115
+ title="[bold]pxinv watch[/bold]",
116
+ subtitle=f"[dim]refreshing every {interval}s — last update {timestamp} — press Ctrl+C to exit[/dim]",
117
+ border_style="dim",
118
+ )
119
+
120
+
121
+ def print_summary(resources, nodes):
122
+ node_table = Table(
123
+ title="Nodes", box=box.SIMPLE_HEAD, show_header=True, header_style="bold"
124
+ )
125
+ node_table.add_column("NODE")
126
+ node_table.add_column("STATUS")
127
+ node_table.add_column("CPU", justify="right")
128
+ node_table.add_column("RAM", justify="right", width=16)
129
+ node_table.add_column("UPTIME", justify="right")
130
+
131
+ for n in nodes:
132
+ mem = f"{_fmt_bytes(n['mem_used'])}/{_fmt_bytes(n['mem_total'])}"
133
+ node_table.add_row(
134
+ n["name"],
135
+ _status_style(n["status"]),
136
+ f"{n['cpu']}%",
137
+ mem,
138
+ _fmt_uptime(n["uptime"]),
139
+ )
140
+
141
+ console.print(node_table)
142
+
143
+ total = len(resources)
144
+ running = sum(1 for r in resources if r["status"] == "running")
145
+ stopped = sum(1 for r in resources if r["status"] == "stopped")
146
+ vms = sum(1 for r in resources if r["type"] == "vm")
147
+ cts = sum(1 for r in resources if r["type"] == "ct")
148
+ total_mem = sum(r["mem_total"] for r in resources)
149
+ used_mem = sum(r["mem_used"] for r in resources)
150
+ total_disk = sum(r["disk_total"] for r in resources if r["disk_total"] > 0)
151
+ used_disk = sum(r["disk_used"] for r in resources if r["disk_total"] > 0)
152
+
153
+ summary_table = Table(
154
+ title="Cluster totals", box=box.SIMPLE_HEAD, header_style="bold"
155
+ )
156
+ summary_table.add_column("METRIC")
157
+ summary_table.add_column("VALUE", justify="right")
158
+
159
+ summary_table.add_row("Total guests", str(total))
160
+ summary_table.add_row(" VMs", str(vms))
161
+ summary_table.add_row(" Containers", str(cts))
162
+ summary_table.add_row("Running", f"[green]{running}[/green]")
163
+ summary_table.add_row("Stopped", f"[red]{stopped}[/red]")
164
+ summary_table.add_row(
165
+ "RAM allocated",
166
+ f"{_fmt_bytes(used_mem)} / {_fmt_bytes(total_mem)}",
167
+ )
168
+ summary_table.add_row(
169
+ "Disk allocated",
170
+ f"{_fmt_bytes(used_disk)} / {_fmt_bytes(total_disk)}" if total_disk > 0 else "— (no data)",
171
+ )
172
+
173
+ console.print(summary_table)
@@ -0,0 +1,272 @@
1
+ Metadata-Version: 2.4
2
+ Name: pxinv
3
+ Version: 0.3.0
4
+ Summary: Proxmox VM & container inventory CLI
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/yourusername/pxinv
7
+ Project-URL: Issues, https://github.com/yourusername/pxinv/issues
8
+ Keywords: proxmox,homelab,cli,inventory,devops
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: System :: Systems Administration
19
+ Requires-Python: >=3.9
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: click>=8.0
22
+ Requires-Dist: proxmoxer>=2.0
23
+ Requires-Dist: requests>=2.28
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: pyyaml>=6.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+ Requires-Dist: ruff>=0.4; extra == "dev"
29
+ Requires-Dist: pip-audit>=2.0; extra == "dev"
30
+
31
+ # pxinv
32
+
33
+ [![CI](https://github.com/thetechiejourney/pxinv/actions/workflows/ci.yml/badge.svg)](https://github.com/thetechiejourney/pxinv/actions/workflows/ci.yml)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
35
+
36
+ A fast CLI for inventorying and managing VMs and containers on your Proxmox homelab.
37
+
38
+ ```
39
+ $ pxinv list
40
+
41
+ VMID NAME NODE TYPE STATUS CPU RAM DISK UPTIME TAGS
42
+ ──────────────────────────────────────────────────────────────────────────────────────────────────────
43
+ 100 homeassistant pve-01 VM running 5.2% 1.0GB/4.0GB 10.0GB/32.0GB 3d homelab
44
+ 101 pihole pve-01 CT running 0.8% 128.0MB/512.0MB 1.0GB/8.0GB 14d dns
45
+ 200 talos-cp-01 pve-02 VM stopped — —/8.0GB —/64.0GB — k8s
46
+
47
+ 3 resource(s) — 2 running, 1 stopped
48
+ ```
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install pxinv
54
+ ```
55
+
56
+ Or install from source:
57
+
58
+ ```bash
59
+ git clone https://github.com/thetechiejourney/pxinv
60
+ cd pxinv
61
+ pip install -e .
62
+ ```
63
+
64
+ ## Authentication
65
+
66
+ `pxinv` uses Proxmox API tokens (recommended over username/password).
67
+
68
+ **Create a token in Proxmox:**
69
+ 1. Go to Datacenter → Permissions → API Tokens
70
+ 2. Add a token for your user (e.g. `root@pam`, token name: `pxinv`)
71
+ 3. Copy the token value — it's only shown once
72
+
73
+ **Required permissions:** `VM.Audit` and `Sys.Audit` on `/` (or per-node).
74
+
75
+ ## Configuration
76
+
77
+ Credentials can be provided in three ways (in order of precedence):
78
+
79
+ ### 1. CLI flags
80
+ ```bash
81
+ pxinv --host 192.168.1.10 --token-name pxinv --token-value <secret> list
82
+ ```
83
+
84
+ ### 2. Environment variables
85
+ ```bash
86
+ export PXINV_HOST=192.168.1.10
87
+ export PXINV_TOKEN_NAME=pxinv
88
+ export PXINV_TOKEN_VALUE=<secret>
89
+ export PXINV_VERIFY_SSL=false # optional, for self-signed certs
90
+
91
+ pxinv list
92
+ ```
93
+
94
+ ### 3. Config file
95
+
96
+ `~/.config/pxinv/config.yaml`:
97
+ ```yaml
98
+ host: 192.168.1.10
99
+ user: root@pam
100
+ token_name: pxinv
101
+ token_value: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
102
+ verify_ssl: false
103
+ ```
104
+
105
+ ## Commands
106
+
107
+ ### `pxinv list`
108
+
109
+ List all VMs and containers.
110
+
111
+ ```bash
112
+ # All guests
113
+ pxinv list
114
+
115
+ # Filter by node
116
+ pxinv list --node pve-01
117
+
118
+ # Only running containers
119
+ pxinv list --type ct --status running
120
+
121
+ # Filter by tag
122
+ pxinv list --tags k8s
123
+ pxinv list --tags homelab --status running
124
+
125
+ # JSON output (pipe to jq, etc.)
126
+ pxinv list --output json | jq '.[] | select(.cpu_usage > 50)'
127
+
128
+ # YAML output
129
+ pxinv list --output yaml
130
+ ```
131
+
132
+ ### `pxinv watch`
133
+
134
+ Live-refresh the VM/container list directly in the terminal. No flickering — powered by `rich.Live`. Press `Ctrl+C` to exit.
135
+
136
+ ```bash
137
+ # Refresh every 5 seconds (default)
138
+ pxinv watch
139
+
140
+ # Custom interval
141
+ pxinv watch --interval 10
142
+
143
+ # Combinable with all list filters
144
+ pxinv watch --status running
145
+ pxinv watch --tags k8s --interval 3
146
+ pxinv watch --node pve-01
147
+ ```
148
+
149
+ The panel header shows the refresh interval and the timestamp of the last update.
150
+
151
+ ### `pxinv summary`
152
+
153
+ Show cluster-wide resource totals and node status.
154
+
155
+ ```bash
156
+ pxinv summary
157
+ pxinv summary --output json
158
+ ```
159
+
160
+ ### `pxinv start <vmid>`
161
+
162
+ Start a VM or container by VMID.
163
+
164
+ ```bash
165
+ pxinv start 100
166
+
167
+ # Wait until the VM is fully running before returning
168
+ pxinv start 100 --wait
169
+
170
+ # Custom timeout (default: 60s)
171
+ pxinv start 100 --wait --timeout 120
172
+ ```
173
+
174
+ ### `pxinv stop <vmid>`
175
+
176
+ Gracefully shut down a VM or container by VMID.
177
+
178
+ ```bash
179
+ pxinv stop 100
180
+
181
+ # Wait until the VM is fully stopped before returning
182
+ pxinv stop 100 --wait
183
+ ```
184
+
185
+ ### `pxinv ansible-inventory`
186
+
187
+ Export the inventory in Ansible dynamic inventory JSON format. Hosts are automatically grouped by status (`running`, `stopped`) and by Proxmox tag (`tag_homelab`, `tag_k8s`, etc.).
188
+
189
+ ```bash
190
+ # Basic inventory (no IPs)
191
+ pxinv ansible-inventory
192
+
193
+ # Fetch IPs via QEMU guest agent (requires qemu-guest-agent installed in VMs)
194
+ pxinv ansible-inventory --with-ips
195
+
196
+ # Only include running guests
197
+ pxinv ansible-inventory --running-only
198
+
199
+ # Use directly with ansible
200
+ ansible -i <(pxinv ansible-inventory) all -m ping
201
+
202
+ # Target a specific group
203
+ ansible -i <(pxinv ansible-inventory) tag_homelab -m ping
204
+ ansible -i <(pxinv ansible-inventory) running -m setup
205
+ ```
206
+
207
+ Example output:
208
+
209
+ ```json
210
+ {
211
+ "all": {
212
+ "hosts": ["homeassistant", "pihole"],
213
+ "children": ["running", "stopped", "tag_homelab", "tag_dns"]
214
+ },
215
+ "running": { "hosts": ["homeassistant", "pihole"] },
216
+ "stopped": { "hosts": [] },
217
+ "tag_homelab": { "hosts": ["homeassistant"] },
218
+ "tag_dns": { "hosts": ["pihole"] },
219
+ "_meta": {
220
+ "hostvars": {
221
+ "homeassistant": {
222
+ "ansible_host": "192.168.1.100",
223
+ "proxmox_vmid": 100,
224
+ "proxmox_node": "pve-01",
225
+ "proxmox_type": "vm",
226
+ "proxmox_status": "running",
227
+ "proxmox_tags": ["homelab"]
228
+ }
229
+ }
230
+ }
231
+ }
232
+ ```
233
+
234
+ > `ansible_host` is only populated when using `--with-ips` and the VM has `qemu-guest-agent` running.
235
+
236
+ ## Tags
237
+
238
+ Proxmox supports tagging VMs and containers via the UI (VM → Options → Tags). `pxinv` shows tags as a column in `pxinv list` and lets you filter by them:
239
+
240
+ ```bash
241
+ pxinv list --tags homelab
242
+ pxinv list --tags k8s --status running
243
+ ```
244
+
245
+ Tag matching is case-insensitive. Multiple tags per VM are supported — filtering matches any VM that contains the specified tag.
246
+
247
+ ## Self-signed certificates
248
+
249
+ If your Proxmox uses the default self-signed cert, disable verification:
250
+
251
+ ```bash
252
+ pxinv --no-verify-ssl list
253
+ # or
254
+ export PXINV_VERIFY_SSL=false
255
+ ```
256
+
257
+ ## Contributing
258
+
259
+ PRs welcome. To set up a dev environment:
260
+
261
+ ```bash
262
+ git clone https://github.com/thetechiejourney/pxinv
263
+ cd pxinv
264
+ python3 -m venv .venv
265
+ source .venv/bin/activate
266
+ pip install -e ".[dev]"
267
+ pytest
268
+ ```
269
+
270
+ ## License
271
+
272
+ MIT
@@ -0,0 +1,10 @@
1
+ pxinv/__init__.py,sha256=JN4aiyKGaHLfu0BbuNN3l1vqDZzFg3TicOSDlGn-2Qo,76
2
+ pxinv/cli.py,sha256=6WlkoVQ9yAGG1ImKSzBpb3G6cVyBmKyvAJ6DH56wmZE,10705
3
+ pxinv/client.py,sha256=HBsdJsibQCUkazSjJkcHv7w3Xvu7FGF1IbFGp4X5AuI,6413
4
+ pxinv/config.py,sha256=g1uqCheY7mNmFl5ZJFVKiJNBxU42l4GVzT83wQ8b68Y,504
5
+ pxinv/output.py,sha256=MT9V1fTug6U_-pZhgSErJ79319Cif4f15J8Y0ARlLX4,5557
6
+ pxinv-0.3.0.dist-info/METADATA,sha256=Zft2q9JsZg0CEXDpK3Lj39zRsujjlcova1vfedjUAKk,6966
7
+ pxinv-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ pxinv-0.3.0.dist-info/entry_points.txt,sha256=MuLSkPDem9ke_lqiZ70kCfvxGXWjUkQ4xCtvGzhuGbI,41
9
+ pxinv-0.3.0.dist-info/top_level.txt,sha256=c0Dn29u9r-aC7GvNAdrvm2tnYz9NIz48oBecPynmeb0,6
10
+ pxinv-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pxinv = pxinv.cli:main
@@ -0,0 +1 @@
1
+ pxinv