Skip to content

Commit 0f4f5d6

Browse files
authored
Fix proxmox_user idempotent behavior and add change detection (#156)
* Fix proxmox_user idempotent behavior and add change detection - Use individual user API endpoint to retrieve group membership data - Add proper change detection to prevent unnecessary updates - Simplify helper functions and inline parameter building - Fix groups comparison between API list and string formats - Remove redundant exit_json calls in main function - Add co-author attribution for substantial modifications * Refactor author information and clean up code formatting in proxmox_user module * Update copyright information and author list in proxmox_user module * Add GitHub handle for Kevin Quick in proxmox_user module author list * Add unit tests for proxmox_user module functionality * Refactor test_proxmox_user.py for code formatting and cleanup * Refactor test_proxmox_user.py: reorganize test structure, improve module argument handling, and enhance user update logic tests
1 parent 4d98c8e commit 0f4f5d6

File tree

2 files changed

+240
-36
lines changed

2 files changed

+240
-36
lines changed

plugins/modules/proxmox_user.py

Lines changed: 70 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
# -*- coding: utf-8 -*-
33
#
44
# Copyright (c) 2025, Jeffrey van Pelt (@Thulium-Drake) <[email protected]>
5+
# Copyright (c) 2025, Kevin Quick <[email protected]>
56
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
67
# SPDX-FileCopyrightText: (c) 2025, Jeffrey van Pelt (@Thulium-Drake) <[email protected]>
8+
# SPDX-FileCopyrightText: (c) 2025, Kevin Quick <[email protected]>
79
# SPDX-License-Identifier: GPL-3.0-or-later
810
from __future__ import (absolute_import, division, print_function)
911
__metaclass__ = type
@@ -13,7 +15,9 @@
1315
short_description: User management for Proxmox VE cluster
1416
description:
1517
- Create or delete a user for Proxmox VE clusters.
16-
author: "Jeffrey van Pelt (@Thulium-Drake) <[email protected]>"
18+
author:
19+
- Jeffrey van Pelt (@Thulium-Drake)
20+
- Kevin Quick (@kevinquick)
1721
version_added: "1.2.0"
1822
attributes:
1923
check_mode:
@@ -123,7 +127,8 @@
123127
"""
124128

125129
from ansible.module_utils.basic import AnsibleModule
126-
from ansible_collections.community.proxmox.plugins.module_utils.proxmox import (proxmox_auth_argument_spec, ProxmoxAnsible)
130+
from ansible_collections.community.proxmox.plugins.module_utils.proxmox import (
131+
proxmox_auth_argument_spec, ProxmoxAnsible)
127132

128133

129134
class ProxmoxUserAnsible(ProxmoxAnsible):
@@ -132,18 +137,39 @@ def is_user_existing(self, userid):
132137
"""Check whether user already exist
133138
134139
:param userid: str - name of the user
135-
:return: bool - is user exists?
140+
:return: dict|bool - user data if exists, False otherwise
136141
"""
137142
try:
138-
users = self.proxmox_api.access.users.get()
139-
for user in users:
140-
if user['userid'] == userid:
141-
return user
142-
return False
143+
user_data = self.proxmox_api.access.users(userid).get()
144+
return user_data
143145
except Exception as e:
144-
self.module.fail_json(msg="Unable to retrieve users: {0}".format(e))
145-
146-
def create_update_user(self, userid, comment=None, email=None, enable=True, expire=0, firstname=None, groups=None, password=None, keys=None, lastname=None):
146+
if "does not exist" in str(e).lower() or "not found" in str(e).lower():
147+
return False
148+
else:
149+
self.module.fail_json(msg="Unable to retrieve user {0}: {1}".format(userid, e))
150+
151+
def _user_needs_update(self, existing_user, comment, email, enable, expire, firstname, lastname, groups, keys):
152+
"""Check if user needs updating by comparing current vs desired state"""
153+
# Check standard fields
154+
fields = [('comment', comment, ''), ('email', email, ''), ('enable', enable, 1),
155+
('expire', expire, 0), ('firstname', firstname, ''),
156+
('lastname', lastname, ''), ('keys', keys, '')]
157+
158+
for field, new_value, default in fields:
159+
if new_value is not None and existing_user.get(field, default) != new_value:
160+
return True
161+
162+
# Check groups (API returns list, we send comma-separated string)
163+
if groups is not None:
164+
existing_groups_str = ','.join(existing_user.get('groups', []))
165+
if existing_groups_str != groups:
166+
return True
167+
168+
return False
169+
170+
def create_update_user(self, userid, comment=None, email=None, enable=True, expire=0,
171+
firstname=None, groups=None, password=None, keys=None,
172+
lastname=None):
147173
"""Create or update Proxmox VE user
148174
149175
:param userid: str - name of the user
@@ -158,30 +184,33 @@ def create_update_user(self, userid, comment=None, email=None, enable=True, expi
158184
:param lastname: str, optional - Lastname of the user
159185
:return: None
160186
"""
161-
162187
# Translate input to make API happy
163188
enable = int(enable)
164-
groups = ','.join(groups)
165-
166-
if self.is_user_existing(userid):
189+
groups = ','.join(groups) if groups else None
190+
existing_user = self.is_user_existing(userid)
191+
if existing_user:
192+
needs_update = self._user_needs_update(existing_user, comment, email, enable, expire,
193+
firstname, lastname, groups, keys)
194+
if not needs_update and not password:
195+
self.module.exit_json(changed=False, userid=userid, msg="User {0} already up to date".format(userid))
167196
if self.module.check_mode:
168-
self.module.exit_json(changed=False, userid=userid, msg="Would update {0} (check mode)".format(userid))
169-
170-
# Update the user details
171-
try:
172-
self.proxmox_api.access.users(userid).put(comment=comment,
173-
email=email,
174-
enable=enable,
175-
expire=expire,
176-
firstname=firstname,
177-
groups=groups,
178-
keys=keys,
179-
lastname=lastname)
180-
181-
self.module.exit_json(changed=True, userid=userid, msg="User {0} updated".format(userid))
197+
self.module.exit_json(changed=needs_update or bool(password), userid=userid,
198+
msg="Would update {0} (check mode)".format(userid))
182199

183-
except Exception as e:
184-
self.module.fail_json(changed=False, userid=userid, msg="Failed to update user with ID {0}: {1}".format(userid, e))
200+
if needs_update:
201+
try:
202+
# Build update parameters - only include non-None values
203+
update_params = {'enable': enable}
204+
for field, value in [('comment', comment), ('email', email), ('expire', expire),
205+
('firstname', firstname), ('lastname', lastname),
206+
('groups', groups), ('keys', keys)]:
207+
if value is not None:
208+
update_params[field] = value
209+
self.proxmox_api.access.users(userid).put(**update_params)
210+
self.module.exit_json(changed=True, userid=userid, msg="User {0} updated".format(userid))
211+
except Exception as e:
212+
self.module.fail_json(changed=False, userid=userid,
213+
msg="Failed to update user with ID {0}: {1}".format(userid, e))
185214

186215
# We have no way of testing if the user's password needs to be changed
187216
# so, if it's provided we will update it anyway
@@ -190,10 +219,11 @@ def create_update_user(self, userid, comment=None, email=None, enable=True, expi
190219
self.proxmox_api.access.password.put(userid=userid, password=password)
191220
self.module.exit_json(changed=True, userid=userid, msg="User {0} updated".format(userid))
192221
except Exception as e:
193-
self.module.fail_json(changed=False, userid=userid, msg="Failed to update user password for user ID {0}: {1}".format(userid, e))
222+
self.module.fail_json(changed=False, userid=userid,
223+
msg="Failed to update user password for user ID {0}: {1}".format(userid, e))
194224

195225
if self.module.check_mode:
196-
self.module.exit_json(changed=True, userid=userid, msg="Would update user {0} (check mode)".format(userid))
226+
self.module.exit_json(changed=True, userid=userid, msg="Would create user {0} (check mode)".format(userid))
197227

198228
# if the user is new, post it to the API
199229
try:
@@ -221,7 +251,8 @@ def delete_user(self, userid):
221251
self.module.exit_json(changed=False, userid=userid, msg="User {0} doesn't exist".format(userid))
222252

223253
if self.module.check_mode:
224-
self.module.exit_json(changed=False, userid=userid, msg="Would deleted user with ID {0} (check mode)".format(userid))
254+
self.module.exit_json(changed=False, userid=userid,
255+
msg="Would deleted user with ID {0} (check mode)".format(userid))
225256

226257
try:
227258
self.proxmox_api.access.users(userid).delete()
@@ -269,12 +300,15 @@ def main():
269300

270301
proxmox = ProxmoxUserAnsible(module)
271302

303+
# Convert empty strings to None for proper comparison
304+
for param in ['comment', 'email', 'firstname', 'lastname', 'keys']:
305+
if locals()[param] == "":
306+
locals()[param] = None
307+
272308
if state == "present":
273309
proxmox.create_update_user(userid, comment, email, enable, expire, firstname, groups, password, keys, lastname)
274-
module.exit_json(changed=True, userid=userid, msg="User {0} successfully created".format(userid))
275310
else:
276311
proxmox.delete_user(userid)
277-
module.exit_json(changed=True, userid=userid, msg="User {0} successfully deleted".format(userid))
278312

279313

280314
if __name__ == "__main__":
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (c) 2025, Kevin Quick <[email protected]>
4+
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
5+
# SPDX-License-Identifier: GPL-3.0-or-later
6+
from __future__ import (absolute_import, division, print_function)
7+
__metaclass__ = type
8+
9+
import sys
10+
from unittest.mock import MagicMock as MagicMike, patch
11+
12+
import pytest
13+
from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import (
14+
AnsibleExitJson, AnsibleFailJson, set_module_args, ModuleTestCase)
15+
16+
# Skip tests if proxmoxer is not available
17+
proxmoxer = pytest.importorskip('proxmoxer')
18+
19+
# Handle different import paths for different test environments
20+
try:
21+
from ansible_collections.community.proxmox.plugins.modules import proxmox_user
22+
import ansible_collections.community.proxmox.plugins.module_utils.proxmox as proxmox_utils
23+
except ImportError:
24+
sys.path.insert(0, 'plugins/modules')
25+
import proxmox_user
26+
sys.path.insert(0, 'plugins/module_utils')
27+
import proxmox as proxmox_utils
28+
29+
30+
class TestProxmoxUserModule(ModuleTestCase):
31+
"""Test cases for proxmox_user module using ModuleTestCase pattern."""
32+
33+
# Common test data
34+
BASIC_MODULE_ARGS = {
35+
'api_host': 'test.proxmox.com',
36+
'api_user': 'root@pam',
37+
'api_password': 'secret',
38+
}
39+
40+
SAMPLE_USER = {
41+
'userid': 'testuser@pam',
42+
'comment': 'Test User',
43+
'email': '[email protected]',
44+
'enable': 1,
45+
'expire': 0,
46+
'firstname': 'John',
47+
'lastname': 'Doe',
48+
'groups': ['admins'],
49+
'keys': ''
50+
}
51+
52+
def setUp(self):
53+
super(TestProxmoxUserModule, self).setUp()
54+
proxmox_utils.HAS_PROXMOXER = True
55+
self.module = proxmox_user
56+
self.connect_mock = patch("ansible_collections.community.proxmox.plugins.module_utils.proxmox.ProxmoxAnsible._connect")
57+
self.connect_mock.start()
58+
59+
def tearDown(self):
60+
self.connect_mock.stop()
61+
super(TestProxmoxUserModule, self).tearDown()
62+
63+
def _create_module_args(self, **kwargs):
64+
"""Helper to create module arguments with defaults."""
65+
args = self.BASIC_MODULE_ARGS.copy()
66+
args.update(kwargs)
67+
return args
68+
69+
def test_module_fail_when_required_args_missing(self):
70+
"""Test module fails with missing required arguments"""
71+
with set_module_args({}):
72+
with pytest.raises(AnsibleFailJson):
73+
proxmox_user.main()
74+
75+
def test_user_creation_check_mode(self):
76+
"""Test user creation in check mode"""
77+
module_args = self._create_module_args(userid='testuser@pam', comment='Test User', state='present', _ansible_check_mode=True)
78+
79+
with set_module_args(module_args):
80+
with patch.object(proxmox_user.ProxmoxUserAnsible, 'is_user_existing', return_value=False):
81+
with pytest.raises(AnsibleExitJson) as exc_info:
82+
proxmox_user.main()
83+
84+
result = exc_info.value.args[0]
85+
assert result['changed'] is True
86+
assert 'check mode' in result['msg']
87+
88+
def test_user_update_no_changes_needed(self):
89+
"""Test user update when no changes needed"""
90+
module_args = self._create_module_args(userid='testuser@pam', comment='Test User', state='present')
91+
existing_user = {
92+
'userid': 'testuser@pam', 'comment': 'Test User', 'email': '', 'enable': 1, 'expire': 0,
93+
'firstname': '', 'lastname': '', 'groups': [], 'keys': ''
94+
}
95+
96+
with set_module_args(module_args):
97+
mock_user_exists = patch.object(proxmox_user.ProxmoxUserAnsible, 'is_user_existing', return_value=existing_user)
98+
mock_needs_update = patch.object(proxmox_user.ProxmoxUserAnsible, '_user_needs_update', return_value=False)
99+
100+
with mock_user_exists, mock_needs_update:
101+
with pytest.raises(AnsibleExitJson) as exc_info:
102+
proxmox_user.main()
103+
104+
result = exc_info.value.args[0]
105+
assert result['changed'] is False
106+
assert 'already up to date' in result['msg']
107+
108+
109+
# Tests for internal methods and business logic
110+
class TestProxmoxUserInternals:
111+
"""Test internal methods and business logic of ProxmoxUserAnsible class."""
112+
113+
# Test data for internal method testing
114+
SAMPLE_EXISTING_USER = {
115+
'userid': 'testuser@pam',
116+
'comment': 'Old comment',
117+
'email': '[email protected]',
118+
'enable': 1,
119+
'expire': 0,
120+
'firstname': 'John',
121+
'lastname': 'Doe',
122+
'groups': ['admins'],
123+
'keys': ''
124+
}
125+
126+
@pytest.fixture
127+
def user_manager(self):
128+
"""Create a ProxmoxUserAnsible instance for internal testing."""
129+
module = MagicMike()
130+
module.check_mode = False
131+
module.exit_json = MagicMike()
132+
module.fail_json = MagicMike()
133+
134+
with patch.object(proxmox_utils.ProxmoxAnsible, '__init__', return_value=None):
135+
manager = proxmox_user.ProxmoxUserAnsible(module)
136+
manager.module = module
137+
manager.proxmox_api = MagicMike()
138+
return manager
139+
140+
def test_user_needs_update_logic(self, user_manager):
141+
"""Test the _user_needs_update comparison logic for various scenarios."""
142+
existing_user = self.SAMPLE_EXISTING_USER.copy()
143+
144+
# Test case: No update needed - identical data
145+
no_update_needed = user_manager._user_needs_update(
146+
existing_user, 'Old comment', '[email protected]', 1, 0, 'John', 'Doe', 'admins', ''
147+
)
148+
assert no_update_needed is False
149+
150+
# Test case: Update needed - different comment
151+
update_needed = user_manager._user_needs_update(
152+
existing_user, 'New comment', '[email protected]', 1, 0, 'John', 'Doe', 'admins', ''
153+
)
154+
assert update_needed is True
155+
156+
def test_groups_format_handling(self, user_manager):
157+
"""Test groups comparison between API format (list) and module input format (string)."""
158+
existing_user_with_groups = {'userid': 'testuser@pam', 'groups': ['admins', 'users']}
159+
160+
# Test case: Same groups in different formats - no update needed
161+
same_groups = user_manager._user_needs_update(
162+
existing_user_with_groups, None, None, 1, None, None, None, 'admins,users', None
163+
)
164+
assert same_groups is False
165+
166+
# Test case: Different groups - update needed
167+
different_groups = user_manager._user_needs_update(
168+
existing_user_with_groups, None, None, 1, None, None, None, 'admins', None
169+
)
170+
assert different_groups is True

0 commit comments

Comments
 (0)