Add basic client for HVN REST API
This commit is contained in:
parent
cadf134247
commit
8bf79c3b6a
@ -99,7 +99,7 @@ evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / stateme
|
||||
[MISCELLANEOUS]
|
||||
|
||||
# List of note tags to take in consideration, separated by a comma.
|
||||
notes=FIXME,XXX,TODO
|
||||
notes=FIXME,XXX
|
||||
|
||||
|
||||
[FORMAT]
|
||||
|
@ -13,3 +13,22 @@
|
||||
# under the License.
|
||||
|
||||
"""Shared constants across the bcbio-nextgen-vm project."""
|
||||
|
||||
GET = "GET"
|
||||
POST = "POST"
|
||||
PUT = "PUT"
|
||||
PATCH = "PATCH"
|
||||
DELETE = "DELETE"
|
||||
|
||||
DELETING = "Deleting"
|
||||
FAILED = "Failed"
|
||||
SUCCEEDED = "Succeeded"
|
||||
UPDATING = "Updating"
|
||||
|
||||
ABSOLUTE = "absolute"
|
||||
WEIGHT = "weight"
|
||||
|
||||
VIRTUAL_APPLIANCE = "VirtualAppliance"
|
||||
VNET_LOCAL = "VnetLocal"
|
||||
VIRTUAL_NETWORK_GATEWAY = "VirtualNetworkGateway"
|
||||
INTERNET = "Internet"
|
||||
|
@ -58,15 +58,41 @@ class DataProcessingError(HNVException):
|
||||
template = "The provided information is incomplete or invalid."
|
||||
|
||||
|
||||
class NotFound(HNVException):
|
||||
class ServiceException(HNVException):
|
||||
|
||||
"""Base exception for all the API interaction related errors."""
|
||||
|
||||
template = "Something went wrong."
|
||||
|
||||
|
||||
class TimeOut(ServiceException):
|
||||
|
||||
"""The request timed out."""
|
||||
|
||||
template = "The request timed out."
|
||||
|
||||
|
||||
class NotFound(ServiceException):
|
||||
|
||||
"""The required object is not available in container."""
|
||||
|
||||
template = "The %(object)r was not found in %(container)s."
|
||||
|
||||
|
||||
class NotSupported(HNVException):
|
||||
class CertificateVerifyFailed(ServiceException):
|
||||
|
||||
"""The received certificate is not valid.
|
||||
|
||||
In order to avoid the current exception the validation of the SSL
|
||||
certificate should be disabled for the metadata provider. In order
|
||||
to do that the `https_allow_insecure` config option should be set.
|
||||
"""
|
||||
|
||||
template = "The received certificate is not valid."
|
||||
|
||||
|
||||
class NotSupported(ServiceException):
|
||||
|
||||
"""The functionality required is not available in the current context."""
|
||||
|
||||
template = "%(feature)s is not available in %(context)s."
|
||||
template = "%(feature)s is not available for %(context)s."
|
||||
|
202
hnv_client/common/utils.py
Normal file
202
hnv_client/common/utils.py
Normal file
@ -0,0 +1,202 @@
|
||||
# Copyright 2017 Cloudbase Solutions Srl
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""Utilities used across the project."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
from oslo_log import log as logging
|
||||
import requests
|
||||
import requests_ntlm
|
||||
import six
|
||||
|
||||
from hnv_client.common import constant
|
||||
from hnv_client.common import exception
|
||||
from hnv_client import config as hnv_config
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
CONFIG = hnv_config.CONFIG
|
||||
|
||||
|
||||
class _HNVClient(object):
|
||||
|
||||
"""Minimalistic client for the Network Controller REST API.
|
||||
|
||||
:param url: The base URL where the agent looks for
|
||||
Network Controller API.
|
||||
:param username: The username required for connecting to the
|
||||
Network Controller API.
|
||||
:param password: The password required for connecting to the
|
||||
Network Controller API.
|
||||
:param allow_insecure: Whether to disable the validation of
|
||||
HTTPS certificates.
|
||||
:param ca_bundle: The path to a CA_BUNDLE file or directory
|
||||
with certificates of trusted CAs.
|
||||
"""
|
||||
|
||||
def __init__(self, url, username=None, password=None, allow_insecure=False,
|
||||
ca_bundle=None):
|
||||
self._base_url = url
|
||||
self._credentials = (username, password)
|
||||
self._https_allow_insecure = allow_insecure
|
||||
self._https_ca_bundle = ca_bundle
|
||||
self._http_session = None
|
||||
|
||||
@property
|
||||
def _session(self):
|
||||
"""The current session used by the client.
|
||||
|
||||
The Session object allows you to persist certain parameters across
|
||||
requests. It also persists cookies across all requests made from
|
||||
the Session instance, and will use urllib3's connection pooling.
|
||||
So if you're making several requests to the same host, the underlying
|
||||
TCP connection will be reused, which can result in a significant
|
||||
performance increase.
|
||||
"""
|
||||
if self._http_session is None:
|
||||
self._http_session = requests.Session()
|
||||
self._http_session.headers.update(self._get_headers())
|
||||
self._http_session.verify = self._verify_https_request()
|
||||
|
||||
if all(self._credentials):
|
||||
username, password = self._credentials
|
||||
self._http_session.auth = requests_ntlm.HttpNtlmAuth(
|
||||
username=username, password=password)
|
||||
|
||||
return self._http_session
|
||||
|
||||
@staticmethod
|
||||
def _get_headers():
|
||||
"""Prepare the HTTP headers for the current request."""
|
||||
|
||||
# TODO(alexcoman): Add the x-ms-client-ip-address header in order
|
||||
# to improve the Network Controller requests logging.
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"Connection": "keep-alive",
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
}
|
||||
|
||||
def _verify_https_request(self):
|
||||
"""Whether to disable the validation of HTTPS certificates.
|
||||
|
||||
.. notes::
|
||||
When `https_allow_insecure` option is `True` the SSL certificate
|
||||
validation for the connection with the Network Controller API will
|
||||
be disabled (please don't use it if you don't know the
|
||||
implications of this behaviour).
|
||||
"""
|
||||
if self._https_ca_bundle:
|
||||
return self._https_ca_bundle
|
||||
else:
|
||||
return not self._https_allow_insecure
|
||||
|
||||
def _http_request(self, resource, method=constant.GET, body=None):
|
||||
url = requests.compat.urljoin(self._base_url, resource)
|
||||
headers = self._get_headers()
|
||||
if method in (constant.PUT, constant.PATCH):
|
||||
etag = (body or {}).get("etag", None)
|
||||
if etag is not None:
|
||||
headers["If-Match"] = etag
|
||||
|
||||
attemts = 0
|
||||
while True:
|
||||
try:
|
||||
response = self._session.request(
|
||||
method=method, url=url, headers=headers,
|
||||
data=json.dumps(body) if body else None,
|
||||
timeout=CONFIG.HNV.http_request_timeout
|
||||
)
|
||||
break
|
||||
except requests.ConnectionError as exc:
|
||||
attemts += 1
|
||||
self._http_session = None
|
||||
LOG.debug("Request failed: %s", exc)
|
||||
if attemts > CONFIG.HNV.retry_count:
|
||||
if isinstance(exc, requests.exceptions.SSLError):
|
||||
raise exception.CertificateVerifyFailed(
|
||||
"HTTPS certificate validation failed.")
|
||||
raise
|
||||
time.sleep(CONFIG.HNV.retry_interval)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
status_code = exc.response.status_code
|
||||
content = exc.response.text
|
||||
LOG.debug("HTTP Error %(status_code)r: %(details)r",
|
||||
{"status_code": status_code, "details": content})
|
||||
|
||||
if status_code == 400:
|
||||
raise exception.ServiceException(
|
||||
("HNV Client failed to communicate with the API. "
|
||||
"Please open an issue with the following information: "
|
||||
"%(resource)r: %(details)r"),
|
||||
resource=resource, details=content
|
||||
)
|
||||
if status_code == 404:
|
||||
raise exception.NotFound(
|
||||
"Resource %(resource)r was not found.", resource=resource)
|
||||
raise
|
||||
|
||||
return response
|
||||
|
||||
def get_resource(self, path):
|
||||
"""Getting the required information from the API."""
|
||||
response = self._http_request(path)
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
raise exception.ServiceException("Invalid service response.")
|
||||
|
||||
def update_resource(self, path, data):
|
||||
"""Update the required resource."""
|
||||
response = self._http_request(path, method="PUT", body=data)
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
raise exception.ServiceException("Invalid service response.")
|
||||
|
||||
def remove_resource(self, path):
|
||||
"""Delete the received resource."""
|
||||
return self._http_request(path, method="DELETE")
|
||||
|
||||
|
||||
# pylint: disable=dangerous-default-value
|
||||
def run_once(function, state={}, errors={}):
|
||||
"""A memoization decorator, whose purpose is to cache calls."""
|
||||
@six.wraps(function)
|
||||
def _wrapper(*args, **kwargs):
|
||||
if function in errors:
|
||||
# Deliberate use of LBYL.
|
||||
six.reraise(*errors[function])
|
||||
|
||||
try:
|
||||
return state[function]
|
||||
except KeyError:
|
||||
try:
|
||||
state[function] = result = function(*args, **kwargs)
|
||||
return result
|
||||
except Exception:
|
||||
errors[function] = sys.exc_info()
|
||||
raise
|
||||
return _wrapper
|
||||
|
||||
|
||||
@run_once
|
||||
def get_client(url, username, password, allow_insecure, ca_bundle):
|
||||
"""Create a new client for the HNV REST API."""
|
||||
return _HNVClient(url, username, password, allow_insecure, ca_bundle)
|
@ -47,10 +47,22 @@ class HVNOptions(config_base.Options):
|
||||
"https_ca_bundle", default=None,
|
||||
help=("The path to a CA_BUNDLE file or directory with "
|
||||
"certificates of trusted CAs.")),
|
||||
cfg.IntOpt(
|
||||
"retry_count", default=5,
|
||||
help="Max. number of attempts for fetching metadata in "
|
||||
"case of transient errors"),
|
||||
cfg.FloatOpt(
|
||||
"retry_interval", default=1,
|
||||
help=("Interval between attempts in case of transient errors, "
|
||||
"expressed in seconds")),
|
||||
cfg.IntOpt(
|
||||
"http_request_timeout", default=None,
|
||||
help=("Number of seconds until network requests stop waiting "
|
||||
"for a response")),
|
||||
cfg.StrOpt(
|
||||
"logical_network", default=None,
|
||||
help=("Logical network to use as a medium for tenant network "
|
||||
"traffic.")),
|
||||
]
|
||||
|
||||
def register(self):
|
||||
|
253
hnv_client/tests/common/test_utils.py
Normal file
253
hnv_client/tests/common/test_utils.py
Normal file
@ -0,0 +1,253 @@
|
||||
# Copyright 2017 Cloudbase Solutions Srl
|
||||
#
|
||||
# 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.
|
||||
|
||||
# pylint: disable=protected-access, missing-docstring
|
||||
|
||||
import unittest
|
||||
try:
|
||||
import unittest.mock as mock
|
||||
except ImportError:
|
||||
import mock
|
||||
|
||||
import requests
|
||||
|
||||
from hnv_client.common import constant
|
||||
from hnv_client.common import exception
|
||||
from hnv_client.common import utils as hnv_utils
|
||||
from hnv_client import config as hnv_config
|
||||
from hnv_client.tests import utils as test_utils
|
||||
|
||||
CONFIG = hnv_config.CONFIG
|
||||
|
||||
|
||||
class TestHNVClient(unittest.TestCase):
|
||||
|
||||
@staticmethod
|
||||
def _get_client(url=mock.sentinel.url, username=mock.sentinel.username,
|
||||
password=mock.sentinel.password,
|
||||
allow_insecure=mock.sentinel.insecure,
|
||||
ca_bundle=mock.sentinel.ca_bundle):
|
||||
return hnv_utils._HNVClient(url, username, password, allow_insecure,
|
||||
ca_bundle)
|
||||
|
||||
@mock.patch("hnv_client.common.utils._HNVClient._get_headers")
|
||||
@mock.patch("hnv_client.common.utils._HNVClient._verify_https_request")
|
||||
@mock.patch("requests_ntlm.HttpNtlmAuth")
|
||||
@mock.patch("requests.Session")
|
||||
def test_session(self, mock_get_session, mock_auth, mock_verify,
|
||||
mock_headers):
|
||||
mock_session = mock.Mock()
|
||||
mock_session.headers = {}
|
||||
mock_get_session.return_value = mock_session
|
||||
mock_verify.return_value = mock.sentinel.verify
|
||||
mock_auth.return_value = mock.sentinel.auth
|
||||
mock_headers.return_value = {"X-HNV-Test": 1}
|
||||
|
||||
client = self._get_client()
|
||||
session = client._session
|
||||
|
||||
self.assertIs(session, mock_session)
|
||||
self.assertIs(mock_session.verify, mock.sentinel.verify)
|
||||
self.assertIs(mock_session.auth, mock.sentinel.auth)
|
||||
self.assertEqual(mock_session.headers.get("X-HNV-Test"), 1)
|
||||
mock_auth.assert_called_once_with(username=mock.sentinel.username,
|
||||
password=mock.sentinel.password)
|
||||
|
||||
def test_verify_https_request(self):
|
||||
ca_bundle_client = self._get_client(allow_insecure=None)
|
||||
insecure_client = self._get_client(ca_bundle=None)
|
||||
|
||||
self.assertIs(ca_bundle_client._verify_https_request(),
|
||||
mock.sentinel.ca_bundle)
|
||||
self.assertFalse(insecure_client._verify_https_request())
|
||||
|
||||
@mock.patch("time.sleep")
|
||||
@mock.patch("json.dumps")
|
||||
@mock.patch("requests.compat.urljoin")
|
||||
@mock.patch("hnv_client.common.utils._HNVClient._session")
|
||||
@mock.patch("hnv_client.common.utils._HNVClient._get_headers")
|
||||
def _test_http_request(self, mock_headers, mock_session, mock_join,
|
||||
mock_dump, mock_sleep,
|
||||
method, body, response, status_code):
|
||||
output = []
|
||||
headers = mock_headers.return_value = {}
|
||||
mock_join.return_value = mock.sentinel.url
|
||||
mock_dump.return_value = mock.sentinel.content
|
||||
|
||||
session_request = mock_session.request = mock.MagicMock()
|
||||
session_request.side_effect = response
|
||||
|
||||
expected_response = response[-1]
|
||||
|
||||
status_check = expected_response.raise_for_status = mock.MagicMock()
|
||||
if status_code != 200:
|
||||
exc_response = mock.MagicMock()
|
||||
exc_response.status_code = status_code
|
||||
exc_response.text = "Expected Error"
|
||||
status_check.side_effect = requests.HTTPError(
|
||||
response=exc_response)
|
||||
output.append("HTTP Error %(status_code)r: 'Expected Error'" %
|
||||
{"status_code": status_code})
|
||||
|
||||
client = self._get_client()
|
||||
with test_utils.LogSnatcher("hnv_client.common.utils") as logging:
|
||||
if isinstance(expected_response, requests.exceptions.SSLError):
|
||||
self.assertRaises(exception.CertificateVerifyFailed,
|
||||
client._http_request,
|
||||
mock.sentinel.resource, method, body)
|
||||
return
|
||||
elif isinstance(expected_response, requests.ConnectionError):
|
||||
self.assertRaises(requests.ConnectionError,
|
||||
client._http_request,
|
||||
mock.sentinel.resource, method, body)
|
||||
return
|
||||
elif status_code == 400:
|
||||
self.assertRaises(exception.ServiceException,
|
||||
client._http_request,
|
||||
mock.sentinel.resource, method, body)
|
||||
elif status_code == 404:
|
||||
self.assertRaises(exception.NotFound,
|
||||
client._http_request,
|
||||
mock.sentinel.resource, method, body)
|
||||
elif status_code != 200:
|
||||
self.assertRaises(requests.HTTPError,
|
||||
client._http_request,
|
||||
mock.sentinel.resource, method, body)
|
||||
else:
|
||||
client_response = client._http_request(mock.sentinel.resource,
|
||||
method, body)
|
||||
|
||||
mock_join.assert_called_once_with(mock.sentinel.url,
|
||||
mock.sentinel.resource)
|
||||
mock_headers.assert_called_once_with()
|
||||
if not method == constant.GET:
|
||||
etag = (body or {}).get("etag", None)
|
||||
self.assertEqual(headers["If-Match"], etag)
|
||||
|
||||
if len(response) == 1:
|
||||
session_request.assert_called_once_with(
|
||||
method=method, url=mock.sentinel.url, headers=headers,
|
||||
data=mock.sentinel.content if body else None,
|
||||
timeout=CONFIG.HNV.http_request_timeout
|
||||
)
|
||||
elif len(response) > 1:
|
||||
# Note(alexcoman): The first response is an exception
|
||||
output.append("Request failed: ")
|
||||
|
||||
self.assertEqual(logging.output, output)
|
||||
if status_code == 200:
|
||||
self.assertIs(client_response, expected_response)
|
||||
|
||||
def test_http_request_get(self):
|
||||
response = [mock.MagicMock()]
|
||||
self._test_http_request(method=constant.GET,
|
||||
body=mock.sentinel.body,
|
||||
response=response,
|
||||
status_code=200)
|
||||
|
||||
def test_http_request_put(self):
|
||||
response = [mock.MagicMock()]
|
||||
self._test_http_request(method=constant.PUT,
|
||||
body={"etag": mock.sentinel.etag},
|
||||
response=response,
|
||||
status_code=200)
|
||||
|
||||
def test_http_request_with_connection_error(self):
|
||||
response = [requests.ConnectionError(), mock.MagicMock()]
|
||||
with test_utils.ConfigPatcher('retry_count', 1, "HNV"):
|
||||
self._test_http_request(method=constant.GET,
|
||||
body=mock.sentinel.body,
|
||||
response=response,
|
||||
status_code=200)
|
||||
|
||||
def test_http_request_connection_error(self):
|
||||
response = [requests.ConnectionError(), requests.ConnectionError()]
|
||||
with test_utils.ConfigPatcher('retry_count', 1, "HNV"):
|
||||
self._test_http_request(method=constant.GET,
|
||||
body=mock.sentinel.body,
|
||||
response=response,
|
||||
status_code=200)
|
||||
|
||||
def test_http_request_ssl_error(self):
|
||||
response = [requests.exceptions.SSLError(),
|
||||
requests.exceptions.SSLError()]
|
||||
with test_utils.ConfigPatcher('retry_count', 1, "HNV"):
|
||||
self._test_http_request(method=constant.GET,
|
||||
body=mock.sentinel.body,
|
||||
response=response,
|
||||
status_code=200)
|
||||
|
||||
def test_http_request_not_found(self):
|
||||
response = [mock.MagicMock()]
|
||||
self._test_http_request(method=constant.GET,
|
||||
body=mock.sentinel.body,
|
||||
response=response,
|
||||
status_code=404)
|
||||
|
||||
def test_http_request_bad_request(self):
|
||||
response = [mock.MagicMock()]
|
||||
self._test_http_request(method=constant.GET,
|
||||
body=mock.sentinel.body,
|
||||
response=response,
|
||||
status_code=400)
|
||||
|
||||
def test_http_request_server_error(self):
|
||||
response = [mock.MagicMock()]
|
||||
self._test_http_request(method=constant.GET,
|
||||
body=mock.sentinel.body,
|
||||
response=response,
|
||||
status_code=500)
|
||||
|
||||
@mock.patch("hnv_client.common.utils._HNVClient._http_request")
|
||||
def test_get_resource(self, mock_http_request):
|
||||
response = mock.Mock()
|
||||
response.json = mock.Mock()
|
||||
response.json.side_effect = [mock.sentinel.response, ValueError]
|
||||
mock_http_request.return_value = response
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
self.assertIs(client.get_resource(mock.sentinel.path),
|
||||
mock.sentinel.response)
|
||||
mock_http_request.assert_called_once_with(mock.sentinel.path)
|
||||
self.assertRaises(exception.ServiceException,
|
||||
client.get_resource, mock.sentinel.path)
|
||||
|
||||
@mock.patch("hnv_client.common.utils._HNVClient._http_request")
|
||||
def test_update_resource(self, mock_http_request):
|
||||
response = mock.Mock()
|
||||
response.json = mock.Mock()
|
||||
response.json.side_effect = [mock.sentinel.response, ValueError]
|
||||
mock_http_request.return_value = response
|
||||
|
||||
client = self._get_client()
|
||||
response = client.update_resource(mock.sentinel.path,
|
||||
mock.sentinel.data)
|
||||
|
||||
self.assertIs(response, mock.sentinel.response)
|
||||
mock_http_request.assert_called_once_with(mock.sentinel.path,
|
||||
method="PUT",
|
||||
body=mock.sentinel.data)
|
||||
self.assertRaises(exception.ServiceException,
|
||||
client.update_resource,
|
||||
mock.sentinel.path, mock.sentinel.data)
|
||||
|
||||
@mock.patch("hnv_client.common.utils._HNVClient._http_request")
|
||||
def test_remove_resource(self, mock_http_request):
|
||||
mock_http_request.return_value = mock.sentinel.response
|
||||
|
||||
client = self._get_client()
|
||||
response = client.remove_resource(mock.sentinel.path)
|
||||
|
||||
self.assertIs(response, mock.sentinel.response)
|
Loading…
x
Reference in New Issue
Block a user