From c133ef1aeee77a7ac500f9ce166549bec8e78626 Mon Sep 17 00:00:00 2001 From: Florian Paul Azim Hoberg Date: Wed, 10 Dec 2025 09:11:28 +0100 Subject: [PATCH] feature: Add support for Proxmox's native HA (affinity/anti-affinity) rules. * Add support of native rules for affinity/anti-affinity types in Proxmox VE * Streamline affinity/anti-affinity rules by Tags, Pools and native Proxmox rules Fixes: #391 --- ...91_add_native_proxmox_ha_rules_support.yml | 2 + proxlb/main.py | 6 +- proxlb/models/guests.py | 10 +- proxlb/models/ha_rules.py | 106 ++++++++++++++++++ proxlb/models/tags.py | 22 +++- 5 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 .changelogs/1.1.11/391_add_native_proxmox_ha_rules_support.yml create mode 100644 proxlb/models/ha_rules.py diff --git a/.changelogs/1.1.11/391_add_native_proxmox_ha_rules_support.yml b/.changelogs/1.1.11/391_add_native_proxmox_ha_rules_support.yml new file mode 100644 index 0000000..d274d83 --- /dev/null +++ b/.changelogs/1.1.11/391_add_native_proxmox_ha_rules_support.yml @@ -0,0 +1,2 @@ +feature: + - Add support for Proxmox's native HA (affinity/anti-affinity) rules (@gyptazy). [#391] diff --git a/proxlb/main.py b/proxlb/main.py index 0b12285..7d90a21 100644 --- a/proxlb/main.py +++ b/proxlb/main.py @@ -25,6 +25,7 @@ from models.groups import Groups from models.calculations import Calculations from models.balancing import Balancing from models.pools import Pools +from models.ha_rules import HaRules from utils.helper import Helper @@ -74,11 +75,12 @@ def main(): meta = {"meta": proxlb_config} nodes = Nodes.get_nodes(proxmox_api, proxlb_config) pools = Pools.get_pools(proxmox_api) - guests = Guests.get_guests(proxmox_api, pools, nodes, meta, proxlb_config) + ha_rules = HaRules.get_ha_rules(proxmox_api) + guests = Guests.get_guests(proxmox_api, pools, ha_rules, nodes, meta, proxlb_config) groups = Groups.get_groups(guests, nodes) # Merge obtained objects from the Proxmox cluster for further usage - proxlb_data = {**meta, **nodes, **guests, **pools, **groups} + proxlb_data = {**meta, **nodes, **guests, **pools, **ha_rules, **groups} Helper.log_node_metrics(proxlb_data) # Validate usable features by PVE versions diff --git a/proxlb/models/guests.py b/proxlb/models/guests.py index e1465f9..ce01c12 100644 --- a/proxlb/models/guests.py +++ b/proxlb/models/guests.py @@ -11,6 +11,7 @@ __license__ = "GPL-3.0" from typing import Dict, Any from utils.logger import SystemdLogger from models.pools import Pools +from models.ha_rules import HaRules from models.tags import Tags import time @@ -36,7 +37,7 @@ class Guests: """ @staticmethod - def get_guests(proxmox_api: any, pools: Dict[str, Any], nodes: Dict[str, Any], meta: Dict[str, Any], proxlb_config: Dict[str, Any]) -> Dict[str, Any]: + def get_guests(proxmox_api: any, pools: Dict[str, Any], ha_rules: Dict[str, Any], nodes: Dict[str, Any], meta: Dict[str, Any], proxlb_config: Dict[str, Any]) -> Dict[str, Any]: """ Get metrics of all guests in a Proxmox cluster. @@ -46,6 +47,8 @@ class Guests: Args: proxmox_api (any): The Proxmox API client instance. + pools (Dict[str, Any]): A dictionary containing information about the pools in the Proxmox cluster. + ha_rules (Dict[str, Any]): A dictionary containing information about the HA rules in the nodes (Dict[str, Any]): A dictionary containing information about the nodes in the Proxmox cluster. meta (Dict[str, Any]): A dictionary containing metadata information. proxmox_config (Dict[str, Any]): A dictionary containing the ProxLB configuration. @@ -95,8 +98,9 @@ class Guests: guests['guests'][guest['name']]['pressure_hot'] = False guests['guests'][guest['name']]['tags'] = Tags.get_tags_from_guests(proxmox_api, node, guest['vmid'], 'vm') guests['guests'][guest['name']]['pools'] = Pools.get_pools_for_guest(guest['name'], pools) - guests['guests'][guest['name']]['affinity_groups'] = Tags.get_affinity_groups(guests['guests'][guest['name']]['tags'], guests['guests'][guest['name']]['pools'], proxlb_config) - guests['guests'][guest['name']]['anti_affinity_groups'] = Tags.get_anti_affinity_groups(guests['guests'][guest['name']]['tags'], guests['guests'][guest['name']]['pools'], proxlb_config) + guests['guests'][guest['name']]['ha_rules'] = HaRules.get_ha_rules_for_guest(guest['name'], ha_rules, guest['vmid']) + guests['guests'][guest['name']]['affinity_groups'] = Tags.get_affinity_groups(guests['guests'][guest['name']]['tags'], guests['guests'][guest['name']]['pools'], guests['guests'][guest['name']]['ha_rules'], proxlb_config) + guests['guests'][guest['name']]['anti_affinity_groups'] = Tags.get_anti_affinity_groups(guests['guests'][guest['name']]['tags'], guests['guests'][guest['name']]['pools'], guests['guests'][guest['name']]['ha_rules'], proxlb_config) guests['guests'][guest['name']]['ignore'] = Tags.get_ignore(guests['guests'][guest['name']]['tags']) guests['guests'][guest['name']]['node_relationships'] = Tags.get_node_relationships(guests['guests'][guest['name']]['tags'], nodes, guests['guests'][guest['name']]['pools'], proxlb_config) guests['guests'][guest['name']]['type'] = 'vm' diff --git a/proxlb/models/ha_rules.py b/proxlb/models/ha_rules.py new file mode 100644 index 0000000..5309350 --- /dev/null +++ b/proxlb/models/ha_rules.py @@ -0,0 +1,106 @@ +""" +The HaRules class retrieves all HA rules defined on a Proxmox cluster +including their affinity settings and member resources. +""" + +__author__ = "Florian Paul Azim Hoberg " +__copyright__ = "Copyright (C) 2025 Florian Paul Azim Hoberg (@gyptazy)" +__license__ = "GPL-3.0" + + +from typing import Dict, Any +from utils.logger import SystemdLogger + +logger = SystemdLogger() + + +class HaRules: + """ + The HaRules class retrieves all HA rules defined on a Proxmox cluster + including their (anti)a-ffinity settings and member resources and translates + them into a ProxLB usable format. + + Methods: + __init__: + Initializes the HaRules class. + + get_ha_rules(proxmox_api: any) -> Dict[str, Any]: + Retrieve HA rule definitions from the Proxmox cluster. + Returns a dict with a top-level "ha_rules" mapping each rule id to + {"rule": , "type": , "members": [...]}. + Converts affinity settings to descriptive format (affinity or anti-affinity). + """ + def __init__(self): + """ + Initializes the HA Rules class with the provided ProxLB data. + """ + + @staticmethod + def get_ha_rules(proxmox_api: any) -> Dict[str, Any]: + """ + Retrieve all HA rules from a Proxmox cluster. + + Queries the Proxmox API for HA rule definitions and returns a dictionary + containing each rule's id, affinity type, and member resources (VM/CT IDs). + This function processes rule affinity settings and converts them to a more + descriptive format (affinity or anti-affinity). + + Args: + proxmox_api (any): Proxmox API client instance. + + Returns: + Dict[str, Any]: Dictionary with a top-level "ha_rules" key mapping rule id + to {"rule": , "type": , "members": [...]}. + """ + logger.debug("Starting: get_ha_rules.") + ha_rules = {"ha_rules": {}} + + for rule in proxmox_api.cluster.ha.rules.get(): + # Create a resource list by splitting on commas and stripping whitespace containing + # the VM and CT IDs that are part of this HA rule + resources_list = [int(r.split(":")[1]) for r in rule["resources"].split(",") if r.strip()] + + # Convert the affinity field to a more descriptive type + if rule.get("affinity", None) == "negative": + affinity_type = "anti-affinity" + else: + affinity_type = "affinity" + + # Create the ha_rule element + ha_rules['ha_rules'][rule['rule']] = {} + ha_rules['ha_rules'][rule['rule']]['rule'] = rule['rule'] + ha_rules['ha_rules'][rule['rule']]['type'] = affinity_type + ha_rules['ha_rules'][rule['rule']]['members'] = resources_list + + logger.debug(f"Got ha-rule: {rule['rule']} as type {affinity_type} affecting guests: {rule['resources']}") + + logger.debug("Finished: ha_rules.") + return ha_rules + + @staticmethod + def get_ha_rules_for_guest(guest_name: str, ha_rules: Dict[str, Any], vm_id: int) -> Dict[str, Any]: + """ + Return the list of HA rules that include the given guest. + + Args: + guest_name (str): Name of the VM or CT to look up. + ha_rules (Dict[str, Any]): HA rules structure as returned by get_ha_rules(), + expected to contain a top-level "ha_rules" mapping each rule id to + {"rule": , "type": , "members": [...]}. + vm_id (int): VM or CT ID of the guest. + + Returns: + list: IDs of HA rules the guest is a member of (empty list if none). + """ + logger.debug("Starting: get_ha_rules_for_guest.") + guest_ha_rules = [] + + for rule in ha_rules["ha_rules"].values(): + if vm_id in rule.get("members", []): + logger.debug(f"Guest: {guest_name} (VMID: {vm_id}) is member of HA Rule: {rule['rule']}.") + guest_ha_rules.append(rule) + else: + logger.debug(f"Guest: {guest_name} (VMID: {vm_id}) is NOT member of HA Rule: {rule['rule']}.") + + logger.debug("Finished: get_ha_rules_for_guest.") + return guest_ha_rules diff --git a/proxlb/models/tags.py b/proxlb/models/tags.py index dcff6e0..ed73acc 100644 --- a/proxlb/models/tags.py +++ b/proxlb/models/tags.py @@ -80,7 +80,7 @@ class Tags: return tags @staticmethod - def get_affinity_groups(tags: List[str], pools: List[str], proxlb_config: Dict[str, Any]) -> List[str]: + def get_affinity_groups(tags: List[str], pools: List[str], ha_rules: List[str], proxlb_config: Dict[str, Any]) -> List[str]: """ Get affinity tags for a guest from the Proxmox cluster by the API. @@ -99,6 +99,7 @@ class Tags: logger.debug("Starting: get_affinity_groups.") affinity_tags = [] + # Tag based affinity groups if len(tags) > 0: for tag in tags: if tag.startswith("plb_affinity"): @@ -107,6 +108,7 @@ class Tags: else: logger.debug(f"Skipping affinity group for tag {tag}.") + # Pool based affinity groups if len(pools) > 0: for pool in pools: if pool in (proxlb_config['balancing'].get('pools') or {}): @@ -116,11 +118,18 @@ class Tags: else: logger.debug(f"Skipping affinity group for pool {pool}.") + # HA rule based affinity groups + if len(ha_rules) > 0: + for ha_rule in ha_rules: + if ha_rule.get('type', None) == 'affinity': + logger.debug(f"Adding affinity group for ha-rule {ha_rule}.") + affinity_tags.append(ha_rule['rule']) + logger.debug("Finished: get_affinity_groups.") return affinity_tags @staticmethod - def get_anti_affinity_groups(tags: List[str], pools: List[str], proxlb_config: Dict[str, Any]) -> List[str]: + def get_anti_affinity_groups(tags: List[str], pools: List[str], ha_rules: List[str], proxlb_config: Dict[str, Any]) -> List[str]: """ Get anti-affinity tags for a guest from the Proxmox cluster by the API. @@ -139,6 +148,7 @@ class Tags: logger.debug("Starting: get_anti_affinity_groups.") anti_affinity_tags = [] + # Tag based anti-affinity groups if len(tags) > 0: for tag in tags: if tag.startswith("plb_anti_affinity"): @@ -147,6 +157,7 @@ class Tags: else: logger.debug(f"Skipping anti-affinity group for tag {tag}.") + # Pool based anti-affinity groups if len(pools) > 0: for pool in pools: if pool in (proxlb_config['balancing'].get('pools') or {}): @@ -156,6 +167,13 @@ class Tags: else: logger.debug(f"Skipping anti-affinity group for pool {pool}.") + # HA rule based anti-affinity groups + if len(ha_rules) > 0: + for ha_rule in ha_rules: + if ha_rule.get('type', None) == 'anti-affinity': + logger.debug(f"Adding anti-affinity group for ha-rule {ha_rule}.") + anti_affinity_tags.append(ha_rule['rule']) + logger.debug("Finished: get_anti_affinity_groups.") return anti_affinity_tags