Skip to content

Commit 0425010

Browse files
authored
feature: add new module proxmox_cluster_ha_rules (#167)
* feature: add new module proxmox_cluster_ha_rules * make params type and resources required if state=present * add test for idempotency
1 parent 376dbd0 commit 0425010

File tree

3 files changed

+877
-0
lines changed

3 files changed

+877
-0
lines changed

meta/runtime.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ action_groups:
1414
- proxmox_backup_schedule
1515
- proxmox_cluster
1616
- proxmox_cluster_ha_resources
17+
- proxmox_cluster_ha_rules
1718
- proxmox_cluster_ha_groups
1819
- proxmox_cluster_join_info
1920
- proxmox_disk
Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
#!/usr/bin/python
2+
3+
# Copyright (c) 2025, Reto Kupferschmid <[email protected]>
4+
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5+
# SPDX-FileCopyrightText: (c) 2025, Reto Kupferschmid <[email protected]>
6+
# SPDX-License-Identifier: GPL-3.0-or-later
7+
from __future__ import absolute_import, division, print_function
8+
9+
__metaclass__ = type
10+
11+
DOCUMENTATION = r"""
12+
---
13+
module: proxmox_cluster_ha_rules
14+
15+
short_description: Management of HA rules
16+
17+
version_added: 1.4.0
18+
19+
description:
20+
- Configure ha rules C(/cluster/ha/rules).
21+
22+
attributes:
23+
check_mode:
24+
support: full
25+
diff_mode:
26+
support: full
27+
28+
options:
29+
affinity:
30+
description: |
31+
Describes whether the HA resource are supposed to be kept on the same node V(positive),
32+
or are supposed to be kept on separate nodes V(negative).
33+
Required if O(type=resource-affinity).
34+
required: false
35+
choices: ['positive', 'negative']
36+
type: str
37+
comment:
38+
description: Description
39+
required: false
40+
type: str
41+
disable:
42+
description: Whether the HA rule is disabled. If not specified, the Proxmox API default C(false) will be used.
43+
required: false
44+
type: bool
45+
force:
46+
description: |
47+
Existing rules with a specific O(type) can not be changed to the other type.
48+
If V(force=true), the existing rule will be deleted and recreated with the new type.
49+
required: false
50+
default: false
51+
type: bool
52+
name:
53+
description: HA rule identifier.
54+
required: true
55+
type: str
56+
nodes:
57+
description: |
58+
List of cluster node members, where a priority can be given to each node. A resource bound to a group will run on the
59+
available nodes with the highest priority. If there are more nodes in the highest priority class, the services will
60+
get distributed to those nodes. The priorities have a
61+
relative meaning only. The higher the number, the higher the priority.
62+
It can either be a string C(node_name:priority,node_name:priority) or an actual list of strings.
63+
Required if O(type=node-affinity).
64+
required: false
65+
type: list
66+
elements: str
67+
resources:
68+
description: List of HA resource IDs. It can either be a string C(vm:100,ct:101) or an actual list of strings.
69+
required: false
70+
type: list
71+
elements: str
72+
state:
73+
description: create or delete rule
74+
required: true
75+
choices: ['present', 'absent']
76+
type: str
77+
strict:
78+
description: |
79+
If false, the HA resource can also be moved to other nodes if there is none of the specified nodes available.
80+
If not specified, the Proxmox API default C(false) will be used.
81+
required: false
82+
type: bool
83+
type:
84+
description: HA rule type.
85+
required: false
86+
choices: ['node-affinity', 'resource-affinity']
87+
type: str
88+
89+
extends_documentation_fragment:
90+
- community.proxmox.proxmox.actiongroup_proxmox
91+
- community.proxmox.proxmox.documentation
92+
- community.proxmox.attributes
93+
94+
author:
95+
- Reto Kupferschmid (@rekup)
96+
"""
97+
98+
EXAMPLES = r"""
99+
- name: Configure ha rule (node-affinity)
100+
community.proxmox.proxmox_cluster_ha_rules:
101+
api_host: "{{ proxmox_api_host }}"
102+
api_user: "{{ proxmox_api_user }}"
103+
api_token_id: "{{ proxmox_api_token_id }}"
104+
api_token_secret: "{{ proxmox_api_token_secret }}"
105+
name: node-affinity-rule-1
106+
state: present
107+
type: node-affinity
108+
comment: VM 100 is supposed run on proxmox02
109+
nodes:
110+
- proxmox01:10
111+
- proxmox02:20
112+
resources:
113+
- vm:100
114+
disable: false
115+
delegate_to: localhost
116+
117+
- name: Configure ha rule (node-affinity) - nodes and resources can also be provided as str
118+
community.proxmox.proxmox_cluster_ha_rules:
119+
api_host: "{{ proxmox_api_host }}"
120+
api_user: "{{ proxmox_api_user }}"
121+
api_token_id: "{{ proxmox_api_token_id }}"
122+
api_token_secret: "{{ proxmox_api_token_secret }}"
123+
name: node-affinity-rule-2
124+
state: present
125+
type: node-affinity
126+
comment: VM 100 is supposed to run on proxmox02
127+
nodes: proxmox01:10,proxmox02:20
128+
resources: vm:100
129+
disable: false
130+
delegate_to: localhost
131+
132+
- name: Configure ha rule (resource-affinity) - resource affinity
133+
community.proxmox.proxmox_cluster_ha_rules:
134+
api_host: "{{ proxmox_api_host }}"
135+
api_user: "{{ proxmox_api_user }}"
136+
api_token_id: "{{ proxmox_api_token_id }}"
137+
api_token_secret: "{{ proxmox_api_token_secret }}"
138+
name: resource-affinity-rule-1
139+
state: present
140+
type: resource-affinity
141+
comment: VM 100 and 101 are supposed to be kept on the same node
142+
affinity: positive
143+
resources:
144+
- vm:100
145+
- vm:101
146+
disable: false
147+
delegate_to: localhost
148+
149+
- name: Configure ha rule (resource-affinity) - resource anti-affinity
150+
community.proxmox.proxmox_cluster_ha_rules:
151+
api_host: "{{ proxmox_api_host }}"
152+
api_user: "{{ proxmox_api_user }}"
153+
api_token_id: "{{ proxmox_api_token_id }}"
154+
api_token_secret: "{{ proxmox_api_token_secret }}"
155+
name: resource-affinity-rule-1
156+
state: present
157+
type: resource-affinity
158+
comment: VM 100 and 101 are supposed to be kept on different nodes
159+
affinity: negative
160+
resources:
161+
- vm:100
162+
- vm:101
163+
disable: false
164+
delegate_to: localhost
165+
"""
166+
167+
RETURN = r"""
168+
rule:
169+
description: A representation of the rule.
170+
returned: success
171+
type: dict
172+
sample: {
173+
"comment": "My first ha rule",
174+
"digest": "f19acd44b43052343763cd9fd45a03b7449b3e2f",
175+
"disable": 0,
176+
"nodes": "proxmox01:10,proxmox02:10",
177+
"order": 2,
178+
"resources": "vm:100",
179+
"rule": "ha-rule1",
180+
"strict": 0,
181+
"type": "node-affinity"
182+
}
183+
184+
"""
185+
186+
from ansible.module_utils.basic import AnsibleModule
187+
188+
from ansible_collections.community.proxmox.plugins.module_utils.proxmox import (
189+
proxmox_auth_argument_spec,
190+
ProxmoxAnsible,
191+
)
192+
193+
194+
class ProxmoxClusterHARuleAnsible(ProxmoxAnsible):
195+
def get(self):
196+
rules = self.proxmox_api.cluster.ha.rules.get()
197+
return rules
198+
199+
def _post(self, data):
200+
return self.proxmox_api.cluster.ha.rules.post(**data)
201+
202+
def _put(self, name, data):
203+
return self.proxmox_api.cluster.ha.rules(name).put(**data)
204+
205+
def _delete(self, name):
206+
return self.proxmox_api.cluster.ha.rules(name).delete()
207+
208+
def create_payload(self):
209+
payload: dict = {
210+
"rule": self.module.params["name"],
211+
"type": self.module.params["type"],
212+
}
213+
214+
if self.module.params["comment"] is not None:
215+
payload["comment"] = self.module.params["comment"]
216+
217+
if self.module.params["disable"] is not None:
218+
payload["disable"] = int(self.module.params["disable"])
219+
220+
if self.module.params["resources"] is not None:
221+
payload["resources"] = ",".join(sorted(self.module.params["resources"]))
222+
223+
if self.module.params["type"] == "node-affinity":
224+
if self.module.params["strict"] is not None:
225+
payload["strict"] = int(self.module.params["strict"])
226+
if self.module.params["nodes"] is not None:
227+
payload["nodes"] = ",".join(sorted(self.module.params["nodes"]))
228+
229+
if self.module.params["type"] == "resource-affinity":
230+
payload["affinity"] = self.module.params["affinity"]
231+
232+
return dict(sorted(payload.items()))
233+
234+
def create(self, existing_rule):
235+
changed: bool = False
236+
diff: dict = {"before": {}, "after": {}}
237+
238+
name = self.module.params["name"]
239+
240+
if existing_rule and existing_rule.get("type") != self.module.params["type"]:
241+
if self.module.params["force"]:
242+
diff["before"] = existing_rule.copy()
243+
if not self.module.check_mode:
244+
self._delete(name=name)
245+
existing_rule = {}
246+
else:
247+
self.module.fail_json(
248+
changed=False,
249+
msg=(
250+
"Rule %s already exists with type=%s. "
251+
"The type of an existing rule can not be changed. "
252+
"Use force=true to delete the existing rule and recreate it with type=%s"
253+
% (
254+
name,
255+
existing_rule.get("type"),
256+
self.module.params.get("type"),
257+
)
258+
),
259+
)
260+
261+
if existing_rule:
262+
# if the rule is enabled the "disabled" key is missing from the api response
263+
existing_rule.setdefault("disable", 0)
264+
265+
# if the rule has no comment, the "comment" key is missing from the api response
266+
existing_rule.setdefault("comment", "")
267+
268+
# sort fields to ensure idempotency
269+
for key in ["nodes", "resources"]:
270+
if existing_rule.get(key, None) is not None:
271+
value_list = existing_rule.get(key).split(",")
272+
existing_rule[key] = ",".join(sorted(value_list))
273+
274+
payload = self.create_payload()
275+
updated_rule = {**existing_rule, **payload}
276+
277+
diff["before"] = existing_rule
278+
diff["after"] = updated_rule
279+
changed = existing_rule != updated_rule
280+
281+
if changed and not self.module.check_mode:
282+
self._put(name, payload)
283+
284+
else:
285+
changed = True
286+
payload = self.create_payload()
287+
288+
if not self.module.check_mode:
289+
self._post(payload)
290+
291+
# fetch the new rule and update the diff
292+
rules = self.get()
293+
diff["after"] = next(
294+
(item for item in rules if item.get("rule") == name), {}
295+
)
296+
else:
297+
diff["after"] = payload
298+
299+
return {"changed": changed, "rule": diff["after"], "diff": diff}
300+
301+
def delete(self, existing_rule, name):
302+
diff: dict = {"before": {}, "after": {}}
303+
304+
if existing_rule:
305+
diff.update({"before": existing_rule})
306+
if not self.module.check_mode:
307+
self._delete(name)
308+
return {"changed": True, "diff": diff}
309+
310+
return {"changed": False, "diff": diff}
311+
312+
313+
def run_module():
314+
module_args = proxmox_auth_argument_spec()
315+
316+
acl_args = dict(
317+
affinity=dict(choices=["positive", "negative"], required=False),
318+
comment=dict(type="str", required=False),
319+
disable=dict(type="bool", required=False),
320+
force=dict(type="bool", default=False, required=False),
321+
name=dict(type="str", required=True),
322+
nodes=dict(type="list", elements="str", required=False),
323+
resources=dict(type="list", elements="str", required=False),
324+
state=dict(choices=["present", "absent"], required=True),
325+
strict=dict(type="bool", required=False),
326+
type=dict(choices=["node-affinity", "resource-affinity"], required=False),
327+
)
328+
329+
module_args.update(acl_args)
330+
331+
result = dict(
332+
changed=False,
333+
rule={},
334+
diff={},
335+
)
336+
337+
module = AnsibleModule(
338+
argument_spec=module_args,
339+
required_one_of=[("api_password", "api_token_id")],
340+
required_together=[("api_token_id", "api_token_secret")],
341+
required_if=[
342+
("state", "present", ["type", "resources"]),
343+
("type", "node-affinity", ["nodes"]),
344+
("type", "resource-affinity", ["affinity"]),
345+
],
346+
supports_check_mode=True,
347+
)
348+
349+
proxmox = ProxmoxClusterHARuleAnsible(module)
350+
351+
name = module.params["name"]
352+
state = module.params["state"]
353+
354+
try:
355+
rules = proxmox.get()
356+
357+
existing: dict = next((item for item in rules if item.get("rule") == name), {})
358+
359+
if state == "present":
360+
result = proxmox.create(existing)
361+
result.update(**result)
362+
else:
363+
result = proxmox.delete(existing, name=name)
364+
result.update(**result)
365+
366+
except Exception as e:
367+
module.fail_json(msg=str(e), **result)
368+
369+
module.exit_json(**result)
370+
371+
372+
def main():
373+
run_module()
374+
375+
376+
if __name__ == "__main__":
377+
main()

0 commit comments

Comments
 (0)