feat: addition of the BlueFlood Client

- add region mapping to every provider driver
- add logic in analytics manager to call the provider, for which the
  domain is present
- add asynchronous requests supports for blueflood and gather results

REQUEST URL:

GET /v1.0/services/{service_id}/analytics?domain={domain}&metricType=requestCount&startTime=2016-01-22T17:42:08&endTime=2016-01-24T17:42:08

RESPONSE:

{
    "domain": "{domain}",
    "requestCount": {
        "India": [{}],
        "EMEA": [{
            "1453420800000": 47,
            "1453507200000": 31,
            "1453593600000": 2
        }],
        "APAC": [{
            "1453593600000": 2
        }],
        "North America": [{}],
        "South America": [{}],
        "Japan": [{
            "1453593600000": 89
        }]
    },
    "flavor": "{flavor}",
    "provider": "{provider}"
}

Partially Implements: blueprint analytics

Change-Id: I82a594c6ed1b2df93158af2b766dbbf2cd5440df
This commit is contained in:
Sriram Madapusi Vasudevan 2016-01-22 16:30:16 -05:00
parent be02a04ce3
commit 0d9f84d616
41 changed files with 1101 additions and 99 deletions

View File

@ -129,7 +129,8 @@ delay = 1
[driver:metrics:blueflood]
blueflood_url = https://global.metrics.api.rackspacecloud.com/v2.0/{project_id}/views/
use_keystone_auth = True
no_of_executors = 6
[drivers:provider]
default_cache_ttl = 86400
@ -179,6 +180,7 @@ group_id = "MY_GROUP_ID"
property_id = "MY_PROPERTY_ID"
# akamai_san_info_storage driver module (e.g. zookeeper, cassandra)
san_info_storage_type = cassandra
metrics_resolution = 86400
[drivers:provider:akamai:storage]
# Zookeeper san_info_storage_type config options

View File

@ -29,6 +29,10 @@ class BadProviderDetail(Exception):
"""Raised when attempted a non existent operation."""
class ProviderNotFound(Exception):
"""Raised when domain is not associated with a known Provider"""
class ServiceNotFound(Exception):
"""Raised when service is not found."""
@ -61,3 +65,8 @@ class ServicesOverLimit(Exception):
class SharedShardsExhausted(Exception):
"""Raised when all shared ssl shards are occupied for a given domain."""
class ServiceProviderDetailsNotFound(Exception):
"""Raised when provider details for a service is None."""

View File

@ -25,8 +25,21 @@ class AnalyticsController(controller.ManagerControllerBase):
"""Home controller base class."""
def __init__(self, manager):
self.manager = manager
super(AnalyticsController, self).__init__(manager)
@property
def storage_controller(self):
return self.manager.storage.services_controller
@property
def providers(self):
return self.manager.providers
@property
def metrics_controller(self):
return self.manager.metrics.services_controller
@abc.abstractmethod
def get_metrics_by_domain(self, project_id, domain_name, **extras):
"""get analytics metrics by domain

View File

@ -12,33 +12,61 @@
# implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
from oslo_log import log
from poppy.common import errors
from poppy.manager import base
LOG = log.getLogger(__name__)
class AnalyticsController(base.AnalyticsController):
def get_metrics_by_domain(self, project_id, domain_name, **extras):
# TODO(TheSriram): Insert call to metrics driver
self.metrics_controller = self._driver.metrics.services_controller
# NOTE(TheSriram): Returning Stubbed return value
metrics_response = {
"domain": "example.com",
"StatusCodes_2XX": [
{
"US": {
"1453136297": 24,
"1453049897": 45
}
},
{
"EMEA": {
"1453136297": 123,
"1453049897": 11
}
}
]
}
return json.dumps(metrics_response)
storage_controller = self.storage_controller
try:
result = storage_controller.get_service_details_by_domain_name(
domain_name=domain_name, project_id=project_id)
except ValueError:
msg = "Domain: {0} was not found for project_id: {1}".format(
domain_name, project_id)
LOG.warn(msg)
raise errors.ServiceNotFound(msg)
if not result:
msg = "Domain: {0} was not found for project_id: {1}".format(
domain_name, project_id)
LOG.warn(msg)
raise errors.ServiceNotFound(msg)
if not result.provider_details:
msg = "Provider Details were None " \
"for the service_id: {0} " \
"corresponding to project_id: {1}".format(result.service_id,
project_id)
LOG.warn(msg)
raise errors.ServiceProviderDetailsNotFound(msg)
provider_details_dict = result.provider_details
provider_for_domain = None
for provider, provider_details in provider_details_dict.items():
if provider_details.get_domain_access_url(domain=domain_name):
provider_for_domain = provider
if not provider_for_domain:
msg = "Provider not found for Domain : {0}".format(domain_name)
LOG.warn(msg)
raise errors.ProviderNotFound(msg)
provider_obj = self.providers[provider_for_domain.lower()].obj
provider_service_controller = provider_obj.service_controller
extras['metrics_controller'] = self.metrics_controller
metrics = provider_service_controller.get_metrics_by_domain(
project_id, domain_name, provider_obj.regions, **extras)
metrics['provider'] = provider_for_domain.lower()
metrics['flavor'] = result.flavor_id
return metrics

View File

@ -28,7 +28,7 @@ class ServicesControllerBase(controller.MetricsControllerBase):
def __init__(self, driver):
super(ServicesControllerBase, self).__init__(driver)
def read(self, metric_name, from_timestamp, to_timestamp, resolution):
def read(self, metric_names, from_timestamp, to_timestamp, resolution):
"""read metrics from cache.
:raises NotImplementedError

View File

