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 +3 -0
- pxinv/cli.py +308 -0
- pxinv/client.py +175 -0
- pxinv/config.py +22 -0
- pxinv/output.py +173 -0
- pxinv-0.3.0.dist-info/METADATA +272 -0
- pxinv-0.3.0.dist-info/RECORD +10 -0
- pxinv-0.3.0.dist-info/WHEEL +5 -0
- pxinv-0.3.0.dist-info/entry_points.txt +2 -0
- pxinv-0.3.0.dist-info/top_level.txt +1 -0
pxinv/__init__.py
ADDED
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
|
+
[](https://github.com/thetechiejourney/pxinv/actions/workflows/ci.yml)
|
|
34
|
+
[](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 @@
|
|
|
1
|
+
pxinv
|