Skip to content

Commit b7ca008

Browse files
committed
feature(patching): Add initial automated node patching support
Fixes: #216
1 parent 8c473b4 commit b7ca008

File tree

5 files changed

+194
-0
lines changed

5 files changed

+194
-0
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,9 @@ The following options can be set in the configuration file `proxlb.yaml`:
274274
| | balanciness | | 10 | `Int` | The maximum delta of resource usage between node with highest and lowest usage. |
275275
| | method | | memory | `Str` | The balancing method that should be used. [values: `memory` (default), `cpu`, `disk`]|
276276
| | mode | | used | `Str` | The balancing mode that should be used. [values: `used` (default), `assigned`] |
277+
| `patching` | | | | | |
278+
| | enable | | True | `Bool` | Enables the guest balancing.|
279+
| | maximum_nodes | | 1 | `Int` | How many nodes may be patched at the same time during a ProxLB run. |
277280
| `service` | | | | | |
278281
| | daemon | | True | `Bool` | If daemon mode should be activated. |
279282
| | `schedule` | | | `Dict` | Schedule config block for rebalancing. |

config/proxlb_example.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ balancing:
3232
method: memory
3333
mode: used
3434

35+
patching:
36+
enable: True
37+
maximum_nodes: 1
38+
3539
service:
3640
daemon: True
3741
schedule:

proxlb/main.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from models.groups import Groups
2424
from models.calculations import Calculations
2525
from models.balancing import Balancing
26+
from models.patching import Patching
2627
from utils.helper import Helper
2728

2829

@@ -78,6 +79,10 @@ def main():
7879
proxlb_data = {**meta, **nodes, **guests, **groups}
7980
Helper.log_node_metrics(proxlb_data)
8081

82+
# Perform preparing patching actions via Proxmox API
83+
if proxlb_data["meta"]["patching"].get("enable", False):
84+
Patching(proxmox_api, proxlb_data)
85+
8186
# Update the initial node resource assignments
8287
# by the previously created groups.
8388
Calculations.set_node_assignments(proxlb_data)
@@ -95,6 +100,11 @@ def main():
95100
# Validate if the JSON output should be
96101
# printed to stdout
97102
Helper.print_json(proxlb_data, cli_args.json)
103+
104+
# Perform patching actions via Proxmox API
105+
if proxlb_data["meta"]["patching"].get("enable", False):
106+
Patching(proxmox_api, proxlb_data, calculations_done=True)
107+
98108
# Validate daemon mode
99109
Helper.get_daemon_mode(proxlb_config)
100110

proxlb/models/nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def get_nodes(proxmox_api: any, proxlb_config: Dict[str, Any]) -> Dict[str, Any]
6161
nodes["nodes"][node["node"]] = {}
6262
nodes["nodes"][node["node"]]["name"] = node["node"]
6363
nodes["nodes"][node["node"]]["maintenance"] = False
64+
nodes["nodes"][node["node"]]["patching"] = False
6465
nodes["nodes"][node["node"]]["cpu_total"] = node["maxcpu"]
6566
nodes["nodes"][node["node"]]["cpu_assigned"] = 0
6667
nodes["nodes"][node["node"]]["cpu_used"] = node["cpu"] * node["maxcpu"]