@ -13,7 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cloud Metrics Cache driver for CDN"""
"""BlueFlood Metrics driver for Poppy"""
from poppy.metrics.blueflood import driver

View File

@ -21,8 +21,15 @@ from poppy.metrics.blueflood import controllers
BLUEFLOOD_OPTIONS = [
cfg.StrOpt('blueflood_url',
default='https://www.metrics.com',
default='https://www.metrics.com/{project_id}/views',
help='Metrics url for retrieving cached content'),
cfg.BoolOpt('use_keystone_auth',
default=True,
help='Use Keystone Authentication?'),
cfg.IntOpt('no_of_executors',
default=6,
help='Number of Executors for Parallel execution of requests'
'to blueflood metrics backing store'),
]
BLUEFLOOD_GROUP = 'drivers:metrics:blueflood'

View File

@ -14,7 +14,15 @@
# limitations under the License.
from oslo_context import context as context_utils
from oslo_log import log
from poppy.metrics import base
from poppy.metrics.blueflood.utils import client
from poppy.metrics.blueflood.utils import errors
from poppy.metrics.blueflood.utils import helper
LOG = log.getLogger(__name__)
class ServicesController(base.ServicesController):
@ -24,8 +32,78 @@ class ServicesController(base.ServicesController):
self.driver = driver
def read(self, metric_name, from_timestamp, to_timestamp, resolution):
def _result_formatter(self, response):
resp_dict = dict()
if not response.ok:
LOG.warn("BlueFlood Metrics Response status Code:{0} "
"Response Text: {1} "
"Request URL: {2}".format(response.status_code,
response.text,
response.url))
return resp_dict
else:
serialized_response = response.json()
try:
time_series = serialized_response['values']
for timerange in time_series:
resp_dict[timerange['timestamp']] = timerange['sum']
except KeyError:
msg = 'content from {0} not conforming ' \
'to API contracts'.format(response.url)
LOG.warn(msg)
raise errors.BlueFloodApiSchemaError(msg)
return resp_dict
def read(self, metric_names, from_timestamp, to_timestamp, resolution):
"""read metrics from metrics driver.
"""
pass
curr_resolution = \
helper.resolution_converter_seconds_to_enum(resolution)
context_dict = context_utils.get_current().to_dict()
project_id = context_dict['tenant']
auth_token = None
if self.driver.metrics_conf.use_keystone_auth:
auth_token = context_dict['auth_token']
tenanted_blueflood_url = \
self.driver.metrics_conf.blueflood_url.format(
project_id=project_id
)
from_timestamp = int(helper.datetime_to_epoch(from_timestamp))
to_timestamp = int(helper.datetime_to_epoch(to_timestamp))
urls = []
params = {
'to': to_timestamp,
'from': from_timestamp,
'resolution': curr_resolution
}
for metric_name in metric_names:
tenanted_blueflood_url_with_metric = helper.join_url(
tenanted_blueflood_url, metric_name)
urls.append(helper.set_qs_on_url(
tenanted_blueflood_url_with_metric,
**params))
executors = self.driver.metrics_conf.no_of_executors
blueflood_client = client.BlueFloodMetricsClient(token=auth_token,
project_id=project_id,
executors=executors)
results = blueflood_client.async_requests(urls)
reordered_metric_names = []
for result in results:
metric_name = helper.retrieve_last_relative_url(result.url)
reordered_metric_names.append(metric_name)
formatted_results = []
for metric_name, result in zip(reordered_metric_names, results):
formatted_result = self._result_formatter(result)
# NOTE(TheSriram): Tuple to pass the associated metric name, along
# with the formatted result
formatted_results.append((metric_name, formatted_result))
return formatted_results

View File

@ -0,0 +1,52 @@
# Copyright (c) 2016 Rackspace, Inc.
#
# 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.
from concurrent import futures
from requests_futures.sessions import FuturesSession
from oslo_log import log
LOG = log.getLogger(__name__)
class BlueFloodMetricsClient(object):
def __init__(self, token, project_id, executors):
self.token = token
self.project_id = project_id
self.session = FuturesSession(max_workers=executors)
self.headers = {
'X-Project-ID': self.project_id
}
if self.token:
self.headers.update({
'X-Auth-Token': self.token
})
self.session.headers.update(self.headers)
def async_requests(self, urls):
futures_results = []
for url in urls:
LOG.info("Request made to URL: {0}".format(url))
futures_results.append(self.session.get(url))
responses = []
for future in futures.as_completed(fs=futures_results):
resp = future.result()
LOG.info("Request completed to URL: {0}".format(resp.url))
responses.append((resp))
return responses

View File

@ -0,0 +1,18 @@
# Copyright (c) 2016 Rackspace, Inc.
#
# 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.
class BlueFloodApiSchemaError(Exception):
pass

View File

@ -0,0 +1,70 @@
# Copyright (c) 2016 Rackspace, Inc.
#
# 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.
import time
try: # pragma: no cover
import six.moves.urllib.parse as parse
except ImportError: # pragma: no cover
import urllib.parse as parse
from oslo_log import log
LOG = log.getLogger(__name__)
def set_qs_on_url(url, **params):
url_parts = list(parse.urlparse(url))
query = dict(parse.parse_qsl(url_parts[4]))
query.update(params)
url_parts[4] = parse.urlencode(query)
return parse.urlunparse(url_parts)
def retrieve_last_relative_url(url):
url_parts = list(parse.urlparse(url))
return url_parts[2].split('/')[-1:][0]
def join_url(base_url, url):
return parse.urljoin(base_url, url)
def datetime_to_epoch(datetime_obj):
return time.mktime(datetime_obj.timetuple()) * 1000
def resolution_converter_seconds_to_enum(resolution_seconds):
resolution_resolver = {
'0': 'FULL',
'300': 'MIN5',
'1200': 'MIN20',
'3600': 'MIN60',
'14400': 'MIN240',
'86400': 'MIN1440'
}
try:
return resolution_resolver[resolution_seconds]
except KeyError:
msg = 'Resolution of {0} does not translate ' \
'into BlueFlood time windows, ' \
'acceptable windows: {1}'.format(resolution_seconds,
resolution_resolver.keys())
LOG.error(msg)
raise ValueError(msg)

View File

@ -25,6 +25,7 @@ from stevedore import driver
from poppy.common import decorators
from poppy.provider.akamai import controllers
from poppy.provider.akamai import geo_zone_code_mapping
from poppy.provider.akamai.mod_san_queue import zookeeper_queue
from poppy.provider import base
import uuid
@ -103,6 +104,11 @@ AKAMAI_OPTIONS = [
cfg.StrOpt(
'group_id',
help='Operator groupID'),
# Metrics related configs
cfg.StrOpt('metrics_resolution',
help='Resolution in seconds for retrieving metrics',
default='86400')
]
AKAMAI_GROUP = 'drivers:provider:akamai'
@ -132,7 +138,7 @@ class CDNProvider(base.Driver):
str(self.akamai_conf.ccu_api_base_url),
'ccu/v2/queues/default'
])
self.regions = geo_zone_code_mapping.REGIONS
self.http_conf_number = self.akamai_conf.akamai_http_config_number
self.https_shared_conf_number = (
self.akamai_conf.akamai_https_shared_config_number)
@ -186,6 +192,8 @@ class CDNProvider(base.Driver):
self.mod_san_queue = (
zookeeper_queue.ZookeeperModSanQueue(self._conf))
self.metrics_resolution = self.akamai_conf.metrics_resolution
@decorators.lazy_property(write=False)
def san_info_storage(self):
storage_backend_type = 'poppy.provider.akamai.san_info_storage'

View File

@ -17,9 +17,10 @@ from oslo_log import log
from poppy.model.helpers import geo_zones
# to use log inside worker, we need to directly use logging
LOG = log.getLogger(__name__)
REGIONS = ['North America', 'South America', 'EMEA', 'Japan', 'India', 'APAC']
REGION_COUNTRY_MAPPING = {
'North America': [
'Antigua and Barbuda',

View File

@ -17,6 +17,11 @@ import datetime
import json
import traceback
try: # pragma: no cover
import six.moves.urllib.parse as parse
except ImportError: # pragma: no cover
import urllib.parse as parse
from oslo_log import log
from poppy.common import decorators
@ -1009,6 +1014,45 @@ class ServiceController(base.ServiceBase):
id_list.append(dp_obj)
return json.dumps(id_list)
def get_metrics_by_domain(self, project_id, domain_name, **extras):
'''Use Akamai's report API to get the metrics by domain.'''
return []
def get_metrics_by_domain(self, project_id, domain_name, regions,
**extras):
"""Use Akamai's report API to get the metrics by domain."""
formatted_results = dict()
metric_buckets = []
metricType = extras['metricType']
startTime = extras['startTime']
endTime = extras['endTime']
metrics_controller = extras['metrics_controller']
resolution = self.driver.metrics_resolution
if 'httpResponseCode' in metricType:
http_series = metricType.split('_')[1]
for region in regions:
metric_buckets.append('_'.join(['requestCount', domain_name,
region,
http_series]))
else:
for region in regions:
metric_buckets.append('_'.join([metricType, domain_name,
region]))
metrics_results = metrics_controller.read(metric_names=metric_buckets,
from_timestamp=startTime,
to_timestamp=endTime,
resolution=resolution)
formatted_results['domain'] = domain_name
formatted_results[metricType] = dict()
for region in regions:
formatted_results[metricType][region] = []
for metric_name, metrics_response in metrics_results:
unquoted_metric_name = parse.unquote(
metric_name.split('_')[2]
).lower()
if region.lower() == unquoted_metric_name:
formatted_results[metricType][region].append(
metrics_response
)
return formatted_results

