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..2d87a8d --- /dev/null +++ b/apypie/foreman.py @@ -0,0 +1,225 @@ +""" +Apypie Foreman module + +opinionated helpers to use Apypie with Foreman +""" +import time + +from typing import cast, Optional, Set, Tuple + +from apypie.api import Api + +from apypie.resource import Resource # pylint: disable=unused-import + +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: str) -> 'Resource': + if resource not in self.resources: + raise ForemanApiException(msg=f"The server doesn't know about {resource}, is the right plugin installed?") + return self.resource(resource) + + def _resource_call(self, resource: str, *args, **kwargs) -> Optional[dict]: + return self._resource(resource).call(*args, **kwargs) + + def _resource_prepare_params(self, resource: str, action: str, params: dict) -> dict: + api_action = self._resource(resource).action(action) + return api_action.prepare_params(params) + + def resource_action(self, resource: str, action: str, params: dict, options=None, data=None, files=None, # pylint: disable=too-many-arguments + ignore_task_errors: bool = False) -> Optional[dict]: + """ + 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 result and is_foreman_task: + result = self.wait_for_task(result, ignore_errors=ignore_task_errors) + except Exception as exc: + msg = f'Error while performing {action} on {resource}: {exc}' + raise ForemanApiException.from_exception(exc, msg) from exc + return result + + def wait_for_task(self, task: dict, ignore_errors: bool = False) -> dict: + """ + 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=f"Timeout waiting for Task {task['id']}") + time.sleep(self.task_poll) + + resource_payload = self._resource_prepare_params('foreman_tasks', 'show', {'id': task['id']}) + task = cast(dict, self._resource_call('foreman_tasks', 'show', resource_payload)) + if not ignore_errors and task['result'] != 'success': + msg = f"Task {task['action']}({task['id']}) did not succeed. Task information: {task['humanized']['errors']}" + raise ForemanApiException(msg=msg) + return task + + def show(self, resource: str, resource_id: int, params: Optional[dict] = None) -> Optional[dict]: + """ + Execute the ``show`` action on an entity. + + :param resource: Plural name of the api resource to show + :param resource_id: The ID of the entity to show + :param params: Lookup parameters (i.e. parent_id for nested entities) + + :return: The entity + """ + payload = {'id': resource_id} + if params: + payload.update(params) + return self.resource_action(resource, 'show', payload) + + def list(self, resource: str, search: Optional[str] = None, params: Optional[dict] = None) -> list: + """ + Execute the ``index`` action on an resource. + + :param resource: Plural name of the api resource to show + :param search: Search string as accepted by the API to limit the results + :param params: Lookup parameters (i.e. parent_id for nested entities) + + :return: List of results + """ + payload: dict = {'per_page': PER_PAGE} + if search is not None: + payload['search'] = search + if params: + payload.update(params) + + result = self.resource_action(resource, 'index', payload) + if result: + return result['results'] + return [] + + def create(self, resource: str, desired_entity: dict, params: Optional[dict] = None) -> Optional[dict]: + """ + Create entity with given properties + + :param resource: Plural name of the api resource to manipulate + :param desired_entity: Desired properties of the entity + :param params: Lookup parameters (i.e. parent_id for nested entities) + + :return: The new current state of the entity + """ + payload = desired_entity.copy() + if params: + payload.update(params) + return self.resource_action(resource, 'create', payload) + + def update(self, resource: str, desired_entity: dict, params: Optional[dict] = None) -> Optional[dict]: + """ + Update entity with given properties + + :param resource: Plural name of the api resource to manipulate + :param desired_entity: Desired properties of the entity + :param params: Lookup parameters (i.e. parent_id for nested entities) + + :return: The new current state of the entity + """ + payload = desired_entity.copy() + if params: + payload.update(params) + return self.resource_action(resource, 'update', payload) + + def delete(self, resource: str, current_entity: dict, params: Optional[dict] = None) -> None: + """ + Delete a given entity + + :param resource: Plural name of the api resource to manipulate + :param current_entity: Current properties of the entity + :param params: Lookup parameters (i.e. parent_id for nested entities) + + :return: The new current state of the entity + """ + 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']) + + def validate_payload(self, resource: str, action: str, payload: dict) -> Tuple[dict, Set[str]]: + """ + Check whether the payload only contains supported keys. + + :param resource: Plural name of the api resource to check + :param action: Name of the action to check payload against + :param payload: API paylod to be checked + + :return: The payload as it can be submitted to the API and set of unssuported parameters + """ + 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..9880b2f --- /dev/null +++ b/tests/test_foreman.py @@ -0,0 +1,148 @@ +# pylint: disable=invalid-name,missing-docstring,protected-access +import io +import json + +import pytest +import requests +import requests.exceptions + +from apypie.foreman import ForemanApi, ForemanApiException, _recursive_dict_keys + + +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) + + +@pytest.fixture +def lunaapi(fixture_dir, requests_mock, tmpdir): + with fixture_dir.join('luna.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): + raw_bytes = io.BytesIO(b'This is bad') + response = requests.Response() + response.raw = raw_bytes + exception = requests.exceptions.HTTPError(response=response) + 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 "Error while performing show on organizations: - This is bad" in str(excinfo.value) + + +def test_resource_action_http_error_json(foremanapi, mocker): + raw_bytes = io.BytesIO(b'{"error":"superbad"}') + response = requests.Response() + response.raw = raw_bytes + exception = requests.exceptions.HTTPError(response=response) + 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 "Error while performing show on organizations: - superbad" in str(excinfo.value) + + +def test_resource_action_wait_for_task(lunaapi, requests_mock): + running_task = {'id': 1, 'state': 'running', 'action': 'test', 'started_at': 'now'} + stopped_task = {'id': 1, 'state': 'stopped', 'result': 'success'} + requests_mock.post('https://api.example.com/api/domains', json=running_task) + requests_mock.get('https://api.example.com/foreman_tasks/api/tasks/1', json=stopped_task) + done = lunaapi.resource_action('domains', 'create', {'name': 'test'}) + assert done['result'] == 'success' + + +def test_wait_for_task(lunaapi, requests_mock): + running_task = {'id': 1, 'state': 'running'} + stopped_task = {'id': 1, 'state': 'stopped', 'result': 'success'} + requests_mock.get('https://api.example.com/foreman_tasks/api/tasks/1', json=stopped_task) + lunaapi.wait_for_task(running_task) + + +def test_wait_for_task_failed_task(lunaapi, requests_mock): + running_task = {'id': 1, 'state': 'running'} + stopped_task = {'id': 1, 'state': 'stopped', 'result': 'error', 'action': 'test', 'humanized': {'errors': 'you lost the game'}} + requests_mock.get('https://api.example.com/foreman_tasks/api/tasks/1', json=stopped_task) + with pytest.raises(ForemanApiException) as excinfo: + lunaapi.wait_for_task(running_task) + assert "Task test(1) did not succeed. Task information: you lost the game" in str(excinfo.value) + + +def test_wait_for_task_timeout(lunaapi, requests_mock): + running_task = {'id': 1, 'state': 'running'} + requests_mock.get('https://api.example.com/foreman_tasks/api/tasks/1', json=running_task) + lunaapi.task_timeout = 1 + with pytest.raises(ForemanApiException) as excinfo: + lunaapi.wait_for_task(running_task) + assert "Timeout waiting for Task 1" in str(excinfo.value) + + +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