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 ADDED
@@ -0,0 +1,3 @@
1
+ """invctl — Infrastructure Inventory Control for Proxmox VE."""
2
+
3
+ __version__ = "0.1.0"
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
+ }