View File

@ -77,10 +77,12 @@ class ServicesControllerBase(controller.ProviderControllerBase):
raise NotImplementedError
@abc.abstractmethod
def get_metrics_by_domain(self, project_id, domain_name, **extras):
def get_metrics_by_domain(self, project_id, domain_name, region, **extras):
"""get analytics metrics by domain from provider
:param service_name
:param project_id
:param domain_name
:param regions
:raises NotImplementedError
"""
raise NotImplementedError

View File

@ -45,6 +45,7 @@ class CDNProvider(base.Driver):
self.cloudfront_client = boto.connect_cloudfront(
aws_access_key_id=self.cloudfront_conf.aws_access_key_id,
aws_secret_access_key=self.cloudfront_conf.aws_secret_access_key)
self.regions = []
def is_alive(self):
"""is_alive.

View File

@ -99,7 +99,8 @@ class ServiceController(base.ServiceBase):
def get_provider_service_id(self, service_obj):
return service_obj.name
def get_metrics_by_domain(self, project_id, domain_name, **extras):
def get_metrics_by_domain(self, project_id, domain_name, regions,
**extras):
'''Use CloudFronts's API to get the metrics by domain.'''
return []

View File

@ -55,6 +55,7 @@ class CDNProvider(base.Driver):
setattr(obj, fastly_scheme, self.fastly_conf.scheme)
self.fastly_client = fastly.connect(self.fastly_conf.apikey)
self.regions = []
def is_alive(self):
"""is_alive.

View File

@ -249,6 +249,7 @@ class ServiceController(base.ServiceBase):
def get_provider_service_id(self, service_obj):
return service_obj.service_id
def get_metrics_by_domain(self, project_id, domain_name, **extras):
def get_metrics_by_domain(self, project_id, domain_name, regions,
**extras):
'''Use Fastly's API to get the metrics by domain.'''
return []

View File

@ -36,7 +36,7 @@ MAXCDN_GROUP = 'drivers:provider:maxcdn'
class CDNProvider(base.Driver):
"""MaxCND Provider."""
"""MaxCDN Provider."""
def __init__(self, conf):
"""Init constructor."""
@ -49,6 +49,7 @@ class CDNProvider(base.Driver):
self.maxcdn_client = maxcdn.MaxCDN(self.maxcdn_conf.alias,
self.maxcdn_conf.consumer_key,
self.maxcdn_conf.consumer_secret)
self.regions = []
def is_alive(self):
"""is_alive.

View File

@ -137,7 +137,8 @@ class ServiceController(base.ServiceBase):
def get_provider_service_id(self, service_obj):
return self._map_service_name(service_obj.name)
def get_metrics_by_domain(self, project_id, domain_name, **extras):
def get_metrics_by_domain(self, project_id, domain_name, region,
**extras):
'''Use MaxCDN's API to get the metrics by domain.'''
return []

View File

@ -28,6 +28,7 @@ class CDNProvider(base.Driver):
def __init__(self, conf):
super(CDNProvider, self).__init__(conf)
self.regions = []
def is_alive(self):
"""is_alive.

