Skip to content

Commit 8a809a2

Browse files
committed
add ForemanApi
1 parent 0120818 commit 8a809a2

File tree

4 files changed

+401
-1
lines changed

4 files changed

+401
-1
lines changed

apypie/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
from apypie.example import Example
1212
from apypie.param import Param
1313
from apypie.inflector import Inflector
14+
from apypie.foreman import ForemanApi, ForemanApiException
1415

15-
__all__ = ['Api', 'Resource', 'Route', 'Action', 'Example', 'Param', 'Inflector']
16+
__all__ = ['Api', 'Resource', 'Route', 'Action', 'Example', 'Param', 'Inflector', 'ForemanApi', 'ForemanApiException']

apypie/foreman.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""
2+
Apypie Foreman module
3+
4+
opinionated helpers to use Apypie with Foreman
5+
"""
6+
import time
7+
8+
try:
9+
from typing import cast, Optional, Set, Tuple
10+
except ImportError:
11+
pass
12+
13+
from apypie.api import Api
14+
15+
from apypie.resource import Resource # pylint: disable=unused-import
16+
17+
# Foreman supports "per_page=all" since 2.2 (https://projects.theforeman.org/issues/29909)
18+
# But plugins, especially Katello, do not: https://github.com/Katello/katello/pull/11126
19+
# To still be able to fetch all results without pagination, we have this constant for now
20+
PER_PAGE = 2 << 31
21+
22+
23+
class ForemanApiException(Exception):
24+
"""
25+
General Exception, raised by any issue in ForemanApi
26+
"""
27+
28+
def __init__(self, msg, error=None):
29+
if error:
30+
super().__init__(f'{msg} - {error}')
31+
else:
32+
super().__init__(msg)
33+
34+
@classmethod
35+
def from_exception(cls, exc, msg):
36+
"""
37+
Create a ForemanException from any other Exception
38+
39+
Especially useful to gather the error message from HTTP responses.
40+
"""
41+
error = None
42+
if hasattr(exc, 'response') and exc.response is not None:
43+
try:
44+
response = exc.response.json()
45+
if 'error' in response:
46+
error = response['error']
47+
else:
48+
error = response
49+
except Exception: # pylint: disable=broad-except
50+
error = exc.response.text
51+
return cls(msg=msg, error=error)
52+
53+
54+
class ForemanApi(Api):
55+
"""
56+
`apypie.Api` with default settings and helper functions for Foreman
57+
58+
Usage::
59+
60+
>>> import apypie
61+
>>> api = apypie.ForemanApi(uri='https://foreman.example.com', username='admin', password='changeme')
62+
"""
63+
64+
def __init__(self, **kwargs):
65+
self.task_timeout = kwargs.pop('task_timeout', 60)
66+
self.task_poll = 4
67+
kwargs['api_version'] = 2
68+
super().__init__(**kwargs)
69+
70+
def _resource(self, resource: str) -> 'Resource':
71+
if resource not in self.resources:
72+
raise ForemanApiException(msg=f"The server doesn't know about {resource}, is the right plugin installed?")
73+
return self.resource(resource)
74+
75+
def _resource_call(self, resource: str, *args, **kwargs) -> Optional[dict]:
76+
return self._resource(resource).call(*args, **kwargs)
77+
78+
def _resource_prepare_params(self, resource: str, action: str, params: dict) -> dict:
79+
api_action = self._resource(resource).action(action)
80+
return api_action.prepare_params(params)
81+
82+
def resource_action(self, resource: str, action: str, params: dict, options=None, data=None, files=None, # pylint: disable=too-many-arguments
83+
ignore_task_errors: bool = False) -> Optional[dict]:
84+
"""
85+
Perform a generic action on a resource
86+
87+
Will wait for tasks if the action returns one
88+
"""
89+
resource_payload = self._resource_prepare_params(resource, action, params)
90+
if options is None:
91+
options = {}
92+
try:
93+
result = self._resource_call(resource, action, resource_payload, options=options, data=data, files=files)
94+
is_foreman_task = isinstance(result, dict) and 'action' in result and 'state' in result and 'started_at' in result
95+
if result and is_foreman_task:
96+
result = self.wait_for_task(result, ignore_errors=ignore_task_errors)
97+
except Exception as exc:
98+
msg = f'Error while performing {action} on {resource}: {exc}'
99+
raise ForemanApiException.from_exception(exc, msg) from exc
100+
return result
101+
102+
def wait_for_task(self, task: dict, ignore_errors: bool = False) -> dict:
103+
"""
104+
Wait for a foreman-tasks task, polling it every ``self.task_poll`` seconds.
105+
106+
Will raise a ForemanApiException when task has not finished in ``self.task_timeout`` seconds.
107+
"""
108+
duration = self.task_timeout
109+
while task['state'] not in ['paused', 'stopped']:
110+
duration -= self.task_poll
111+
if duration <= 0:
112+
raise ForemanApiException(msg=f"Timeout waiting for Task {task['id']}")
113+
time.sleep(self.task_poll)
114+
115+
resource_payload = self._resource_prepare_params('foreman_tasks', 'show', {'id': task['id']})
116+
task = cast(dict, self._resource_call('foreman_tasks', 'show', resource_payload))
117+
if not ignore_errors and task['result'] != 'success':
118+
msg = f"Task {task['action']}({task['id']}) did not succeed. Task information: {task['humanized']['errors']}"
119+
raise ForemanApiException(msg=msg)
120+
return task
121+
122+
def show(self, resource: str, resource_id: int, params: Optional[dict] = None) -> Optional[dict]:
123+
"""
124+
Execute the ``show`` action on an entity.
125+
126+
:param resource: Plural name of the api resource to show
127+
:param resource_id: The ID of the entity to show
128+
:param params: Lookup parameters (i.e. parent_id for nested entities)
129+
130+
:return: The entity
131+
"""
132+
payload = {'id': resource_id}
133+
if params:
134+
payload.update(params)
135+
return self.resource_action(resource, 'show', payload)
136+
137+
def list(self, resource: str, search: Optional[str] = None, params: Optional[dict] = None) -> list:
138+
"""
139+
Execute the ``index`` action on an resource.
140+
141+
:param resource: Plural name of the api resource to show
142+
:param search: Search string as accepted by the API to limit the results
143+
:param params: Lookup parameters (i.e. parent_id for nested entities)
144+
145+
:return: List of results
146+
"""
147+
payload: dict = {'per_page': PER_PAGE}
148+
if search is not None:
149+
payload['search'] = search
150+
if params:
151+
payload.update(params)
152+
153+
result = self.resource_action(resource, 'index', payload)
154+
if result:
155+
return result['results']
156+
return []
157+
158+
def create(self, resource: str, desired_entity: dict, params: Optional[dict] = None) -> Optional[dict]:
159+
"""
160+
Create entity with given properties
161+
162+
:param resource: Plural name of the api resource to manipulate
163+
:param desired_entity: Desired properties of the entity
164+
:param params: Lookup parameters (i.e. parent_id for nested entities)
165+
166+
:return: The new current state of the entity
167+
"""
168+
payload = desired_entity.copy()
169+
if params:
170+
payload.update(params)
171+
return self.resource_action(resource, 'create', payload)
172+
173+
def update(self, resource: str, desired_entity: dict, params: Optional[dict] = None) -> Optional[dict]:
174+
"""
175+
Update entity with given properties
176+
177+
:param resource: Plural name of the api resource to manipulate
178+
:param desired_entity: Desired properties of the entity
179+
:param params: Lookup parameters (i.e. parent_id for nested entities)
180+
181+
:return: The new current state of the entity
182+
"""
183+
payload = desired_entity.copy()
184+
if params:
185+
payload.update(params)
186+
return self.resource_action(resource, 'update', payload)
187+
188+
def delete(self, resource: str, current_entity: dict, params: Optional[dict] = None) -> None:
189+
"""
190+
Delete a given entity
191+
192+
:param resource: Plural name of the api resource to manipulate
193+
:param current_entity: Current properties of the entity
194+
:param params: Lookup parameters (i.e. parent_id for nested entities)
195+
196+
:return: The new current state of the entity
197+
"""
198+
payload = {'id': current_entity['id']}
199+
if params:
200+
payload.update(params)
201+
entity = self.resource_action(resource, 'destroy', payload)
202+
203+
# this is a workaround for https://projects.theforeman.org/issues/26937
204+
if entity and isinstance(entity, dict) and 'error' in entity and 'message' in entity['error']:
205+
raise ForemanApiException(msg=entity['error']['message'])
206+
207+
def validate_payload(self, resource: str, action: str, payload: dict) -> Tuple[dict, Set[str]]:
208+
"""
209+
Check whether the payload only contains supported keys.
210+
211+
:param resource: Plural name of the api resource to check
212+
:param action: Name of the action to check payload against
213+
:param payload: API paylod to be checked
214+
215+
:return: The payload as it can be submitted to the API and set of unssuported parameters
216+
"""
217+
filtered_payload = self._resource_prepare_params(resource, action, payload)
218+
unsupported_parameters = set(payload.keys()) - _recursive_dict_keys(filtered_payload)
219+
return (filtered_payload, unsupported_parameters)
220+
221+
222+
def _recursive_dict_keys(a_dict: dict) -> set:
223+
"""Find all keys of a nested dictionary"""
224+
keys = set(a_dict.keys())
225+
for value in a_dict.values():
226+
if isinstance(value, dict):
227+
keys.update(_recursive_dict_keys(value))
228+
return keys

docs/api.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ Developer Interface
66
.. module:: apypie
77
.. autoclass:: Api
88
:inherited-members:
9+
.. autoclass:: ForemanApi
10+
:inherited-members:
911
.. autoclass:: Resource
1012
:inherited-members:
1113
.. autoclass:: Action

0 commit comments

Comments
 (0)