diff --git a/plugins/module_utils/_apypie.py b/plugins/module_utils/_apypie.py index 09ea11b27c..a7292cd898 100644 --- a/plugins/module_utils/_apypie.py +++ b/plugins/module_utils/_apypie.py @@ -921,3 +921,169 @@ def path_with_params(self, params=None): else: raise KeyError("missing param '{}' in parameters".format(param)) return result + + +import time + + +class ForemanApiException(Exception): + + def __init__(self, msg, error=None): + self.msg = msg + self.error = error + return super(ForemanApiException, self).__init__() + + def __repr__(self): + return str(self) + + def __str__(self): + s = f'{self.__class__.__name__}: {self.msg}' + if self.error: + s += f' - {self.error}' + return s + + @classmethod + def from_exception(cls, exc, msg): + error = None + if hasattr(exc, 'response'): + try: + response = exc.response.json() + if 'error' in response: + error = response['error'] + else: + error = response + except Exception: + error = exc.response.text + return cls(msg=msg, error=error) + + +class ForemanApi(Api): + + def __init__(self, **kwargs): + kwargs['api_version'] = 2 + self.task_timeout = 60 + self.task_poll = 4 + return super(ForemanApi, self).__init__(**kwargs) + + def _resource(self, resource): + if resource not in self.resources: + raise Exception("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, + ignore_task_errors=False): + 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 e: + msg = 'Error while performing {0} on {1}: {2}'.format( + action, resource, str(e)) + raise ForemanApiException.from_exception(e, msg) from e + return result + + def wait_for_task(self, task, ignore_errors=False): + 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 + """ + PER_PAGE = 2 << 31 + 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 delete(self, resource, current_entity, params=None): + """ + 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 diff --git a/plugins/module_utils/foreman_helper.py b/plugins/module_utils/foreman_helper.py index 30d4f017e2..911da4be28 100644 --- a/plugins/module_utils/foreman_helper.py +++ b/plugins/module_utils/foreman_helper.py @@ -13,7 +13,6 @@ import os import operator import re -import time import traceback from contextlib import contextmanager @@ -394,7 +393,6 @@ def __init__(self, **kwargs): self.fail_json(msg="The server URL needs to be either HTTPS or HTTP!") self.task_timeout = 60 - self.task_poll = 4 self._thin_default = False self.state = 'undefined' @@ -605,14 +603,15 @@ def connect(self): that are required by the module. """ - self.foremanapi = apypie.Api( + self.foremanapi = apypie.ForemanApi( uri=self._foremanapi_server_url, username=to_bytes(self._foremanapi_username), password=to_bytes(self._foremanapi_password), - api_version=2, verify_ssl=self._foremanapi_validate_certs, ) + self.foremanapi.task_timeout = self.task_timeout + _status = self.status() self.foreman_version = LooseVersion(_status.get('version', '0.0.0')) self.apply_apidoc_patches() @@ -648,18 +647,6 @@ def status(self): return self.foremanapi.resource('home').call('status') - def _resource(self, resource): - if resource not in self.foremanapi.resources: - raise Exception("The server doesn't know about {0}, is the right plugin installed?".format(resource)) - return self.foremanapi.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) - @_exception2fail_json(msg='Failed to show resource: {0}') def show_resource(self, resource, resource_id, params=None): """ @@ -673,16 +660,7 @@ def show_resource(self, resource, resource_id, params=None): :type params: Union[dict,None], optional """ - if params is None: - params = {} - else: - params = params.copy() - - params['id'] = resource_id - - params = self._resource_prepare_params(resource, 'show', params) - - return self._resource_call(resource, 'show', params) + return self.foremanapi.show(resource, resource_id, params) @_exception2fail_json(msg='Failed to list resource: {0}') def list_resource(self, resource, search=None, params=None): @@ -697,18 +675,7 @@ def list_resource(self, resource, search=None, params=None): :type params: Union[dict,None], optional """ - if params is None: - params = {} - else: - params = params.copy() - - if search is not None: - params['search'] = search - params['per_page'] = PER_PAGE - - params = self._resource_prepare_params(resource, 'index', params) - - return self._resource_call(resource, 'index', params)['results'] + return self.foremanapi.list(resource, search, params) def find_resource(self, resource, search, params=None, failsafe=False, thin=None): list_params = {} @@ -1021,7 +988,7 @@ def _validate_supported_payload(self, resource, action, payload): :return: The payload as it can be submitted to the API :rtype: dict """ - filtered_payload = self._resource_prepare_params(resource, action, payload) + filtered_payload = self.foremanapi._resource_prepare_params(resource, action, payload) # On Python 2 dict.keys() is just a list, but we need a set here. unsupported_parameters = set(payload.keys()) - set(_recursive_dict_keys(filtered_payload)) if unsupported_parameters: @@ -1047,14 +1014,12 @@ def _create_entity(self, resource, desired_entity, params, foreman_spec): """ payload = _flatten_entity(desired_entity, foreman_spec) self._validate_supported_payload(resource, 'create', payload) + self.set_changed() if not self.check_mode: - if params: - payload.update(params) - return self.resource_action(resource, 'create', payload) + return self.foremanapi.create(resource, payload, params) else: fake_entity = desired_entity.copy() fake_entity['id'] = -1 - self.set_changed() return fake_entity def _update_entity(self, resource, desired_entity, current_entity, params, foreman_spec): @@ -1171,29 +1136,18 @@ def _delete_entity(self, resource, current_entity, params): :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']: - self.fail_json(msg=entity['error']['message']) - - return None + self.set_changed() + if not self.check_mode: + return self.foremanapi.delete(resource, current_entity, params) + else: + return None def resource_action(self, resource, action, params, options=None, data=None, files=None, ignore_check_mode=False, record_change=True, ignore_task_errors=False): - resource_payload = self._resource_prepare_params(resource, action, params) - if options is None: - options = {} try: result = None if ignore_check_mode or not self.check_mode: - 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) + result = self.foremanapi.resource_action(resource, action, params, options, data, files, ignore_task_errors) except Exception as e: msg = 'Error while performing {0} on {1}: {2}'.format( action, resource, to_native(e)) @@ -1204,18 +1158,7 @@ def resource_action(self, resource, action, params, options=None, data=None, fil return result def wait_for_task(self, task, ignore_errors=False): - duration = self.task_timeout - while task['state'] not in ['paused', 'stopped']: - duration -= self.task_poll - if duration <= 0: - self.fail_json(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': - self.fail_json(msg='Task {0}({1}) did not succeed. Task information: {2}'.format(task['action'], task['id'], task['humanized']['errors'])) - return task + return self.foremanapi.wait_for_task(task, ignore_errors) def fail_from_exception(self, exc, msg): fail = {'msg': msg}