_get_vm_disk_and_boot() now returns a list of disk dicts (name, size_mb)
instead of a single total. Each disk entry is read from the VM config
via /qemu/{vmid}/config or /lxc/{vmid}/config. The onboot flag is also
read from the same config endpoint.
Both QEMU VMs and LXC containers are covered. The 'disks' and 'onboot'
keys are added to every entry in the vms_snapshot.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
397 lines
17 KiB
Python
397 lines
17 KiB
Python
# Copyright 2025 The NetOrk Project Authors. All rights reserved.
|
||
#
|
||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||
# you may not use this file except in compliance with the License.
|
||
# You may obtain a copy of the License at
|
||
#
|
||
# http://www.apache.org/licenses/LICENSE-2.0
|
||
#
|
||
# Unless required by applicable law or agreed to in writing, software
|
||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
# See the License for the specific language governing permissions and
|
||
# limitations under the License.
|
||
|
||
"""VM/container NAPALM getters for Proxmox VE nodes."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
from napalm_proxmox import utils
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_JsonDict = dict[str, Any]
|
||
|
||
|
||
class ProxmoxVMMixin:
|
||
"""Mixin providing VM and container NAPALM methods."""
|
||
|
||
def get_vm_interfaces(
|
||
self, vmid: int, vm_type: str
|
||
) -> tuple[dict[str, _JsonDict], bool, bool]:
|
||
"""Return network interfaces for a single VM or LXC container.
|
||
|
||
Returns a 3-tuple ``(interfaces, agent_running, agent_enabled)``:
|
||
|
||
* ``interfaces`` – dict keyed by interface name, each with
|
||
NAPALM-compatible fields plus ``ipv4``, ``bridge``, and ``tag``.
|
||
* ``agent_running`` – True if the QEMU Guest Agent responded during
|
||
this call (always False for LXC).
|
||
* ``agent_enabled`` – True if the QEMU Guest Agent is enabled in the
|
||
VM's Proxmox config (always False for LXC).
|
||
|
||
LXC : uses ``/nodes/{node}/lxc/{vmid}/interfaces`` + LXC config
|
||
QEMU : tries QEMU guest agent first, falls back to VM config parsing.
|
||
In both paths the VM config is fetched to derive bridge/tag.
|
||
"""
|
||
_NET_MODELS = {"virtio", "e1000", "e1000e", "vmxnet3", "rtl8139", "ne2k_pci"}
|
||
|
||
def _parse_net_entry(val_str: str) -> tuple[str, str, int | None]:
|
||
"""Parse a Proxmox net config value → (mac_upper, bridge, tag|None)."""
|
||
mac = bridge = ""
|
||
tag: int | None = None
|
||
for part in str(val_str).split(","):
|
||
if "=" not in part:
|
||
continue
|
||
k, v = part.split("=", 1)
|
||
k = k.strip().lower()
|
||
if k in _NET_MODELS:
|
||
mac = v.strip()
|
||
elif k == "bridge":
|
||
bridge = v.strip()
|
||
elif k == "tag":
|
||
try:
|
||
tag = int(v.strip())
|
||
except ValueError:
|
||
pass
|
||
return mac.upper() if mac else "", bridge, tag
|
||
|
||
interfaces: dict[str, _JsonDict] = {}
|
||
|
||
if vm_type == "container":
|
||
# Build iface_name → (bridge, tag) from LXC config
|
||
# LXC net entries look like: net0=name=eth0,bridge=vmbr40,tag=40,...
|
||
lxc_net_map: dict[str, tuple[str, int | None]] = {}
|
||
try:
|
||
config = self._node_api().lxc(vmid).config.get() or {}
|
||
net_re = re.compile(r"^net(\d+)$")
|
||
for key, val in config.items():
|
||
if not net_re.match(key):
|
||
continue
|
||
iface_name = ""
|
||
bridge = ""
|
||
tag: int | None = None
|
||
for part in str(val).split(","):
|
||
if "=" not in part:
|
||
continue
|
||
k, v = part.split("=", 1)
|
||
k = k.strip().lower()
|
||
if k == "name":
|
||
iface_name = v.strip()
|
||
elif k == "bridge":
|
||
bridge = v.strip()
|
||
elif k == "tag":
|
||
try:
|
||
tag = int(v.strip())
|
||
except ValueError:
|
||
pass
|
||
if iface_name:
|
||
lxc_net_map[iface_name] = (bridge, tag)
|
||
except Exception as exc:
|
||
logger.debug("get_vm_interfaces: LXC %s config failed: %s", vmid, exc)
|
||
|
||
try:
|
||
for iface in (self._node_api().lxc(vmid).interfaces.get() or []):
|
||
name = iface.get("name", "")
|
||
if not name or name == "lo":
|
||
continue
|
||
mac = iface.get("hwaddr", "")
|
||
ipv4 = ""
|
||
inet = iface.get("inet", "")
|
||
if inet:
|
||
ipv4 = inet.split("/")[0]
|
||
bridge, tag = lxc_net_map.get(name, ("", None))
|
||
interfaces[name] = {
|
||
"is_up": True,
|
||
"is_enabled": True,
|
||
"description": bridge,
|
||
"mac_address": mac.upper() if mac else "",
|
||
"speed": -1,
|
||
"mtu": 1500,
|
||
"last_flapped": -1.0,
|
||
"ipv4": ipv4,
|
||
"bridge": bridge,
|
||
"tag": tag,
|
||
}
|
||
except Exception as exc:
|
||
logger.debug("get_vm_interfaces: LXC %s ifaces failed: %s", vmid, exc)
|
||
|
||
# LXC containers do not use QEMU Guest Agent
|
||
return interfaces, False, False
|
||
|
||
else:
|
||
# QEMU: pre-fetch VM config to build MAC → (bridge, tag) map and
|
||
# to check whether the QEMU Guest Agent is enabled.
|
||
mac_to_net: dict[str, tuple[str, int | None]] = {} # mac_upper → (bridge, tag)
|
||
net_idx_map: dict[str, tuple[str, str, int | None]] = {} # "netN" → (mac, bridge, tag)
|
||
agent_enabled = False
|
||
try:
|
||
config = self._node_api().qemu(vmid).config.get() or {}
|
||
# Proxmox stores the agent setting as agent=1, agent=0, or
|
||
# agent=enabled=1[,fstrim_cloned_disks=1,...]
|
||
raw_agent = str(config.get("agent", "0"))
|
||
# Treat any truthy value ("1", "enabled=1", ...) as enabled
|
||
agent_enabled = bool(
|
||
raw_agent.strip() in ("1", "true")
|
||
or raw_agent.startswith("enabled=1")
|
||
or raw_agent.startswith("1,")
|
||
)
|
||
net_re = re.compile(r"^net(\d+)$")
|
||
for key, val in config.items():
|
||
m = net_re.match(key)
|
||
if not m:
|
||
continue
|
||
mac, bridge, tag = _parse_net_entry(val)
|
||
iface_key = f"net{m.group(1)}"
|
||
net_idx_map[iface_key] = (mac, bridge, tag)
|
||
if mac:
|
||
mac_to_net[mac] = (bridge, tag)
|
||
except Exception as exc:
|
||
logger.debug("get_vm_interfaces: QEMU %s config fetch failed: %s", vmid, exc)
|
||
|
||
# Try guest agent first
|
||
agent_ok = False
|
||
try:
|
||
agent_result = self._node_api().qemu(vmid).agent("network-get-interfaces").get()
|
||
for iface in (agent_result or {}).get("result", []):
|
||
name = iface.get("name", "")
|
||
if not name or name == "lo":
|
||
continue
|
||
mac = (iface.get("hardware-address", "") or "").upper()
|
||
ipv4 = ""
|
||
for addr in iface.get("ip-addresses", []):
|
||
if addr.get("ip-address-type") == "ipv4":
|
||
ipv4 = addr.get("ip-address", "")
|
||
break
|
||
bridge, tag = mac_to_net.get(mac, ("", None))
|
||
interfaces[name] = {
|
||
"is_up": True,
|
||
"is_enabled": True,
|
||
"description": bridge,
|
||
"mac_address": mac,
|
||
"speed": -1,
|
||
"mtu": 1500,
|
||
"last_flapped": -1.0,
|
||
"ipv4": ipv4,
|
||
"bridge": bridge,
|
||
"tag": tag,
|
||
}
|
||
agent_ok = bool(interfaces)
|
||
except Exception as exc:
|
||
logger.debug("QEMU guest agent network-get-interfaces failed: %s", exc)
|
||
|
||
if not agent_ok:
|
||
# Fall back to config-only (gives MAC + bridge + tag, no IP)
|
||
for iface_key, (mac, bridge, tag) in net_idx_map.items():
|
||
interfaces[iface_key] = {
|
||
"is_up": False,
|
||
"is_enabled": True,
|
||
"description": bridge,
|
||
"mac_address": mac,
|
||
"speed": -1,
|
||
"mtu": 1500,
|
||
"last_flapped": -1.0,
|
||
"ipv4": "",
|
||
"bridge": bridge,
|
||
"tag": tag,
|
||
}
|
||
|
||
return interfaces, agent_ok, agent_enabled
|
||
|
||
def get_vms(self) -> list[_JsonDict]:
|
||
"""Return all VMs (QEMU) and containers (LXC) on this node.
|
||
|
||
Each entry contains:
|
||
* vmid (int) - Proxmox VM/container ID
|
||
* name (str) - display name
|
||
* type (str) - ``"vm"`` or ``"container"``
|
||
* status (str) - ``"running"``, ``"stopped"``, etc.
|
||
* vcpus (int) - allocated vCPUs
|
||
* memory (int) - configured RAM in megabytes
|
||
* cpu_usage (float) - current CPU utilisation 0.0–1.0 (from last stats cycle)
|
||
* memory_usage (int)- current RSS in megabytes
|
||
* uptime (int) - uptime in seconds (0 if stopped)
|
||
* node (str) - cluster node name
|
||
* interfaces (dict) - network interfaces (NAPALM format + ipv4 field)
|
||
* ipv4 (str) - primary IPv4 address (empty string if unknown)
|
||
"""
|
||
result: list[_JsonDict] = []
|
||
|
||
# QEMU VMs
|
||
try:
|
||
for vm in (self._node_api().qemu.get() or []):
|
||
vmid = int(vm.get("vmid", 0))
|
||
name = vm.get("name", f"vm-{vmid}")
|
||
status = vm.get("status", "unknown")
|
||
cpu_usage = float(vm.get("cpu", 0.0) or 0.0)
|
||
uptime = int(vm.get("uptime", 0) or 0)
|
||
|
||
# mem/maxmem are in bytes
|
||
maxmem_bytes = int(vm.get("maxmem", 0) or 0)
|
||
mem_bytes = int(vm.get("mem", 0) or 0)
|
||
memory_mb = maxmem_bytes // (1024 * 1024)
|
||
memory_usage_mb = mem_bytes // (1024 * 1024)
|
||
|
||
# vcpus can be in "cpus" key for running VMs
|
||
vcpus = int(vm.get("cpus", vm.get("vcpus", 0)) or 0)
|
||
|
||
interfaces, agent_running, agent_enabled = self.get_vm_interfaces(vmid, "vm")
|
||
ipv4 = next(
|
||
(iface["ipv4"] for iface in interfaces.values() if iface.get("ipv4")),
|
||
"",
|
||
)
|
||
|
||
disks, onboot = self._get_vm_disk_and_boot(vmid, "qemu")
|
||
result.append({
|
||
"vmid": vmid,
|
||
"name": name,
|
||
"type": "vm",
|
||
"status": status,
|
||
"vcpus": vcpus,
|
||
"memory": memory_mb,
|
||
"cpu_usage": round(cpu_usage, 4),
|
||
"memory_usage": memory_usage_mb,
|
||
"uptime": uptime,
|
||
"node": self._node_name,
|
||
"interfaces": interfaces,
|
||
"ipv4": ipv4,
|
||
"agent_enabled": agent_enabled,
|
||
"agent_running": agent_running,
|
||
"disks": disks,
|
||
"onboot": onboot,
|
||
})
|
||
except Exception as exc:
|
||
logger.warning("get_vms: failed to list QEMU VMs: %s", exc)
|
||
|
||
# LXC containers
|
||
try:
|
||
for ct in (self._node_api().lxc.get() or []):
|
||
vmid = int(ct.get("vmid", 0))
|
||
name = ct.get("name", f"ct-{vmid}")
|
||
status = ct.get("status", "unknown")
|
||
cpu_usage = float(ct.get("cpu", 0.0) or 0.0)
|
||
uptime = int(ct.get("uptime", 0) or 0)
|
||
|
||
maxmem_bytes = int(ct.get("maxmem", 0) or 0)
|
||
mem_bytes = int(ct.get("mem", 0) or 0)
|
||
memory_mb = maxmem_bytes // (1024 * 1024)
|
||
memory_usage_mb = mem_bytes // (1024 * 1024)
|
||
|
||
vcpus = int(ct.get("cpus", 0) or 0)
|
||
|
||
interfaces, agent_running, agent_enabled = self.get_vm_interfaces(vmid, "container")
|
||
ipv4 = next(
|
||
(iface["ipv4"] for iface in interfaces.values() if iface.get("ipv4")),
|
||
"",
|
||
)
|
||
|
||
disks, onboot = self._get_vm_disk_and_boot(vmid, "lxc")
|
||
result.append({
|
||
"vmid": vmid,
|
||
"name": name,
|
||
"type": "container",
|
||
"status": status,
|
||
"vcpus": vcpus,
|
||
"memory": memory_mb,
|
||
"cpu_usage": round(cpu_usage, 4),
|
||
"memory_usage": memory_usage_mb,
|
||
"uptime": uptime,
|
||
"node": self._node_name,
|
||
"interfaces": interfaces,
|
||
"ipv4": ipv4,
|
||
"agent_enabled": agent_enabled,
|
||
"agent_running": agent_running,
|
||
"disks": disks,
|
||
"onboot": onboot,
|
||
})
|
||
except Exception as exc:
|
||
logger.warning("get_vms: failed to list LXC containers: %s", exc)
|
||
|
||
return sorted(result, key=lambda x: x["vmid"])
|
||
|
||
# Disk-key prefixes for QEMU: scsi, virtio, ide, sata (exclude cdrom/none entries)
|
||
_DISK_KEYS_VM = re.compile(r"^(scsi|virtio|ide|sata)\d+$")
|
||
# Disk-key prefix for LXC: rootfs, mp (mount points)
|
||
_DISK_KEYS_CT = re.compile(r"^(rootfs|mp\d+)$")
|
||
|
||
def _get_vm_disk_and_boot(
|
||
self, vmid: int, vm_type: str
|
||
) -> tuple[list[dict], bool]:
|
||
"""Return (disks, onboot) for a VM or container.
|
||
|
||
Fetches config from /nodes/{node}/{qemu|lxc}/{vmid}/config.
|
||
Each disk entry is a dict with 'name' and 'size_mb'.
|
||
Returns ([], False) on any error.
|
||
"""
|
||
try:
|
||
if vm_type == "qemu":
|
||
cfg = self._node_api().qemu(vmid).config.get() or {}
|
||
disk_re = self._DISK_KEYS_VM
|
||
else:
|
||
cfg = self._node_api().lxc(vmid).config.get() or {}
|
||
disk_re = self._DISK_KEYS_CT
|
||
|
||
onboot = bool(int(cfg.get("onboot", 0) or 0))
|
||
|
||
disks: list[dict] = []
|
||
for key, value in sorted(cfg.items()):
|
||
if not disk_re.match(key):
|
||
continue
|
||
val_str = str(value or "")
|
||
# Skip CD-ROM / none entries
|
||
if "media=cdrom" in val_str or val_str.startswith("none"):
|
||
continue
|
||
# Extract size=NNX from the value string
|
||
m = re.search(r"\bsize=(\d+)([GMK]?)", val_str, re.I)
|
||
if not m:
|
||
continue
|
||
num = int(m.group(1))
|
||
unit = m.group(2).upper()
|
||
if unit == "G" or unit == "":
|
||
size_mb = num * 1024
|
||
elif unit == "M":
|
||
size_mb = num
|
||
elif unit == "K":
|
||
size_mb = num // 1024
|
||
else:
|
||
size_mb = num * 1024
|
||
disks.append({"name": key, "size_mb": size_mb})
|
||
|
||
return disks, onboot
|
||
except Exception as exc:
|
||
logger.debug("Failed to fetch disk/boot config for vmid %s: %s", vmid, exc)
|
||
return [], False
|
||
|
||
_POWER_ACTIONS_VM = {'start', 'stop', 'shutdown', 'reboot', 'reset'}
|
||
_POWER_ACTIONS_CT = {'start', 'stop', 'shutdown', 'reboot'}
|
||
|
||
def power_vm(self, vmid: int, vm_type: str, action: str) -> dict:
|
||
"""Send a power action to a VM or container on this node.
|
||
|
||
Supported actions for VMs: start, stop, shutdown, reboot, reset
|
||
Supported actions for containers: start, stop, shutdown, reboot
|
||
"""
|
||
allowed = self._POWER_ACTIONS_VM if vm_type == 'vm' else self._POWER_ACTIONS_CT
|
||
if action not in allowed:
|
||
return {"success": False, "error": f"Action '{action}' not supported for {vm_type} (allowed: {sorted(allowed)})"}
|
||
try:
|
||
vm_api = self._node_api().qemu(vmid) if vm_type == 'vm' else self._node_api().lxc(vmid)
|
||
task_id = getattr(vm_api.status, action).post()
|
||
return {"success": True, "task_id": task_id or ""}
|
||
except Exception as exc:
|
||
return {"success": False, "error": str(exc)}
|