4f58211e7a
- Move from lxml lib direct usage to defusedxml lib [1] to fix B320 [2] [1] https://pypi.org/project/defusedxml/0.7.1/ [2] https://bandit.readthedocs.io/en/1.7.5/blacklists/blacklist_calls.html#b313-b320-xml Change-Id: Id1faa9227415884d15c737ace5a0e0e5de3afa81
485 lines
18 KiB
Python
485 lines
18 KiB
Python
# Copyright (c) 2014-2020 VMware, Inc.
|
|
# All Rights Reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
"""
|
|
Common classes that provide access to vSphere services.
|
|
"""
|
|
|
|
import http.client as httplib
|
|
import io
|
|
import logging
|
|
|
|
import netaddr
|
|
from oslo_utils import timeutils
|
|
from oslo_utils import uuidutils
|
|
import requests
|
|
import suds
|
|
from suds import cache
|
|
from suds import client
|
|
from suds import plugin
|
|
import suds.sax.element as element
|
|
from suds import transport
|
|
|
|
from oslo_vmware._i18n import _
|
|
from oslo_vmware import exceptions
|
|
from oslo_vmware import vim_util
|
|
|
|
CACHE_TIMEOUT = 60 * 60 # One hour cache timeout
|
|
ADDRESS_IN_USE_ERROR = 'Address already in use'
|
|
CONN_ABORT_ERROR = 'Software caused connection abort'
|
|
RESP_NOT_XML_ERROR = 'Response is "text/html", not "text/xml"'
|
|
|
|
SERVICE_INSTANCE = 'ServiceInstance'
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
|
|
class ServiceMessagePlugin(plugin.MessagePlugin):
|
|
"""Suds plug-in handling some special cases while calling VI SDK."""
|
|
|
|
# list of XML elements which are allowed to be empty
|
|
EMPTY_ELEMENTS = ["VirtualMachineEmptyProfileSpec"]
|
|
|
|
def add_attribute_for_value(self, node):
|
|
"""Helper to handle AnyType.
|
|
|
|
Suds does not handle AnyType properly. But VI SDK requires type
|
|
attribute to be set when AnyType is used.
|
|
|
|
:param node: XML value node
|
|
"""
|
|
if node.name == 'value' or node.name == 'val':
|
|
node.set('xsi:type', 'xsd:string')
|
|
# removeKey may be a 'int' or a 'string'
|
|
if node.name == 'removeKey':
|
|
try:
|
|
int(node.text)
|
|
node.set('xsi:type', 'xsd:int')
|
|
except (ValueError, TypeError):
|
|
node.set('xsi:type', 'xsd:string')
|
|
|
|
def prune(self, el):
|
|
pruned = []
|
|
for c in el.children:
|
|
self.prune(c)
|
|
if c.isempty(False) and c.name not in self.EMPTY_ELEMENTS:
|
|
pruned.append(c)
|
|
for p in pruned:
|
|
el.children.remove(p)
|
|
|
|
def marshalled(self, context):
|
|
"""Modifies the envelope document before it is sent.
|
|
|
|
This method provides the plug-in with the opportunity to prune empty
|
|
nodes and fix nodes before sending it to the server.
|
|
|
|
:param context: send context
|
|
"""
|
|
# Suds builds the entire request object based on the WSDL schema.
|
|
# VI SDK throws server errors if optional SOAP nodes are sent
|
|
# without values; e.g., <test/> as opposed to <test>test</test>.
|
|
|
|
self.prune(context.envelope)
|
|
context.envelope.walk(self.add_attribute_for_value)
|
|
|
|
|
|
class Response(io.BytesIO):
|
|
"""Response with an input stream as source."""
|
|
|
|
def __init__(self, stream, status=200, headers=None):
|
|
self.status = status
|
|
self.headers = headers or {}
|
|
self.reason = requests.status_codes._codes.get(
|
|
status, [''])[0].upper().replace('_', ' ') # nosec
|
|
io.BytesIO.__init__(self, stream)
|
|
|
|
@property
|
|
def _original_response(self):
|
|
return self
|
|
|
|
@property
|
|
def msg(self):
|
|
return self
|
|
|
|
def read(self, chunk_size, **kwargs):
|
|
return io.BytesIO.read(self, chunk_size)
|
|
|
|
def info(self):
|
|
return self
|
|
|
|
def get_all(self, name, default):
|
|
result = self.headers.get(name)
|
|
if not result:
|
|
return default
|
|
return [result]
|
|
|
|
def getheaders(self, name):
|
|
return self.get_all(name, [])
|
|
|
|
def release_conn(self):
|
|
self.close()
|
|
|
|
|
|
class LocalFileAdapter(requests.adapters.HTTPAdapter):
|
|
"""Transport adapter for local files.
|
|
|
|
See http://stackoverflow.com/a/22989322
|
|
"""
|
|
def __init__(self, pool_maxsize=10):
|
|
super(LocalFileAdapter, self).__init__(pool_connections=pool_maxsize,
|
|
pool_maxsize=pool_maxsize)
|
|
|
|
def _build_response_from_file(self, request):
|
|
file_path = request.url[7:]
|
|
with open(file_path, 'rb') as f:
|
|
file_content = f.read()
|
|
buff = bytearray(file_content.decode(), "utf-8")
|
|
resp = Response(buff)
|
|
return self.build_response(request, resp)
|
|
|
|
def send(self, request, stream=False, timeout=None,
|
|
verify=True, cert=None, proxies=None):
|
|
"""Sends request for a local file."""
|
|
return self._build_response_from_file(request)
|
|
|
|
|
|
class RequestsTransport(transport.Transport):
|
|
def __init__(self, cacert=None, insecure=True, pool_maxsize=10,
|
|
connection_timeout=None):
|
|
transport.Transport.__init__(self)
|
|
# insecure flag is used only if cacert is not
|
|
# specified.
|
|
self.verify = cacert if cacert else not insecure
|
|
self.session = requests.Session()
|
|
self.session.mount('file:///',
|
|
LocalFileAdapter(pool_maxsize=pool_maxsize))
|
|
self.session.mount('https://', requests.adapters.HTTPAdapter(
|
|
pool_connections=pool_maxsize, pool_maxsize=pool_maxsize))
|
|
self.cookiejar = self.session.cookies
|
|
self._connection_timeout = connection_timeout
|
|
|
|
def open(self, request):
|
|
resp = self.session.get(request.url, verify=self.verify)
|
|
return io.BytesIO(resp.content)
|
|
|
|
def send(self, request):
|
|
resp = self.session.post(request.url,
|
|
data=request.message,
|
|
headers=request.headers,
|
|
verify=self.verify,
|
|
timeout=self._connection_timeout)
|
|
return transport.Reply(resp.status_code, resp.headers, resp.content)
|
|
|
|
|
|
class MemoryCache(cache.ObjectCache):
|
|
def __init__(self):
|
|
self._cache = {}
|
|
|
|
def get(self, key):
|
|
"""Retrieves the value for a key or None."""
|
|
now = timeutils.utcnow_ts()
|
|
for k in list(self._cache):
|
|
(timeout, _value) = self._cache[k]
|
|
if timeout and now >= timeout:
|
|
del self._cache[k]
|
|
|
|
return self._cache.get(key, (0, None))[1]
|
|
|
|
def put(self, key, value, time=CACHE_TIMEOUT):
|
|
"""Sets the value for a key."""
|
|
timeout = 0
|
|
if time != 0:
|
|
timeout = timeutils.utcnow_ts() + time
|
|
self._cache[key] = (timeout, value)
|
|
return True
|
|
|
|
|
|
_CACHE = MemoryCache()
|
|
|
|
|
|
class CompatibilitySudsClient(client.Client):
|
|
"""suds client with added cookiejar attribute
|
|
|
|
The cookiejar properties allow reading/setting the cookiejar used by the
|
|
underlying transport.
|
|
"""
|
|
def __init__(self, *args, **kwargs):
|
|
super(CompatibilitySudsClient, self).__init__(*args, **kwargs)
|
|
|
|
@property
|
|
def cookiejar(self):
|
|
return self.options.transport.cookiejar
|
|
|
|
@cookiejar.setter
|
|
def cookiejar(self, cookies):
|
|
self.options.transport.session.cookies = cookies
|
|
self.options.transport.cookiejar = cookies
|
|
|
|
|
|
class Service(object):
|
|
"""Base class containing common functionality for invoking vSphere
|
|
services
|
|
"""
|
|
|
|
def __init__(self, wsdl_url=None, soap_url=None,
|
|
cacert=None, insecure=True, pool_maxsize=10,
|
|
connection_timeout=None, op_id_prefix='oslo.vmware'):
|
|
self.wsdl_url = wsdl_url
|
|
self.soap_url = soap_url
|
|
self.op_id_prefix = op_id_prefix
|
|
LOG.debug("Creating suds client with soap_url='%s' and wsdl_url='%s'",
|
|
self.soap_url, self.wsdl_url)
|
|
transport = RequestsTransport(cacert=cacert,
|
|
insecure=insecure,
|
|
pool_maxsize=pool_maxsize,
|
|
connection_timeout=connection_timeout)
|
|
self.client = CompatibilitySudsClient(self.wsdl_url,
|
|
transport=transport,
|
|
location=self.soap_url,
|
|
plugins=[ServiceMessagePlugin()],
|
|
cache=_CACHE)
|
|
self._service_content = None
|
|
self._vc_session_cookie = None
|
|
|
|
@staticmethod
|
|
def build_base_url(protocol, host, port):
|
|
proto_str = '%s://' % protocol
|
|
host_str = '[%s]' % host if netaddr.valid_ipv6(host) else host
|
|
port_str = '' if port is None else ':%d' % port
|
|
return proto_str + host_str + port_str
|
|
|
|
@staticmethod
|
|
def _retrieve_properties_ex_fault_checker(response):
|
|
"""Checks the RetrievePropertiesEx API response for errors.
|
|
|
|
Certain faults are sent in the SOAP body as a property of missingSet.
|
|
This method raises VimFaultException when a fault is found in the
|
|
response.
|
|
|
|
:param response: response from RetrievePropertiesEx API call
|
|
:raises: VimFaultException
|
|
"""
|
|
fault_list = []
|
|
details = {}
|
|
if not response:
|
|
# This is the case when the session has timed out. ESX SOAP
|
|
# server sends an empty RetrievePropertiesExResponse. Normally
|
|
# missingSet in the response objects has the specifics about
|
|
# the error, but that's not the case with a timed out idle
|
|
# session. It is as bad as a terminated session for we cannot
|
|
# use the session. Therefore setting fault to NotAuthenticated
|
|
# fault.
|
|
LOG.debug("RetrievePropertiesEx API response is empty; setting "
|
|
"fault to %s.",
|
|
exceptions.NOT_AUTHENTICATED)
|
|
fault_list = [exceptions.NOT_AUTHENTICATED]
|
|
else:
|
|
for obj_cont in response.objects:
|
|
if hasattr(obj_cont, 'missingSet'):
|
|
for missing_elem in obj_cont.missingSet:
|
|
f_type = missing_elem.fault.fault
|
|
f_name = f_type.__class__.__name__
|
|
fault_list.append(f_name)
|
|
if f_name == exceptions.NO_PERMISSION:
|
|
details['object'] = \
|
|
vim_util.get_moref_value(f_type.object)
|
|
details['privilegeId'] = f_type.privilegeId
|
|
|
|
if fault_list:
|
|
fault_string = _("Error occurred while calling "
|
|
"RetrievePropertiesEx.")
|
|
raise exceptions.VimFaultException(fault_list,
|
|
fault_string,
|
|
details=details)
|
|
|
|
def _set_soap_headers(self, op_id):
|
|
"""Set SOAP headers for the next remote call to vCenter.
|
|
|
|
SOAP headers may include operation ID and vcSessionCookie.
|
|
The operation ID is a random string which allows to correlate log
|
|
messages across different systems (OpenStack, vCenter, ESX).
|
|
vcSessionCookie is needed when making PBM calls.
|
|
"""
|
|
headers = []
|
|
if self._vc_session_cookie:
|
|
elem = element.Element('vcSessionCookie').setText(
|
|
self._vc_session_cookie)
|
|
headers.append(elem)
|
|
if op_id:
|
|
elem = element.Element('operationID').setText(op_id)
|
|
headers.append(elem)
|
|
if headers:
|
|
self.client.set_options(soapheaders=headers)
|
|
|
|
@property
|
|
def service_content(self):
|
|
if self._service_content is None:
|
|
self._service_content = self.retrieve_service_content()
|
|
return self._service_content
|
|
|
|
def get_http_cookie(self):
|
|
"""Return the vCenter session cookie."""
|
|
cookies = self.client.cookiejar
|
|
for cookie in cookies:
|
|
if cookie.name.lower() == 'vmware_soap_session':
|
|
return cookie.value
|
|
|
|
def __getattr__(self, attr_name):
|
|
"""Returns the method to invoke API identified by param attr_name."""
|
|
|
|
def request_handler(managed_object, **kwargs):
|
|
"""Handler for vSphere API calls.
|
|
|
|
Invokes the API and parses the response for fault checking and
|
|
other errors.
|
|
|
|
:param managed_object: managed object reference argument of the
|
|
API call
|
|
:param kwargs: keyword arguments of the API call
|
|
:returns: response of the API call
|
|
:raises: VimException, VimFaultException, VimAttributeException,
|
|
VimSessionOverLoadException, VimConnectionException
|
|
"""
|
|
try:
|
|
if isinstance(managed_object, str):
|
|
# For strings, use string value for value and type
|
|
# of the managed object.
|
|
managed_object = vim_util.get_moref(managed_object,
|
|
managed_object)
|
|
if managed_object is None:
|
|
return
|
|
|
|
skip_op_id = kwargs.pop('skip_op_id', False)
|
|
op_id = None
|
|
if not skip_op_id:
|
|
# Generate opID. It will appear in vCenter and ESX logs for
|
|
# this particular remote call.
|
|
op_id = '%s-%s' % (self.op_id_prefix,
|
|
uuidutils.generate_uuid())
|
|
LOG.debug('Invoking %s.%s with opID=%s',
|
|
vim_util.get_moref_type(managed_object),
|
|
attr_name,
|
|
op_id)
|
|
self._set_soap_headers(op_id)
|
|
request = getattr(self.client.service, attr_name)
|
|
response = request(managed_object, **kwargs)
|
|
if (attr_name.lower() == 'retrievepropertiesex'):
|
|
Service._retrieve_properties_ex_fault_checker(response)
|
|
return response
|
|
except exceptions.VimFaultException:
|
|
# Catch the VimFaultException that is raised by the fault
|
|
# check of the SOAP response.
|
|
raise
|
|
|
|
except suds.WebFault as excep:
|
|
fault_string = None
|
|
if excep.fault:
|
|
fault_string = excep.fault.faultstring
|
|
|
|
doc = excep.document
|
|
detail = None
|
|
if doc is not None:
|
|
detail = doc.childAtPath('/detail')
|
|
if not detail:
|
|
# NOTE(arnaud): this is needed with VC 5.1
|
|
detail = doc.childAtPath('/Envelope/Body/Fault/detail')
|
|
fault_list = []
|
|
details = {}
|
|
if detail:
|
|
for fault in detail.getChildren():
|
|
fault_type = fault.get('type')
|
|
# NOTE(vbala): PBM faults use vim25 namespace. Also,
|
|
# PBM APIs throw NotAuthenticated in vSphere 6.5 for
|
|
# session expiry.
|
|
if (fault_type.endswith(exceptions.SECURITY_ERROR) or
|
|
fault_type.endswith(
|
|
exceptions.NOT_AUTHENTICATED)):
|
|
fault_type = exceptions.NOT_AUTHENTICATED
|
|
fault_list.append(fault_type)
|
|
for child in fault.getChildren():
|
|
details[child.name] = child.getText()
|
|
raise exceptions.VimFaultException(fault_list, fault_string,
|
|
excep, details)
|
|
|
|
except AttributeError as excep:
|
|
raise exceptions.VimAttributeException(
|
|
_("No such SOAP method %s.") % attr_name, excep)
|
|
|
|
except (httplib.CannotSendRequest,
|
|
httplib.ResponseNotReady,
|
|
httplib.CannotSendHeader) as excep:
|
|
raise exceptions.VimSessionOverLoadException(
|
|
_("httplib error in %s.") % attr_name, excep)
|
|
|
|
except requests.RequestException as excep:
|
|
raise exceptions.VimConnectionException(
|
|
_("requests error in %s.") % attr_name, excep)
|
|
|
|
except Exception as excep:
|
|
# TODO(vbala) should catch specific exceptions and raise
|
|
# appropriate VimExceptions.
|
|
|
|
# Socket errors which need special handling; some of these
|
|
# might be caused by server API call overload.
|
|
if (str(excep).find(ADDRESS_IN_USE_ERROR) != -1 or
|
|
str(excep).find(CONN_ABORT_ERROR)) != -1:
|
|
raise exceptions.VimSessionOverLoadException(
|
|
_("Socket error in %s.") % attr_name, excep)
|
|
# Type error which needs special handling; it might be caused
|
|
# by server API call overload.
|
|
elif str(excep).find(RESP_NOT_XML_ERROR) != -1:
|
|
raise exceptions.VimSessionOverLoadException(
|
|
_("Type error in %s.") % attr_name, excep)
|
|
else:
|
|
raise exceptions.VimException(
|
|
_("Exception in %s.") % attr_name, excep)
|
|
return request_handler
|
|
|
|
def __repr__(self):
|
|
return "vSphere object"
|
|
|
|
def __str__(self):
|
|
return "vSphere object"
|
|
|
|
|
|
class SudsLogFilter(logging.Filter):
|
|
"""Filter to mask/truncate vCenter credentials in suds logs."""
|
|
|
|
def filter(self, record):
|
|
if not hasattr(record.msg, 'childAtPath'):
|
|
return True
|
|
|
|
# Suds will log vCenter credentials if SessionManager.Login or
|
|
# SessionManager.SessionIsActive fails.
|
|
login = (record.msg.childAtPath('/Envelope/Body/Login') or
|
|
record.msg.childAtPath('/Envelope/Body/SessionIsActive'))
|
|
if login is None:
|
|
return True
|
|
|
|
if login.childAtPath('userName') is not None:
|
|
login.childAtPath('userName').setText('***')
|
|
if login.childAtPath('password') is not None: # nosec
|
|
login.childAtPath('password').setText('***') # nosec
|
|
|
|
session_id = login.childAtPath('sessionID')
|
|
if session_id is not None:
|
|
session_id.setText(session_id.getText()[-5:])
|
|
|
|
return True
|
|
|
|
|
|
# Set log filter to mask/truncate vCenter credentials in suds logs.
|
|
suds.client.log.addFilter(SudsLogFilter())
|