proxlb/models/patching.py

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""
2+
The Patching class is responsible for orchestrating the patching process of nodes in a Proxmox cluster,
3+
based on the provided ProxLB data and using the Proxmox API. It determines which nodes require
4+
patching, selects nodes for patching according to configuration, and executes patching actions
5+
while ensuring no running guests are present.
6+
"""
7+
8+
9+
__author__ = "Florian Paul Azim Hoberg <gyptazy>"
10+
__copyright__ = "Copyright (C) 2025 Florian Paul Azim Hoberg (@gyptazy)"
11+
__license__ = "GPL-3.0"
12+
13+
14+
from utils.logger import SystemdLogger
15+
from typing import Dict, Any
16+
17+
logger = SystemdLogger()
18+
19+
20+
class Patching:
21+
"""
22+
Patching
23+
24+
This class is responsible for orchestrating the patching process of nodes in a Proxmox cluster,
25+
based on the provided ProxLB data and using the Proxmox API. It determines which nodes require
26+
patching, selects nodes for patching according to configuration, and executes patching actions
27+
while ensuring no running guests are present.
28+
29+
Functions:
30+
-----------
31+
__init__(self, proxmox_api: any, proxlb_data: Dict[str, Any], calculations_done: bool = False)
32+
- Initializes the Patching class and triggers either patch preparation or execution based on the calculations_done flag.
33+
- Inputs:
34+
- proxmox_api: Proxmox API client instance.
35+
- proxlb_data: Dictionary containing cluster and node information.
36+
- calculations_done: Boolean flag to determine operation mode.
37+
- Outputs: None
38+
39+
val_nodes_packages(self, proxmox_api: any, proxlb_data: Dict[str, Any]) -> Dict[str, Any]
40+
- Checks each node for available package updates and updates their patching status.
41+
- Inputs:
42+
- proxmox_api: Proxmox API client instance.
43+
- proxlb_data: Dictionary with node and maintenance information.
44+
- Outputs:
45+
- Updated proxlb_data dictionary with patching status for each node.
46+
47+
get_nodes_to_patch(self, proxlb_data: Dict[str, Any]) -> Dict[str, Any]
48+
- Selects nodes to patch in the current run based on configuration and node status.
49+
- Inputs:
50+
- proxlb_data: Dictionary with ProxLB configuration and node information.
51+
- Outputs:
52+
- Updated proxlb_data with selected nodes for patching in this run.
53+
54+
patch_node(self, proxmox_api: any, proxlb_data: Dict[str, Any])
55+
- Executes the patching process for selected nodes, ensuring no running guests are present before proceeding.
56+
- Inputs:
57+
- proxmox_api: Proxmox API client instance.
58+
- proxlb_data: Dictionary with metadata and list of nodes to patch.
59+
- Outputs: None
60+
"""
61+
def __init__(self, proxmox_api: any, proxlb_data: Dict[str, Any], calculations_done: bool = False):
62+
"""
63+
Initializes the Patching class with the provided ProxLB data.
64+
"""
65+
if not calculations_done:
66+
logger.debug("Starting: Patching preparations.")
67+
self.val_nodes_packages(proxmox_api, proxlb_data)
68+
self.get_nodes_to_patch(proxlb_data)
69+
logger.debug("Finished: Patching preparations.")
70+
else:
71+
logger.debug("Starting: Patching executions.")
72+
self.patch_node(proxmox_api, proxlb_data)
73+
logger.debug("Finished: Patching executions.")
74+
75+
def val_nodes_packages(self, proxmox_api: any, proxlb_data: Dict[str, Any]) -> Dict[str, Any]:
76+
"""
77+
Checks each node in the provided ProxLB data for available package updates using the Proxmox API,
78+
and updates the node's patching status accordingly.
79+
80+
Args:
81+
proxmox_api (Any): An instance of the Proxmox API client used to query node package updates.
82+
proxlb_data (Dict[str, Any]): A dictionary containing node information, including maintenance status.
83+
84+
Returns:
85+
Dict[str, Any]: The updated proxlb_data dictionary with patching status set for each node.
86+
"""
87+
logger.debug("Starting: val_nodes_packages.")
88+
89+
for node in proxlb_data['nodes'].keys():
90+
if proxlb_data['nodes'][node]['maintenance'] is False:
91+
node_pkgs = proxmox_api.nodes(node).apt.update.get()
92+
93+
if len(node_pkgs) > 0:
94+
proxlb_data['nodes'][node]['patching'] = True
95+
logger.debug(f"Node {node} has {len(node_pkgs)} packages to update.")
96+
else:
97+
logger.debug(f"Node {node} is up to date and has no packages to update.")
98+
99+
logger.debug("Finished: val_nodes_packages.")
100+
return proxlb_data
101+
102+
def get_nodes_to_patch(self, proxlb_data: Dict[str, Any]):
103+
"""
104+
Determines which nodes should be patched in the current run based on the ProxLB configuration and node status.
105+
106+
Args:
107+
proxlb_data (Dict[str, Any]): A dictionary containing ProxLB configuration, metadata, and node information.
108+
- proxlb_data["meta"]["patching"]["maximum_nodes"]: Maximum number of nodes to patch in this run (default is 1).
109+
- proxlb_data["nodes"]: Dictionary of node objects, each with a "patching" status and "name".
110+
111+
Returns:
112+
Dict[str, Any]: The updated proxlb_data dictionary with:
113+
- proxlb_data["meta"]["patching"]: List of node names selected for patching in this run.
114+
- proxlb_data["nodes"]: Updated node objects with "patching" status set to True for selected nodes.
115+
"""
116+
logger.debug("Starting: get_node_patching.")
117+
118+
nodes_patching_execution = []
119+
nodes_patching_count = proxlb_data["meta"].get("patching", {}).get("maximum_nodes", 1)
120+
nodes_patching = [node for node in proxlb_data["nodes"].values() if node["patching"]]
121+
nodes_patching_sorted = sorted(nodes_patching, key=lambda x: x["name"])
122+
logger.debug(f"{len(nodes_patching)} nodes are pending for patching. Patching up to {nodes_patching_count} nodes in this run.")
123+
124+
if len(nodes_patching_sorted) > 0:
125+
nodes = nodes_patching_sorted[:nodes_patching_count]
126+
for node in nodes:
127+
nodes_patching_execution.append(node["name"])
128+
proxlb_data['nodes'][node['name']]['patching'] = True
129+
logger.info(f"Node {node['name']} is going to be patched.")
130+
logger.info(f"Node {node['name']} is set to maintenance.")
131+
132+
proxlb_data["meta"]["patching"] = nodes_patching_execution
133+
134+
logger.debug("Finished: get_node_patching.")
135+
return proxlb_data
136+
137+
def patch_node(self, proxmox_api: any, proxlb_data:Dict[str, Any]):
138+
"""
139+
Patches Proxmox nodes if no running guests are detected.
140+
141+
This method iterates over the nodes specified in the `proxlb_data` dictionary under the "meta" -> "patching" key.
142+
For each node, it checks for running QEMU (VM) and LXC (container) guests using the provided Proxmox API client.
143+
If any guests are running, patching is skipped for that node and a warning is logged.
144+
If no guests are running, the method proceeds to patch the node (API calls are commented out) and logs the actions.
145+
Rebooting the node after patching is also logged (API call commented out).
146+
147+
Args:
148+
proxmox_api (Any): An instance of the Proxmox API client used to interact with the cluster.
149+
proxlb_data (Dict[str, Any]): A dictionary containing metadata, including the list of nodes to patch under "meta" -> "patching".
150+
151+
Returns:
152+
None
153+
"""
154+
logger.debug("Starting: patch_node.")
155+
156+
for node in proxlb_data["meta"]["patching"]:
157+
node_guests = []
158+
guests_vm = proxmox_api.nodes(node).qemu.get()
159+
guests_ct = proxmox_api.nodes(node).lxc.get()
160+
guests_vm = [vm for vm in guests_vm if vm["status"] == "running"]
161+
guests_ct = [ct for ct in guests_ct if ct["status"] == "running"]
162+
guests_count = len(guests_vm) + len(guests_ct)
163+
164+
# Do not proceed when we still have someho guests running on the node
165+
if guests_vm or guests_ct:
166+
logger.warning(f"Node {node} has {guests_count} running guest(s). Patching will be skipped.")
167+
else:
168+
logger.debug(f"Node {node} has no running guests. Proceeding with patching.")
169+
# Upgrading a node by API requires the patched 'pve-manager' package
170+
# from gyptazy including the new 'upgrade' endpoint.
171+
#proxmox_api.nodes(node).apt.upgrade.post()
172+
logger.debug(f"Node {node} has been patched.")
173+
logger.debug(f"Node {node} is going to reboot.")
174+
#proxmox_api.nodes(node).status.reboot.post()
175+
176+
logger.debug("Finished: patch_node.")

0 commit comments

Comments
 (0)