1# Copyright (c) 2014, 2015, Oracle and/or its affiliates. All rights reserved.
2#
3#    Licensed under the Apache License, Version 2.0 (the "License"); you may
4#    not use this file except in compliance with the License. You may obtain
5#    a copy of the License at
6#
7#         http://www.apache.org/licenses/LICENSE-2.0
8#
9#    Unless required by applicable law or agreed to in writing, software
10#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12#    License for the specific language governing permissions and limitations
13#    under the License.
14"""
15ZFS Storage Appliance REST API Client Programmatic Interface
16"""
17
18import json
19import ssl
20import time
21
22from oslo_log import log
23from oslo_utils import strutils
24import six
25from six.moves import http_client
26from six.moves import urllib
27
28LOG = log.getLogger(__name__)
29
30
31class Status(object):
32    """Result HTTP Status"""
33
34    def __init__(self):
35        pass
36
37    #: Request return OK
38    OK = http_client.OK
39
40    #: New resource created successfully
41    CREATED = http_client.CREATED
42
43    #: Command accepted
44    ACCEPTED = http_client.ACCEPTED
45
46    #: Command returned OK but no data will be returned
47    NO_CONTENT = http_client.NO_CONTENT
48
49    #: Bad Request
50    BAD_REQUEST = http_client.BAD_REQUEST
51
52    #: User is not authorized
53    UNAUTHORIZED = http_client.UNAUTHORIZED
54
55    #: The request is not allowed
56    FORBIDDEN = http_client.FORBIDDEN
57
58    #: The requested resource was not found
59    NOT_FOUND = http_client.NOT_FOUND
60
61    #: The request is not allowed
62    NOT_ALLOWED = http_client.METHOD_NOT_ALLOWED
63
64    #: Request timed out
65    TIMEOUT = http_client.REQUEST_TIMEOUT
66
67    #: Invalid request
68    CONFLICT = http_client.CONFLICT
69
70    #: Service Unavailable
71    BUSY = http_client.SERVICE_UNAVAILABLE
72
73
74class RestResult(object):
75    """Result from a REST API operation"""
76    def __init__(self, response=None, err=None):
77        """Initialize a RestResult containing the results from a REST call.
78
79        :param response: HTTP response
80        """
81        self.response = response
82        self.error = err
83        self.data = ""
84        self.status = 0
85        if self.response:
86            self.status = self.response.getcode()
87            result = self.response.read()
88            while result:
89                self.data += result
90                result = self.response.read()
91
92        if self.error:
93            self.status = self.error.code
94            self.data = http_client.responses[self.status]
95
96        LOG.debug('Response code: %s', self.status)
97        LOG.debug('Response data: %s', self.data)
98
99    def get_header(self, name):
100        """Get an HTTP header with the given name from the results
101
102        :param name: HTTP header name
103        :return: The header value or None if no value is found
104        """
105        if self.response is None:
106            return None
107        info = self.response.info()
108        return info.getheader(name)
109
110
111class RestClientError(Exception):
112    """Exception for ZFS REST API client errors"""
113    def __init__(self, status, name="ERR_INTERNAL", message=None):
114
115        """Create a REST Response exception
116
117        :param status: HTTP response status
118        :param name: The name of the REST API error type
119        :param message: Descriptive error message returned from REST call
120        """
121        super(RestClientError, self).__init__(message)
122        self.code = status
123        self.name = name
124        self.msg = message
125        if status in http_client.responses:
126            self.msg = http_client.responses[status]
127
128    def __str__(self):
129        return "%d %s %s" % (self.code, self.name, self.msg)
130
131
132class RestClientURL(object):
133    """ZFSSA urllib client"""
134    def __init__(self, url, **kwargs):
135        """Initialize a REST client.
136
137        :param url: The ZFSSA REST API URL
138        :key session: HTTP Cookie value of x-auth-session obtained from a
139                      normal BUI login.
140        :key timeout: Time in seconds to wait for command to complete.
141                      (Default is 60 seconds)
142        """
143        self.url = url
144        self.local = kwargs.get("local", False)
145        self.base_path = kwargs.get("base_path", "/api")
146        self.timeout = kwargs.get("timeout", 60)
147        self.headers = None
148        if kwargs.get('session'):
149            self.headers['x-auth-session'] = kwargs.get('session')
150
151        self.headers = {"content-type": "application/json"}
152        self.do_logout = False
153        self.auth_str = None
154
155    def _path(self, path, base_path=None):
156        """build rest url path"""
157        if path.startswith("http://") or path.startswith("https://"):
158            return path
159        if base_path is None:
160            base_path = self.base_path
161        if not path.startswith(base_path) and not (
162                self.local and ("/api" + path).startswith(base_path)):
163            path = "%s%s" % (base_path, path)
164        if self.local and path.startswith("/api"):
165            path = path[4:]
166        return self.url + path
167
168    def _authorize(self):
169        """Performs authorization setting x-auth-session"""
170        self.headers['authorization'] = 'Basic %s' % self.auth_str
171        if 'x-auth-session' in self.headers:
172            del self.headers['x-auth-session']
173
174        try:
175            result = self.post("/access/v1")
176            del self.headers['authorization']
177            if result.status == http_client.CREATED:
178                self.headers['x-auth-session'] = \
179                    result.get_header('x-auth-session')
180                self.do_logout = True
181                LOG.info('ZFSSA version: %s',
182                         result.get_header('x-zfssa-version'))
183
184            elif result.status == http_client.NOT_FOUND:
185                raise RestClientError(result.status, name="ERR_RESTError",
186                                      message="REST Not Available: \
187                                      Please Upgrade")
188
189        except RestClientError:
190            del self.headers['authorization']
191            raise
192
193    def login(self, auth_str):
194        """Login to an appliance using a user name and password.
195
196        Start a session like what is done logging into the BUI.  This is not a
197        requirement to run REST commands, since the protocol is stateless.
198        What is does is set up a cookie session so that some server side
199        caching can be done.  If login is used remember to call logout when
200        finished.
201
202        :param auth_str: Authorization string (base64)
203        """
204        self.auth_str = auth_str
205        self._authorize()
206
207    def logout(self):
208        """Logout of an appliance"""
209        result = None
210        try:
211            result = self.delete("/access/v1", base_path="/api")
212        except RestClientError:
213            pass
214
215        self.headers.clear()
216        self.do_logout = False
217        return result
218
219    def islogin(self):
220        """return if client is login"""
221        return self.do_logout
222
223    @staticmethod
224    def mkpath(*args, **kwargs):
225        """Make a path?query string for making a REST request
226
227        :cmd_params args: The path part
228        :cmd_params kwargs: The query part
229        """
230        buf = six.StringIO()
231        query = "?"
232        for arg in args:
233            buf.write("/")
234            buf.write(arg)
235        for k in kwargs:
236            buf.write(query)
237            if query == "?":
238                query = "&"
239            buf.write(k)
240            buf.write("=")
241            buf.write(kwargs[k])
242        return buf.getvalue()
243
244    def request(self, path, request, body=None, **kwargs):
245        """Make an HTTP request and return the results
246
247        :param path: Path used with the initialized URL to make a request
248        :param request: HTTP request type (GET, POST, PUT, DELETE)
249        :param body: HTTP body of request
250        :key accept: Set HTTP 'Accept' header with this value
251        :key base_path: Override the base_path for this request
252        :key content: Set HTTP 'Content-Type' header with this value
253        """
254        out_hdrs = dict.copy(self.headers)
255        if kwargs.get("accept"):
256            out_hdrs['accept'] = kwargs.get("accept")
257
258        if body:
259            if isinstance(body, dict):
260                body = str(json.dumps(body))
261
262        if body and len(body):
263            out_hdrs['content-length'] = len(body)
264
265        zfssaurl = self._path(path, kwargs.get("base_path"))
266        req = urllib.request.Request(zfssaurl, body, out_hdrs)
267        req.get_method = lambda: request
268        maxreqretries = kwargs.get("maxreqretries", 10)
269        retry = 0
270        response = None
271
272        LOG.debug('Request: %(request)s %(url)s',
273                  {'request': request, 'url': zfssaurl})
274        LOG.debug('Out headers: %s', out_hdrs)
275        if body and body != '':
276            # body may contain chap secret so must be masked
277            LOG.debug('Body: %s', strutils.mask_password(body))
278
279        context = None
280        if hasattr(ssl, '_create_unverified_context'):
281            context = ssl._create_unverified_context()
282        else:
283            context = None
284
285        while retry < maxreqretries:
286            try:
287                if context:
288                    # only schemes that can be used will be http or https if it
289                    # is given in the path variable, or the path will begin
290                    # with the REST API location meaning invalid or unwanted
291                    # schemes cannot be used
292                    response = urllib.request.urlopen(req,  # nosec
293                                                      timeout=self.timeout,
294                                                      context=context)
295                else:
296                    response = urllib.request.urlopen(req,  # nosec : see above
297                                                      timeout=self.timeout)
298            except urllib.error.HTTPError as err:
299                if err.code == http_client.NOT_FOUND:
300                    LOG.debug('REST Not Found: %s', err.code)
301                else:
302                    LOG.error('REST Not Available: %s', err.code)
303
304                if err.code == http_client.SERVICE_UNAVAILABLE and \
305                   retry < maxreqretries:
306                    retry += 1
307                    time.sleep(1)
308                    LOG.error('Server Busy retry request: %s', retry)
309                    continue
310                if (err.code == http_client.UNAUTHORIZED or
311                    err.code == http_client.INTERNAL_SERVER_ERROR) and \
312                   '/access/v1' not in zfssaurl:
313                    try:
314                        LOG.error('Authorizing request: %(zfssaurl)s '
315                                  'retry: %(retry)d.',
316                                  {'zfssaurl': zfssaurl, 'retry': retry})
317                        self._authorize()
318                        req.add_header('x-auth-session',
319                                       self.headers['x-auth-session'])
320                    except RestClientError:
321                        pass
322                    retry += 1
323                    time.sleep(1)
324                    continue
325
326                return RestResult(err=err)
327
328            except urllib.error.URLError as err:
329                LOG.error('URLError: %s', err.reason)
330                raise RestClientError(-1, name="ERR_URLError",
331                                      message=err.reason)
332
333            break
334
335        if (response and
336            (response.getcode() == http_client.SERVICE_UNAVAILABLE and
337                retry >= maxreqretries)):
338            raise RestClientError(response.getcode(), name="ERR_HTTPError",
339                                  message="REST Not Available: Disabled")
340
341        return RestResult(response=response)
342
343    def get(self, path, **kwargs):
344        """Make an HTTP GET request
345
346        :param path: Path to resource.
347        """
348        return self.request(path, "GET", **kwargs)
349
350    def post(self, path, body="", **kwargs):
351        """Make an HTTP POST request
352
353        :param path: Path to resource.
354        :param body: Post data content
355        """
356        return self.request(path, "POST", body, **kwargs)
357
358    def put(self, path, body="", **kwargs):
359        """Make an HTTP PUT request
360
361        :param path: Path to resource.
362        :param body: Put data content
363        """
364        return self.request(path, "PUT", body, **kwargs)
365
366    def delete(self, path, **kwargs):
367        """Make an HTTP DELETE request
368
369        :param path: Path to resource that will be deleted.
370        """
371        return self.request(path, "DELETE", **kwargs)
372
373    def head(self, path, **kwargs):
374        """Make an HTTP HEAD request
375
376        :param path: Path to resource.
377        """
378        return self.request(path, "HEAD", **kwargs)
379