blob: 061dcfa81cda63ef9acb65a995ac42bf3f7f18d0 [file] [log] [blame]
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +01001# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""MAAS OAuth API connection library."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
azvyagintsevbbc7daa2019-01-30 20:25:10 +020010)
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +010011
12str = None
13
14__metaclass__ = type
15__all__ = [
16 'MAASClient',
17 'MAASDispatcher',
18 'MAASOAuth',
azvyagintsevbbc7daa2019-01-30 20:25:10 +020019]
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +010020
21import gzip
azvyagintsevbbc7daa2019-01-30 20:25:10 +020022import time
23from functools import wraps
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +010024from io import BytesIO
25import urllib2
26
Damian Szelugad0ac0ac2017-03-29 15:15:33 +020027from encode_json import encode_json_data
28from multipart import encode_multipart_data
29from utils import urlencode
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +010030import oauth.oauth as oauth
31
32
azvyagintsevbbc7daa2019-01-30 20:25:10 +020033def retry(ExceptionToCheck, tries=4, delay=3, backoff=2, logger=None):
34 """Retry calling the decorated function using an exponential backoff.
35
36 http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/
37 original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry
38
39 :param ExceptionToCheck: the exception to check. may be a tuple of
40 exceptions to check
41 :type ExceptionToCheck: Exception or tuple
42 :param tries: number of times to try (not retry) before giving up
43 :type tries: int
44 :param delay: initial delay between retries in seconds
45 :type delay: int
46 :param backoff: backoff multiplier e.g. value of 2 will double the delay
47 each retry
48 :type backoff: int
49 :param logger: logger to use. If None, print
50 :type logger: logging.Logger instance
51 """
52
53 def deco_retry(f):
54
55 @wraps(f)
56 def f_retry(*args, **kwargs):
57 mtries, mdelay = tries, delay
58 while mtries > 1:
59 try:
60 return f(*args, **kwargs)
61 except ExceptionToCheck, e:
62 msg = "%s, Retrying in %d seconds..." % (str(e), mdelay)
63 if logger:
64 logger.warning(msg)
65 else:
66 print
67 msg
68 time.sleep(mdelay)
69 mtries -= 1
70 mdelay *= backoff
71 return f(*args, **kwargs)
72
73 return f_retry # true decorator
74
75 return deco_retry
76
77
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +010078class MAASOAuth:
79 """Helper class to OAuth-sign an HTTP request."""
80
81 def __init__(self, consumer_key, resource_token, resource_secret):
82 resource_tok_string = "oauth_token_secret=%s&oauth_token=%s" % (
83 resource_secret, resource_token)
84 self.resource_token = oauth.OAuthToken.from_string(resource_tok_string)
85 self.consumer_token = oauth.OAuthConsumer(consumer_key, "")
86
87 def sign_request(self, url, headers):
88 """Sign a request.
89
90 @param url: The URL to which the request is to be sent.
91 @param headers: The headers in the request. These will be updated
92 with the signature.
93 """
94 oauth_request = oauth.OAuthRequest.from_consumer_and_token(
95 self.consumer_token, token=self.resource_token, http_url=url)
96 oauth_request.sign_request(
97 oauth.OAuthSignatureMethod_PLAINTEXT(), self.consumer_token,
98 self.resource_token)
99 headers.update(oauth_request.to_header())
100
101
102class NoAuth:
103 """Anonymous authentication class for making unauthenticated requests."""
104
105 def __init__(self, *args, **kwargs):
106 pass
107
108 def sign_request(self, *args, **kwargs):
109 """Go through the motions of signing a request.
110
111 Since this class does not really authenticate, this does nothing.
112 """
113
114
115class RequestWithMethod(urllib2.Request):
116 """Enhances urllib2.Request so an http method can be supplied."""
azvyagintsevbbc7daa2019-01-30 20:25:10 +0200117
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +0100118 def __init__(self, *args, **kwargs):
119 self._method = kwargs.pop('method', None)
120 urllib2.Request.__init__(self, *args, **kwargs)
121
122 def get_method(self):
123 return (
124 self._method if self._method
125 else super(RequestWithMethod, self).get_method())
126
127
128class MAASDispatcher:
129 """Helper class to connect to a MAAS server using blocking requests.
130
131 Be careful when changing its API: this class is designed so that it
132 can be replaced with a Twisted-enabled alternative. See the MAAS
133 provider in Juju for the code this would require.
134 """
135
azvyagintsevbbc7daa2019-01-30 20:25:10 +0200136 @retry(urllib2.URLError, tries=2, delay=5, backoff=2)
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +0100137 def dispatch_query(self, request_url, headers, method="GET", data=None):
138 """Synchronously dispatch an OAuth-signed request to L{request_url}.
139
140 :param request_url: The URL to which the request is to be sent.
141 :param headers: Headers to include in the request.
142 :type headers: A dict.
143 :param method: The HTTP method, e.g. C{GET}, C{POST}, etc.
144 An AssertionError is raised if trying to pass data for a GET.
145 :param data: The data to send, if any.
146 :type data: A byte string.
147
148 :return: A open file-like object that contains the response.
149 """
150 headers = dict(headers)
151 # header keys are case insensitive, so we have to pass over them
152 set_accept_encoding = False
153 for key in headers:
154 if key.lower() == 'accept-encoding':
155 # The user already supplied a requested encoding, so just pass
156 # it along.
157 break
158 else:
159 set_accept_encoding = True
160 headers['Accept-encoding'] = 'gzip'
161 req = RequestWithMethod(request_url, data, headers, method=method)
162 res = urllib2.urlopen(req)
163 # If we set the Accept-encoding header, then we decode the header for
164 # the caller.
165 is_gzip = (
166 set_accept_encoding
167 and res.info().get('Content-Encoding') == 'gzip')
168 if is_gzip:
169 # Workaround python's gzip failure, gzip.GzipFile wants to be able
170 # to seek the file object.
171 res_content_io = BytesIO(res.read())
172 ungz = gzip.GzipFile(mode='rb', fileobj=res_content_io)
173 res = urllib2.addinfourl(ungz, res.headers, res.url, res.code)
174 return res
175
176
177class MAASClient:
178 """Base class for connecting to MAAS servers.
179
180 All "path" parameters can be either a string describing an absolute
181 resource path, or a sequence of items that, when represented as unicode,
182 make up the elements of the resource's path. So `['nodes', node_id]`
183 is equivalent to `"nodes/%s" % node_id`.
184 """
185
186 def __init__(self, auth, dispatcher, base_url):
187 """Intialise the client.
188
189 :param auth: A `MAASOAuth` to sign requests.
190 :param dispatcher: An object implementing the MAASOAuthConnection
191 base class.
192 :param base_url: The base URL for the MAAS server, e.g.
193 http://my.maas.com:5240/
194 """
195 self.dispatcher = dispatcher
196 self.auth = auth
197 self.url = base_url
198
199 def _make_url(self, path):
200 """Compose an absolute URL to `path`.
201
202 :param path: Either a string giving a path to the desired resource,
203 or a sequence of items that make up the path.
204 :return: An absolute URL leading to `path`.
205 """
206 assert not isinstance(path, bytes)
207 if not isinstance(path, unicode):
208 assert not any(isinstance(element, bytes) for element in path)
209 path = '/'.join(unicode(element) for element in path)
210 # urljoin is very sensitive to leading slashes and when spurious
211 # slashes appear it removes path parts. This is why joining is
212 # done manually here.
213 return self.url.rstrip("/") + "/" + path.lstrip("/")
214
215 def _formulate_get(self, path, params=None):
216 """Return URL and headers for a GET request.
217
218 This is similar to _formulate_change, except parameters are encoded
219 into the URL.
220
221 :param path: Path to the object to issue a GET on.
222 :param params: Optional dict of parameter values.
223 :return: A tuple: URL and headers for the request.
224 """
225 url = self._make_url(path)
226 if params is not None and len(params) > 0:
227 url += "?" + urlencode(params.items())
228 headers = {}
229 self.auth.sign_request(url, headers)
230 return url, headers
231
232 def _formulate_change(self, path, params, as_json=False):
233 """Return URL, headers, and body for a non-GET request.
234
235 This is similar to _formulate_get, except parameters are encoded as
236 a multipart form body.
237
238 :param path: Path to the object to issue a GET on.
239 :param params: A dict of parameter values.
240 :param as_json: Encode params as application/json instead of
241 multipart/form-data. Only use this if you know the API already
242 supports JSON requests.
243 :return: A tuple: URL, headers, and body for the request.
244 """
245 url = self._make_url(path)
246 if 'op' in params:
247 params = dict(params)
248 op = params.pop('op')
249 url += '?' + urlencode([('op', op)])
250 if as_json:
251 body, headers = encode_json_data(params)
252 else:
253 body, headers = encode_multipart_data(params, {})
254 self.auth.sign_request(url, headers)
255 return url, headers, body
256
257 def get(self, path, op=None, **kwargs):
258 """Dispatch a GET.
259
260 :param op: Optional: named GET operation to invoke. If given, any
261 keyword arguments are passed to the named operation.
262 :return: The result of the dispatch_query call on the dispatcher.
263 """
264 if op is not None:
265 kwargs['op'] = op
266 url, headers = self._formulate_get(path, kwargs)
267 return self.dispatcher.dispatch_query(
268 url, method="GET", headers=headers)
269
270 def post(self, path, op, as_json=False, **kwargs):
271 """Dispatch POST method `op` on `path`, with the given parameters.
272
273 :param as_json: Instead of POSTing the content as multipart/form-data
274 POST it as application/json
275 :return: The result of the dispatch_query call on the dispatcher.
276 """
Krzysztof Szukiełojćc4b33092017-02-15 13:25:38 +0100277 if op is not None:
278 kwargs['op'] = op
Krzysztof Szukiełojć15b62b72017-02-15 08:58:18 +0100279 url, headers, body = self._formulate_change(
280 path, kwargs, as_json=as_json)
281 return self.dispatcher.dispatch_query(
282 url, method="POST", headers=headers, data=body)
283
284 def put(self, path, **kwargs):
285 """Dispatch a PUT on the resource at `path`."""
286 url, headers, body = self._formulate_change(path, kwargs)
287 return self.dispatcher.dispatch_query(
288 url, method="PUT", headers=headers, data=body)
289
290 def delete(self, path):
291 """Dispatch a DELETE on the resource at `path`."""
292 url, headers, body = self._formulate_change(path, {})
293 return self.dispatcher.dispatch_query(
Krzysztof Szukiełojća6352a42017-03-17 14:21:57 +0100294 url, method="DELETE", headers=headers, data=body)