Repository: ansible/ansible
Pull request: Skip recursive suboption validation if sub_parameters is not a dict
##### SUMMARY
Fixes #75612

I tested the same argspec + invalid option with modules and the traceback doesn't occur - this is specific to the role argspec validator.

* Skips recursive suboption validation if the sub_parameter is not a dictionary

* Ensure sub parameter elements is a list to prevent iterating over string and causing duplicate error messages (due to iterating over each char)

* Add a test

##### ISSUE TYPE
- Bugfix Pull Request


Problem statement:
Unexpected exception when validating a role argspec when a dict with suboptions is expected but a str is given
### Summary

An exception is thrown when an argspec expecting a dict with suboptions is given a string.
This only occurs when the argument in the argspec has suboptions.

### Issue Type

Bug Report

### Component Name

module_utils/common/parameters.py

### Ansible Version

```console
ansible [core 2.11.4]
```


### Configuration

```console
$ ansible-config dump --only-changed
```


### OS / Environment

RHEL 8

### Steps to Reproduce

`meta/argument_specs.yml`
```yaml
---
argument_specs:
  main:
    options:
      dict_option:
        type: dict
        options:
          some_option:
            type: str
```
Playbook:
```
---
- hosts: localhost
  gather_facts: false
  vars:
    dict_option: this is a string
  roles:
    - test
```

### Expected Results

An exception should not be thrown.

### Actual Results

