invctl 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.
- invctl/__init__.py +3 -0
- invctl/client.py +96 -0
- invctl/collectors/__init__.py +23 -0
- invctl/collectors/backups.py +66 -0
- invctl/collectors/base.py +37 -0
- invctl/collectors/cluster.py +81 -0
- invctl/collectors/containers.py +162 -0
- invctl/collectors/firewall.py +187 -0
- invctl/collectors/network.py +91 -0
- invctl/collectors/nodes.py +97 -0
- invctl/collectors/storage.py +101 -0
- invctl/collectors/users.py +131 -0
- invctl/collectors/vms.py +171 -0
- invctl/exporters/__init__.py +7 -0
- invctl/exporters/base.py +16 -0
- invctl/exporters/html_exporter.py +47 -0
- invctl/exporters/json_exporter.py +22 -0
- invctl/exporters/markdown_exporter.py +379 -0
- invctl/main.py +461 -0
- invctl/models.py +405 -0
- invctl/templates/report.html.j2 +638 -0
- invctl-0.1.0.dist-info/METADATA +210 -0
- invctl-0.1.0.dist-info/RECORD +26 -0
- invctl-0.1.0.dist-info/WHEEL +4 -0
- invctl-0.1.0.dist-info/entry_points.txt +2 -0
- invctl-0.1.0.dist-info/licenses/LICENSE +192 -0
invctl/__init__.py
ADDED
invctl/client.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Proxmox API client factory with Rich error reporting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from proxmoxer import ProxmoxAPI
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
_console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_client(
|
|
17
|
+
host: str,
|
|
18
|
+
port: int = 8006,
|
|
19
|
+
token_id: str | None = None,
|
|
20
|
+
token_secret: str | None = None,
|
|
21
|
+
user: str | None = None,
|
|
22
|
+
password: str | None = None,
|
|
23
|
+
verify_ssl: bool = True,
|
|
24
|
+
) -> ProxmoxAPI:
|
|
25
|
+
"""Return an authenticated ProxmoxAPI instance.
|
|
26
|
+
|
|
27
|
+
Authentication priority:
|
|
28
|
+
1. API token (token_id + token_secret) — recommended
|
|
29
|
+
2. Username + password
|
|
30
|
+
|
|
31
|
+
Env vars (used when CLI flags are absent):
|
|
32
|
+
- PROXMOX_HOST, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET
|
|
33
|
+
- PROXMOX_USER, PROXMOX_PASSWORD
|
|
34
|
+
|
|
35
|
+
Raises SystemExit with a Rich-formatted error on connection failure.
|
|
36
|
+
"""
|
|
37
|
+
host = host or os.getenv("PROXMOX_HOST", "")
|
|
38
|
+
if not host:
|
|
39
|
+
_console.print("[bold red]Error:[/] --host / PROXMOX_HOST is required.")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
token_id = token_id or os.getenv("PROXMOX_TOKEN_ID")
|
|
43
|
+
token_secret = token_secret or os.getenv("PROXMOX_TOKEN_SECRET")
|
|
44
|
+
user = user or os.getenv("PROXMOX_USER")
|
|
45
|
+
password = password or os.getenv("PROXMOX_PASSWORD")
|
|
46
|
+
|
|
47
|
+
kwargs: dict[str, Any] = {
|
|
48
|
+
"host": host,
|
|
49
|
+
"port": port,
|
|
50
|
+
"verify_ssl": verify_ssl,
|
|
51
|
+
"service": "PVE",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if token_id and token_secret:
|
|
55
|
+
# token_id format: "USER@REALM!TOKENNAME" or just "TOKENNAME" when user is separate
|
|
56
|
+
if "!" in token_id:
|
|
57
|
+
kwargs["user"] = token_id
|
|
58
|
+
kwargs["password"] = token_secret
|
|
59
|
+
else:
|
|
60
|
+
# Assume user flag holds the user@realm part
|
|
61
|
+
if not user:
|
|
62
|
+
_console.print(
|
|
63
|
+
"[bold red]Error:[/] --token-id without '@realm!' prefix requires --user."
|
|
64
|
+
)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
kwargs["user"] = f"{user}!{token_id}"
|
|
67
|
+
kwargs["password"] = token_secret
|
|
68
|
+
elif user and password:
|
|
69
|
+
kwargs["user"] = user
|
|
70
|
+
kwargs["password"] = password
|
|
71
|
+
else:
|
|
72
|
+
_console.print(
|
|
73
|
+
"[bold red]Error:[/] Provide either --token-id/--token-secret "
|
|
74
|
+
"or --user/--password (or matching env vars)."
|
|
75
|
+
)
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
proxmox = ProxmoxAPI(**kwargs)
|
|
80
|
+
# Probe connectivity — version endpoint is always available
|
|
81
|
+
proxmox.version.get()
|
|
82
|
+
return proxmox
|
|
83
|
+
except requests.exceptions.SSLError as exc:
|
|
84
|
+
_console.print(
|
|
85
|
+
f"[bold red]SSL Error:[/] {exc}\n"
|
|
86
|
+
"[dim]Tip: use --no-verify-ssl to skip certificate validation.[/]"
|
|
87
|
+
)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
except requests.exceptions.ConnectionError as exc:
|
|
90
|
+
_console.print(f"[bold red]Connection Error:[/] Cannot reach {host}:{port} — {exc}")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
except Exception as exc: # proxmoxer raises various auth errors as generic exceptions
|
|
93
|
+
msg = str(exc)
|
|
94
|
+
# Never leak token secrets or passwords in error output
|
|
95
|
+
_console.print(f"[bold red]Authentication Error:[/] {msg}")
|
|
96
|
+
sys.exit(1)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Proxmox data collectors."""
|
|
2
|
+
|
|
3
|
+
from invctl.collectors.backups import BackupsCollector
|
|
4
|
+
from invctl.collectors.cluster import ClusterCollector
|
|
5
|
+
from invctl.collectors.containers import ContainersCollector
|
|
6
|
+
from invctl.collectors.firewall import FirewallCollector
|
|
7
|
+
from invctl.collectors.network import NetworkCollector
|
|
8
|
+
from invctl.collectors.nodes import NodesCollector
|
|
9
|
+
from invctl.collectors.storage import StorageCollector
|
|
10
|
+
from invctl.collectors.users import UsersCollector
|
|
11
|
+
from invctl.collectors.vms import VMsCollector
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BackupsCollector",
|
|
15
|
+
"ClusterCollector",
|
|
16
|
+
"ContainersCollector",
|
|
17
|
+
"FirewallCollector",
|
|
18
|
+
"NetworkCollector",
|
|
19
|
+
"NodesCollector",
|
|
20
|
+
"StorageCollector",
|
|
21
|
+
"UsersCollector",
|
|
22
|
+
"VMsCollector",
|
|
23
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Backup collector: scheduled backup jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from invctl.collectors.base import BaseCollector
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BackupsCollector(BaseCollector):
|
|
11
|
+
"""Collect all vzdump backup jobs configured in the cluster."""
|
|
12
|
+
|
|
13
|
+
def collect(self) -> dict[str, Any]:
|
|
14
|
+
"""Return a BackupInfo-compatible dict."""
|
|
15
|
+
try:
|
|
16
|
+
return self._collect()
|
|
17
|
+
except Exception as exc:
|
|
18
|
+
self._warn("backups", exc)
|
|
19
|
+
return {"available": False, "error": str(exc), "jobs": []}
|
|
20
|
+
|
|
21
|
+
def _collect(self) -> dict[str, Any]:
|
|
22
|
+
jobs: list[dict[str, Any]] = []
|
|
23
|
+
try:
|
|
24
|
+
raw_jobs: list[dict[str, Any]] = self.proxmox.cluster.backup.get()
|
|
25
|
+
except Exception as exc:
|
|
26
|
+
self._warn("backups.list", exc)
|
|
27
|
+
return {"available": True, "jobs": []}
|
|
28
|
+
|
|
29
|
+
for job in raw_jobs:
|
|
30
|
+
try:
|
|
31
|
+
parsed = self._parse_job(job)
|
|
32
|
+
jobs.append(parsed)
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
self._warn(f"backups.job.{job.get('id')}", exc)
|
|
35
|
+
|
|
36
|
+
return {"available": True, "jobs": jobs}
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _parse_job(raw: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
"""Convert a raw backup job config to our schema."""
|
|
41
|
+
vmid_raw: str = raw.get("vmid", "") or ""
|
|
42
|
+
all_vms = not vmid_raw or vmid_raw.strip() == "all"
|
|
43
|
+
vmids = [v.strip() for v in vmid_raw.split(",") if v.strip() and v.strip() != "all"]
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"job_id": raw.get("id"),
|
|
47
|
+
"schedule": raw.get("schedule") or raw.get("starttime"),
|
|
48
|
+
"storage": raw.get("storage"),
|
|
49
|
+
"vmids": vmids,
|
|
50
|
+
"all_vms": all_vms,
|
|
51
|
+
"mode": raw.get("mode"),
|
|
52
|
+
"keep_last": _int_or_none(raw.get("keep-last")),
|
|
53
|
+
"keep_daily": _int_or_none(raw.get("keep-daily")),
|
|
54
|
+
"keep_weekly": _int_or_none(raw.get("keep-weekly")),
|
|
55
|
+
"keep_monthly": _int_or_none(raw.get("keep-monthly")),
|
|
56
|
+
"keep_yearly": _int_or_none(raw.get("keep-yearly")),
|
|
57
|
+
"comment": raw.get("comment"),
|
|
58
|
+
"enabled": bool(raw.get("enabled", 1)),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _int_or_none(val: Any) -> int | None:
|
|
63
|
+
try:
|
|
64
|
+
return int(val)
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Abstract base class for all Proxmox data collectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from proxmoxer import ProxmoxAPI
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaseCollector(ABC):
|
|
15
|
+
"""Base class every collector must subclass.
|
|
16
|
+
|
|
17
|
+
Subclasses implement :meth:`collect` and return a plain dict
|
|
18
|
+
whose structure matches the corresponding Pydantic model.
|
|
19
|
+
On total failure the dict must be ``{"available": False, "error": "<msg>"}``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, proxmox: ProxmoxAPI) -> None:
|
|
23
|
+
"""Initialise with an authenticated ProxmoxAPI client."""
|
|
24
|
+
self.proxmox = proxmox
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def collect(self) -> dict[str, Any]:
|
|
28
|
+
"""Run the collection and return a serialisable dict."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
# Shared helpers
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _warn(self, context: str, exc: Exception) -> None:
|
|
36
|
+
"""Print a non-fatal warning to stderr via Rich."""
|
|
37
|
+
console.print(f"[yellow]Warning[/] [{context}]: {exc}")
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Cluster-level collector: name, quorum, HA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from invctl.collectors.base import BaseCollector
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClusterCollector(BaseCollector):
|
|
11
|
+
"""Collect cluster name, quorum status, and HA configuration."""
|
|
12
|
+
|
|
13
|
+
def collect(self) -> dict[str, Any]:
|
|
14
|
+
"""Return ClusterInfo-compatible dict."""
|
|
15
|
+
try:
|
|
16
|
+
return self._collect()
|
|
17
|
+
except Exception as exc:
|
|
18
|
+
self._warn("cluster", exc)
|
|
19
|
+
return {"available": False, "error": str(exc)}
|
|
20
|
+
|
|
21
|
+
def _collect(self) -> dict[str, Any]:
|
|
22
|
+
name: str | None = None
|
|
23
|
+
quorum_status: str | None = None
|
|
24
|
+
node_count: int | None = None
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
status_items: list[dict[str, Any]] = self.proxmox.cluster.status.get()
|
|
28
|
+
for item in status_items:
|
|
29
|
+
if item.get("type") == "cluster":
|
|
30
|
+
name = item.get("name")
|
|
31
|
+
quorum_status = "ok" if item.get("quorate", 0) else "nok"
|
|
32
|
+
node_count = item.get("nodes")
|
|
33
|
+
break
|
|
34
|
+
except Exception as exc:
|
|
35
|
+
self._warn("cluster.status", exc)
|
|
36
|
+
|
|
37
|
+
ha_info: dict[str, Any] | None = None
|
|
38
|
+
try:
|
|
39
|
+
ha_info = self._collect_ha()
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
self._warn("cluster.ha", exc)
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"available": True,
|
|
45
|
+
"name": name,
|
|
46
|
+
"quorum_status": quorum_status,
|
|
47
|
+
"node_count": node_count,
|
|
48
|
+
"ha": ha_info,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def _collect_ha(self) -> dict[str, Any]:
|
|
52
|
+
"""Collect HA status and groups."""
|
|
53
|
+
status = "unknown"
|
|
54
|
+
try:
|
|
55
|
+
ha_status: list[dict[str, Any]] = self.proxmox.cluster.ha.status.current.get()
|
|
56
|
+
if ha_status:
|
|
57
|
+
states = {r.get("state", "") for r in ha_status}
|
|
58
|
+
status = "ok" if all(s in ("started", "ignored") for s in states) else "degraded"
|
|
59
|
+
else:
|
|
60
|
+
status = "no resources"
|
|
61
|
+
except Exception as exc:
|
|
62
|
+
self._warn("cluster.ha.status", exc)
|
|
63
|
+
|
|
64
|
+
groups: list[dict[str, Any]] = []
|
|
65
|
+
try:
|
|
66
|
+
raw_groups: list[dict[str, Any]] = self.proxmox.cluster.ha.groups.get()
|
|
67
|
+
for g in raw_groups:
|
|
68
|
+
nodes_str: str = g.get("nodes", "")
|
|
69
|
+
node_names = [n.split(":")[0] for n in nodes_str.split(",") if n]
|
|
70
|
+
groups.append(
|
|
71
|
+
{
|
|
72
|
+
"name": g.get("group", ""),
|
|
73
|
+
"nodes": node_names,
|
|
74
|
+
"restricted": bool(g.get("restricted", 0)),
|
|
75
|
+
"nofailback": bool(g.get("nofailback", 0)),
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
self._warn("cluster.ha.groups", exc)
|
|
80
|
+
|
|
81
|
+
return {"status": status, "groups": groups}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Container collector: LXC containers, config and runtime stats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from invctl.collectors.base import BaseCollector
|
|
9
|
+
|
|
10
|
+
_NET_RE = re.compile(r"^net\d+$")
|
|
11
|
+
_MP_RE = re.compile(r"^mp\d+$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_ct_net(key: str, value: str) -> dict[str, Any]:
|
|
15
|
+
"""Parse an LXC network config string into a CTNetworkInterface dict."""
|
|
16
|
+
parts_dict: dict[str, str] = {}
|
|
17
|
+
for part in value.split(","):
|
|
18
|
+
if "=" in part:
|
|
19
|
+
k, v = part.split("=", 1)
|
|
20
|
+
parts_dict[k] = v
|
|
21
|
+
return {
|
|
22
|
+
"key": key,
|
|
23
|
+
"name": parts_dict.get("name"),
|
|
24
|
+
"bridge": parts_dict.get("bridge"),
|
|
25
|
+
"ip": parts_dict.get("ip"),
|
|
26
|
+
"ip6": parts_dict.get("ip6"),
|
|
27
|
+
"gateway": parts_dict.get("gw"),
|
|
28
|
+
"mac": parts_dict.get("hwaddr"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_storage_spec(value: str, mp_path: str | None = None) -> dict[str, Any]:
|
|
33
|
+
"""Parse a rootfs/mp config string into a MountpointInfo dict."""
|
|
34
|
+
parts = value.split(",")
|
|
35
|
+
storage_volume = parts[0]
|
|
36
|
+
storage_backend = storage_volume.split(":")[0] if ":" in storage_volume else None
|
|
37
|
+
parts_dict: dict[str, str] = {}
|
|
38
|
+
for part in parts[1:]:
|
|
39
|
+
if "=" in part:
|
|
40
|
+
k, v = part.split("=", 1)
|
|
41
|
+
parts_dict[k] = v
|
|
42
|
+
return {
|
|
43
|
+
"storage_backend": storage_backend,
|
|
44
|
+
"size": parts_dict.get("size"),
|
|
45
|
+
"mountpoint": parts_dict.get("mp", mp_path),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ContainersCollector(BaseCollector):
|
|
50
|
+
"""Collect all LXC containers across every node."""
|
|
51
|
+
|
|
52
|
+
def collect(self) -> dict[str, Any]:
|
|
53
|
+
"""Return a list of ContainerInfo-compatible dicts under key 'containers'."""
|
|
54
|
+
containers: list[dict[str, Any]] = []
|
|
55
|
+
try:
|
|
56
|
+
raw_nodes: list[dict[str, Any]] = self.proxmox.nodes.get()
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
self._warn("containers.nodes", exc)
|
|
59
|
+
return {"containers": []}
|
|
60
|
+
|
|
61
|
+
for node in raw_nodes:
|
|
62
|
+
node_name: str = node.get("node", "")
|
|
63
|
+
if node.get("status") != "online":
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
node_cts = self._collect_node_containers(node_name)
|
|
67
|
+
containers.extend(node_cts)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
self._warn(f"containers.{node_name}", exc)
|
|
70
|
+
|
|
71
|
+
return {"containers": containers}
|
|
72
|
+
|
|
73
|
+
def _collect_node_containers(self, node_name: str) -> list[dict[str, Any]]:
|
|
74
|
+
"""Collect all LXC containers on a single node."""
|
|
75
|
+
containers: list[dict[str, Any]] = []
|
|
76
|
+
try:
|
|
77
|
+
raw_list: list[dict[str, Any]] = self.proxmox.nodes(node_name).lxc.get()
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
self._warn(f"containers.{node_name}.list", exc)
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
for ct_summary in raw_list:
|
|
83
|
+
vmid = ct_summary.get("vmid")
|
|
84
|
+
if vmid is None:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
ct_data = self._collect_container(node_name, vmid, ct_summary)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
self._warn(f"containers.{node_name}.{vmid}", exc)
|
|
90
|
+
ct_data = {
|
|
91
|
+
"available": False,
|
|
92
|
+
"error": str(exc),
|
|
93
|
+
"vmid": vmid,
|
|
94
|
+
"node": node_name,
|
|
95
|
+
"status": ct_summary.get("status", "unknown"),
|
|
96
|
+
}
|
|
97
|
+
containers.append(ct_data)
|
|
98
|
+
|
|
99
|
+
return containers
|
|
100
|
+
|
|
101
|
+
def _collect_container(
|
|
102
|
+
self, node_name: str, vmid: int, summary: dict[str, Any]
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Collect detailed config and stats for a single LXC container."""
|
|
105
|
+
config: dict[str, Any] = {}
|
|
106
|
+
try:
|
|
107
|
+
config = self.proxmox.nodes(node_name).lxc(vmid).config.get()
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
self._warn(f"containers.{node_name}.{vmid}.config", exc)
|
|
110
|
+
|
|
111
|
+
snapshots: list[dict[str, Any]] = []
|
|
112
|
+
try:
|
|
113
|
+
raw_snaps: list[dict[str, Any]] = (
|
|
114
|
+
self.proxmox.nodes(node_name).lxc(vmid).snapshot.get()
|
|
115
|
+
)
|
|
116
|
+
for snap in raw_snaps:
|
|
117
|
+
if snap.get("name") == "current":
|
|
118
|
+
continue
|
|
119
|
+
snapshots.append(
|
|
120
|
+
{
|
|
121
|
+
"name": snap.get("name", ""),
|
|
122
|
+
"snaptime": snap.get("snaptime"),
|
|
123
|
+
"description": snap.get("description"),
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
self._warn(f"containers.{node_name}.{vmid}.snapshots", exc)
|
|
128
|
+
|
|
129
|
+
networks = [_parse_ct_net(k, v) for k, v in config.items() if _NET_RE.match(k)]
|
|
130
|
+
mountpoints = [_parse_storage_spec(v) for k, v in config.items() if _MP_RE.match(k)]
|
|
131
|
+
|
|
132
|
+
rootfs: dict[str, Any] | None = None
|
|
133
|
+
if "rootfs" in config:
|
|
134
|
+
rootfs = _parse_storage_spec(config["rootfs"], "/")
|
|
135
|
+
|
|
136
|
+
tags_raw: str = config.get("tags", "") or summary.get("tags", "") or ""
|
|
137
|
+
tags = [t.strip() for t in re.split(r"[;,]", tags_raw) if t.strip()]
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"available": True,
|
|
141
|
+
"vmid": vmid,
|
|
142
|
+
"name": config.get("hostname") or summary.get("name"),
|
|
143
|
+
"node": node_name,
|
|
144
|
+
"status": summary.get("status", "unknown"),
|
|
145
|
+
"cpu_allocated": config.get("cores") or config.get("cpulimit"),
|
|
146
|
+
"ram_allocated_mb": config.get("memory"),
|
|
147
|
+
"swap_allocated_mb": config.get("swap"),
|
|
148
|
+
"cpu_usage_percent": round(float(summary.get("cpu", 0)) * 100, 2)
|
|
149
|
+
if summary.get("status") == "running"
|
|
150
|
+
else None,
|
|
151
|
+
"ram_usage_bytes": summary.get("mem")
|
|
152
|
+
if summary.get("status") == "running"
|
|
153
|
+
else None,
|
|
154
|
+
"rootfs": rootfs,
|
|
155
|
+
"mountpoints": mountpoints,
|
|
156
|
+
"networks": networks,
|
|
157
|
+
"os_template": config.get("ostype"),
|
|
158
|
+
"unprivileged": bool(config.get("unprivileged", 0)),
|
|
159
|
+
"tags": tags,
|
|
160
|
+
"description": config.get("description"),
|
|
161
|
+
"snapshots": snapshots,
|
|
162
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Firewall collector: cluster, node, and per-VM/CT rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from invctl.collectors.base import BaseCollector
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FirewallCollector(BaseCollector):
|
|
11
|
+
"""Collect firewall configuration at every level of the hierarchy."""
|
|
12
|
+
|
|
13
|
+
def collect(self) -> dict[str, Any]:
|
|
14
|
+
"""Return a FirewallInfo-compatible dict."""
|
|
15
|
+
try:
|
|
16
|
+
return self._collect()
|
|
17
|
+
except Exception as exc:
|
|
18
|
+
self._warn("firewall", exc)
|
|
19
|
+
return {"available": False, "error": str(exc)}
|
|
20
|
+
|
|
21
|
+
def _collect(self) -> dict[str, Any]:
|
|
22
|
+
cluster_opts = self._collect_cluster_options()
|
|
23
|
+
security_groups = self._collect_security_groups()
|
|
24
|
+
node_rules, vm_firewalls = self._collect_node_and_vm_rules()
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
"available": True,
|
|
28
|
+
"cluster_enabled": cluster_opts.get("cluster_enabled", False),
|
|
29
|
+
"cluster_policy_in": cluster_opts.get("policy_in"),
|
|
30
|
+
"cluster_policy_out": cluster_opts.get("policy_out"),
|
|
31
|
+
"cluster_policy_forward": cluster_opts.get("policy_forward"),
|
|
32
|
+
"ebtables": cluster_opts.get("ebtables", False),
|
|
33
|
+
"security_groups": security_groups,
|
|
34
|
+
"node_rules": node_rules,
|
|
35
|
+
"vm_firewalls": vm_firewalls,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def _collect_cluster_options(self) -> dict[str, Any]:
|
|
39
|
+
try:
|
|
40
|
+
opts: dict[str, Any] = self.proxmox.cluster.firewall.options.get()
|
|
41
|
+
return {
|
|
42
|
+
"cluster_enabled": bool(opts.get("enable", 0)),
|
|
43
|
+
"policy_in": opts.get("policy_in"),
|
|
44
|
+
"policy_out": opts.get("policy_out"),
|
|
45
|
+
"policy_forward": opts.get("policy_forward"),
|
|
46
|
+
"ebtables": bool(opts.get("ebtables", 0)),
|
|
47
|
+
}
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
self._warn("firewall.cluster.options", exc)
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
def _collect_security_groups(self) -> list[dict[str, Any]]:
|
|
53
|
+
groups: list[dict[str, Any]] = []
|
|
54
|
+
try:
|
|
55
|
+
raw_groups: list[dict[str, Any]] = self.proxmox.cluster.firewall.groups.get()
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
self._warn("firewall.cluster.groups", exc)
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
for g in raw_groups:
|
|
61
|
+
group_name = g.get("group", "")
|
|
62
|
+
rules: list[dict[str, Any]] = []
|
|
63
|
+
try:
|
|
64
|
+
raw_rules: list[dict[str, Any]] = (
|
|
65
|
+
self.proxmox.cluster.firewall.groups(group_name).get()
|
|
66
|
+
)
|
|
67
|
+
rules = [_parse_rule(r) for r in raw_rules]
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
self._warn(f"firewall.group.{group_name}.rules", exc)
|
|
70
|
+
|
|
71
|
+
groups.append(
|
|
72
|
+
{
|
|
73
|
+
"name": group_name,
|
|
74
|
+
"comment": g.get("comment"),
|
|
75
|
+
"rules": rules,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
return groups
|
|
79
|
+
|
|
80
|
+
def _collect_node_and_vm_rules(
|
|
81
|
+
self,
|
|
82
|
+
) -> tuple[dict[str, list[dict[str, Any]]], list[dict[str, Any]]]:
|
|
83
|
+
node_rules: dict[str, list[dict[str, Any]]] = {}
|
|
84
|
+
vm_firewalls: list[dict[str, Any]] = []
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
raw_nodes: list[dict[str, Any]] = self.proxmox.nodes.get()
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
self._warn("firewall.nodes", exc)
|
|
90
|
+
return node_rules, vm_firewalls
|
|
91
|
+
|
|
92
|
+
for node in raw_nodes:
|
|
93
|
+
node_name: str = node.get("node", "")
|
|
94
|
+
if node.get("status") != "online":
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
raw_rules: list[dict[str, Any]] = (
|
|
99
|
+
self.proxmox.nodes(node_name).firewall.rules.get()
|
|
100
|
+
)
|
|
101
|
+
node_rules[node_name] = [_parse_rule(r) for r in raw_rules]
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
self._warn(f"firewall.{node_name}.rules", exc)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
vm_list: list[dict[str, Any]] = self.proxmox.nodes(node_name).qemu.get()
|
|
107
|
+
for vm in vm_list:
|
|
108
|
+
vmid = vm.get("vmid")
|
|
109
|
+
if vmid is None:
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
fw = self._collect_vm_firewall(node_name, vmid, "qemu")
|
|
113
|
+
vm_firewalls.append(fw)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
self._warn(f"firewall.{node_name}.qemu.{vmid}", exc)
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
self._warn(f"firewall.{node_name}.vms", exc)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
ct_list: list[dict[str, Any]] = self.proxmox.nodes(node_name).lxc.get()
|
|
121
|
+
for ct in ct_list:
|
|
122
|
+
vmid = ct.get("vmid")
|
|
123
|
+
if vmid is None:
|
|
124
|
+
continue
|
|
125
|
+
try:
|
|
126
|
+
fw = self._collect_vm_firewall(node_name, vmid, "lxc")
|
|
127
|
+
vm_firewalls.append(fw)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
self._warn(f"firewall.{node_name}.lxc.{vmid}", exc)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
self._warn(f"firewall.{node_name}.cts", exc)
|
|
132
|
+
|
|
133
|
+
return node_rules, vm_firewalls
|
|
134
|
+
|
|
135
|
+
def _collect_vm_firewall(
|
|
136
|
+
self, node_name: str, vmid: int, vm_type: str
|
|
137
|
+
) -> dict[str, Any]:
|
|
138
|
+
"""Collect firewall options and rules for a single VM or container."""
|
|
139
|
+
api_node = self.proxmox.nodes(node_name)
|
|
140
|
+
vm_api = api_node.qemu(vmid) if vm_type == "qemu" else api_node.lxc(vmid)
|
|
141
|
+
|
|
142
|
+
policy_in: str | None = None
|
|
143
|
+
policy_out: str | None = None
|
|
144
|
+
enabled = False
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
opts: dict[str, Any] = vm_api.firewall.options.get()
|
|
148
|
+
enabled = bool(opts.get("enable", 0))
|
|
149
|
+
policy_in = opts.get("policy_in")
|
|
150
|
+
policy_out = opts.get("policy_out")
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
self._warn(f"firewall.{node_name}.{vm_type}.{vmid}.options", exc)
|
|
153
|
+
|
|
154
|
+
rules: list[dict[str, Any]] = []
|
|
155
|
+
try:
|
|
156
|
+
raw_rules: list[dict[str, Any]] = vm_api.firewall.rules.get()
|
|
157
|
+
rules = [_parse_rule(r) for r in raw_rules]
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
self._warn(f"firewall.{node_name}.{vm_type}.{vmid}.rules", exc)
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
"vmid": vmid,
|
|
163
|
+
"node": node_name,
|
|
164
|
+
"vm_type": vm_type,
|
|
165
|
+
"enabled": enabled,
|
|
166
|
+
"policy_in": policy_in,
|
|
167
|
+
"policy_out": policy_out,
|
|
168
|
+
"rules": rules,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_rule(raw: dict[str, Any]) -> dict[str, Any]:
|
|
173
|
+
"""Convert a raw Proxmox firewall rule dict to our schema."""
|
|
174
|
+
return {
|
|
175
|
+
"pos": raw.get("pos"),
|
|
176
|
+
"rule_type": raw.get("type"),
|
|
177
|
+
"action": raw.get("action"),
|
|
178
|
+
"source": raw.get("source"),
|
|
179
|
+
"dest": raw.get("dest"),
|
|
180
|
+
"proto": raw.get("proto"),
|
|
181
|
+
"dport": raw.get("dport"),
|
|
182
|
+
"sport": raw.get("sport"),
|
|
183
|
+
"comment": raw.get("comment"),
|
|
184
|
+
"enabled": bool(raw.get("enable", 1)),
|
|
185
|
+
"macro": raw.get("macro"),
|
|
186
|
+
"iface": raw.get("iface"),
|
|
187
|
+
}
|