proxlb 1.1.12.dev4__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.
@@ -0,0 +1,90 @@
1
+ proxmox_api:
2
+ hosts: ['virt01.example.com', '10.10.10.10', 'fe01:bad:code::cafe']
3
+ user: root@pam
4
+ pass: crazyPassw0rd!
5
+ # API Token method
6
+ # token_id: proxlb
7
+ # token_secret: 430e308f-1337-1337-beef-1337beefcafe
8
+ ssl_verification: True
9
+ timeout: 10
10
+ # API Connection retries
11
+ # retries: 1
12
+ # wait_time: 1
13
+
14
+ proxmox_cluster:
15
+ maintenance_nodes: ['virt66.example.com']
16
+ ignore_nodes: []
17
+ overprovisioning: True
18
+
19
+ balancing:
20
+ enable: True
21
+ enforce_affinity: False
22
+ enforce_pinning: False
23
+ parallel: False
24
+ # If running parallel job, you can define
25
+ # the amount of prallel jobs (default: 5)
26
+ parallel_jobs: 1
27
+ live: True
28
+ with_local_disks: True
29
+ with_conntrack_state: True
30
+ balance_types: ['vm', 'ct'] # 'vm' | 'ct'
31
+ max_job_validation: 1800 # Maximum time (in seconds) a job validation may take
32
+ memory_threshold: 75 # Optional: Maximum threshold (in percent) to trigger balancing actions
33
+ cpu_threshold: 75 # Optional: Maximum threshold (in percent) to trigger balancing actions
34
+ balanciness: 5 # Maximum delta of resource usage between highest and lowest usage node
35
+ method: memory # 'memory' | 'cpu' | 'disk'
36
+ mode: used # 'assigned' | 'used' | 'psi'
37
+ balance_larger_guests_first: False # Option to prioritize balancing of larger or smaller guests first
38
+ node_resource_reserve: # Optional: Define resource reservations for nodes (in GB)
39
+ defaults: # Default reservation values applying to all nodes (unless explicitly overridden)
40
+ memory: 4 # Default: 4 GB memory reserved per node
41
+ node01: # Specific node reservation override for node 'node01'
42
+ memory: 6 # Specific: 6 GB memory reserved for node 'node01'
43
+ # # PSI thresholds only apply when using mode 'psi'
44
+ # psi:
45
+ # nodes:
46
+ # memory:
47
+ # pressure_full: 0.20
48
+ # pressure_some: 0.20
49
+ # pressure_spikes: 1.00
50
+ # cpu:
51
+ # pressure_full: 0.20
52
+ # pressure_some: 0.20
53
+ # pressure_spikes: 1.00
54
+ # disk:
55
+ # pressure_full: 0.20
56
+ # pressure_some: 0.20
57
+ # pressure_spikes: 1.00
58
+ # guests:
59
+ # memory:
60
+ # pressure_full: 0.20
61
+ # pressure_some: 0.20
62
+ # pressure_spikes: 1.00
63
+ # cpu:
64
+ # pressure_full: 0.20
65
+ # pressure_some: 0.20
66
+ # pressure_spikes: 1.00
67
+ # disk:
68
+ # pressure_full: 0.20
69
+ # pressure_some: 0.20
70
+ # pressure_spikes: 1.00
71
+ pools: # Optional: Define affinity/anti-affinity rules per pool
72
+ dev: # Pool name: dev
73
+ type: affinity # Type: affinity (keeping VMs together)
74
+ de-nbg01-db: # Pool name: de-nbg01-db
75
+ type: anti-affinity # Type: anti-affinity (spreading VMs apart)
76
+ pin: # Define a pinning og guests to specific node(s)
77
+ - virt66
78
+ - virt77
79
+ strict: False # Disable strict mode of node pinning for this pool
80
+
81
+ service:
82
+ daemon: True
83
+ schedule:
84
+ interval: 12
85
+ format: hours
86
+ delay:
87
+ enable: False
88
+ time: 1
89
+ format: hours
90
+ log_level: INFO
@@ -0,0 +1,13 @@
1
+ [Unit]
2
+ Description=ProxLB - A loadbalancer for Proxmox clusters
3
+ After=network-online.target pveproxy.service
4
+ Wants=network-online.target pveproxy.service
5
+
6
+ [Service]
7
+ ExecStart=python3 /usr/lib/python3/dist-packages/proxlb/main.py -c /etc/proxlb/proxlb.yaml
8
+ User=plb
9
+ ExecReload=/bin/kill -HUP $MAINPID
10
+ KillMode=process
11
+
12
+ [Install]
13
+ WantedBy=multi-user.target
proxlb/__init__.py ADDED
File without changes
proxlb/main.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ ProxLB is a load balancing tool for Proxmox Virtual Environment (PVE) clusters.
3
+ It connects to the Proxmox API, retrieves information about nodes, guests, and groups,
4
+ and performs calculations to determine the optimal distribution of resources across the
5
+ cluster. The tool supports daemon mode for continuous operation and can log metrics and
6
+ perform balancing actions based on the configuration provided. It also includes a CLI
7
+ parser for handling command-line arguments and a custom logger for systemd integration.
8
+ """
9
+
10
+ __author__ = "Florian Paul Azim Hoberg <gyptazy>"
11
+ __copyright__ = "Copyright (C) 2025 Florian Paul Azim Hoberg (@gyptazy)"
12
+ __license__ = "GPL-3.0"
13
+
14
+
15
+ import logging
16
+ import signal
17
+ from utils.logger import SystemdLogger
18
+ from utils.cli_parser import CliParser
19
+ from utils.config_parser import ConfigParser
20
+ from utils.proxmox_api import ProxmoxApi
21
+ from models.nodes import Nodes
22
+ from models.features import Features
23
+ from models.guests import Guests
24
+ from models.groups import Groups
25
+ from models.calculations import Calculations
26
+ from models.balancing import Balancing
27
+ from models.pools import Pools
28
+ from models.ha_rules import HaRules
29
+ from utils.helper import Helper
30
+
31
+
32
+ def main():
33
+ """
34
+ ProxLB main function
35
+ """
36
+ # Initialize logging handler
37
+ logger = SystemdLogger(level=logging.INFO)
38
+
39
+ # Initialize handlers
40
+ signal.signal(signal.SIGHUP, Helper.handler_sighup)
41
+ signal.signal(signal.SIGINT, Helper.handler_sigint)
42
+
43
+ # Parses arguments passed from the CLI
44
+ cli_parser = CliParser()
45
+ cli_args = cli_parser.parse_args()
46
+ Helper.get_version(cli_args.version)
47
+
48
+ # Parse ProxLB config file
49
+ config_parser = ConfigParser(cli_args.config)
50
+ proxlb_config = config_parser.get_config()
51
+
52
+ # Update log level from config and fallback to INFO if not defined
53
+ logger.set_log_level(proxlb_config.get('service', {}).get('log_level', 'INFO'))
54
+
55
+ # Validate of an optional service delay
56
+ Helper.get_service_delay(proxlb_config)
57
+
58
+ # Connect to Proxmox API & create API object
59
+ proxmox_api = ProxmoxApi(proxlb_config)
60
+
61
+ # Overwrite password after creating the API object
62
+ proxlb_config["proxmox_api"]["pass"] = "********"
63
+
64
+ while True:
65
+
66
+ # Validate if reload signal was sent during runtime
67
+ # and reload the ProxLB configuration and adjust log level
68
+ if Helper.proxlb_reload:
69
+ logger.info("Reloading ProxLB configuration.")
70
+ proxlb_config = config_parser.get_config()
71
+ logger.set_log_level(proxlb_config.get('service', {}).get('log_level', 'INFO'))
72
+ Helper.proxlb_reload = False
73
+
74
+ # Get all required objects from the Proxmox cluster
75
+ meta = {"meta": proxlb_config}
76
+ nodes = Nodes.get_nodes(proxmox_api, proxlb_config)
77
+ meta = Features.validate_any_non_pve9_node(meta, nodes)
78
+ pools = Pools.get_pools(proxmox_api)
79
+ ha_rules = HaRules.get_ha_rules(proxmox_api, meta)
80
+ guests = Guests.get_guests(proxmox_api, pools, ha_rules, nodes, meta, proxlb_config)
81
+ groups = Groups.get_groups(guests, nodes)
82
+
83
+ # Merge obtained objects from the Proxmox cluster for further usage
84
+ proxlb_data = {**meta, **nodes, **guests, **pools, **ha_rules, **groups}
85
+ Helper.log_node_metrics(proxlb_data)
86
+
87
+ # Validate usable features by PVE versions
88
+ Features.validate_available_features(proxlb_data)
89
+
90
+ # Update the initial node resource assignments
91
+ # by the previously created groups.
92
+ Calculations.set_node_assignments(proxlb_data)
93
+ Helper.log_node_metrics(proxlb_data, init=False)
94
+ Calculations.set_node_hot(proxlb_data)
95
+ Calculations.set_guest_hot(proxlb_data)
96
+ target = Calculations.get_most_free_node(proxlb_data, cli_args.best_node)
97
+ if target is None:
98
+ logger.warning("No suitable target node found for balancing. Skipping this run.")
99
+ else:
100
+ Calculations.validate_affinity_map(proxlb_data)
101
+ Calculations.relocate_guests_on_maintenance_nodes(proxlb_data)
102
+ Calculations.get_balanciness(proxlb_data)
103
+ Calculations.relocate_guests(proxlb_data)
104
+ Helper.log_node_metrics(proxlb_data, init=False)
105
+
106
+ # Perform balancing actions via Proxmox API
107
+ if proxlb_data["meta"]["balancing"].get("enable", False):
108
+ if not cli_args.dry_run:
109
+ Balancing(proxmox_api, proxlb_data)
110
+
111
+ # Validate if the JSON output should be
112
+ # printed to stdout
113
+ Helper.print_json(proxlb_data, cli_args.json)
114
+ # Validate daemon mode
115
+ Helper.get_daemon_mode(proxlb_config)
116
+ logger.debug(f"Finished: __main__")
117
+
118
+
119
+ if __name__ == "__main__":
120
+ main()
File without changes
@@ -0,0 +1,270 @@
1
+ """
2
+ The Balancing class is responsible for processing workloads on Proxmox clusters.
3
+ It processes the previously generated data (held in proxlb_data) and moves guests
4
+ and other supported types across Proxmox clusters based on the defined values by an operator.
5
+ """
6
+
7
+
8
+ __author__ = "Florian Paul Azim Hoberg <gyptazy>"
9
+ __copyright__ = "Copyright (C) 2025 Florian Paul Azim Hoberg (@gyptazy)"
10
+ __license__ = "GPL-3.0"
11
+
12
+
13
+ import proxmoxer
14
+ import time
15
+ from itertools import islice
16
+ from utils.logger import SystemdLogger
17
+ from typing import Dict, Any
18
+
19
+ logger = SystemdLogger()
20
+
21
+
22
+ class Balancing:
23
+ """
24
+ The balancing class is responsible for processing workloads on Proxmox clusters.
25
+ The previously generated data (hold in proxlb_data) will processed and guests and
26
+ other supported types will be moved across Proxmox clusters based on the defined
27
+ values by an operator.
28
+
29
+ Methods:
30
+ __init__(self, proxmox_api: any, proxlb_data: Dict[str, Any]):
31
+ Initializes the Balancing class with the provided ProxLB data and initiates the rebalancing
32
+ process for guests.
33
+
34
+ exec_rebalancing_vm(self, proxmox_api: any, proxlb_data: Dict[str, Any], guest_name: str) -> None:
35
+ Executes the rebalancing of a virtual machine (VM) to a new node within the cluster. Logs the migration
36
+ process and handles exceptions.
37
+
38
+ exec_rebalancing_ct(self, proxmox_api: any, proxlb_data: Dict[str, Any], guest_name: str) -> None:
39
+ Executes the rebalancing of a container (CT) to a new node within the cluster. Logs the migration
40
+ process and handles exceptions.
41
+
42
+ get_rebalancing_job_status(self, proxmox_api: any, proxlb_data: Dict[str, Any], guest_name: str, guest_current_node: str, job_id: int, retry_counter: int = 1) -> bool:
43
+ Monitors the status of a rebalancing job on a Proxmox node until it completes or a timeout
44
+ is reached. Returns True if the job completed successfully, False otherwise.
45
+ """
46
+
47
+ def __init__(self, proxmox_api: any, proxlb_data: Dict[str, Any]):
48
+ """
49
+ Initializes the Balancing class with the provided ProxLB data.
50
+
51
+ Args:
52
+ proxmox_api (object): The Proxmox API client instance used to interact with the Proxmox cluster.
53
+ proxlb_data (dict): A dictionary containing data related to the ProxLB load balancing configuration.
54
+ """
55
+ def chunk_dict(data, size):
56
+ """
57
+ Splits a dictionary into chunks of a specified size.
58
+ Args:
59
+ data (dict): The dictionary to be split into chunks.
60
+ size (int): The size of each chunk.
61
+ Yields:
62
+ dict: A chunk of the original dictionary with the specified size.
63
+ """
64
+ logger.debug("Starting: chunk_dict.")
65
+ it = iter(data.items())
66
+ for chunk in range(0, len(data), size):
67
+ yield dict(islice(it, size))
68
+
69
+ # Validate if balancing should be performed in parallel or sequentially.
70
+ # If parallel balancing is enabled, set the number of parallel jobs.
71
+ parallel_jobs = proxlb_data["meta"]["balancing"].get("parallel_jobs", 5)
72
+ if not proxlb_data["meta"]["balancing"].get("parallel", False):
73
+ parallel_jobs = 1
74
+ logger.debug("Balancing: Parallel balancing is disabled. Running sequentially.")
75
+ else:
76
+ logger.debug(f"Balancing: Parallel balancing is enabled. Running with {parallel_jobs} parallel jobs.")
77
+
78
+ for chunk in chunk_dict(proxlb_data["guests"], parallel_jobs):
79
+ jobs_to_wait = []
80
+
81
+ for guest_name, guest_meta in chunk.items():
82
+
83
+ # Check if the guest's target is not the same as the current node
84
+ if guest_meta["node_current"] != guest_meta["node_target"]:
85
+
86
+ # Check if the guest is not ignored and perform the balancing
87
+ # operation based on the guest type
88
+ if not guest_meta["ignore"]:
89
+ job_id = None
90
+
91
+ # VM Balancing
92
+ if guest_meta["type"] == "vm":
93
+ if 'vm' in proxlb_data["meta"]["balancing"].get("balance_types", []):
94
+ logger.debug(f"Balancing: Balancing for guest {guest_name} of type VM started.")
95
+ job_id = self.exec_rebalancing_vm(proxmox_api, proxlb_data, guest_name)
96
+ else:
97
+ logger.debug(
98
+ f"Balancing: Balancing for guest {guest_name} will not be performed. "
99
+ "Guest is of type VM which is not included in allowed balancing types.")
100
+
101
+ # CT Balancing
102
+ elif guest_meta["type"] == "ct":
103
+ if 'ct' in proxlb_data["meta"]["balancing"].get("balance_types", []):
104
+ logger.debug(f"Balancing: Balancing for guest {guest_name} of type CT started.")
105
+ job_id = self.exec_rebalancing_ct(proxmox_api, proxlb_data, guest_name)
106
+ else:
107
+ logger.debug(
108
+ f"Balancing: Balancing for guest {guest_name} will not be performed. "
109
+ "Guest is of type CT which is not included in allowed balancing types.")
110
+
111
+ # Just in case we get a new type of guest in the future
112
+ else:
113
+ logger.critical(f"Balancing: Got unexpected guest type: {guest_meta['type']}. Cannot proceed guest: {guest_meta['name']}.")
114
+
115
+ if job_id:
116
+ jobs_to_wait.append((guest_name, guest_meta["node_current"], job_id))
117
+
118
+ else:
119
+ logger.debug(f"Balancing: Guest {guest_name} is ignored and will not be rebalanced.")
120
+ else:
121
+ logger.debug(f"Balancing: Guest {guest_name} is already on the target node {guest_meta['node_target']} and will not be rebalanced.")
122
+
123
+ # Wait for all jobs in the current chunk to complete
124
+ for guest_name, node, job_id in jobs_to_wait:
125
+ if job_id:
126
+ self.get_rebalancing_job_status(proxmox_api, proxlb_data, guest_name, node, job_id)
127
+
128
+ def exec_rebalancing_vm(self, proxmox_api: any, proxlb_data: Dict[str, Any], guest_name: str) -> None:
129
+ """
130
+ Executes the rebalancing of a virtual machine (VM) to a new node within the cluster.
131
+ This function initiates the migration of a specified VM to a target node as part of the
132
+ load balancing process. It logs the migration process and handles any exceptions that
133
+ may occur during the migration.
134
+ Args:
135
+ proxmox_api (object): The Proxmox API client instance used to interact with the Proxmox cluster.
136
+ proxlb_data (dict): A dictionary containing data related to the ProxLB load balancing configuration.
137
+ guest_name (str): The name of the guest VM to be migrated.
138
+ Raises:
139
+ proxmox_api.core.ResourceException: If an error occurs during the migration process.
140
+ Returns:
141
+ None
142
+ """
143
+ logger.debug("Starting: exec_rebalancing_vm.")
144
+ guest_id = proxlb_data["guests"][guest_name]["id"]
145
+ guest_node_current = proxlb_data["guests"][guest_name]["node_current"]
146
+ guest_node_target = proxlb_data["guests"][guest_name]["node_target"]
147
+ job_id = None
148
+
149
+ if proxlb_data["meta"]["balancing"].get("live", True):
150
+ online_migration = 1
151
+ else:
152
+ online_migration = 0
153
+
154
+ if proxlb_data["meta"]["balancing"].get("with_local_disks", True):
155
+ with_local_disks = 1
156
+ else:
157
+ with_local_disks = 0
158
+
159
+ migration_options = {
160
+ 'target': guest_node_target,
161
+ 'online': online_migration,
162
+ 'with-local-disks': with_local_disks,
163
+ }
164
+
165
+ # Conntrack state aware migrations are not supported in older
166
+ # PVE versions, so we should not add it by default.
167
+ if proxlb_data["meta"]["balancing"].get("with_conntrack_state", True):
168
+ migration_options['with-conntrack-state'] = 1
169
+
170
+ try:
171
+ logger.info(f"Balancing: Starting to migrate VM guest {guest_name} from {guest_node_current} to {guest_node_target}.")
172
+ job_id = proxmox_api.nodes(guest_node_current).qemu(guest_id).migrate().post(**migration_options)
173
+ except proxmoxer.core.ResourceException as proxmox_api_error:
174
+ logger.critical(f"Balancing: Failed to migrate guest {guest_name} of type VM due to some Proxmox errors. Please check if resource is locked or similar.")
175
+ logger.debug(f"Balancing: Failed to migrate guest {guest_name} of type VM due to some Proxmox errors: {proxmox_api_error}")
176
+
177
+ logger.debug("Finished: exec_rebalancing_vm.")
178
+ return job_id
179
+
180
+ def exec_rebalancing_ct(self, proxmox_api: any, proxlb_data: Dict[str, Any], guest_name: str) -> None:
181
+ """
182
+ Executes the rebalancing of a container (CT) to a new node within the cluster.
183
+ This function initiates the migration of a specified CT to a target node as part of the
184
+ load balancing process. It logs the migration process and handles any exceptions that
185
+ may occur during the migration.
186
+ Args:
187
+ proxmox_api (object): The Proxmox API client instance used to interact with the Proxmox cluster.
188
+ proxlb_data (dict): A dictionary containing data related to the ProxLB load balancing configuration.
189
+ guest_name (str): The name of the guest CT to be migrated.
190
+ Raises:
191
+ proxmox_api.core.ResourceException: If an error occurs during the migration process.
192
+ Returns:
193
+ None
194
+ """
195
+ logger.debug("Starting: exec_rebalancing_ct.")
196
+ guest_id = proxlb_data["guests"][guest_name]["id"]
197
+ guest_node_current = proxlb_data["guests"][guest_name]["node_current"]
198
+ guest_node_target = proxlb_data["guests"][guest_name]["node_target"]
199
+ job_id = None
200
+
201
+ try:
202
+ logger.info(f"Balancing: Starting to migrate CT guest {guest_name} from {guest_node_current} to {guest_node_target}.")
203
+ job_id = proxmox_api.nodes(guest_node_current).lxc(guest_id).migrate().post(target=guest_node_target, restart=1)
204
+ except proxmoxer.core.ResourceException as proxmox_api_error:
205
+ logger.critical(f"Balancing: Failed to migrate guest {guest_name} of type CT due to some Proxmox errors. Please check if resource is locked or similar.")
206
+ logger.debug(f"Balancing: Failed to migrate guest {guest_name} of type CT due to some Proxmox errors: {proxmox_api_error}")
207
+
208
+ logger.debug("Finished: exec_rebalancing_ct.")
209
+ return job_id
210
+
211
+ def get_rebalancing_job_status(self, proxmox_api: any, proxlb_data: Dict[str, Any], guest_name: str, guest_current_node: str, job_id: int, retry_counter: int = 1) -> bool:
212
+ """
213
+ Monitors the status of a rebalancing job on a Proxmox node until it completes or a timeout is reached.
214
+
215
+ Args:
216
+ proxmox_api (object): The Proxmox API client instance.
217
+ proxlb_data (dict): The ProxLB configuration data.
218
+ guest_name (str): The name of the guest (virtual machine) being rebalanced.
219
+ guest_current_node (str): The current node where the guest is running.
220
+ job_id (str): The ID of the rebalancing job to monitor.
221
+ retry_counter (int, optional): The current retry count. Defaults to 1.
222
+
223
+ Returns:
224
+ bool: True if the job completed successfully, False otherwise.
225
+ """
226
+ logger.debug("Starting: get_rebalancing_job_status.")
227
+ job = proxmox_api.nodes(guest_current_node).tasks(job_id).status().get()
228
+
229
+ # Fetch actual migration job status if this got spawned by a HA job
230
+ if job["type"] == "hamigrate":
231
+ logger.debug(f"Balancing: Job ID {job_id} (guest: {guest_name}) is a HA migration job. Fetching underlying migration job...")
232
+ time.sleep(1)
233
+ vm_id = int(job["id"])
234
+ qm_migrate_jobs = proxmox_api.nodes(guest_current_node).tasks.get(typefilter="qmigrate", vmid=vm_id, start=0, source="active", limit=1)
235
+
236
+ if len(qm_migrate_jobs) > 0:
237
+ job = qm_migrate_jobs[0]
238
+ job_id = job["upid"]
239
+ logger.debug(f'Overwriting job polling for: ID {job_id} (guest: {guest_name}) by {job}')
240
+ else:
241
+ logger.debug(f"Balancing: Job ID {job_id} (guest: {guest_name}) is a standard migration job. Proceeding with status check.")
242
+
243
+ # Watch job id until it finalizes
244
+ # Note: Unsaved jobs are delivered in uppercase from Proxmox API
245
+ if job.get("status", "").lower() == "running":
246
+ # Do not hammer the API while
247
+ # watching the job status
248
+ time.sleep(10)
249
+ retry_counter += 1
250
+
251
+ # Run recursion until we hit the soft-limit of maximum migration time for a guest
252
+ if retry_counter < proxlb_data["meta"]["balancing"].get("max_job_validation", 1800):
253
+ logger.debug(f"Balancing: Job ID {job_id} (guest: {guest_name}) for migration is still running... (Run: {retry_counter})")
254
+ self.get_rebalancing_job_status(proxmox_api, proxlb_data, guest_name, guest_current_node, job_id, retry_counter)
255
+ else:
256
+ logger.warning(f"Balancing: Job ID {job_id} (guest: {guest_name}) for migration took too long. Please check manually.")
257
+ logger.debug("Finished: get_rebalancing_job_status.")
258
+ return False
259
+
260
+ # Validate job output for errors when finished
261
+ if job["status"] == "stopped":
262
+
263
+ if job["exitstatus"] == "OK":
264
+ logger.debug(f"Balancing: Job ID {job_id} (guest: {guest_name}) was successfully.")
265
+ logger.debug("Finished: get_rebalancing_job_status.")
266
+ return True
267
+ else:
268
+ logger.critical(f"Balancing: Job ID {job_id} (guest: {guest_name}) went into an error! Please check manually.")
269
+ logger.debug("Finished: get_rebalancing_job_status.")
270
+ return False