diff --git a/apypie/__init__.py b/apypie/__init__.py index df0302f..55b6cf0 100644 --- a/apypie/__init__.py +++ b/apypie/__init__.py @@ -11,5 +11,6 @@ from apypie.example import Example from apypie.param import Param from apypie.inflector import Inflector +from apypie.foreman import ForemanApi, ForemanApiException -__all__ = ['Api', 'Resource', 'Route', 'Action', 'Example', 'Param', 'Inflector'] +__all__ = ['Api', 'Resource', 'Route', 'Action', 'Example', 'Param', 'Inflector', 'ForemanApi', 'ForemanApiException'] diff --git a/apypie/foreman.py b/apypie/foreman.py new file mode 100644 index 0000000..696c6b0 --- /dev/null +++ b/apypie/foreman.py @@ -0,0 +1,239 @@ +""" +Apypie Foreman module + +opinionated helpers to use Apypie with Foreman +""" +import time + +from apypie.api import Api + +PER_PAGE = 2 << 31 + + +class ForemanApiException(Exception): + """ + General Exception, raised by any issue in ForemanApi + """ + + def __init__(self, msg, error=None): + self.msg = msg + self.error = error + super().__init__() + + def __repr__(self): + return str(self) + + def __str__(self): + string = f'{self.__class__.__name__}: {self.msg}' + if self.error: + string += f' - {self.error}' + return string + + @classmethod + def from_exception(cls, exc, msg): + """ + Create a ForemanException from any other Exception + + Especially useful to gather the error message from HTTP responses. + """ + error = None + if hasattr(exc, 'response') and exc.response is not None: + try: + response = exc.response.json() + if 'error' in response: + error = response['error'] + else: + error = response + except Exception: # pylint: disable=broad-except + error = exc.response.text + return cls(msg=msg, error=error) + + +class ForemanApi(Api): + """ + apypie.Api with default settings and helper functions for Foreman + """ + + def __init__(self, **kwargs): + self.task_timeout = kwargs.pop('task_timeout', 60) + self.task_poll = 4 + kwargs['api_version'] = 2 + super().__init__(**kwargs) + + def _resource(self, resource): + if resource not in self.resources: + raise ForemanApiException(msg="The server doesn't know about {0}, is the right plugin installed?".format(resource)) + return self.resource(resource) + + def _resource_call(self, resource, *args, **kwargs): + return self._resource(resource).call(*args, **kwargs) + + def _resource_prepare_params(self, resource, action, params): + api_action = self._resource(resource).action(action) + return api_action.prepare_params(params) + + def resource_action(self, resource, action, params, options=None, data=None, files=None, # pylint: disable=too-many-arguments + ignore_task_errors=False): + """ + Perform a generic action on a resource + + Will wait for tasks if the action returns one + """ + resource_payload = self._resource_prepare_params(resource, action, params) + if options is None: + options = {} + try: + result = self._resource_call(resource, action, resource_payload, options=options, data=data, files=files) + is_foreman_task = isinstance(result, dict) and 'action' in result and 'state' in result and 'started_at' in result + if is_foreman_task: + result = self.wait_for_task(result, ignore_errors=ignore_task_errors) + except Exception as exc: + msg = 'Error while performing {0} on {1}: {2}'.format( + action, resource, str(exc)) + raise ForemanApiException.from_exception(exc, msg) from exc + return result + + def wait_for_task(self, task, ignore_errors=False): + """ + Wait for a foreman-tasks task, polling it every ``self.task_poll`` seconds. + + Will raise a ForemanApiException when task has not finished in ``self.task_timeout`` seconds. + """ + duration = self.task_timeout + while task['state'] not in ['paused', 'stopped']: + duration -= self.task_poll + if duration <= 0: + raise ForemanApiException(msg="Timeout waiting for Task {0}".format(task['id'])) + time.sleep(self.task_poll) + + resource_payload = self._resource_prepare_params('foreman_tasks', 'show', {'id': task['id']}) + task = self._resource_call('foreman_tasks', 'show', resource_payload) + if not ignore_errors and task['result'] != 'success': + msg = 'Task {0}({1}) did not succeed. Task information: {2}'.format(task['action'], task['id'], task['humanized']['errors']) + raise ForemanApiException(msg=msg) + return task + + def show(self, resource, resource_id, params=None): + """ + Execute the ``show`` action on an entity. + + :param resource: Plural name of the api resource to show + :type resource: str + :param resource_id: The ID of the entity to show + :type resource_id: int + :param params: Lookup parameters (i.e. parent_id for nested entities) + :type params: Union[dict,None], optional + """ + payload = {'id': resource_id} + if params: + payload.update(params) + return self.resource_action(resource, 'show', payload) + + def list(self, resource, search=None, params=None): + """ + Execute the ``index`` action on an resource. + + :param resource: Plural name of the api resource to show + :type resource: str + :param search: Search string as accepted by the API to limit the results + :type search: str, optional + :param params: Lookup parameters (i.e. parent_id for nested entities) + :type params: Union[dict,None], optional + """ + payload = {'per_page': PER_PAGE} + if search is not None: + payload['search'] = search + if params: + payload.update(params) + + return self.resource_action(resource, 'index', payload)['results'] + + def create(self, resource, desired_entity, params=None): + """ + Create entity with given properties + + :param resource: Plural name of the api resource to manipulate + :type resource: str + :param desired_entity: Desired properties of the entity + :type desired_entity: dict + :param params: Lookup parameters (i.e. parent_id for nested entities) + :type params: dict, optional + + :return: The new current state of the entity + :rtype: dict + """ + payload = desired_entity.copy() + if params: + payload.update(params) + return self.resource_action(resource, 'create', payload) + + def update(self, resource, desired_entity, params=None): + """ + Update entity with given properties + + :param resource: Plural name of the api resource to manipulate + :type resource: str + :param desired_entity: Desired properties of the entity + :type desired_entity: dict + :param params: Lookup parameters (i.e. parent_id for nested entities) + :type params: dict, optional + + :return: The new current state of the entity + :rtype: dict + """ + payload = desired_entity.copy() + if params: + payload.update(params) + return self.resource_action(resource, 'update', payload) + + def delete(self, resource, current_entity, params=None): # pylint: disable=useless-return + """ + Delete a given entity + + :param resource: Plural name of the api resource to manipulate + :type resource: str + :param current_entity: Current properties of the entity + :type current_entity: dict + :param params: Lookup parameters (i.e. parent_id for nested entities) + :type params: dict, optional + + :return: The new current state of the entity + :rtype: Union[dict,None] + """ + payload = {'id': current_entity['id']} + if params: + payload.update(params) + entity = self.resource_action(resource, 'destroy', payload) + + # this is a workaround for https://projects.theforeman.org/issues/26937 + if entity and isinstance(entity, dict) and 'error' in entity and 'message' in entity['error']: + raise ForemanApiException(msg=entity['error']['message']) + + return None + + def validate_payload(self, resource, action, payload): + """ + Check whether the payload only contains supported keys. + + :param resource: Plural name of the api resource to check + :type resource: str + :param action: Name of the action to check payload against + :type action: str + :param payload: API paylod to be checked + :type payload: dict + + :return: The payload as it can be submitted to the API and set of unssuported parameters + :rtype: tuple(dict, set) + """ + filtered_payload = self._resource_prepare_params(resource, action, payload) + unsupported_parameters = set(payload.keys()) - set(_recursive_dict_keys(filtered_payload)) + return (filtered_payload, unsupported_parameters) + + +def _recursive_dict_keys(a_dict): + """Find all keys of a nested dictionary""" + keys = set(a_dict.keys()) + for _k, value in a_dict.items(): + if isinstance(value, dict): + keys.update(_recursive_dict_keys(value)) + return keys diff --git a/tests/test_foreman.py b/tests/test_foreman.py new file mode 100644 index 0000000..1e784fa --- /dev/null +++ b/tests/test_foreman.py @@ -0,0 +1,92 @@ +# pylint: disable=invalid-name,missing-docstring,protected-access +from apypie.foreman import ForemanApi, ForemanApiException, _recursive_dict_keys + +import pytest + +import json + + +def test_recursive_dict_keys(): + a_dict = {'level1': 'has value', 'level2': {'real_level2': 'more value', 'level3': {'real_level3': 'nope'}}} + expected_keys = set(['level1', 'level2', 'level3', 'real_level2', 'real_level3']) + assert _recursive_dict_keys(a_dict) == expected_keys + + +@pytest.fixture +def foremanapi(fixture_dir, requests_mock, tmpdir): + with fixture_dir.join('foreman.json').open() as read_file: + data = json.load(read_file) + requests_mock.get('https://api.example.com/apidoc/v2.json', json=data) + return ForemanApi(uri='https://api.example.com', apidoc_cache_dir=tmpdir.strpath) + + +def test_init(foremanapi): + assert foremanapi + assert foremanapi.apidoc + + +def test_resources(foremanapi): + assert 'domains' in foremanapi.resources + + +def test_resource_action(foremanapi, requests_mock): + requests_mock.get('https://api.example.com/api/organizations/1', json={'id': 1}) + org = foremanapi.resource_action('organizations', 'show', {'id': 1}) + assert org + + +def test_resource_action_unknown_resource(foremanapi): + with pytest.raises(ForemanApiException) as excinfo: + foremanapi.resource_action('bubblegums', 'show', {'id': 1}) + assert "The server doesn't know about bubblegums, is the right plugin installed?" in str(excinfo.value) + + +def test_resource_action_http_error(foremanapi, mocker): + mocker.patch('apypie.foreman.ForemanApi._resource_call', autospec=True, side_effect=Exception) + with pytest.raises(ForemanApiException) as excinfo: + foremanapi.resource_action('organizations', 'show', {'id': 1}) + assert "ForemanApiException: Error while performing show on organizations" in str(excinfo.value) + + +def test_wait_for_task(): + pass + + +def test_show(foremanapi, requests_mock): + requests_mock.get('https://api.example.com/api/organizations/1', json={'id': 1}) + org = foremanapi.show('organizations', 1) + assert org + + +def test_list(foremanapi, requests_mock): + requests_mock.get('https://api.example.com/api/organizations?per_page=4294967296', json={'results': [{'id': 1}]}) + orgs = foremanapi.list('organizations') + assert orgs + + +def test_create(foremanapi, requests_mock): + # needs to match the sent json! + requests_mock.post('https://api.example.com/api/organizations', json={'id': 1}) + org = foremanapi.create('organizations', {'name': 'Test'}) + assert org + + +def test_update(foremanapi, requests_mock): + # needs to match the sent json! + requests_mock.put('https://api.example.com/api/organizations/1', json={'id': 1}) + org = foremanapi.update('organizations', {'id': 1, 'name': 'Test'}) + assert org + + +def test_delete(foremanapi, requests_mock): + requests_mock.delete('https://api.example.com/api/organizations/1', status_code=204) + foremanapi.delete('organizations', {'id': 1}) + + +@pytest.mark.parametrize("params,expected", [ + ({'name': 'test'}, ({'organization': {'name': 'test'}}, set())), + ({'name': 'test', 'nope': 'nope'}, ({'organization': {'name': 'test'}}, {'nope'})), +]) +def test_validate_payload(foremanapi, params, expected): + result = foremanapi.validate_payload('organizations', 'create', params) + assert result == expected