View File

@ -54,7 +54,8 @@ class ServiceController(base.ServiceBase):
def get_provider_service_id(self, service_obj):
return []
def get_metrics_by_domain(self, project_id, domain_name, **extras):
def get_metrics_by_domain(self, project_id, domain_name, regions,
**extras):
return []
@decorators.lazy_property(write=False)

View File

@ -898,7 +898,7 @@ class ServicesController(base.ServicesController):
results[provider_name] = provider_detail_obj
return results
def get_service_details_by_domain_name(self, domain_name):
def get_service_details_by_domain_name(self, domain_name, project_id=None):
"""get_provider_details_by_domain_name.
:param domain_name
@ -920,6 +920,11 @@ class ServicesController(base.ServicesController):
details = None
for r in complete_results:
proj_id = r.get('project_id')
if project_id and proj_id != project_id:
raise ValueError("Domain: {0} not "
"present under "
"project_id: {1}".format(domain_name,
project_id))
service = r.get('service_id')
details = self.get(proj_id, service)
return details

View File

@ -170,7 +170,8 @@ class ServicesController(base.ServicesController):
if key in self.certs:
self.certs[key].cert_details = cert_details
def get_service_details_by_domain_name(self, domain_name):
def get_service_details_by_domain_name(self, domain_name,
project_id=None):
for service_id in self.created_services:
service_dict_in_cache = self.created_services[service_id]
if domain_name in [d['domain']

View File

@ -117,14 +117,18 @@ class ServicesAnalyticsController(base.Controller, hooks.HookController):
domain = call_args.pop('domain')
analytics_controller = \
self._driver.manager.analytics_controller
res = analytics_controller.get_metrics_by_domain(
self.project_id,
domain,
**call_args
)
return pecan.Response(res, 200)
try:
res = analytics_controller.get_metrics_by_domain(
self.project_id,
domain,
**call_args
)
except errors.ServiceNotFound:
return pecan.Response(status=404)
except Exception:
return pecan.Response(status=500)
else:
return pecan.Response(json_body=res, status=200)
class ServicesController(base.Controller, hooks.HookController):

View File

@ -493,7 +493,8 @@ def is_valid_analytics_request(request):
"%Y-%m-%dT%H:%M:%S"))
endTime = request.GET.get('endTime',
default_end_time.strftime("%Y-%m-%dT%H:%M:%S"))
# Default metric type will be all metrics
# NOTE(TheSriram): metricType is a required entity
metricType = request.GET.get('metricType', None)
if not is_valid_domain_name(domain):
@ -515,11 +516,16 @@ def is_valid_analytics_request(request):
raise exceptions.ValidationFailed('startTime cannot be later than'
' endTime')
# Leave these 3 metric types for now.
# NOTE(TheSriram): The metrics listed below are the currently supported
# metric types
valid_metric_types = [
'requestCount',
'bandwithOut',
'httpResponseCode'
'bandwidthOut',
'httpResponseCode_1XX',
'httpResponseCode_2XX',
'httpResponseCode_3XX',
'httpResponseCode_4XX',
'httpResponseCode_5XX'
]
if metricType not in valid_metric_types:
raise exceptions.ValidationFailed('Must provide an metric name....'

View File

@ -0,0 +1 @@
requests-futures

View File

@ -14,11 +14,16 @@
# limitations under the License.
import datetime
import json
import mock
import uuid
import ddt
import six
from poppy.common import errors
from poppy.manager.default.analytics import AnalyticsController
from tests.functional.transport.pecan import base
if six.PY2: # pragma: no cover
@ -37,36 +42,47 @@ class TestServicesAnalytics(base.FunctionalTest):
self.service_id = str(uuid.uuid1())
self.endTime = datetime.datetime.now()
self.startTime = self.endTime - datetime.timedelta(hours=3)
self.start_time = datetime.datetime.strftime(
self.startTime,
"%Y-%m-%dT%H:%M:%S")
self.end_time = datetime.datetime.strftime(
self.endTime,
"%Y-%m-%dT%H:%M:%S")
def test_services_analytics_happy_path_with_default_timewindow(self):
response = self.app.get('/v1.0/services/%s/analytics' %
self.service_id,
params=urllib.urlencode({
'domain': 'abc.com',
'metricType': 'requestCount',
}),
headers={
'X-Project-ID': self.project_id
})
with mock.patch.object(AnalyticsController,
'get_metrics_by_domain') as mock_get:
mock_get.return_value = json.dumps({})
response = self.app.get('/v1.0/services/%s/analytics' %
self.service_id,
params=urllib.urlencode({
'domain': 'abc.com',
'metricType': 'requestCount',
}),
headers={
'X-Project-ID': self.project_id
})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, 200)
def test_services_analytics_happy_path(self):
response = self.app.get('/v1.0/services/%s/analytics' %
self.service_id,
params=urllib.urlencode({
'domain': 'abc.com',
'metricType': 'requestCount',
'startTime': datetime.datetime.strftime(
self.startTime, "%Y-%m-%dT%H:%M:%S"),
'endTime': datetime.datetime.strftime(
self.endTime, "%Y-%m-%dT%H:%M:%S")
}),
headers={
'X-Project-ID': self.project_id
})
with mock.patch.object(AnalyticsController,
'get_metrics_by_domain') as mock_get:
mock_get.return_value = json.dumps({})
self.assertEqual(response.status_code, 200)
response = self.app.get('/v1.0/services/%s/analytics' %
self.service_id,
params=urllib.urlencode({
'domain': 'abc.com',
'metricType': 'requestCount',
'startTime': self.start_time,
'endTime': self.end_time
}),
headers={
'X-Project-ID': self.project_id
})
self.assertEqual(response.status_code, 200)
@ddt.file_data("data_services_analytics_bad_input.json")
def test_services_analytics_negative(self, get_params):
@ -79,3 +95,61 @@ class TestServicesAnalytics(base.FunctionalTest):
expect_errors=True)
self.assertEqual(response.status_code, 400)
def test_services_analytics_exceptions_no_service(self):
with mock.patch.object(AnalyticsController,
'get_metrics_by_domain') as mock_get:
mock_get.side_effect = errors.ServiceNotFound
response = self.app.get('/v1.0/services/%s/analytics' %
self.service_id,
params=urllib.urlencode({
'domain': 'abc.com',
'metricType': 'requestCount',
'startTime': self.start_time,
'endTime': self.end_time
}),
headers={
'X-Project-ID': self.project_id
},
expect_errors=True)
self.assertEqual(response.status_code, 404)
def test_services_analytics_exceptions_provider_details(self):
with mock.patch.object(AnalyticsController,
'get_metrics_by_domain') as mock_get:
mock_get.side_effect = errors.ServiceProviderDetailsNotFound
response = self.app.get('/v1.0/services/%s/analytics' %
self.service_id,
params=urllib.urlencode({
'domain': 'abc.com',
'metricType': 'requestCount',
'startTime': self.start_time,
'endTime': self.end_time
}),
headers={
'X-Project-ID': self.project_id
},
expect_errors=True)
self.assertEqual(response.status_code, 500)
def test_services_analytics_negative_exceptions_no_provider(self):
with mock.patch.object(AnalyticsController,
'get_metrics_by_domain') as mock_get:
mock_get.side_effect = errors.ProviderNotFound
response = self.app.get('/v1.0/services/%s/analytics' %
self.service_id,
params=urllib.urlencode({
'domain': 'abc.com',
'metricType': 'requestCount',
'startTime': self.start_time,
'endTime': self.end_time
}),
headers={
'X-Project-ID': self.project_id
},
expect_errors=True)
self.assertEqual(response.status_code, 500)

View File

@ -7,6 +7,7 @@ mock
nose
openstack.nose_plugin
oslotest>=1.9.0
requests-mock
testrepository
testtools
python-heatclient

View File

@ -0,0 +1,204 @@
# Copyright (c) 2016 Rackspace, Inc.
#
# 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.
import uuid
import ddt
import mock
from oslo_config import cfg
from poppy.common import errors
from poppy.manager.default import driver
from poppy.manager.default import services
from tests.unit import base
class StorageResult(object):
def __init__(self, provider_details=None, flavor_id=None):
self.provider_details = provider_details
self.service_id = str(uuid.uuid4())
self.flavor_id = flavor_id
@ddt.ddt
class DefaultManagerServiceTests(base.TestCase):
@mock.patch('poppy.notification.base.driver.NotificationDriverBase')
@mock.patch('poppy.dns.base.driver.DNSDriverBase')
@mock.patch('poppy.storage.base.driver.StorageDriverBase')
@mock.patch('poppy.distributed_task.base.driver.DistributedTaskDriverBase')
@mock.patch('poppy.metrics.base.driver.MetricsDriverBase')
def setUp(self, mock_metrics, mock_distributed_task,
mock_storage, mock_dns, mock_notification):
# NOTE(TheSriram): the mock.patch decorator applies mocks
# in the reverse order of the arguments present
super(DefaultManagerServiceTests, self).setUp()
# create mocked config and driver
conf = cfg.ConfigOpts()
_DRIVER_DNS_OPTIONS = [
cfg.IntOpt(
'retries',
default=5,
help='Total number of Retries after '
'Exponentially Backing Off'),
cfg.IntOpt(
'min_backoff_range',
default=20,
help='Minimum Number of seconds to sleep between retries'),
cfg.IntOpt(
'max_backoff_range',
default=30,
help='Maximum Number of seconds to sleep between retries'),
]
_PROVIDER_OPTIONS = [
cfg.IntOpt(
'default_cache_ttl',
default=86400,
help='Default ttl to be set, when no caching '
'rules are specified'),
]
_MAX_SERVICE_OPTIONS = [
cfg.IntOpt('max_services_per_project', default=20,
help='Default max service per project_id')
]
_DRIVER_DNS_GROUP = 'driver:dns'
_PROVIDER_GROUP = 'drivers:provider'
_MAX_SERVICE_GROUP = 'drivers:storage'
conf.register_opts(_PROVIDER_OPTIONS, group=_PROVIDER_GROUP)
conf.register_opts(_DRIVER_DNS_OPTIONS, group=_DRIVER_DNS_GROUP)
conf.register_opts(_MAX_SERVICE_OPTIONS, group=_MAX_SERVICE_GROUP)
self.max_services_per_project = \
conf[_MAX_SERVICE_GROUP].max_services_per_project
provider_mock = mock.Mock()
provider_mock.obj = mock.Mock()
provider_mock.obj.service_controller = mock.Mock()
provider_mock.obj.service_controller.get_metrics_by_domain = \
mock.Mock(return_value=dict())
# mock a stevedore provider extension
def get_provider_by_name(name):
name_p_name_mapping = {
'mock_provider': provider_mock,
'maxcdn': 'MaxCDN',
'cloudfront': 'CloudFront',
'fastly': 'Fastly',
'mock': 'Mock',
'Provider': 'Provider'
}
if name == 'mock_provider':
return name_p_name_mapping[name]
else:
return mock.Mock(obj=mock.Mock(provider_name=(
name_p_name_mapping[name])))
mock_providers = mock.MagicMock()
mock_providers.__getitem__.side_effect = get_provider_by_name
manager_driver = driver.DefaultManagerDriver(conf,
mock_storage,
mock_providers,
mock_dns,
mock_distributed_task,
mock_notification,
mock_metrics)
# stubbed driver
self.sc = services.DefaultServicesController(manager_driver)
self.manager = manager_driver
self.project_id = str(uuid.uuid4())
self.domain_name = str(uuid.uuid4())
def test_analytics_get_metrics_by_domain_provider_details_None(self):
analytics_controller = \
self.manager.analytics_controller
extras = {}
storage_controller = \
self.manager.analytics_controller._driver.storage
services_controller = storage_controller.services_controller
services_controller.get_service_details_by_domain_name = \
mock.Mock(return_value=StorageResult())
self.assertRaises(errors.ServiceProviderDetailsNotFound,
analytics_controller.get_metrics_by_domain,
self.project_id, self.domain_name, **extras)
def test_analytics_get_metrics_by_domain_service_not_found(self):
analytics_controller = \
self.manager.analytics_controller
extras = {}
storage_controller = \
self.manager.analytics_controller._driver.storage
services_controller = storage_controller.services_controller
services_controller.get_service_details_by_domain_name = \
mock.Mock(return_value=None)
self.assertRaises(errors.ServiceNotFound,
analytics_controller.get_metrics_by_domain,
self.project_id, self.domain_name, **extras)
def test_analytics_get_metrics_by_domain_provider_not_found(self):
analytics_controller = \
self.manager.analytics_controller
extras = {}
storage_controller = \
self.manager.analytics_controller._driver.storage
actual_provider_details = mock.Mock()
actual_provider_details.get_domain_access_url = \
mock.Mock(return_value=None)
provider_details_dict = {
'Provider': actual_provider_details
}
services_controller = storage_controller.services_controller
services_controller.get_service_details_by_domain_name = \
mock.Mock(return_value=StorageResult(
provider_details=provider_details_dict))
self.assertRaises(errors.ProviderNotFound,
analytics_controller.get_metrics_by_domain,
self.project_id, self.domain_name, **extras)
def test_analytics_get_metrics_by_domain_happy_path(self):
analytics_controller = \
self.manager.analytics_controller
extras = {}
storage_controller = \
self.manager.analytics_controller._driver.storage
actual_provider_details = mock.Mock()
actual_provider_details.get_domain_access_url = \
mock.Mock(return_value=self.domain_name)
provider_details_dict = {
'Mock_Provider': actual_provider_details
}
services_controller = storage_controller.services_controller
services_controller.get_service_details_by_domain_name = \
mock.Mock(return_value=StorageResult(
provider_details=provider_details_dict,
flavor_id='mock_flavor'))
results = analytics_controller.get_metrics_by_domain(self.project_id,
self.domain_name,
**extras)
self.assertEqual(results['provider'], 'mock_provider')
self.assertEqual(results['flavor'], 'mock_flavor')

View File

@ -0,0 +1,66 @@
# Copyright (c) 2016 Rackspace, Inc.
#
# 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.
"""Unittests for BlueFlood client"""
import uuid
from poppy.metrics.blueflood.utils import client
from tests.unit import base
import requests_mock
class TestBlueFloodClient(base.TestCase):
def setUp(self):
super(TestBlueFloodClient, self).setUp()
self.project_id = uuid.uuid4()
self.token = uuid.uuid4()
self.executors = 5
self.headers = {
'X-Project-ID': self.project_id,
'X-Auth-Token': self.token
}
self.bf_client = client.BlueFloodMetricsClient(
project_id=self.project_id,
token=self.token,
executors=self.executors
)
def test_client_init(self):
self.assertEqual(self.project_id, self.bf_client.project_id)
self.assertEqual(self.token, self.bf_client.token)
self.assertEqual(
sorted(self.headers.items()),
sorted(self.bf_client.headers.items()))
def test_client_async_results(self):
urls = ["http://blueflood.com/{0}/views/{1}".format(
self.project_id, i) for i in range(10)]
with requests_mock.mock() as req_mock:
for url in urls:
req_mock.get(url, text='Success')
results = self.bf_client.async_requests(urls)
re_ordered_urls = []
for result in results:
self.assertEqual(result.status_code, 200)
self.assertEqual(result.text, 'Success')
re_ordered_urls.append(result.url)
self.assertEqual(sorted(urls), sorted(re_ordered_urls))

View File

@ -15,16 +15,36 @@
"""Unittests for BlueFlood metrics service_controller."""
import datetime
import random
import time
import uuid
import ddt
import mock
from oslo_config import cfg
from oslo_context import context as context_utils
from poppy.metrics.blueflood import driver
from poppy.metrics.blueflood.utils import client
from poppy.metrics.blueflood.utils import errors
from tests.unit import base
from hypothesis import given
from hypothesis import strategies
class Response(object):
def __init__(self, ok, url, text, json_dict):
self.ok = ok
self.url = url
self.text = text
self.json_dict = json_dict
def json(self):
return self.json_dict
@ddt.ddt
class TestBlueFloodServiceController(base.TestCase):
def setUp(self):
@ -34,21 +54,87 @@ class TestBlueFloodServiceController(base.TestCase):
self.metrics_driver = (
driver.BlueFloodMetricsDriver(self.conf))
@given(strategies.text(), strategies.integers(),
strategies.integers(), strategies.integers())
def test_read(self, metric_name, from_timestamp, to_timestamp, resolution):
self.metrics_driver.services_controller.__class__.read = \
mock.Mock(return_value='success')
@ddt.data('requestCount', 'bandwidthOut', 'httpResponseCode_1XX',
'httpResponseCode_2XX', 'httpResponseCode_3XX',
'httpResponseCode_4XX', 'httpResponseCode_5XX')
def test_read(self, metric_name):
project_id = str(uuid.uuid4())
auth_token = str(uuid.uuid4())
domain_name = 'www.' + str(uuid.uuid4()) + '.com'
to_timestamp = datetime.datetime.utcnow()
from_timestamp = \
(datetime.datetime.utcnow() - datetime.timedelta(days=1))
context_utils.get_current = mock.Mock()
context_utils.get_current().to_dict = \
mock.Mock(return_value={'tenant': project_id,
'auth_token': auth_token})
with mock.patch.object(client.BlueFloodMetricsClient,
'async_requests',
auto_spec=True) as mock_async:
timestamp1 = str(int(time.time()))
timestamp2 = str(int(time.time()) + 100)
timestamp3 = str(int(time.time()) + 200)
json_dict = {
'values': [
{
'timestamp': timestamp1,
'sum': 45
},
{
'timestamp': timestamp2,
'sum': 34
},
{
'timestamp': timestamp3,
'sum': 11
},
]
}
metric_names = []
regions = ['Mock_region{0}'.format(i) for i in range(6)]
for region in regions:
metric_names.append('_'.join([metric_name, domain_name,
region]))
mock_async_responses = []
for metric_name in metric_names:
url = 'https://www.metrics.com/{0}/{1}'.format(
project_id, metric_name)
res = Response(ok=True,
url=url,
text='success',
json_dict=json_dict)
mock_async_responses.append(res)
self.metrics_driver.services_controller.read(
metric_name=metric_name,
from_timestamp=from_timestamp,
to_timestamp=to_timestamp,
resolution=resolution
)
self.metrics_driver.services_controller.read.assert_called_once_with(
metric_name=metric_name,
from_timestamp=from_timestamp,
to_timestamp=to_timestamp,
resolution=resolution
)
# NOTE(TheSriram): shuffle the order of responses
random.shuffle(mock_async_responses)
mock_async.return_value = mock_async_responses
results = self.metrics_driver.services_controller.read(
metric_names=metric_names,
from_timestamp=from_timestamp,
to_timestamp=to_timestamp,
resolution='86400'
)
for result in results:
metric_name, response = result
self.assertIn(metric_name, metric_names)
metric_names.remove(metric_name)
self.assertEqual(response[timestamp1], 45)
self.assertEqual(response[timestamp2], 34)
self.assertEqual(response[timestamp3], 11)
def test_format_results_exception(self):
json_dict = {
'this is a error': [
{
'errorcode': 400,
}
]
}
resp = Response(ok=True,
url='https://www.metrics.com',
text='success',
json_dict=json_dict)
formatter = self.metrics_driver.services_controller._result_formatter
self.assertRaises(errors.BlueFloodApiSchemaError, formatter, resp)

View File

@ -0,0 +1,78 @@
# Copyright (c) 2016 Rackspace, Inc.
#
# 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.
"""Unittests for BlueFlood utils"""
import datetime
import time
from poppy.metrics.blueflood.utils import helper
from tests.unit import base
class TestBlueFloodUtils(base.TestCase):
def setUp(self):
super(TestBlueFloodUtils, self).setUp()
self.url = 'https://www.metrics.com'
def _almostequal(self, entity1, entity2, delta=1):
if abs(entity1-entity2) <= delta:
return True
else:
return False
def test_helper_set_qs_on_url(self):
params = {
'metricType': 'requestCount',
'domain': 'poppy.org'
}
url_with_qs_set = helper.set_qs_on_url(self.url, **params)
self.assertIn('metricType=requestCount', url_with_qs_set)
self.assertIn('domain=poppy.org', url_with_qs_set)
def test_helper_join_url(self):
relative_url = 'requestCount'
expected_url = self.url + '/' + relative_url
self.assertEqual(helper.join_url(self.url, relative_url),
expected_url)
def test_retrieve_last_relative_url(self):
relative_url = 'requestCount'
non_relative_url = self.url + '/' + relative_url
self.assertEqual(helper.retrieve_last_relative_url(non_relative_url),
relative_url)
def test_datetime_to_epoch(self):
datetime_obj = datetime.datetime.today()
expected = helper.datetime_to_epoch(datetime_obj=datetime_obj)
observed = int(time.time()) * 1000
equality = self._almostequal(expected, observed)
self.assertEqual(equality, True)
def test_resolution_converter_seconds_to_enum_happy(self):
seconds_series = ['0', '300', '1200', '3600', '14400', '86400']
for seconds in seconds_series:
helper.resolution_converter_seconds_to_enum(seconds)
def test_resolution_converter_seconds_to_enum_exception(self):
self.assertRaises(ValueError,
helper.resolution_converter_seconds_to_enum,
'12345')

View File

@ -111,7 +111,8 @@ class MockStorageController(mock.Mock):
}
)
def get_service_details_by_domain_name(self, domain_name):
def get_service_details_by_domain_name(self, domain_name,
project_id=None):
r = service.Service(
str(uuid.uuid4()),
str(uuid.uuid4()),

View File

@ -97,7 +97,13 @@ AKAMAI_OPTIONS = [
help='Operator groupID'),
cfg.StrOpt(
'property_id',
help='Operator propertyID')
help='Operator propertyID'),
# Metrics related configs
cfg.IntOpt('metrics_resolution',
help='Resolution in seconds for retrieving metrics',
default=86400)
]

View File

@ -15,6 +15,7 @@
import datetime
import json
import time
import uuid
import ddt
@ -26,6 +27,7 @@ from poppy.model.helpers import origin
from poppy.model.helpers import restriction
from poppy.model.helpers import rule
from poppy.model.service import Service
from poppy.provider.akamai import geo_zone_code_mapping
from poppy.provider.akamai import services
from poppy.transport.pecan.models.request import service
from poppy.transport.pecan.models.request import ssl_certificate
@ -48,6 +50,8 @@ class TestServices(base.TestCase):
self.driver.akamai_https_access_url_suffix = str(uuid.uuid1())
self.san_cert_cnames = [str(x) for x in range(7)]
self.driver.san_cert_cnames = self.san_cert_cnames
self.driver.regions = geo_zone_code_mapping.REGIONS
self.driver.metrics_resolution = 86400
self.controller = services.ServiceController(self.driver)
service_id = str(uuid.uuid4())
domains_old = domain.Domain(domain='cdn.poppy.org')
@ -611,3 +615,104 @@ class TestServices(base.TestCase):
controller.sps_api_base_url.format(spsId=lastSpsId))
self.assertFalse(controller.sps_api_client.post.called)
return
def test_regions(self):
controller = services.ServiceController(self.driver)
self.assertEqual(controller.driver.regions,
geo_zone_code_mapping.REGIONS)
@ddt.data('requestCount', 'bandwidthOut', 'httpResponseCode_1XX',
'httpResponseCode_2XX', 'httpResponseCode_3XX',
'httpResponseCode_4XX', 'httpResponseCode_5XX')
def test_get_metrics_by_domain_metrics_controller(self, metrictype):
controller = services.ServiceController(self.driver)
project_id = str(uuid.uuid4())
domain_name = 'www.' + str(uuid.uuid4()) + '.com'
regions = controller.driver.regions
end_time = datetime.datetime.utcnow()
start_time = (datetime.datetime.utcnow() - datetime.timedelta(days=1))
startTime = start_time.strftime("%Y-%m-%dT%H:%M:%S")
endTime = end_time.strftime("%Y-%m-%dT%H:%M:%S")
metrics_controller = mock.Mock()
# NOTE(TheSriram): We mock a empty return value, to just test
# what the call args were for the metrics_controller
metrics_controller.read = mock.Mock(return_value=[])
extras = {
'metricType': metrictype,
'startTime': startTime,
'endTime': endTime,
'metrics_controller': metrics_controller
}
controller.get_metrics_by_domain(project_id, domain_name,
regions, **extras)
call_args = metrics_controller.read.call_args[1]
self.assertEqual(call_args['resolution'],
self.driver.metrics_resolution)
self.assertEqual(call_args['to_timestamp'], endTime)
self.assertEqual(call_args['from_timestamp'], startTime)
metric_names = call_args['metric_names']
for metric_name in metric_names:
metric_split = metric_name.split('_')
if len(metric_split) == 3:
self.assertEqual(metric_split[0], metrictype)
self.assertEqual(metric_split[1], domain_name)
self.assertIn(metric_split[2], regions)
else:
self.assertEqual(metric_split[0], 'requestCount')
self.assertEqual(metric_split[1], domain_name)
self.assertIn(metric_split[2], regions)
self.assertIn(metric_split[3], metrictype.split('_')[1])
@ddt.data('requestCount', 'bandwidthOut', 'httpResponseCode_1XX',
'httpResponseCode_2XX', 'httpResponseCode_3XX',
'httpResponseCode_4XX', 'httpResponseCode_5XX')
def test_get_metrics_by_domain_metrics_controller_return(self, metrictype):
controller = services.ServiceController(self.driver)
project_id = str(uuid.uuid4())
domain_name = 'www.' + str(uuid.uuid4()) + '.com'
regions = controller.driver.regions
end_time = datetime.datetime.utcnow()
start_time = (datetime.datetime.utcnow() - datetime.timedelta(days=1))
startTime = start_time.strftime("%Y-%m-%dT%H:%M:%S")
endTime = end_time.strftime("%Y-%m-%dT%H:%M:%S")
metrics_controller = mock.Mock()
metric_buckets = []
if 'httpResponseCode' in metrictype:
http_series = metrictype.split('_')[1]
for region in regions:
metric_buckets.append('_'.join(['requestCount', domain_name,
region,
http_series]))
else:
for region in regions:
metric_buckets.append('_'.join([metrictype, domain_name,
region]))
timestamp = str(int(time.time()))
value = 55
metrics_response = [(metric_bucket, {timestamp: value})
for metric_bucket in metric_buckets]
metrics_controller.read = mock.Mock(return_value=metrics_response)
extras = {
'metricType': metrictype,
'startTime': startTime,
'endTime': endTime,
'metrics_controller': metrics_controller
}
formatted_results = controller.get_metrics_by_domain(project_id,
domain_name,
regions,
**extras)
self.assertEqual(formatted_results['domain'], domain_name)
self.assertEqual(sorted(formatted_results[metrictype].keys()),
sorted(regions))
for timestamp_counter in formatted_results[metrictype].values():
self.assertEqual(timestamp_counter[0][timestamp], value)

View File

@ -36,6 +36,7 @@ class TestServices(base.TestCase):
self.provider_service_id = uuid.uuid1()
self.mock_get_client = mock_get_client
self.driver = MockDriver()
self.driver.regions = []
self.controller = services.ServiceController(self.driver)
def test_get(self):
@ -131,3 +132,6 @@ class TestServices(base.TestCase):
# TODO(tonytan4ever/obulpathi): fill in once correct
# current_customer logic is done
self.assertTrue(self.controller.current_customer is None)
def test_regions(self):
self.assertEqual(self.controller.driver.regions, [])

View File

@ -38,6 +38,7 @@ class TestServices(base.TestCase):
super(TestServices, self).setUp()
self.driver = mock_driver()
self.driver.provider_name = 'Fastly'
self.driver.regions = []
self.mock_service = mock_service
self.mock_version = mock_version
@ -445,3 +446,9 @@ class TestProviderValidation(base.TestCase):
resp = self.controller.get('magic-service')
self.assertIn('error', resp[self.driver.provider_name])
def test_regions(self):
driver = mock.Mock()
driver.regions = []
controller = services.ServiceController(driver)
self.assertEqual(controller.driver.regions, [])

View File

@ -302,3 +302,12 @@ class TestServices(base.TestCase):
controller.current_customer
except RuntimeError as e:
self.assertTrue(str(e) == "Get maxcdn current customer failed...")
@mock.patch('poppy.provider.maxcdn.driver.CDNProvider.client')
@mock.patch('poppy.provider.maxcdn.driver.CDNProvider')
def test_regions(self, mock_controllerclient, mock_driver):
driver = mock_driver()
driver.regions = []
driver.attach_mock(mock_controllerclient, 'client')
controller = services.ServiceController(driver)
self.assertEqual(controller.driver.regions, [])

View File

@ -31,6 +31,7 @@ class MockProviderServicesTest(base.TestCase):
def setUp(self, mock_driver):
super(MockProviderServicesTest, self).setUp()
self.driver = mock_driver()
self.driver.regions = []
self.test_provider_service_id = uuid.uuid1()
self.sc = services.ServiceController(self.driver)
@ -66,3 +67,6 @@ class MockProviderServicesTest(base.TestCase):
def test_current_customer(self):
self.assertTrue(self.sc.current_customer is None)
def test_regions(self):
self.assertEqual(self.sc._driver.regions, [])