Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
293 changes: 293 additions & 0 deletions virtual-host-gatherer/lib/gatherer/modules/Proxmox.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
# Copyright (c) 2025 SUSE LLC, Inc. 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.
#

"""
Proxmox module implementation.
"""

from __future__ import print_function, absolute_import, division

import logging
from collections import OrderedDict
import importlib.util

from gatherer.modules import WorkerInterface

try:
import proxmoxer

# Check for HTTPS backend
IS_VALID = importlib.util.find_spec("requests") is not None
except ImportError:
IS_VALID = False


# pylint: disable=too-many-instance-attributes
class Proxmox(WorkerInterface):
"""
Worker class for Libvirt.
"""

DEFAULT_PARAMETERS = OrderedDict(
[
("host", None),
("port", None),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a default port you can set here instead of using None?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 8006, I will do so

("username", None),
("password", None),
("api_token_id", None),
("api_token_secret", None),
("verify_ssl", None),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe the default for this should be True?

Copy link
Author

@signed-log signed-log Jun 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a de-facto True, I manipulate it in the other functions, but I guess it's stupid in hindsight. I mostly didn't know if it would always send a boolean.

https://github.com/uyuni-project/virtual-host-gatherer/blob/528d8c52fea6523a45c07563e0ea02b4f8536ec2/virtual-host-gatherer/lib/gatherer/modules/Proxmox.py#L97C1-L100C35

]
)

def __init__(self):
"""
Constructor.

:return:
"""
super().__init__()
self.log = logging.getLogger(__name__)
self.uri = None
self.host = None
self.username = None
self.password = None
self.api_token_id = None
self.api_token_secret = None
self.verify_ssl = None
self.token_auth = None

# disable the duplicate code check
def set_node(self, node):
"""
Set node information

:param node: Dictionary of the node description.
:return: void
"""

try:
self._validate_parameters(node)
except AttributeError as error:
self.log.error(error)
raise error

self.uri = node.get("host") + ":" + str(node.get("port"))
self.token_auth = bool(
node.get("api_token_id") and node.get("api_token_secret")
)
if self.token_auth:
self.api_token_id = node.get("api_token_id")
self.api_token_secret = node.get("api_token_secret")
else:
self.username = node.get("username")
self.password = node.get("password")
if node.get("verify_ssl") is not None:
self.verify_ssl = node.get("verify_ssl")
else:
self.verify_ssl = True

def parameters(self):
"""
Return default parameters

:return: default parameter dictionary
"""

return self.DEFAULT_PARAMETERS

def run(self):
"""
Start worker.
"""

self.log.info("Using Proxmox API uri %s", self.uri)
try:
conn = self.get_connection()
if conn:
output = {}
node_list = conn.nodes.get()
if not node_list:
self.log.error("No nodes found in Proxmox cluster.")
return {}
for node in node_list:
if node.get("status", "") != "online":
self.log.warning(
"Node %s is not online, skipping.", node["node"]
)
continue
output = self.process_node(conn, output, node["node"])
return output
self.log.error("Failed to connect to Proxmox API at %s", self.uri)
return {}

except (
proxmoxer.ResourceException,
proxmoxer.AuthenticationError,
KeyError,
) as err:
self.log.error(err)
return {}

def valid(self):
"""
Check plugin class validity.

:return: True if all components are installed
"""

return IS_VALID

def _validate_parameters(self, node):
"""
Validate parameters.

:param node: Dictionary with the node description.
:return:
"""

if not node.get("host"):
raise AttributeError("Missing host parameter in infile")

if not node.get("port"):
raise AttributeError("Missing port parameter in infile")

if not bool(node.get("username") and node.get("password")) ^ bool(
node.get("api_token_id") and node.get("api_token_secret")
):
raise AttributeError(
"Either both username/password or api_token_id/api_token_secret must be set"
)

if node.get("verify_ssl") is not None and not isinstance(
node.get("verify_ssl"), bool
):
raise AttributeError(
"Invalid value for verify_ssl parameter. Expected boolean."
)

def get_connection(self):
"""
get connection object from libvirt module.

:return: a :py:class:`ProxmoxAPI` instance on success.
"""

# When using ssh transport, it is expected that the
# ssh public key is copied onto the remote machine.
conn = None
try:
if self.token_auth:
# Use API token authentication
self.log.info("Using API token authentication")
conn = proxmoxer.ProxmoxAPI(
self.uri,
user=self.api_token_id,
token=self.api_token_secret,
verify_ssl=self.verify_ssl,
)
else:
# Use username/password authentication
self.log.info("Using username/password authentication")
conn = proxmoxer.ProxmoxAPI(
self.uri,
user=self.username,
password=self.password,
verify_ssl=self.verify_ssl,
)
except proxmoxer.AuthenticationError as err:
self.log.error(err)
return conn

