diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index a6d64f5defd..5343e9894e3 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1207,6 +1207,8 @@ files: $plugins/lookup/onepassword: maintainers: samdoran ignored: azenk + $plugins/lookup/manifold: + maintainers: galanoff ############################### # plugins/netconf $plugins/netconf/: diff --git a/lib/ansible/plugins/lookup/manifold.py b/lib/ansible/plugins/lookup/manifold.py new file mode 100644 index 00000000000..9d6e78a73ca --- /dev/null +++ b/lib/ansible/plugins/lookup/manifold.py @@ -0,0 +1,276 @@ +# (c) 2018, Arigato Machine Inc. +# (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + author: + - Kyrylo Galanov (galanoff@gmail.com) + lookup: manifold + version_added: "2.8" + short_description: get credentials from Manifold.co + description: + - Retrieves resources' credentials from Manifold.co + options: + _terms: + description: + - Optional list of resource labels to lookup on Manifold.co. If no resources are specified, all + matched resources will be returned. + type: list + elements: string + required: False + api_token: + description: + - manifold API token + type: string + required: True + env: + - name: MANIFOLD_API_TOKEN + project: + description: + - The project label you want to get the resource for. + type: string + required: False + team: + description: + - The team label you want to get the resource for. + type: string + required: False +''' + +EXAMPLES = ''' + - name: all available resources + debug: msg="{{ lookup('manifold', api_token='SecretToken') }}" + - name: all available resources for a specific project in specific team + debug: msg="{{ lookup('manifold', api_token='SecretToken', project='poject-1', team='team-2') }}" + - name: two specific resources + debug: msg="{{ lookup('manifold', 'resource-1', 'resource-2') }}" +''' + +RETURN = ''' + _raw: + description: + - dictionary of credentials ready to be consumed as environment variables. If multiple resources define + the same environment variable(s), the last one returned by the Manifold API will take precedence. + type: dict +''' +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase +from ansible.module_utils.urls import open_url, ConnectionError, SSLValidationError +from ansible.module_utils.six.moves.urllib.error import HTTPError, URLError +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.module_utils import six +from ansible.utils.display import Display +from traceback import format_exception +import json +import sys +import os + +display = Display() + + +class ApiError(Exception): + pass + + +class ManifoldApiClient(object): + base_url = 'https://api.{api}.manifold.co/v1/{endpoint}' + http_agent = 'python-manifold-ansible-1.0.0' + + def __init__(self, token): + self._token = token + + def request(self, api, endpoint, *args, **kwargs): + """ + Send a request to API backend and pre-process a response. + :param api: API to send a request to + :type api: str + :param endpoint: API endpoint to fetch data from + :type endpoint: str + :param args: other args for open_url + :param kwargs: other kwargs for open_url + :return: server response. JSON response is automatically deserialized. + :rtype: dict | list | str + """ + + default_headers = { + 'Authorization': "Bearer {0}".format(self._token), + 'Accept': "*/*" # Otherwise server doesn't set content-type header + } + + url = self.base_url.format(api=api, endpoint=endpoint) + + headers = default_headers + arg_headers = kwargs.pop('headers', None) + if arg_headers: + headers.update(arg_headers) + + try: + display.vvvv('manifold lookup connecting to {0}'.format(url)) + response = open_url(url, headers=headers, http_agent=self.http_agent, *args, **kwargs) + data = response.read() + if response.headers.get('content-type') == 'application/json': + data = json.loads(data) + return data + except ValueError: + raise ApiError('JSON response can\'t be parsed while requesting {url}:\n{json}'.format(json=data, url=url)) + except HTTPError as e: + raise ApiError('Server returned: {err} while requesting {url}:\n{response}'.format( + err=str(e), url=url, response=e.read())) + except URLError as e: + raise ApiError('Failed lookup url for {url} : {err}'.format(url=url, err=str(e))) + except SSLValidationError as e: + raise ApiError('Error validating the server\'s certificate for {url}: {err}'.format(url=url, err=str(e))) + except ConnectionError as e: + raise ApiError('Error connecting to {url}: {err}'.format(url=url, err=str(e))) + + def get_resources(self, team_id=None, project_id=None, label=None): + """ + Get resources list + :param team_id: ID of the Team to filter resources by + :type team_id: str + :param project_id: ID of the project to filter resources by + :type project_id: str + :param label: filter resources by a label, returns a list with one or zero elements + :type label: str + :return: list of resources + :rtype: list + """ + api = 'marketplace' + endpoint = 'resources' + query_params = {} + + if team_id: + query_params['team_id'] = team_id + if project_id: + query_params['project_id'] = project_id + if label: + query_params['label'] = label + + if query_params: + endpoint += '?' + urlencode(query_params) + + return self.request(api, endpoint) + + def get_teams(self, label=None): + """ + Get teams list + :param label: filter teams by a label, returns a list with one or zero elements + :type label: str + :return: list of teams + :rtype: list + """ + api = 'identity' + endpoint = 'teams' + data = self.request(api, endpoint) + # Label filtering is not supported by API, however this function provides uniform interface + if label: + data = list(filter(lambda x: x['body']['label'] == label, data)) + return data + + def get_projects(self, label=None): + """ + Get projects list + :param label: filter projects by a label, returns a list with one or zero elements + :type label: str + :return: list of projects + :rtype: list + """ + api = 'marketplace' + endpoint = 'projects' + query_params = {} + + if label: + query_params['label'] = label + + if query_params: + endpoint += '?' + urlencode(query_params) + + return self.request(api, endpoint) + + def get_credentials(self, resource_id): + """ + Get resource credentials + :param resource_id: ID of the resource to filter credentials by + :type resource_id: str + :return: + """ + api = 'marketplace' + endpoint = 'credentials?' + urlencode({'resource_id': resource_id}) + return self.request(api, endpoint) + + +class LookupModule(LookupBase): + + def run(self, terms, variables=None, api_token=None, project=None, team=None): + """ + :param terms: a list of resources lookups to run. + :param variables: ansible variables active at the time of the lookup + :param api_token: API token + :param project: optional project label + :param team: optional team label + :return: a dictionary of resources credentials + """ + + if not api_token: + api_token = os.getenv('MANIFOLD_API_TOKEN') + if not api_token: + raise AnsibleError('API token is required. Please set api_token parameter or MANIFOLD_API_TOKEN env var') + + try: + labels = terms + client = ManifoldApiClient(api_token) + + if team: + team_data = client.get_teams(team) + if len(team_data) == 0: + raise AnsibleError("Team '{0}' does not exist".format(team)) + team_id = team_data[0]['id'] + else: + team_id = None + + if project: + project_data = client.get_projects(project) + if len(project_data) == 0: + raise AnsibleError("Project '{0}' does not exist".format(project)) + project_id = project_data[0]['id'] + else: + project_id = None + + if len(labels) == 1: # Use server-side filtering if one resource is requested + resources_data = client.get_resources(team_id=team_id, project_id=project_id, label=labels[0]) + else: # Get all resources and optionally filter labels + resources_data = client.get_resources(team_id=team_id, project_id=project_id) + if labels: + resources_data = list(filter(lambda x: x['body']['label'] in labels, resources_data)) + + if labels and len(resources_data) < len(labels): + fetched_labels = [r['body']['label'] for r in resources_data] + not_found_labels = [label for label in labels if label not in fetched_labels] + raise AnsibleError("Resource(s) {0} do not exist".format(', '.join(not_found_labels))) + + credentials = {} + cred_map = {} + for resource in resources_data: + resource_credentials = client.get_credentials(resource['id']) + if len(resource_credentials) and resource_credentials[0]['body']['values']: + for cred_key, cred_val in six.iteritems(resource_credentials[0]['body']['values']): + label = resource['body']['label'] + if cred_key in credentials: + display.warning("'{cred_key}' with label '{old_label}' was replaced by resource data " + "with label '{new_label}'".format(cred_key=cred_key, + old_label=cred_map[cred_key], + new_label=label)) + credentials[cred_key] = cred_val + cred_map[cred_key] = label + + ret = [credentials] + return ret + except ApiError as e: + raise AnsibleError('API Error: {0}'.format(str(e))) + except AnsibleError as e: + raise e + except Exception: + exc_type, exc_value, exc_traceback = sys.exc_info() + raise AnsibleError(format_exception(exc_type, exc_value, exc_traceback)) diff --git a/test/units/plugins/lookup/test_manifold.py b/test/units/plugins/lookup/test_manifold.py new file mode 100644 index 00000000000..1cc1b68967a --- /dev/null +++ b/test/units/plugins/lookup/test_manifold.py @@ -0,0 +1,536 @@ +# (c) 2018, Arigato Machine Inc. +# (c) 2018, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from units.compat import unittest +from units.compat.mock import patch, call +from ansible.errors import AnsibleError +from ansible.module_utils.urls import ConnectionError, SSLValidationError +from ansible.module_utils.six.moves.urllib.error import HTTPError, URLError +from ansible.module_utils import six +from ansible.plugins.lookup.manifold import ManifoldApiClient, LookupModule, ApiError +import json + + +API_FIXTURES = { + 'https://api.marketplace.manifold.co/v1/resources': + [ + { + "body": { + "label": "resource-1", + "name": "Resource 1" + }, + "id": "rid-1" + }, + { + "body": { + "label": "resource-2", + "name": "Resource 2" + }, + "id": "rid-2" + } + ], + 'https://api.marketplace.manifold.co/v1/resources?label=resource-1': + [ + { + "body": { + "label": "resource-1", + "name": "Resource 1" + }, + "id": "rid-1" + } + ], + 'https://api.marketplace.manifold.co/v1/resources?label=resource-2': + [ + { + "body": { + "label": "resource-2", + "name": "Resource 2" + }, + "id": "rid-2" + } + ], + 'https://api.marketplace.manifold.co/v1/resources?team_id=tid-1': + [ + { + "body": { + "label": "resource-1", + "name": "Resource 1" + }, + "id": "rid-1" + } + ], + 'https://api.marketplace.manifold.co/v1/resources?project_id=pid-1': + [ + { + "body": { + "label": "resource-2", + "name": "Resource 2" + }, + "id": "rid-2" + } + ], + 'https://api.marketplace.manifold.co/v1/resources?project_id=pid-2': + [ + { + "body": { + "label": "resource-1", + "name": "Resource 1" + }, + "id": "rid-1" + }, + { + "body": { + "label": "resource-3", + "name": "Resource 3" + }, + "id": "rid-3" + } + ], + 'https://api.marketplace.manifold.co/v1/resources?team_id=tid-1&project_id=pid-1': + [ + { + "body": { + "label": "resource-1", + "name": "Resource 1" + }, + "id": "rid-1" + } + ], + 'https://api.marketplace.manifold.co/v1/projects': + [ + { + "body": { + "label": "project-1", + "name": "Project 1", + }, + "id": "pid-1", + }, + { + "body": { + "label": "project-2", + "name": "Project 2", + }, + "id": "pid-2", + } + ], + 'https://api.marketplace.manifold.co/v1/projects?label=project-2': + [ + { + "body": { + "label": "project-2", + "name": "Project 2", + }, + "id": "pid-2", + } + ], + 'https://api.marketplace.manifold.co/v1/credentials?resource_id=rid-1': + [ + { + "body": { + "resource_id": "rid-1", + "values": { + "RESOURCE_TOKEN_1": "token-1", + "RESOURCE_TOKEN_2": "token-2" + } + }, + "id": "cid-1", + } + ], + 'https://api.marketplace.manifold.co/v1/credentials?resource_id=rid-2': + [ + { + "body": { + "resource_id": "rid-2", + "values": { + "RESOURCE_TOKEN_3": "token-3", + "RESOURCE_TOKEN_4": "token-4" + } + }, + "id": "cid-2", + } + ], + 'https://api.marketplace.manifold.co/v1/credentials?resource_id=rid-3': + [ + { + "body": { + "resource_id": "rid-3", + "values": { + "RESOURCE_TOKEN_1": "token-5", + "RESOURCE_TOKEN_2": "token-6" + } + }, + "id": "cid-3", + } + ], + 'https://api.identity.manifold.co/v1/teams': + [ + { + "id": "tid-1", + "body": { + "name": "Team 1", + "label": "team-1" + } + }, + { + "id": "tid-2", + "body": { + "name": "Team 2", + "label": "team-2" + } + } + ] +} + + +def mock_fixture(open_url_mock, fixture=None, data=None, headers=None): + if not headers: + headers = {} + if fixture: + data = json.dumps(API_FIXTURES[fixture]) + if 'content-type' not in headers: + headers['content-type'] = 'application/json' + + open_url_mock.return_value.read.return_value = data + open_url_mock.return_value.headers = headers + + +class TestManifoldApiClient(unittest.TestCase): + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_sends_default_headers(self, open_url_mock): + mock_fixture(open_url_mock, data='hello') + client = ManifoldApiClient('token-123') + client.request('test', 'endpoint') + open_url_mock.assert_called_with('https://api.test.manifold.co/v1/endpoint', + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0') + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_decodes_json(self, open_url_mock): + mock_fixture(open_url_mock, fixture='https://api.marketplace.manifold.co/v1/resources') + client = ManifoldApiClient('token-123') + self.assertIsInstance(client.request('marketplace', 'resources'), list) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_streams_text(self, open_url_mock): + mock_fixture(open_url_mock, data='hello', headers={'content-type': "text/plain"}) + client = ManifoldApiClient('token-123') + self.assertEqual('hello', client.request('test', 'endpoint')) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_processes_parameterized_headers(self, open_url_mock): + mock_fixture(open_url_mock, data='hello') + client = ManifoldApiClient('token-123') + client.request('test', 'endpoint', headers={'X-HEADER': 'MANIFOLD'}) + open_url_mock.assert_called_with('https://api.test.manifold.co/v1/endpoint', + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123', + 'X-HEADER': 'MANIFOLD'}, + http_agent='python-manifold-ansible-1.0.0') + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_passes_arbitrary_parameters(self, open_url_mock): + mock_fixture(open_url_mock, data='hello') + client = ManifoldApiClient('token-123') + client.request('test', 'endpoint', use_proxy=False, timeout=5) + open_url_mock.assert_called_with('https://api.test.manifold.co/v1/endpoint', + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0', + use_proxy=False, timeout=5) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_raises_on_incorrect_json(self, open_url_mock): + mock_fixture(open_url_mock, data='noJson', headers={'content-type': "application/json"}) + client = ManifoldApiClient('token-123') + with self.assertRaises(ApiError) as context: + client.request('test', 'endpoint') + self.assertEqual('JSON response can\'t be parsed while requesting https://api.test.manifold.co/v1/endpoint:\n' + 'noJson', + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_raises_on_status_500(self, open_url_mock): + open_url_mock.side_effect = HTTPError('https://api.test.manifold.co/v1/endpoint', + 500, 'Server error', {}, six.StringIO('ERROR')) + client = ManifoldApiClient('token-123') + with self.assertRaises(ApiError) as context: + client.request('test', 'endpoint') + self.assertEqual('Server returned: HTTP Error 500: Server error while requesting ' + 'https://api.test.manifold.co/v1/endpoint:\nERROR', + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_raises_on_bad_url(self, open_url_mock): + open_url_mock.side_effect = URLError('URL is invalid') + client = ManifoldApiClient('token-123') + with self.assertRaises(ApiError) as context: + client.request('test', 'endpoint') + self.assertEqual('Failed lookup url for https://api.test.manifold.co/v1/endpoint : ', + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_raises_on_ssl_error(self, open_url_mock): + open_url_mock.side_effect = SSLValidationError('SSL Error') + client = ManifoldApiClient('token-123') + with self.assertRaises(ApiError) as context: + client.request('test', 'endpoint') + self.assertEqual('Error validating the server\'s certificate for https://api.test.manifold.co/v1/endpoint: ' + 'SSL Error', + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_request_raises_on_connection_error(self, open_url_mock): + open_url_mock.side_effect = ConnectionError('Unknown connection error') + client = ManifoldApiClient('token-123') + with self.assertRaises(ApiError) as context: + client.request('test', 'endpoint') + self.assertEqual('Error connecting to https://api.test.manifold.co/v1/endpoint: Unknown connection error', + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_get_resources_get_all(self, open_url_mock): + url = 'https://api.marketplace.manifold.co/v1/resources' + mock_fixture(open_url_mock, fixture=url) + client = ManifoldApiClient('token-123') + self.assertListEqual(API_FIXTURES[url], client.get_resources()) + open_url_mock.assert_called_with(url, + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0') + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_get_resources_filter_label(self, open_url_mock): + url = 'https://api.marketplace.manifold.co/v1/resources?label=resource-1' + mock_fixture(open_url_mock, fixture=url) + client = ManifoldApiClient('token-123') + self.assertListEqual(API_FIXTURES[url], client.get_resources(label='resource-1')) + open_url_mock.assert_called_with(url, + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0') + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_get_resources_filter_team_and_project(self, open_url_mock): + url = 'https://api.marketplace.manifold.co/v1/resources?team_id=tid-1&project_id=pid-1' + mock_fixture(open_url_mock, fixture=url) + client = ManifoldApiClient('token-123') + self.assertListEqual(API_FIXTURES[url], client.get_resources(team_id='tid-1', project_id='pid-1')) + args, kwargs = open_url_mock.call_args + url_called = args[0] + # Dict order is not guaranteed, so an url may have querystring parameters order randomized + self.assertIn('team_id=tid-1', url_called) + self.assertIn('project_id=pid-1', url_called) + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_get_teams_get_all(self, open_url_mock): + url = 'https://api.identity.manifold.co/v1/teams' + mock_fixture(open_url_mock, fixture=url) + client = ManifoldApiClient('token-123') + self.assertListEqual(API_FIXTURES[url], client.get_teams()) + open_url_mock.assert_called_with(url, + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0') + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_get_teams_filter_label(self, open_url_mock): + url = 'https://api.identity.manifold.co/v1/teams' + mock_fixture(open_url_mock, fixture=url) + client = ManifoldApiClient('token-123') + self.assertListEqual(API_FIXTURES[url][1:2], client.get_teams(label='team-2')) + open_url_mock.assert_called_with(url, + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0') + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_get_projects_get_all(self, open_url_mock): + url = 'https://api.marketplace.manifold.co/v1/projects' + mock_fixture(open_url_mock, fixture=url) + client = ManifoldApiClient('token-123') + self.assertListEqual(API_FIXTURES[url], client.get_projects()) + open_url_mock.assert_called_with(url, + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0') + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_get_projects_filter_label(self, open_url_mock): + url = 'https://api.marketplace.manifold.co/v1/projects?label=project-2' + mock_fixture(open_url_mock, fixture=url) + client = ManifoldApiClient('token-123') + self.assertListEqual(API_FIXTURES[url], client.get_projects(label='project-2')) + open_url_mock.assert_called_with(url, + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0') + + @patch('ansible.plugins.lookup.manifold.open_url') + def test_get_credentials(self, open_url_mock): + url = 'https://api.marketplace.manifold.co/v1/credentials?resource_id=rid-1' + mock_fixture(open_url_mock, fixture=url) + client = ManifoldApiClient('token-123') + self.assertListEqual(API_FIXTURES[url], client.get_credentials(resource_id='rid-1')) + open_url_mock.assert_called_with(url, + headers={'Accept': '*/*', 'Authorization': 'Bearer token-123'}, + http_agent='python-manifold-ansible-1.0.0') + + +class TestLookupModule(unittest.TestCase): + def setUp(self): + self.lookup = LookupModule() + self.lookup._load_name = "manifold" + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_get_all(self, client_mock): + expected_result = [{'RESOURCE_TOKEN_1': 'token-1', + 'RESOURCE_TOKEN_2': 'token-2', + 'RESOURCE_TOKEN_3': 'token-3', + 'RESOURCE_TOKEN_4': 'token-4' + }] + client_mock.return_value.get_resources.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/resources'] + client_mock.return_value.get_credentials.side_effect = lambda x: API_FIXTURES['https://api.marketplace.manifold.co/v1/' + 'credentials?resource_id={0}'.format(x)] + self.assertListEqual(expected_result, self.lookup.run([], api_token='token-123')) + client_mock.assert_called_with('token-123') + client_mock.return_value.get_resources.assert_called_with(team_id=None, project_id=None) + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_get_one_resource(self, client_mock): + expected_result = [{'RESOURCE_TOKEN_3': 'token-3', + 'RESOURCE_TOKEN_4': 'token-4' + }] + client_mock.return_value.get_resources.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/resources?label=resource-2'] + client_mock.return_value.get_credentials.side_effect = lambda x: API_FIXTURES['https://api.marketplace.manifold.co/v1/' + 'credentials?resource_id={0}'.format(x)] + self.assertListEqual(expected_result, self.lookup.run(['resource-2'], api_token='token-123')) + client_mock.return_value.get_resources.assert_called_with(team_id=None, project_id=None, label='resource-2') + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_get_two_resources(self, client_mock): + expected_result = [{'RESOURCE_TOKEN_1': 'token-1', + 'RESOURCE_TOKEN_2': 'token-2', + 'RESOURCE_TOKEN_3': 'token-3', + 'RESOURCE_TOKEN_4': 'token-4' + }] + client_mock.return_value.get_resources.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/resources'] + client_mock.return_value.get_credentials.side_effect = lambda x: API_FIXTURES['https://api.marketplace.manifold.co/v1/' + 'credentials?resource_id={0}'.format(x)] + self.assertListEqual(expected_result, self.lookup.run(['resource-1', 'resource-2'], api_token='token-123')) + client_mock.assert_called_with('token-123') + client_mock.return_value.get_resources.assert_called_with(team_id=None, project_id=None) + + @patch('ansible.plugins.lookup.manifold.display') + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_get_resources_with_same_credential_names(self, client_mock, display_mock): + expected_result = [{'RESOURCE_TOKEN_1': 'token-5', + 'RESOURCE_TOKEN_2': 'token-6' + }] + client_mock.return_value.get_resources.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/resources?project_id=pid-2'] + client_mock.return_value.get_projects.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/projects?label=project-2'] + client_mock.return_value.get_credentials.side_effect = lambda x: API_FIXTURES['https://api.marketplace.manifold.co/v1/' + 'credentials?resource_id={0}'.format(x)] + self.assertListEqual(expected_result, self.lookup.run([], api_token='token-123', project='project-2')) + client_mock.assert_called_with('token-123') + display_mock.warning.assert_has_calls([ + call("'RESOURCE_TOKEN_1' with label 'resource-1' was replaced by resource data with label 'resource-3'"), + call("'RESOURCE_TOKEN_2' with label 'resource-1' was replaced by resource data with label 'resource-3'")], + any_order=True + ) + client_mock.return_value.get_resources.assert_called_with(team_id=None, project_id='pid-2') + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_filter_by_team(self, client_mock): + expected_result = [{'RESOURCE_TOKEN_1': 'token-1', + 'RESOURCE_TOKEN_2': 'token-2' + }] + client_mock.return_value.get_resources.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/resources?team_id=tid-1'] + client_mock.return_value.get_teams.return_value = API_FIXTURES['https://api.identity.manifold.co/v1/teams'][0:1] + client_mock.return_value.get_credentials.side_effect = lambda x: API_FIXTURES['https://api.marketplace.manifold.co/v1/' + 'credentials?resource_id={0}'.format(x)] + self.assertListEqual(expected_result, self.lookup.run([], api_token='token-123', team='team-1')) + client_mock.assert_called_with('token-123') + client_mock.return_value.get_resources.assert_called_with(team_id='tid-1', project_id=None) + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_filter_by_project(self, client_mock): + expected_result = [{'RESOURCE_TOKEN_3': 'token-3', + 'RESOURCE_TOKEN_4': 'token-4' + }] + client_mock.return_value.get_resources.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/resources?project_id=pid-1'] + client_mock.return_value.get_projects.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/projects'][0:1] + client_mock.return_value.get_credentials.side_effect = lambda x: API_FIXTURES['https://api.marketplace.manifold.co/v1/' + 'credentials?resource_id={0}'.format(x)] + self.assertListEqual(expected_result, self.lookup.run([], api_token='token-123', project='project-1')) + client_mock.assert_called_with('token-123') + client_mock.return_value.get_resources.assert_called_with(team_id=None, project_id='pid-1') + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_filter_by_team_and_project(self, client_mock): + expected_result = [{'RESOURCE_TOKEN_1': 'token-1', + 'RESOURCE_TOKEN_2': 'token-2' + }] + client_mock.return_value.get_resources.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/resources?team_id=tid-1&project_id=pid-1'] + client_mock.return_value.get_teams.return_value = API_FIXTURES['https://api.identity.manifold.co/v1/teams'][0:1] + client_mock.return_value.get_projects.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/projects'][0:1] + client_mock.return_value.get_credentials.side_effect = lambda x: API_FIXTURES['https://api.marketplace.manifold.co/v1/' + 'credentials?resource_id={0}'.format(x)] + self.assertListEqual(expected_result, self.lookup.run([], api_token='token-123', project='project-1')) + client_mock.assert_called_with('token-123') + client_mock.return_value.get_resources.assert_called_with(team_id=None, project_id='pid-1') + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_raise_team_doesnt_exist(self, client_mock): + client_mock.return_value.get_teams.return_value = [] + with self.assertRaises(AnsibleError) as context: + self.lookup.run([], api_token='token-123', team='no-team') + self.assertEqual("Team 'no-team' does not exist", + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_raise_project_doesnt_exist(self, client_mock): + client_mock.return_value.get_projects.return_value = [] + with self.assertRaises(AnsibleError) as context: + self.lookup.run([], api_token='token-123', project='no-project') + self.assertEqual("Project 'no-project' does not exist", + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_raise_resource_doesnt_exist(self, client_mock): + client_mock.return_value.get_resources.return_value = API_FIXTURES['https://api.marketplace.manifold.co/v1/resources'] + with self.assertRaises(AnsibleError) as context: + self.lookup.run(['resource-1', 'no-resource-1', 'no-resource-2'], api_token='token-123') + self.assertEqual("Resource(s) no-resource-1, no-resource-2 do not exist", + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_catch_api_error(self, client_mock): + client_mock.side_effect = ApiError('Generic error') + with self.assertRaises(AnsibleError) as context: + self.lookup.run([], api_token='token-123') + self.assertEqual("API Error: Generic error", + str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_catch_unhandled_exception(self, client_mock): + client_mock.side_effect = Exception('Unknown error') + with self.assertRaises(AnsibleError) as context: + self.lookup.run([], api_token='token-123') + self.assertTrue('Exception: Unknown error' in str(context.exception)) + + @patch('ansible.plugins.lookup.manifold.os.getenv') + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_falls_back_to_env_var(self, client_mock, getenv_mock): + getenv_mock.return_value = 'token-321' + client_mock.return_value.get_resources.return_value = [] + client_mock.return_value.get_credentials.return_value = [] + self.lookup.run([]) + getenv_mock.assert_called_with('MANIFOLD_API_TOKEN') + client_mock.assert_called_with('token-321') + + @patch('ansible.plugins.lookup.manifold.os.getenv') + @patch('ansible.plugins.lookup.manifold.ManifoldApiClient') + def test_falls_raises_on_no_token(self, client_mock, getenv_mock): + getenv_mock.return_value = None + client_mock.return_value.get_resources.return_value = [] + client_mock.return_value.get_credentials.return_value = [] + with self.assertRaises(AnsibleError) as context: + self.lookup.run([]) + self.assertEqual('API token is required. Please set api_token parameter or MANIFOLD_API_TOKEN env var', + str(context.exception))