From 8fbb9d718e4b7a2d17e5f8bae9ea68777413d12b Mon Sep 17 00:00:00 2001 From: Abhijeet Kasurde Date: Tue, 14 Apr 2026 13:21:48 -0400 Subject: [PATCH 1/4] random_string: Add parameter avoid_special_first and avoid_special_last * Avoid first character of random string if it is special when avoid_special_first * Avoid last character of random string if it is special when avoid_special_last Fixes: #11816 Signed-off-by: Abhijeet Kasurde --- changelogs/fragments/random_string_avoid.yml | 3 + plugins/lookup/random_string.py | 87 ++++++++++++++----- .../targets/lookup_random_string/test.yml | 22 ++++- 3 files changed, 90 insertions(+), 22 deletions(-) create mode 100644 changelogs/fragments/random_string_avoid.yml diff --git a/changelogs/fragments/random_string_avoid.yml b/changelogs/fragments/random_string_avoid.yml new file mode 100644 index 0000000000..eccb07bb74 --- /dev/null +++ b/changelogs/fragments/random_string_avoid.yml @@ -0,0 +1,3 @@ +--- +bugfixes: + - random_string - allow user to set avoid_special_first and avoid_special_last (https://github.com/ansible-collections/community.general/issues/11816, https://github.com/ansible-collections/community.general/pull/11819). diff --git a/plugins/lookup/random_string.py b/plugins/lookup/random_string.py index baa6c70785..665b3d5ba8 100644 --- a/plugins/lookup/random_string.py +++ b/plugins/lookup/random_string.py @@ -106,6 +106,18 @@ options: B(Do not use the generated string as a password or a secure token when using this option!) type: str version_added: 11.3.0 + avoid_special_first: + description: + - Avoid special characters at the first position of the string. + type: bool + default: false + version_added: 12.6.0 + avoid_special_last: + description: + - Avoid special characters at the last position of the string. + type: bool + default: false + version_added: 12.6.0 """ EXAMPLES = r""" @@ -155,6 +167,16 @@ EXAMPLES = r""" vars: hex_chars: '0123456789ABCDEF' # Example result: ['D2A40737'] + +- name: Generate random string with avoid_special_first + debug: + var: query('community.general.random_string', avoid_special_first=true) + # Example result: ['Uan0hUiX5kVG'] + +- name: Generate random string with avoid_special_last + debug: + var: query('community.general.random_string', avoid_special_last=true) + # Example result: ['Uan0hUiX5kVG'] """ RETURN = r""" @@ -196,8 +218,11 @@ class LookupModule(LookupBase): length = self.get_option("length") base64_flag = self.get_option("base64") override_all = self.get_option("override_all") + override_special = self.get_option("override_special") ignore_similar_chars = self.get_option("ignore_similar_chars") similar_chars = self.get_option("similar_chars") + avoid_special_first = self.get_option("avoid_special_first") + avoid_special_last = self.get_option("avoid_special_last") seed = self.get_option("seed") if seed is None: @@ -205,7 +230,7 @@ class LookupModule(LookupBase): else: random_generator = random.Random(seed) - values = "" + result = [] available_chars_set = "" if ignore_similar_chars: @@ -222,7 +247,6 @@ class LookupModule(LookupBase): lower = self.get_option("lower") numbers = self.get_option("numbers") special = self.get_option("special") - override_special = self.get_option("override_special") if override_special: special_chars = override_special @@ -236,27 +260,50 @@ class LookupModule(LookupBase): if special: available_chars_set += special_chars - mapping = { - "min_numeric": number_chars, - "min_lower": lower_chars, - "min_upper": upper_chars, - "min_special": special_chars, - } + if len(available_chars_set) == 0: + raise AnsibleLookupError("Available characters cannot be empty, please change constraints") - for m in mapping: - if self.get_option(m): - values += self.get_random(random_generator, mapping[m], self.get_option(m)) + min_numeric = self.get_option("min_numeric") + min_lower = self.get_option("min_lower") + min_upper = self.get_option("min_upper") + min_special = self.get_option("min_special") + if length < min_numeric + min_lower + min_upper + min_special: + raise AnsibleLookupError("Minimum requirements exceed total length, please increase the length") - remaining_pass_len = length - len(values) - values += self.get_random(random_generator, available_chars_set, remaining_pass_len) + # Ensure minimum requirements + result += [random_generator.choice(number_chars) for dummy in range(min_numeric)] + result += [random_generator.choice(lower_chars) for dummy in range(min_lower)] + result += [random_generator.choice(upper_chars) for dummy in range(min_upper)] + if override_special: + result += [random_generator.choice(override_special) for dummy in range(min_special)] + else: + result += [random_generator.choice(special_chars) for dummy in range(min_special)] - shuffled_values = list(values) - if seed is None: - # Get pseudo randomization - # Randomize the order - random.shuffle(shuffled_values) + # Fill remaining length + remaining_length = length - len(result) + result += [random_generator.choice(available_chars_set) for dummy in range(remaining_length)] + + # Shuffle to avoid predictable pattern + random_generator.shuffle(result) + + # Handle first/last character constraints + if avoid_special_first and result[0] in special_chars: + for i in range(1, len(result)): + if result[i] not in special_chars: + result[0], result[i] = result[i], result[0] + break + else: + raise AnsibleLookupError("No character satisfies the constraints for avoid_special_first") + + if avoid_special_last and result[-1] in special_chars: + for i in range(len(result) - 2, -1, -1): + if result[i] not in special_chars: + result[-1], result[i] = result[i], result[-1] + break + else: + raise AnsibleLookupError("No character satisfies the constraints for avoid_special_last") if base64_flag: - return [self.b64encode("".join(shuffled_values))] + return [self.b64encode("".join(result))] - return ["".join(shuffled_values)] + return ["".join(result)] diff --git a/tests/integration/targets/lookup_random_string/test.yml b/tests/integration/targets/lookup_random_string/test.yml index 31b5001ceb..55a3abafc4 100644 --- a/tests/integration/targets/lookup_random_string/test.yml +++ b/tests/integration/targets/lookup_random_string/test.yml @@ -11,7 +11,7 @@ result1: "{{ query('community.general.random_string') }}" result2: "{{ query('community.general.random_string', length=0) }}" result3: "{{ query('community.general.random_string', length=10) }}" - result4: "{{ query('community.general.random_string', length=-1) }}" + result4: "{{ query('community.general.random_string', length=6) }}" result5: "{{ query('community.general.random_string', override_special='_', min_special=1) }}" result6: "{{ query('community.general.random_string', upper=false, special=false) }}" # lower case only result7: "{{ query('community.general.random_string', lower=false, special=false) }}" # upper case only @@ -23,6 +23,8 @@ result13: "{{ query('community.general.random_string', override_all='0', length=2) }}" result14: "{{ query('community.general.random_string', seed='1234') }}" result15: "{{ query('community.general.random_string', seed='1234') }}" + result16: "{{ query('community.general.random_string', avoid_special_first=true) }}" + result17: "{{ query('community.general.random_string', avoid_special_last=true) }}" - name: Raise error when impossible constraints are provided set_fact: @@ -30,13 +32,25 @@ ignore_errors: true register: impossible_result + - name: Raise error when override_all is provided with special characters and avoid_special_first is provided + set_fact: + error: "{{ query('community.general.random_string', override_all='!@#$%^&*()', avoid_special_first=true) }}" + ignore_errors: true + register: override_all_special_characters_avoid_special_first_error + + - name: Raise error when override_all is provided with special characters and avoid_special_last is provided + set_fact: + error: "{{ query('community.general.random_string', override_all='!@#$%^&*()', avoid_special_last=true) }}" + ignore_errors: true + register: override_all_special_characters_avoid_special_last_error + - name: Check results assert: that: - result1[0] | length == 8 - result2[0] | length == 0 - result3[0] | length == 10 - - result4[0] | length == 0 + - result4[0] | length == 6 - result5[0] | length == 8 - "'_' in result5[0]" - result6[0] is lower @@ -54,3 +68,7 @@ - impossible_result is failed - "'Available characters cannot' in impossible_result.msg" - result15[0] == result14[0] + - override_all_special_characters_avoid_special_first_error is failed + - "'No character satisfies the constraints for avoid_special_first' in override_all_special_characters_avoid_special_first_error.msg" + - override_all_special_characters_avoid_special_last_error is failed + - "'No character satisfies the constraints for avoid_special_last' in override_all_special_characters_avoid_special_last_error.msg" From c3206aeccbd4113330374031b080a2bdaa885d05 Mon Sep 17 00:00:00 2001 From: Abhijeet Kasurde Date: Thu, 16 Apr 2026 12:34:26 -0400 Subject: [PATCH 2/4] Review Comment and more tests Signed-off-by: Abhijeet Kasurde --- changelogs/fragments/random_string_avoid.yml | 4 ++-- plugins/lookup/random_string.py | 16 +++++++++++++--- .../targets/lookup_random_string/test.yml | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/changelogs/fragments/random_string_avoid.yml b/changelogs/fragments/random_string_avoid.yml index eccb07bb74..60eaf397d8 100644 --- a/changelogs/fragments/random_string_avoid.yml +++ b/changelogs/fragments/random_string_avoid.yml @@ -1,3 +1,3 @@ --- -bugfixes: - - random_string - allow user to set avoid_special_first and avoid_special_last (https://github.com/ansible-collections/community.general/issues/11816, https://github.com/ansible-collections/community.general/pull/11819). +minor_changes: + - random_string lookup plugin - allow user to set ``avoid_special_first`` and ``avoid_special_last`` (https://github.com/ansible-collections/community.general/issues/11816, https://github.com/ansible-collections/community.general/pull/11819). diff --git a/plugins/lookup/random_string.py b/plugins/lookup/random_string.py index 665b3d5ba8..4f5e0bf453 100644 --- a/plugins/lookup/random_string.py +++ b/plugins/lookup/random_string.py @@ -171,12 +171,12 @@ EXAMPLES = r""" - name: Generate random string with avoid_special_first debug: var: query('community.general.random_string', avoid_special_first=true) - # Example result: ['Uan0hUiX5kVG'] + # Example result: ['U@n0hUiX5kVG'] - name: Generate random string with avoid_special_last debug: var: query('community.general.random_string', avoid_special_last=true) - # Example result: ['Uan0hUiX5kVG'] + # Example result: ['U@n0hUiX5kVG'] """ RETURN = r""" @@ -267,7 +267,13 @@ class LookupModule(LookupBase): min_lower = self.get_option("min_lower") min_upper = self.get_option("min_upper") min_special = self.get_option("min_special") - if length < min_numeric + min_lower + min_upper + min_special: + min_non_special = min_numeric + min_lower + min_upper + if avoid_special_first and avoid_special_last: + min_non_special += 2 + elif avoid_special_first or avoid_special_last: + min_non_special += 1 + + if length < min_special + min_non_special: raise AnsibleLookupError("Minimum requirements exceed total length, please increase the length") # Ensure minimum requirements @@ -281,6 +287,10 @@ class LookupModule(LookupBase): # Fill remaining length remaining_length = length - len(result) + if min_non_special <= remaining_length: + # atleast one non-special character + available_chars_set = available_chars_set.replace(special_chars, '') + result += [random_generator.choice(available_chars_set) for dummy in range(remaining_length)] # Shuffle to avoid predictable pattern diff --git a/tests/integration/targets/lookup_random_string/test.yml b/tests/integration/targets/lookup_random_string/test.yml index 55a3abafc4..52d581ac6b 100644 --- a/tests/integration/targets/lookup_random_string/test.yml +++ b/tests/integration/targets/lookup_random_string/test.yml @@ -25,6 +25,7 @@ result15: "{{ query('community.general.random_string', seed='1234') }}" result16: "{{ query('community.general.random_string', avoid_special_first=true) }}" result17: "{{ query('community.general.random_string', avoid_special_last=true) }}" + result18: "{{ query('community.general.random_string', length=10, avoid_special_first=true, avoid_special_last=true, min_special=8) }}" - name: Raise error when impossible constraints are provided set_fact: @@ -44,6 +45,14 @@ ignore_errors: true register: override_all_special_characters_avoid_special_last_error + - name: Raise error when impossible constraints are provided + set_fact: + error: "{{ query('community.general.random_string', length=10, avoid_special_first=true, avoid_special_last=true, min_special=9) }}" + ignore_errors: true + register: len_10_avoid_special_first_avoid_special_last_min_special_9 + + - debug: + var: len_10_avoid_special_first_avoid_special_last_min_special_9 - name: Check results assert: that: @@ -68,7 +77,14 @@ - impossible_result is failed - "'Available characters cannot' in impossible_result.msg" - result15[0] == result14[0] + - result16[0][0] not in '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + - result17[0][-1] not in '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + - result18[0] | length == 10 + - result18[0][0] not in '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' + - result18[0][-1] not in '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' - override_all_special_characters_avoid_special_first_error is failed - "'No character satisfies the constraints for avoid_special_first' in override_all_special_characters_avoid_special_first_error.msg" - override_all_special_characters_avoid_special_last_error is failed - "'No character satisfies the constraints for avoid_special_last' in override_all_special_characters_avoid_special_last_error.msg" + - len_10_avoid_special_first_avoid_special_last_min_special_9 is failed + - "'Minimum requirements exceed total length, please increase the length' in len_10_avoid_special_first_avoid_special_last_min_special_9.msg" From 4738d7ce95132a7a3abac4e5199545ddc9a8c5ed Mon Sep 17 00:00:00 2001 From: Abhijeet Kasurde Date: Fri, 17 Apr 2026 09:26:13 -0400 Subject: [PATCH 3/4] Review Comment Signed-off-by: Abhijeet Kasurde --- plugins/lookup/random_string.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/plugins/lookup/random_string.py b/plugins/lookup/random_string.py index 4f5e0bf453..23e3810edd 100644 --- a/plugins/lookup/random_string.py +++ b/plugins/lookup/random_string.py @@ -197,12 +197,6 @@ from ansible.plugins.lookup import LookupBase class LookupModule(LookupBase): - @staticmethod - def get_random(random_generator, chars, length): - if not chars: - raise AnsibleLookupError("Available characters cannot be None, please change constraints") - return "".join(random_generator.choice(chars) for dummy in range(length)) - @staticmethod def b64encode(string_value, encoding="utf-8"): return to_text(base64.b64encode(to_bytes(string_value, encoding=encoding, errors="surrogate_or_strict"))) @@ -269,9 +263,9 @@ class LookupModule(LookupBase): min_special = self.get_option("min_special") min_non_special = min_numeric + min_lower + min_upper if avoid_special_first and avoid_special_last: - min_non_special += 2 + min_non_special = max(min_non_special, 2) elif avoid_special_first or avoid_special_last: - min_non_special += 1 + min_non_special = max(min_non_special, 1) if length < min_special + min_non_special: raise AnsibleLookupError("Minimum requirements exceed total length, please increase the length") @@ -303,7 +297,7 @@ class LookupModule(LookupBase): result[0], result[i] = result[i], result[0] break else: - raise AnsibleLookupError("No character satisfies the constraints for avoid_special_first") + raise AssertionError("No character satisfies the constraints for avoid_special_first") if avoid_special_last and result[-1] in special_chars: for i in range(len(result) - 2, -1, -1): @@ -311,7 +305,7 @@ class LookupModule(LookupBase): result[-1], result[i] = result[i], result[-1] break else: - raise AnsibleLookupError("No character satisfies the constraints for avoid_special_last") + raise AssertionError("No character satisfies the constraints for avoid_special_last") if base64_flag: return [self.b64encode("".join(result))] From b4f98308622cfb8d3f64b49b7e496a403e5dd367 Mon Sep 17 00:00:00 2001 From: Abhijeet Kasurde Date: Fri, 17 Apr 2026 09:40:13 -0400 Subject: [PATCH 4/4] Ruff formatting Signed-off-by: Abhijeet Kasurde --- plugins/lookup/random_string.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/lookup/random_string.py b/plugins/lookup/random_string.py index 23e3810edd..b777e48fe0 100644 --- a/plugins/lookup/random_string.py +++ b/plugins/lookup/random_string.py @@ -283,7 +283,7 @@ class LookupModule(LookupBase): remaining_length = length - len(result) if min_non_special <= remaining_length: # atleast one non-special character - available_chars_set = available_chars_set.replace(special_chars, '') + available_chars_set = available_chars_set.replace(special_chars, "") result += [random_generator.choice(available_chars_set) for dummy in range(remaining_length)]