def process_node(self, conn, output, node_name):
"""
Process host/guest mapping info and fill the output structure

:param conn: a :py:class:`ProxmoxAPI` instance
:return: Dictionary with host/guest info
"""
total_cpu_sockets = (
conn.nodes(node_name).status.get().get("cpuinfo", {}).get("sockets", 0)
)
total_cpu_cores = (
conn.nodes(node_name).status.get().get("cpuinfo", {}).get("cores", 0)
)
total_cpu_threads = (
conn.nodes(node_name).status.get().get("cpuinfo", {}).get("cpus", 0)
)
cpu_mhz = conn.nodes(node_name).status.get().get("cpuinfo", {}).get("mhz", 0)

output[node_name] = {
"name": node_name,
# Proxmox has no unique host identifier for nodes, using node name as identifier
# could also used MAC address of the first network interface
"hostIdentifier": node_name,
"fallbackHostIdentifier": node_name,
"totalCpuSockets": total_cpu_sockets,
"totalCpuCores": total_cpu_cores,
"totalCpuThreads": total_cpu_threads,
"cpuMhz": cpu_mhz,
# 'cpuVendor': Proxmox does not provide CPU vendor information as a easily accessible field
"cpuDescription": conn.nodes(node_name)
.status.get()
.get("cpuinfo", {})
.get("model", "unknown"),
"cpuArch": "x86_64", # Proxmox is only officially supported on x86_64 architecture
"ramMb": conn.nodes(node_name)
.status.get()
.get("memory", {})
.get("total", 0)
// (1024**2),
"vms": {},
"optionalVmData": {},
"os": "ProxmoxVE",
"os_version": conn.nodes(node_name).version.get().get("release", "unknown"),
}

vm_list = conn.nodes(node_name).qemu.get()
if not vm_list:
self.log.warning("No VMs found on node %s", node_name)
for vm_entry in vm_list:
vm_name = vm_entry.get("name", "unknown")
vm_id = vm_entry.get("vmid", "unknown")
# WARNING: Multiple VMs can have the same name
output[node_name]["vms"][vm_name] = vm_id
output[node_name]["optionalVmData"][vm_name] = {
"vmState": vm_entry.get("status", "unknown"),
"proxmoxVmid": vm_id,
"uptime": vm_entry.get("uptime", 0),
"totalCpuThreads": vm_entry.get("cpus", 0),
"memory": vm_entry.get("maxmem", 0) // (1024**2),
"disk": vm_entry.get("maxdisk", 0) // (1024**2),
}

lxc_list = conn.nodes(node_name).lxc.get()
if not lxc_list:
self.log.warning("No LXC containers found on node %s", node_name)
return output
for lxc_entry in lxc_list:
lxc_name = lxc_entry.get("name", "unknown")
vm_id = lxc_entry.get("vmid", "unknown")
# WARNING: Multiple LXC containers can have the same name
output[node_name]["vms"][lxc_name] = vm_id
output[node_name]["optionalVmData"][lxc_name] = {
"vmState": lxc_entry.get("status", "unknown"),
"proxmoxVmid": vm_id,
"uptime": lxc_entry.get("uptime", 0),
"totalCpuThreads": lxc_entry.get("cpus", 0),
"memory": lxc_entry.get("maxmem", 0) // (1024**2),
"disk": lxc_entry.get("maxdisk", 0) // (1024**2),
}
return output
5 changes: 5 additions & 0 deletions virtual-host-gatherer/virtual-host-gatherer.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
-------------------------------------------------------------------
Sun Jun 22 15:25:34 CET 2025 - stigpro@outlook.fr

- Add Proxmox module

-------------------------------------------------------------------
Fri Jan 10 16:06:34 CET 2025 - michael.calmer@suse.com

Expand Down
15 changes: 15 additions & 0 deletions virtual-host-gatherer/virtual-host-gatherer.spec
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ Requires: %{python_module libvirt-python}
%description Libvirt
Libvirt connection module for gatherer

%package Proxmox
Summary: Proxmox connection module
Group: Development/Languages
Requires: %{name} = %{version}
Requires: %{python_module proxmoxer}
Requires: %{python_module requests}

%description Proxmox
Proxmox connection module for gatherer

%prep
%setup -q

Expand Down Expand Up @@ -194,4 +204,9 @@ rm -rf %{buildroot}
%{python_sitelib}/gatherer/modules/Libvirt.py*
%{python_sitelib}/gatherer/modules/__pycache__/Libvirt.*

%files Proxmox
%defattr(-,root,root,-)
%{python_sitelib}/gatherer/modules/Proxmox.py*
%{python_sitelib}/gatherer/modules/__pycache__/Proxmox.*

%changelog