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"