```console
The full traceback is:
Traceback (most recent call last):
  File "/home/user/ansible4/lib/python3.8/site-packages/ansible/executor/task_executor.py", line 158, in run
    res = self._execute()
  File "/home/user/ansible4/lib/python3.8/site-packages/ansible/executor/task_executor.py", line 582, in _execute
    result = self._handler.run(task_vars=variables)
  File "/home/user/ansible4/lib/python3.8/site-packages/ansible/plugins/action/validate_argument_spec.py", line 83, in run
    validation_result = validator.validate(provided_arguments)
  File "/home/user/ansible4/lib/python3.8/site-packages/ansible/module_utils/common/arg_spec.py", line 244, in validate
    _validate_sub_spec(self.argument_spec, result._validated_parameters,
  File "/home/user/ansible4/lib/python3.8/site-packages/ansible/module_utils/common/parameters.py", line 761, in _validate_sub_spec
    unsupported_parameters.update(_get_unsupported_parameters(sub_spec, sub_parameters, legal_inputs, options_context))
  File "/home/user/ansible4/lib/python3.8/site-packages/ansible/module_utils/common/parameters.py", line 177, in _get_unsupported_parameters
    for k in parameters.keys():
AttributeError: 'str' object has no attribute 'keys'
fatal: [localhost]: FAILED! => {
    "msg": "Unexpected failure during module execution.",
    "stdout": ""
}
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct

Hints from issue discussion:
Files identified in the description:
* [`test/integration/targets/argspec/library/argspec.py`](https://github.com/ansible/ansible/blob/devel/test/integration/targets/argspec/library/argspec.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
Files identified in the description:
* [`lib/ansible/module_utils/common/parameters.py`](https://github.com/ansible/ansible/blob/devel/lib/ansible/module_utils/common/parameters.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->

Commit message: Preserve no log values for invalid suboption types before skipping element validation

Patch to review:
diff --git a/changelogs/fragments/75635-fix-role-argspec-suboption-validation.yml b/changelogs/fragments/75635-fix-role-argspec-suboption-validation.yml
new file mode 100644
index 00000000000000..981f2548c364da
--- /dev/null
+++ b/changelogs/fragments/75635-fix-role-argspec-suboption-validation.yml
@@ -0,0 +1,2 @@
+bugfixes:
+  - validate_argument_spec - Skip suboption validation if the top level option is an invalid type (https://github.com/ansible/ansible/issues/75612).
diff --git a/lib/ansible/module_utils/common/parameters.py b/lib/ansible/module_utils/common/parameters.py
index 2624bb5092763e..76a42e97ffcd60 100644
--- a/lib/ansible/module_utils/common/parameters.py
+++ b/lib/ansible/module_utils/common/parameters.py
@@ -724,14 +724,17 @@ def _validate_sub_spec(argument_spec, parameters, prefix='', options_context=Non
             options_context.append(param)
 
             # Make sure we can iterate over the elements
-            if isinstance(parameters[param], dict):
+            if not isinstance(parameters[param], list):
                 elements = [parameters[param]]
             else:
                 elements = parameters[param]
 
             for idx, sub_parameters in enumerate(elements):
+                no_log_values.update(set_fallbacks(sub_spec, sub_parameters))
+
                 if not isinstance(sub_parameters, dict):
                     errors.append(SubParameterTypeError("value of '%s' must be of type dict or list of dicts" % param))
+                    continue
 
                 # Set prefix for warning messages
                 new_prefix = prefix + param
@@ -739,8 +742,6 @@ def _validate_sub_spec(argument_spec, parameters, prefix='', options_context=Non
                     new_prefix += '[%d]' % idx
                 new_prefix += '.'
 
-                no_log_values.update(set_fallbacks(sub_spec, sub_parameters))
-
                 alias_warnings = []
                 alias_deprecations = []
                 try:
diff --git a/test/integration/targets/roles_arg_spec/roles/test1/meta/argument_specs.yml b/test/integration/targets/roles_arg_spec/roles/test1/meta/argument_specs.yml
index 02edac6606596b..427946e5389f4f 100644
--- a/test/integration/targets/roles_arg_spec/roles/test1/meta/argument_specs.yml
+++ b/test/integration/targets/roles_arg_spec/roles/test1/meta/argument_specs.yml
@@ -73,6 +73,11 @@ argument_specs:
          some_second_level:
            type: "bool"
            default: true
+     some_more_dict_options:
+       type: "dict"
+       options:
+         some_second_level:
+           type: "str"
      some_str_removed_in:
        type: "str"
        removed_in: 2.10
diff --git a/test/integration/targets/roles_arg_spec/test_complex_role_fails.yml b/test/integration/targets/roles_arg_spec/test_complex_role_fails.yml
index a04785fb121856..923e92f71927a1 100644
--- a/test/integration/targets/roles_arg_spec/test_complex_role_fails.yml
+++ b/test/integration/targets/roles_arg_spec/test_complex_role_fails.yml
@@ -34,7 +34,10 @@
                   "value of test1_choices must be one of: this paddle game, the astray, this remote control, the chair, got: My dog",
                   "value of some_choices must be one of: choice1, choice2, got: choice4",
                   "argument 'some_second_level' is of type <class 'ansible.parsing.yaml.objects.AnsibleUnicode'> found in 'some_dict_options'. and we were unable to convert to bool: The value 'not-a-bool' is not a valid boolean.  ",
-                  "argument 'third_level' is of type <class 'ansible.parsing.yaml.objects.AnsibleUnicode'> found in 'multi_level_option -> second_level'. and we were unable to convert to int: <class 'ansible.parsing.yaml.objects.AnsibleUnicode'> cannot be converted to an int"
+                  "argument 'third_level' is of type <class 'ansible.parsing.yaml.objects.AnsibleUnicode'> found in 'multi_level_option -> second_level'. and we were unable to convert to int: <class 'ansible.parsing.yaml.objects.AnsibleUnicode'> cannot be converted to an int",
+                  "argument 'some_more_dict_options' is of type <class 'ansible.parsing.yaml.objects.AnsibleUnicode'> and we were unable to convert to dict: dictionary requested, could not parse JSON or key=value",
+                  "value of 'some_more_dict_options' must be of type dict or list of dicts",
+                  "dictionary requested, could not parse JSON or key=value",
                   ]
 
   tasks:
@@ -86,6 +89,7 @@
                 some_str_removed_in: "foo"
                 some_dict_options:
                   some_second_level: "not-a-bool"
+                some_more_dict_options: "not-a-dict"
                 multi_level_option:
                   second_level:
                     third_level: "should_be_int"


Code review comments:
diff_hunk: @@ -724,23 +724,24 @@ def _validate_sub_spec(argument_spec, parameters, prefix='', options_context=Non
             options_context.append(param)
 
             # Make sure we can iterate over the elements
-            if isinstance(parameters[param], dict):
+            if not isinstance(parameters[param], list):
path: lib/ansible/module_utils/common/parameters.py
text: @user1:
we should not be using dict nor list as it is easy to sneak in comparable things like set() or tuple(), Mapping and Sequence (sans strings) should be used instead

@author:
Fixed, thanks