1# Copyright (c) 2012 NetApp, Inc.  All rights reserved.
2# Copyright (c) 2014 Navneet Singh.  All rights reserved.
3# Copyright (c) 2014 Glenn Gobeli.  All rights reserved.
4# Copyright (c) 2014 Clinton Knight.  All rights reserved.
5# Copyright (c) 2015 Alex Meade.  All rights reserved.
6#
7#    Licensed under the Apache License, Version 2.0 (the "License"); you may
8#    not use this file except in compliance with the License. You may obtain
9#    a copy of the License at
10#
11#         http://www.apache.org/licenses/LICENSE-2.0
12#
13#    Unless required by applicable law or agreed to in writing, software
14#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
15#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
16#    License for the specific language governing permissions and limitations
17#    under the License.
18"""
19NetApp API for Data ONTAP and OnCommand DFM.
20
21Contains classes required to issue API calls to Data ONTAP and OnCommand DFM.
22"""
23
24from eventlet import greenthread
25from eventlet import semaphore
26
27from lxml import etree
28from oslo_log import log as logging
29from oslo_utils import netutils
30import random
31import six
32from six.moves import urllib
33
34from cinder import exception
35from cinder.i18n import _
36from cinder import ssh_utils
37from cinder import utils
38from cinder.volume.drivers.netapp import utils as na_utils
39
40LOG = logging.getLogger(__name__)
41
42EAPIERROR = '13001'
43EAPIPRIVILEGE = '13003'
44EAPINOTFOUND = '13005'
45ESNAPSHOTNOTALLOWED = '13023'
46ESIS_CLONE_NOT_LICENSED = '14956'
47EOBJECTNOTFOUND = '15661'
48ESOURCE_IS_DIFFERENT = '17105'
49ERELATION_EXISTS = '17122'
50ERELATION_NOT_QUIESCED = '17127'
51ENOTRANSFER_IN_PROGRESS = '17130'
52EANOTHER_OP_ACTIVE = '17131'
53ETRANSFER_IN_PROGRESS = '17137'
54
55
56class NaServer(object):
57    """Encapsulates server connection logic."""
58
59    TRANSPORT_TYPE_HTTP = 'http'
60    TRANSPORT_TYPE_HTTPS = 'https'
61    SERVER_TYPE_FILER = 'filer'
62    SERVER_TYPE_DFM = 'dfm'
63    URL_FILER = 'servlets/netapp.servlets.admin.XMLrequest_filer'
64    URL_DFM = 'apis/XMLrequest'
65    NETAPP_NS = 'http://www.netapp.com/filer/admin'
66    STYLE_LOGIN_PASSWORD = 'basic_auth'
67    STYLE_CERTIFICATE = 'certificate_auth'
68
69    def __init__(self, host, server_type=SERVER_TYPE_FILER,
70                 transport_type=TRANSPORT_TYPE_HTTP,
71                 style=STYLE_LOGIN_PASSWORD, username=None,
72                 password=None, port=None, api_trace_pattern=None):
73        self._host = host
74        self.set_server_type(server_type)
75        self.set_transport_type(transport_type)
76        self.set_style(style)
77        if port:
78            self.set_port(port)
79        self._username = username
80        self._password = password
81        self._refresh_conn = True
82
83        if api_trace_pattern is not None:
84            na_utils.setup_api_trace_pattern(api_trace_pattern)
85
86        LOG.debug('Using NetApp controller: %s', self._host)
87
88    def set_transport_type(self, transport_type):
89        """Set the transport type protocol for API.
90
91        Supports http and https transport types.
92        """
93        if not transport_type:
94            raise ValueError('No transport type specified')
95        if transport_type.lower() not in (
96                NaServer.TRANSPORT_TYPE_HTTP,
97                NaServer.TRANSPORT_TYPE_HTTPS):
98            raise ValueError('Unsupported transport type')
99        self._protocol = transport_type.lower()
100        if self._protocol == NaServer.TRANSPORT_TYPE_HTTP:
101            if self._server_type == NaServer.SERVER_TYPE_FILER:
102                self.set_port(80)
103            else:
104                self.set_port(8088)
105        else:
106            if self._server_type == NaServer.SERVER_TYPE_FILER:
107                self.set_port(443)
108            else:
109                self.set_port(8488)
110        self._refresh_conn = True
111
112    def set_style(self, style):
113        """Set the authorization style for communicating with the server.
114
115        Supports basic_auth for now. Certificate_auth mode to be done.
116        """
117        if style.lower() not in (NaServer.STYLE_LOGIN_PASSWORD,
118                                 NaServer.STYLE_CERTIFICATE):
119            raise ValueError('Unsupported authentication style')
120        self._auth_style = style.lower()
121
122    def set_server_type(self, server_type):
123        """Set the target server type.
124
125        Supports filer and dfm server types.
126        """
127        if server_type.lower() not in (NaServer.SERVER_TYPE_FILER,
128                                       NaServer.SERVER_TYPE_DFM):
129            raise ValueError('Unsupported server type')
130        self._server_type = server_type.lower()
131        if self._server_type == NaServer.SERVER_TYPE_FILER:
132            self._url = NaServer.URL_FILER
133        else:
134            self._url = NaServer.URL_DFM
135        self._ns = NaServer.NETAPP_NS
136        self._refresh_conn = True
137
138    def set_api_version(self, major, minor):
139        """Set the API version."""
140        try:
141            self._api_major_version = int(major)
142            self._api_minor_version = int(minor)
143            self._api_version = six.text_type(major) + "." + \
144                six.text_type(minor)
145        except ValueError:
146            raise ValueError('Major and minor versions must be integers')
147        self._refresh_conn = True
148
149    def get_api_version(self):
150        """Gets the API version tuple."""
151        if hasattr(self, '_api_version'):
152            return (self._api_major_version, self._api_minor_version)
153        return None
154
155    def set_port(self, port):
156        """Set the server communication port."""
157        try:
158            int(port)
159        except ValueError:
160            raise ValueError('Port must be integer')
161        self._port = six.text_type(port)
162        self._refresh_conn = True
163
164    def set_timeout(self, seconds):
165        """Sets the timeout in seconds."""
166        try:
167            self._timeout = int(seconds)
168        except ValueError:
169            raise ValueError('timeout in seconds must be integer')
170
171    def set_vfiler(self, vfiler):
172        """Set the vfiler to use if tunneling gets enabled."""
173        self._vfiler = vfiler
174
175    def set_vserver(self, vserver):
176        """Set the vserver to use if tunneling gets enabled."""
177        self._vserver = vserver
178
179    @utils.trace_api(filter_function=na_utils.trace_filter_func_api)
180    def send_http_request(self, na_element, enable_tunneling=False):
181        """Invoke the API on the server."""
182        if not na_element or not isinstance(na_element, NaElement):
183            raise ValueError('NaElement must be supplied to invoke API')
184
185        request, request_element = self._create_request(na_element,
186                                                        enable_tunneling)
187
188        if not hasattr(self, '_opener') or not self._opener \
189                or self._refresh_conn:
190            self._build_opener()
191        try:
192            if hasattr(self, '_timeout'):
193                response = self._opener.open(request, timeout=self._timeout)
194            else:
195                response = self._opener.open(request)
196        except urllib.error.HTTPError as e:
197            raise NaApiError(e.code, e.msg)
198        except Exception:
199            LOG.exception("Error communicating with NetApp filer.")
200            raise NaApiError('Unexpected error')
201
202        response_xml = response.read()
203        response_element = self._get_result(response_xml)
204
205        return response_element
206
207    def invoke_successfully(self, na_element, enable_tunneling=False):
208        """Invokes API and checks execution status as success.
209
210        Need to set enable_tunneling to True explicitly to achieve it.
211        This helps to use same connection instance to enable or disable
212        tunneling. The vserver or vfiler should be set before this call
213        otherwise tunneling remains disabled.
214        """
215        result = self.send_http_request(na_element, enable_tunneling)
216        if result.has_attr('status') and result.get_attr('status') == 'passed':
217            return result
218        code = result.get_attr('errno')\
219            or result.get_child_content('errorno')\
220            or 'ESTATUSFAILED'
221        if code == ESIS_CLONE_NOT_LICENSED:
222            msg = 'Clone operation failed: FlexClone not licensed.'
223        else:
224            msg = result.get_attr('reason')\
225                or result.get_child_content('reason')\
226                or 'Execution status is failed due to unknown reason'
227        raise NaApiError(code, msg)
228
229    def send_request(self, api_name, api_args=None, enable_tunneling=True):
230        """Sends request to Ontapi."""
231        request = NaElement(api_name)
232        if api_args:
233            request.translate_struct(api_args)
234        return self.invoke_successfully(request, enable_tunneling)
235
236    def _create_request(self, na_element, enable_tunneling=False):
237        """Creates request in the desired format."""
238        netapp_elem = NaElement('netapp')
239        netapp_elem.add_attr('xmlns', self._ns)
240        if hasattr(self, '_api_version'):
241            netapp_elem.add_attr('version', self._api_version)
242        if enable_tunneling:
243            self._enable_tunnel_request(netapp_elem)
244        netapp_elem.add_child_elem(na_element)
245        request_d = netapp_elem.to_string()
246        request = urllib.request.Request(
247            self._get_url(), data=request_d,
248            headers={'Content-Type': 'text/xml', 'charset': 'utf-8'})
249        return request, netapp_elem
250
251    def _enable_tunnel_request(self, netapp_elem):
252        """Enables vserver or vfiler tunneling."""
253        if hasattr(self, '_vfiler') and self._vfiler:
254            if hasattr(self, '_api_major_version') and \
255                    hasattr(self, '_api_minor_version') and \
256                    self._api_major_version >= 1 and \
257                    self._api_minor_version >= 7:
258                netapp_elem.add_attr('vfiler', self._vfiler)
259            else:
260                raise ValueError('ontapi version has to be atleast 1.7'
261                                 ' to send request to vfiler')
262        if hasattr(self, '_vserver') and self._vserver:
263            if hasattr(self, '_api_major_version') and \
264                    hasattr(self, '_api_minor_version') and \
265                    self._api_major_version >= 1 and \
266                    self._api_minor_version >= 15:
267                netapp_elem.add_attr('vfiler', self._vserver)
268            else:
269                raise ValueError('ontapi version has to be atleast 1.15'
270                                 ' to send request to vserver')
271
272    def _parse_response(self, response):
273        """Get the NaElement for the response."""
274        if not response:
275            raise NaApiError('No response received')
276        xml = etree.XML(response)
277        return NaElement(xml)
278
279    def _get_result(self, response):
280        """Gets the call result."""
281        processed_response = self._parse_response(response)
282        return processed_response.get_child_by_name('results')
283
284    def _get_url(self):
285        host = self._host
286
287        if netutils.is_valid_ipv6(host):
288            host = netutils.escape_ipv6(host)
289
290        return '%s://%s:%s/%s' % (self._protocol, host, self._port,
291                                  self._url)
292
293    def _build_opener(self):
294        if self._auth_style == NaServer.STYLE_LOGIN_PASSWORD:
295            auth_handler = self._create_basic_auth_handler()
296        else:
297            auth_handler = self._create_certificate_auth_handler()
298        opener = urllib.request.build_opener(auth_handler)
299        self._opener = opener
300
301    def _create_basic_auth_handler(self):
302        password_man = urllib.request.HTTPPasswordMgrWithDefaultRealm()
303        password_man.add_password(None, self._get_url(), self._username,
304                                  self._password)
305        auth_handler = urllib.request.HTTPBasicAuthHandler(password_man)
306        return auth_handler
307
308    def _create_certificate_auth_handler(self):
309        raise NotImplementedError()
310
311    def __str__(self):
312        return "server: %s" % self._host
313
314
315class NaElement(object):
316    """Class wraps basic building block for NetApp API request."""
317
318    def __init__(self, name):
319        """Name of the element or etree.Element."""
320        if isinstance(name, etree._Element):
321            self._element = name
322        else:
323            self._element = etree.Element(name)
324
325    def get_name(self):
326        """Returns the tag name of the element."""
327        return self._element.tag
328
329    def set_content(self, text):
330        """Set the text string for the element."""
331        self._element.text = text
332
333    def get_content(self):
334        """Get the text for the element."""
335        return self._element.text
336
337    def add_attr(self, name, value):
338        """Add the attribute to the element."""
339        self._element.set(name, value)
340
341    def add_attrs(self, **attrs):
342        """Add multiple attributes to the element."""
343        for attr in attrs.keys():
344            self._element.set(attr, attrs.get(attr))
345
346    def add_child_elem(self, na_element):
347        """Add the child element to the element."""
348        if isinstance(na_element, NaElement):
349            self._element.append(na_element._element)
350            return
351        raise
352
353    def get_child_by_name(self, name):
354        """Get the child element by the tag name."""
355        for child in self._element.iterchildren():
356            if child.tag == name or etree.QName(child.tag).localname == name:
357                return NaElement(child)
358        return None
359
360    def get_child_content(self, name):
361        """Get the content of the child."""
362        for child in self._element.iterchildren():
363            if child.tag == name or etree.QName(child.tag).localname == name:
364                return child.text
365        return None
366
367    def get_children(self):
368        """Get the children for the element."""
369        return [NaElement(el) for el in self._element.iterchildren()]
370
371    def has_attr(self, name):
372        """Checks whether element has attribute."""
373        attributes = self._element.attrib or {}
374        return name in attributes.keys()
375
376    def get_attr(self, name):
377        """Get the attribute with the given name."""
378        attributes = self._element.attrib or {}
379        return attributes.get(name)
380
381    def get_attr_names(self):
382        """Returns the list of attribute names."""
383        attributes = self._element.attrib or {}
384        return list(attributes.keys())
385
386    def add_new_child(self, name, content, convert=False):
387        """Add child with tag name and content.
388
389           Convert replaces entity refs to chars.
390        """
391        child = NaElement(name)
392        if convert:
393            content = NaElement._convert_entity_refs(content)
394        child.set_content(content)
395        self.add_child_elem(child)
396
397    @staticmethod
398    def _convert_entity_refs(text):
399        """Converts entity refs to chars to handle etree auto conversions."""
400        text = text.replace("&lt;", "<")
401        text = text.replace("&gt;", ">")
402        return text
403
404    @staticmethod
405    def create_node_with_children(node, **children):
406        """Creates and returns named node with children."""
407        parent = NaElement(node)
408        for child in children.keys():
409            parent.add_new_child(child, children.get(child, None))
410        return parent
411
412    def add_node_with_children(self, node, **children):
413        """Creates named node with children."""
414        parent = NaElement.create_node_with_children(node, **children)
415        self.add_child_elem(parent)
416
417    def to_string(self, pretty=False, method='xml', encoding='UTF-8'):
418        """Prints the element to string."""
419        return etree.tostring(self._element, method=method, encoding=encoding,
420                              pretty_print=pretty)
421
422    def __str__(self):
423        xml = self.to_string(pretty=True)
424        if six.PY3:
425            xml = xml.decode('utf-8')
426        return xml
427
428    def __eq__(self, other):
429        return str(self) == str(other)
430
431    def __ne__(self, other):
432        return not self.__eq__(other)
433
434    def __hash__(self):
435        return hash(str(self))
436
437    def __repr__(self):
438        return str(self)
439
440    def __getitem__(self, key):
441        """Dict getter method for NaElement.
442
443            Returns NaElement list if present,
444            text value in case no NaElement node
445            children or attribute value if present.
446        """
447
448        child = self.get_child_by_name(key)
449        if child:
450            if child.get_children():
451                return child
452            else:
453                return child.get_content()
454        elif self.has_attr(key):
455            return self.get_attr(key)
456        raise KeyError(_('No element by given name %s.') % (key))
457
458    def __setitem__(self, key, value):
459        """Dict setter method for NaElement.
460
461           Accepts dict, list, tuple, str, int, float and long as valid value.
462        """
463        if key:
464            if value:
465                if isinstance(value, NaElement):
466                    child = NaElement(key)
467                    child.add_child_elem(value)
468                    self.add_child_elem(child)
469                elif isinstance(value, six.integer_types + (str, float)):
470                    self.add_new_child(key, six.text_type(value))
471                elif isinstance(value, (list, tuple, dict)):
472                    child = NaElement(key)
473                    child.translate_struct(value)
474                    self.add_child_elem(child)
475                else:
476                    raise TypeError(_('Not a valid value for NaElement.'))
477            else:
478                self.add_child_elem(NaElement(key))
479        else:
480            raise KeyError(_('NaElement name cannot be null.'))
481
482    def translate_struct(self, data_struct):
483        """Convert list, tuple, dict to NaElement and appends.
484
485           Example usage:
486
487           1.
488
489           .. code-block:: xml
490
491                <root>
492                    <elem1>vl1</elem1>
493                    <elem2>vl2</elem2>
494                    <elem3>vl3</elem3>
495                </root>
496
497           The above can be achieved by doing
498
499           .. code-block:: python
500
501                root = NaElement('root')
502                root.translate_struct({'elem1': 'vl1', 'elem2': 'vl2',
503                                       'elem3': 'vl3'})
504
505           2.
506
507           .. code-block:: xml
508
509                <root>
510                    <elem1>vl1</elem1>
511                    <elem2>vl2</elem2>
512                    <elem1>vl3</elem1>
513                </root>
514
515           The above can be achieved by doing
516
517           .. code-block:: python
518
519                root = NaElement('root')
520                root.translate_struct([{'elem1': 'vl1', 'elem2': 'vl2'},
521                                       {'elem1': 'vl3'}])
522        """
523        if isinstance(data_struct, (list, tuple)):
524            for el in data_struct:
525                if isinstance(el, (list, tuple, dict)):
526                    self.translate_struct(el)
527                else:
528                    self.add_child_elem(NaElement(el))
529        elif isinstance(data_struct, dict):
530            for k in data_struct.keys():
531                child = NaElement(k)
532                if isinstance(data_struct[k], (dict, list, tuple)):
533                    child.translate_struct(data_struct[k])
534                else:
535                    if data_struct[k]:
536                        child.set_content(six.text_type(data_struct[k]))
537                self.add_child_elem(child)
538        else:
539            raise ValueError(_('Type cannot be converted into NaElement.'))
540
541
542class NaApiError(Exception):
543    """Base exception class for NetApp API errors."""
544
545    def __init__(self, code='unknown', message='unknown'):
546        self.code = code
547        self.message = message
548
549    def __str__(self, *args, **kwargs):
550        return 'NetApp API failed. Reason - %s:%s' % (self.code, self.message)
551
552
553class SSHUtil(object):
554    """Encapsulates connection logic and command execution for SSH client."""
555
556    MAX_CONCURRENT_SSH_CONNECTIONS = 5
557    RECV_TIMEOUT = 3
558    CONNECTION_KEEP_ALIVE = 600
559    WAIT_ON_STDOUT_TIMEOUT = 3
560
561    def __init__(self, host, username, password, port=22):
562        self.ssh_pool = self._init_ssh_pool(host, port, username, password)
563
564        # Note(cfouts) Number of SSH connections made to the backend need to be
565        # limited. Use of SSHPool allows connections to be cached and reused
566        # instead of creating a new connection each time a command is executed
567        # via SSH.
568        self.ssh_connect_semaphore = semaphore.Semaphore(
569            self.MAX_CONCURRENT_SSH_CONNECTIONS)
570
571    def _init_ssh_pool(self, host, port, username, password):
572        return ssh_utils.SSHPool(host,
573                                 port,
574                                 self.CONNECTION_KEEP_ALIVE,
575                                 username,
576                                 password)
577
578    def execute_command(self, client, command_text, timeout=RECV_TIMEOUT):
579        LOG.debug("execute_command() - Sending command.")
580        stdin, stdout, stderr = client.exec_command(command_text)
581        stdin.close()
582        self._wait_on_stdout(stdout, timeout)
583        output = stdout.read()
584        LOG.debug("Output of length %(size)d received.",
585                  {'size': len(output)})
586        stdout.close()
587        stderr.close()
588        return output
589
590    def execute_command_with_prompt(self,
591                                    client,
592                                    command,
593                                    expected_prompt_text,
594                                    prompt_response,
595                                    timeout=RECV_TIMEOUT):
596        LOG.debug("execute_command_with_prompt() - Sending command.")
597        stdin, stdout, stderr = client.exec_command(command)
598        self._wait_on_stdout(stdout, timeout)
599        response = stdout.channel.recv(999)
600        if response.strip() != expected_prompt_text:
601            msg = _("Unexpected output. Expected [%(expected)s] but "
602                    "received [%(output)s]") % {
603                'expected': expected_prompt_text,
604                'output': response.strip(),
605            }
606            LOG.error(msg)
607            stdin.close()
608            stdout.close()
609            stderr.close()
610            raise exception.VolumeBackendAPIException(msg)
611        else:
612            LOG.debug("execute_command_with_prompt() - Sending answer")
613            stdin.write(prompt_response + '\n')
614            stdin.flush()
615        stdin.close()
616        stdout.close()
617        stderr.close()
618
619    def _wait_on_stdout(self, stdout, timeout=WAIT_ON_STDOUT_TIMEOUT):
620        wait_time = 0.0
621        # NOTE(cfouts): The server does not always indicate when EOF is reached
622        # for stdout. The timeout exists for this reason and an attempt is made
623        # to read from stdout.
624        while not stdout.channel.exit_status_ready():
625            # period is 10 - 25 centiseconds
626            period = random.randint(10, 25) / 100.0
627            greenthread.sleep(period)
628            wait_time += period
629            if wait_time > timeout:
630                LOG.debug("Timeout exceeded while waiting for exit status.")
631                break
632