-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
248 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'): | ||
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): | ||
""" | ||
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 # pylint: disable=useless-return | ||
|
||
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
from apypie.foreman import _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 |