diff --git a/awx_collection/README.md b/awx_collection/README.md index fe65d0d013..31fd683c5c 100644 --- a/awx_collection/README.md +++ b/awx_collection/README.md @@ -34,7 +34,7 @@ in the `awx_collection_test_venv` folder so that `make test_collection` can be ran to actually run the tests. A single test can be ran via: ``` -make test_collection MODULE_TEST_DIRS=awx_collection/test/awx/test_organization.py +make test_collection COLLECTION_TEST_DIRS=awx_collection/test/awx/test_organization.py ``` ## Building diff --git a/awx_collection/plugins/module_utils/ansible_tower.py b/awx_collection/plugins/module_utils/ansible_tower.py index ef687a669c..c71f096455 100644 --- a/awx_collection/plugins/module_utils/ansible_tower.py +++ b/awx_collection/plugins/module_utils/ansible_tower.py @@ -98,8 +98,8 @@ class TowerModule(AnsibleModule): ) args.update(argument_spec) - mutually_exclusive = kwargs.get('mutually_exclusive', []) - kwargs['mutually_exclusive'] = mutually_exclusive.extend(( + kwargs.setdefault('mutually_exclusive', []) + kwargs['mutually_exclusive'].extend(( ('tower_config_file', 'tower_host'), ('tower_config_file', 'tower_username'), ('tower_config_file', 'tower_password'), diff --git a/awx_collection/plugins/modules/tower_credential.py b/awx_collection/plugins/modules/tower_credential.py index 7ac08b28d6..587d8205dd 100644 --- a/awx_collection/plugins/modules/tower_credential.py +++ b/awx_collection/plugins/modules/tower_credential.py @@ -53,9 +53,23 @@ options: description: - Type of credential being added. - The ssh choice refers to a Tower Machine credential. - required: True + required: False type: str choices: ["ssh", "vault", "net", "scm", "aws", "vmware", "satellite6", "cloudforms", "gce", "azure_rm", "openstack", "rhv", "insights", "tower"] + credential_type: + description: + - Name of credential type. + required: False + version_added: "2.10" + type: str + inputs: + description: + - >- + Credential inputs where the keys are var names used in templating. + Refer to the Ansible Tower documentation for example syntax. + required: False + version_added: "2.9" + type: dict host: description: - Host for this credential. @@ -185,6 +199,15 @@ EXAMPLES = ''' tower_host: https://localhost run_once: true delegate_to: localhost + +- name: Add Credential with Custom Credential Type + tower_credential: + name: Workshop Credential + credential_type: MyCloudCredential + organization: Default + tower_username: admin + tower_password: ansible + tower_host: https://localhost ''' import os @@ -219,7 +242,17 @@ KIND_CHOICES = { } -def credential_type_for_v1_kind(params, module): +OLD_INPUT_NAMES = ( + 'authorize', 'authorize_password', 'client', + 'security_token', 'secret', 'tenant', 'subscription', + 'domain', 'become_method', 'become_username', + 'become_password', 'vault_password', 'project', 'host', + 'username', 'password', 'ssh_key_data', 'vault_id', + 'ssh_key_unlock' +) + + +def credential_type_for_kind(params): credential_type_res = tower_cli.get_resource('credential_type') kind = params.pop('kind') arguments = {'managed_by_tower': True} @@ -244,8 +277,9 @@ def main(): name=dict(required=True), user=dict(), team=dict(), - kind=dict(required=True, - choices=KIND_CHOICES.keys()), + kind=dict(choices=KIND_CHOICES.keys()), + credential_type=dict(), + inputs=dict(type='dict'), host=dict(), username=dict(), password=dict(no_log=True), @@ -270,7 +304,14 @@ def main(): vault_id=dict(), ) - module = TowerModule(argument_spec=argument_spec, supports_check_mode=True) + mutually_exclusive = [ + ('kind', 'credential_type') + ] + for input_name in OLD_INPUT_NAMES: + mutually_exclusive.append(('inputs', input_name)) + + module = TowerModule(argument_spec=argument_spec, supports_check_mode=True, + mutually_exclusive=mutually_exclusive) name = module.params.get('name') organization = module.params.get('organization') @@ -298,10 +339,26 @@ def main(): # /api/v1/ backwards compat # older versions of tower-cli don't *have* a credential_type # resource - params['kind'] = module.params['kind'] + params['kind'] = module.params.get('kind') else: - credential_type = credential_type_for_v1_kind(module.params, module) - params['credential_type'] = credential_type['id'] + if module.params.get('credential_type'): + credential_type_res = tower_cli.get_resource('credential_type') + try: + credential_type = credential_type_res.get(name=module.params['credential_type']) + except (exc.NotFound) as excinfo: + module.fail_json(msg=( + 'Failed to update credential, credential_type not found: {0}' + ).format(excinfo), changed=False) + params['credential_type'] = credential_type['id'] + + if module.params.get('inputs'): + params['inputs'] = module.params.get('inputs') + + elif module.params.get('kind'): + credential_type = credential_type_for_kind(module.params) + params['credential_type'] = credential_type['id'] + else: + module.fail_json(msg='must either specify credential_type or kind', changed=False) if module.params.get('description'): params['description'] = module.params.get('description') @@ -333,12 +390,7 @@ def main(): if module.params.get('vault_id', None) and module.params.get('kind') != 'vault': module.fail_json(msg="Parameter 'vault_id' is only valid if parameter 'kind' is specified as 'vault'") - for key in ('authorize', 'authorize_password', 'client', - 'security_token', 'secret', 'tenant', 'subscription', - 'domain', 'become_method', 'become_username', - 'become_password', 'vault_password', 'project', 'host', - 'username', 'password', 'ssh_key_data', 'vault_id', - 'ssh_key_unlock'): + for key in OLD_INPUT_NAMES: if 'kind' in params: params[key] = module.params.get(key) elif module.params.get(key): diff --git a/awx_collection/test/awx/conftest.py b/awx_collection/test/awx/conftest.py index bdaa0db3bf..3cf7295246 100644 --- a/awx_collection/test/awx/conftest.py +++ b/awx_collection/test/awx/conftest.py @@ -62,6 +62,9 @@ def run_module(): # We should consider supporting that in the future resource_module = importlib.import_module('plugins.modules.{}'.format(module_name)) + if not isinstance(module_params, dict): + raise RuntimeError('Module params must be dict, got {}'.format(type(module_params))) + # Ansible params can be passed as an invocation argument or over stdin # this short circuits within the AnsibleModule interface def mock_load_params(self): diff --git a/awx_collection/test/awx/test_credential.py b/awx_collection/test/awx/test_credential.py new file mode 100644 index 0000000000..12b4935ff7 --- /dev/null +++ b/awx_collection/test/awx/test_credential.py @@ -0,0 +1,151 @@ +import pytest + +from awx.main.models import Credential, CredentialType, Organization + + +@pytest.mark.django_db +def test_create_machine_credential(run_module, admin_user): + Organization.objects.create(name='test-org') + # create the ssh credential type + CredentialType.defaults['ssh']().save() + # Example from docs + result = run_module('tower_credential', dict( + name='Team Name', + description='Team Description', + organization='test-org', + kind='ssh', + state='present' + ), admin_user) + + cred = Credential.objects.get(name='Team Name') + result.pop('invocation') + assert result == { + "credential": "Team Name", + "state": "present", + "id": cred.pk, + "changed": True + } + + +@pytest.mark.django_db +def test_create_custom_credential_type(run_module, admin_user): + # Example from docs + result = run_module('tower_credential_type', dict( + name='Nexus', + description='Credentials type for Nexus', + kind='cloud', + inputs={"fields": [{"id": "server", "type": "string", "default": "", "label": ""}], "required": []}, + injectors={'extra_vars': {'nexus_credential': 'test'}}, + state='present', + validate_certs='false' + ), admin_user) + + ct = CredentialType.objects.get(name='Nexus') + result.pop('invocation') + assert result == { + "credential_type": "Nexus", + "state": "present", + "id": ct.pk, + "changed": True + } + + assert ct.inputs == {"fields": [{"id": "server", "type": "string", "default": "", "label": ""}], "required": []} + assert ct.injectors == {'extra_vars': {'nexus_credential': 'test'}} + + +@pytest.mark.django_db +def test_kind_ct_exclusivity(run_module, admin_user): + result = run_module('tower_credential', dict( + name='A credential', + organization='test-org', + kind='ssh', + credential_type='foobar', # cannot specify if kind is also specified + state='present' + ), admin_user) + + result.pop('invocation') + assert result == { + 'failed': True, + 'msg': 'parameters are mutually exclusive: kind|credential_type' + } + + +@pytest.mark.django_db +def test_input_exclusivity(run_module, admin_user): + result = run_module('tower_credential', dict( + name='A credential', + organization='test-org', + kind='ssh', + inputs={'token': '7rEZK38DJl58A7RxA6EC7lLvUHbBQ1'}, + security_token='7rEZK38DJl58A7RxA6EC7lLvUHbBQ1', + state='present' + ), admin_user) + + result.pop('invocation') + assert result == { + 'failed': True, + 'msg': 'parameters are mutually exclusive: inputs|security_token' + } + + +@pytest.mark.django_db +def test_missing_credential_type(run_module, admin_user): + Organization.objects.create(name='test-org') + result = run_module('tower_credential', dict( + name='A credential', + organization='test-org', + credential_type='foobar', + state='present' + ), admin_user) + + result.pop('invocation') + assert result == { + "changed": False, + "failed": True, + 'msg': 'Failed to update credential, credential_type not found: The requested object could not be found.' + } + + +@pytest.mark.django_db +def test_make_use_of_custom_credential_type(run_module, admin_user): + Organization.objects.create(name='test-org') + # Make a credential type which will be used by the credential + ct = CredentialType.objects.create( + name='Ansible Galaxy Token', + inputs={ + "fields": [ + { + "id": "token", + "type": "string", + "secret": True, + "label": "Ansible Galaxy Secret Token Value" + } + ], + "required": ["token"] + }, + injectors={ + "extra_vars": { + "galaxy_token": "{{token}}", + } + } + ) + result = run_module('tower_credential', dict( + name='Galaxy Token for Steve', + organization='test-org', + credential_type='Ansible Galaxy Token', + inputs={'token': '7rEZK38DJl58A7RxA6EC7lLvUHbBQ1'}, + state='present' + ), admin_user) + + cred = Credential.objects.get(name='Galaxy Token for Steve') + assert cred.credential_type_id == ct.id + assert list(cred.inputs.keys()) == ['token'] + assert cred.inputs['token'].startswith('$encrypted$') + assert len(cred.inputs['token']) >= len('$encrypted$') + len('7rEZK38DJl58A7RxA6EC7lLvUHbBQ1') + result.pop('invocation') + assert result == { + "credential": "Galaxy Token for Steve", + "state": "present", + "id": cred.pk, + "changed": True + }