Volume CRD through Cinder API gateway
This patch is to implement basic volume operation: post/get_one /get_all/detail/delete through Cinder API gateway Some TODO work was left in the code for later implementation BP: https://blueprints.launchpad.net/tricircle/+spec/implement-stateless Change-Id: Id338894592a2522dc3e138d754d278c604b21758 Signed-off-by: Chaoyi Huang <joehuang@huawei.com>
This commit is contained in:
parent
b1991481c8
commit
5e886aebda
|
@ -104,3 +104,11 @@ will be used.
|
|||
```
|
||||
nova boot --flavor 1 --image $image_id --nic net-id=$net_id --availability-zone az1 vm1
|
||||
```
|
||||
- 9 Create, list, show and delete volume.
|
||||
```
|
||||
cinder --debug create --availability-zone=az1 1
|
||||
cinder --debug list
|
||||
cinder --debug show $volume_id
|
||||
cinder --debug delete $volume_id
|
||||
cinder --debug list
|
||||
```
|
||||
|
|
|
@ -44,19 +44,7 @@ function create_nova_apigw_accounts {
|
|||
local tricircle_nova_apigw=$(get_or_create_service "nova" \
|
||||
"compute" "Nova Compute Service")
|
||||
|
||||
local endpoint_id
|
||||
interface_list="public admin internal"
|
||||
for interface in $interface_list; do
|
||||
endpoint_id=$(openstack endpoint list \
|
||||
--service "$tricircle_nova_apigw" \
|
||||
--interface "$interface" \
|
||||
--region "$REGION_NAME" \
|
||||
-c ID -f value)
|
||||
if [[ -n "$endpoint_id" ]]; then
|
||||
# Delete endpoint
|
||||
openstack endpoint delete "$endpoint_id"
|
||||
fi
|
||||
done
|
||||
remove_old_endpoint_conf $tricircle_nova_apigw
|
||||
|
||||
get_or_create_endpoint $tricircle_nova_apigw \
|
||||
"$REGION_NAME" \
|
||||
|
@ -80,16 +68,40 @@ function create_cinder_apigw_accounts {
|
|||
|
||||
if [[ "$KEYSTONE_CATALOG_BACKEND" = 'sql' ]]; then
|
||||
local tricircle_cinder_apigw=$(get_or_create_service "cinder" \
|
||||
"volume" "Cinder Volume Service")
|
||||
"volumev2" "Cinder Volume Service")
|
||||
|
||||
remove_old_endpoint_conf $tricircle_cinder_apigw
|
||||
|
||||
get_or_create_endpoint $tricircle_cinder_apigw \
|
||||
"$REGION_NAME" \
|
||||
"$SERVICE_PROTOCOL://$TRICIRCLE_CINDER_APIGW_HOST:$TRICIRCLE_CINDER_APIGW_PORT/v2/" \
|
||||
"$SERVICE_PROTOCOL://$TRICIRCLE_CINDER_APIGW_HOST:$TRICIRCLE_CINDER_APIGW_PORT/v2/" \
|
||||
"$SERVICE_PROTOCOL://$TRICIRCLE_CINDER_APIGW_HOST:$TRICIRCLE_CINDER_APIGW_PORT/v2/"
|
||||
"$SERVICE_PROTOCOL://$TRICIRCLE_CINDER_APIGW_HOST:$TRICIRCLE_CINDER_APIGW_PORT/v2/"'$(tenant_id)s' \
|
||||
"$SERVICE_PROTOCOL://$TRICIRCLE_CINDER_APIGW_HOST:$TRICIRCLE_CINDER_APIGW_PORT/v2/"'$(tenant_id)s' \
|
||||
"$SERVICE_PROTOCOL://$TRICIRCLE_CINDER_APIGW_HOST:$TRICIRCLE_CINDER_APIGW_PORT/v2/"'$(tenant_id)s'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# common config-file configuration for tricircle services
|
||||
function remove_old_endpoint_conf {
|
||||
local service=$1
|
||||
|
||||
local endpoint_id
|
||||
interface_list="public admin internal"
|
||||
for interface in $interface_list; do
|
||||
endpoint_id=$(openstack endpoint list \
|
||||
--service "$service" \
|
||||
--interface "$interface" \
|
||||
--region "$REGION_NAME" \
|
||||
-c ID -f value)
|
||||
if [[ -n "$endpoint_id" ]]; then
|
||||
# Delete endpoint
|
||||
openstack endpoint delete "$endpoint_id"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
# create_tricircle_cache_dir() - Set up cache dir for tricircle
|
||||
function create_tricircle_cache_dir {
|
||||
|
||||
|
@ -308,6 +320,12 @@ if [[ "$Q_ENABLE_TRICIRCLE" == "True" ]]; then
|
|||
create_cinder_apigw_accounts
|
||||
|
||||
run_process t-cgw "python $TRICIRCLE_CINDER_APIGW --config-file $TRICIRCLE_CINDER_APIGW_CONF"
|
||||
|
||||
get_or_create_endpoint "volumev2" \
|
||||
"$POD_REGION_NAME" \
|
||||
"$CINDER_SERVICE_PROTOCOL://$CINDER_SERVICE_HOST:$CINDER_SERVICE_PORT/v2/"'$(tenant_id)s' \
|
||||
"$CINDER_SERVICE_PROTOCOL://$CINDER_SERVICE_HOST:$CINDER_SERVICE_PORT/v2/"'$(tenant_id)s' \
|
||||
"$CINDER_SERVICE_PROTOCOL://$CINDER_SERVICE_HOST:$CINDER_SERVICE_PORT/v2/"'$(tenant_id)s'
|
||||
fi
|
||||
|
||||
if is_service_enabled t-job; then
|
||||
|
|
|
@ -17,6 +17,8 @@ import pecan
|
|||
|
||||
import oslo_log.log as logging
|
||||
|
||||
from tricircle.cinder_apigw.controllers import volume
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -61,12 +63,20 @@ class V2Controller(object):
|
|||
|
||||
def __init__(self):
|
||||
|
||||
self.sub_controllers = {
|
||||
|
||||
self.resource_controller = {
|
||||
'volumes': volume.VolumeController,
|
||||
}
|
||||
|
||||
for name, ctrl in self.sub_controllers.items():
|
||||
setattr(self, name, ctrl)
|
||||
@pecan.expose()
|
||||
def _lookup(self, tenant_id, *remainder):
|
||||
if not remainder:
|
||||
pecan.abort(404)
|
||||
return
|
||||
resource = remainder[0]
|
||||
if resource not in self.resource_controller:
|
||||
pecan.abort(404)
|
||||
return
|
||||
return self.resource_controller[resource](tenant_id), remainder[1:]
|
||||
|
||||
@pecan.expose(generic=True, template='json')
|
||||
def index(self):
|
||||
|
|
|
@ -0,0 +1,332 @@
|
|||
# Copyright (c) 2015 Huawei Tech. Co., Ltd.
|
||||
# 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.
|
||||
|
||||
import pecan
|
||||
from pecan import expose
|
||||
from pecan import request
|
||||
from pecan import response
|
||||
from pecan import Response
|
||||
from pecan import rest
|
||||
|
||||
from oslo_log import log as logging
|
||||
from oslo_serialization import jsonutils
|
||||
|
||||
from tricircle.common import az_ag
|
||||
from tricircle.common import constants as cons
|
||||
import tricircle.common.context as t_context
|
||||
from tricircle.common import httpclient as hclient
|
||||
from tricircle.common.i18n import _
|
||||
from tricircle.common.i18n import _LE
|
||||
|
||||
import tricircle.db.api as db_api
|
||||
from tricircle.db import core
|
||||
from tricircle.db import models
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VolumeController(rest.RestController):
|
||||
|
||||
def __init__(self, tenant_id):
|
||||
self.tenant_id = tenant_id
|
||||
|
||||
@expose(generic=True, template='json')
|
||||
def post(self, **kw):
|
||||
context = t_context.extract_context_from_environ()
|
||||
|
||||
if 'volume' not in kw:
|
||||
pecan.abort(400, _('Volume not found in request body'))
|
||||
return
|
||||
|
||||
if 'availability_zone' not in kw['volume']:
|
||||
pecan.abort(400, _('Availability zone not set in request'))
|
||||
return
|
||||
|
||||
pod = az_ag.get_pod_by_az_tenant(
|
||||
context,
|
||||
az_name=kw['volume']['availability_zone'],
|
||||
tenant_id=self.tenant_id)
|
||||
if not pod:
|
||||
pecan.abort(500, _('Pod not configured or scheduling failure'))
|
||||
LOG.error(_LE("Pod not configured or scheduling failure"))
|
||||
return
|
||||
|
||||
t_pod = db_api.get_top_pod(context)
|
||||
if not t_pod:
|
||||
pecan.abort(500, _('Top Pod not configured'))
|
||||
LOG.error(_LE("Top Po not configured"))
|
||||
return
|
||||
|
||||
# TODO(joehuang): get release from pod configuration,
|
||||
# to convert the content
|
||||
# b_release = pod['release']
|
||||
# t_release = t_pod['release']
|
||||
t_release = 'Mitaka'
|
||||
b_release = 'Mitaka'
|
||||
|
||||
s_ctx = hclient.get_pod_service_ctx(
|
||||
context,
|
||||
request.url,
|
||||
pod['pod_name'],
|
||||
s_type=cons.ST_CINDER)
|
||||
|
||||
if s_ctx['b_url'] == '':
|
||||
pecan.abort(500, _('bottom pod endpoint incorrect'))
|
||||
LOG.error(_LE("bottom pod endpoint incorrect %s") %
|
||||
pod['pod_name'])
|
||||
return
|
||||
|
||||
b_headers = self._convert_header(t_release,
|
||||
b_release,
|
||||
request.headers)
|
||||
|
||||
t_vol = kw['volume']
|
||||
|
||||
# add or remove key-value in the request for diff. version
|
||||
b_vol_req = self._convert_object(t_release, b_release, t_vol,
|
||||
res_type=cons.RT_VOLUME)
|
||||
|
||||
# convert az to the configured one
|
||||
# remove the AZ parameter to bottom request for default one
|
||||
b_vol_req['availability_zone'] = pod['pod_az_name']
|
||||
if b_vol_req['availability_zone'] == '':
|
||||
b_vol_req.pop("availability_zone", None)
|
||||
|
||||
b_body = jsonutils.dumps({'volume': b_vol_req})
|
||||
|
||||
resp = hclient.forward_req(
|
||||
context,
|
||||
'POST',
|
||||
b_headers,
|
||||
s_ctx['b_url'],
|
||||
b_body)
|
||||
b_status = resp.status_code
|
||||
b_ret_body = jsonutils.loads(resp.content)
|
||||
|
||||
# build routing and convert response from the bottom pod
|
||||
# for different version.
|
||||
response.status = b_status
|
||||
if b_status == 202:
|
||||
if b_ret_body.get('volume') is not None:
|
||||
b_vol_ret = b_ret_body['volume']
|
||||
|
||||
try:
|
||||
with context.session.begin():
|
||||
core.create_resource(
|
||||
context, models.ResourceRouting,
|
||||
{'top_id': b_vol_ret['id'],
|
||||
'bottom_id': b_vol_ret['id'],
|
||||
'pod_id': pod['pod_id'],
|
||||
'project_id': self.tenant_id,
|
||||
'resource_type': cons.RT_VOLUME})
|
||||
except Exception as e:
|
||||
LOG.error(_LE('Fail to create volume: %(exception)s'),
|
||||
{'exception': e})
|
||||
return Response(_('Failed to create volume'), 500)
|
||||
|
||||
ret_vol = self._convert_object(b_release, t_release,
|
||||
b_vol_ret,
|
||||
res_type=cons.RT_VOLUME)
|
||||
|
||||
ret_vol['availability_zone'] = pod['az_name']
|
||||
|
||||
return {'volume': ret_vol}
|
||||
|
||||
return {'error': b_ret_body}
|
||||
|
||||
@expose(generic=True, template='json')
|
||||
def get_one(self, _id):
|
||||
context = t_context.extract_context_from_environ()
|
||||
|
||||
if _id == 'detail':
|
||||
return {'volumes': self._get_all(context)}
|
||||
|
||||
# TODO(joehuang): get the release of top and bottom
|
||||
t_release = 'MITATA'
|
||||
b_release = 'MITATA'
|
||||
|
||||
b_headers = self._convert_header(t_release,
|
||||
b_release,
|
||||
request.headers)
|
||||
|
||||
s_ctx = self._get_res_routing_ref(context, _id, request.url)
|
||||
if not s_ctx:
|
||||
return Response(_('Failed to find resource'), 404)
|
||||
|
||||
if s_ctx['b_url'] == '':
|
||||
return Response(_('bottom pod endpoint incorrect'), 404)
|
||||
|
||||
resp = hclient.forward_req(context, 'GET',
|
||||
b_headers,
|
||||
s_ctx['b_url'],
|
||||
request.body)
|
||||
|
||||
b_ret_body = jsonutils.loads(resp.content)
|
||||
|
||||
b_status = resp.status_code
|
||||
response.status = b_status
|
||||
if b_status == 200:
|
||||
if b_ret_body.get('volume') is not None:
|
||||
b_vol_ret = b_ret_body['volume']
|
||||
ret_vol = self._convert_object(b_release, t_release,
|
||||
b_vol_ret,
|
||||
res_type=cons.RT_VOLUME)
|
||||
|
||||
pod = self._get_pod_by_top_id(context, _id)
|
||||
if pod:
|
||||
ret_vol['availability_zone'] = pod['az_name']
|
||||
|
||||
return {'volume': ret_vol}
|
||||
|
||||
# resource not find but routing exist, remove the routing
|
||||
if b_status == 404:
|
||||
filters = [{'key': 'top_id', 'comparator': 'eq', 'value': _id},
|
||||
{'key': 'resource_type',
|
||||
'comparator': 'eq',
|
||||
'value': cons.RT_VOLUME}]
|
||||
with context.session.begin():
|
||||
core.delete_resources(context,
|
||||
models.ResourceRouting,
|
||||
filters)
|
||||
return b_ret_body
|
||||
|
||||
@expose(generic=True, template='json')
|
||||
def get_all(self):
|
||||
|
||||
# TODO(joehuang): here should return link instead,
|
||||
# now combined with 'detail'
|
||||
|
||||
context = t_context.extract_context_from_environ()
|
||||
return {'volumes': self._get_all(context)}
|
||||
|
||||
def _get_all(self, context):
|
||||
|
||||
# TODO(joehuang): query optimization for pagination, sort, etc
|
||||
ret = []
|
||||
pods = az_ag.list_pods_by_tenant(context, self.tenant_id)
|
||||
for pod in pods:
|
||||
if pod['pod_name'] == '':
|
||||
continue
|
||||
|
||||
s_ctx = hclient.get_pod_service_ctx(
|
||||
context,
|
||||
request.url,
|
||||
pod['pod_name'],
|
||||
s_type=cons.ST_CINDER)
|
||||
if s_ctx['b_url'] == '':
|
||||
LOG.error(_LE("bottom pod endpoint incorrect %s")
|
||||
% pod['pod_name'])
|
||||
continue
|
||||
|
||||
# TODO(joehuang): convert header and body content
|
||||
resp = hclient.forward_req(context, 'GET',
|
||||
request.headers,
|
||||
s_ctx['b_url'],
|
||||
request.body)
|
||||
|
||||
if resp.status_code == 200:
|
||||
|
||||
routings = db_api.get_bottom_mappings_by_tenant_pod(
|
||||
context, self.tenant_id,
|
||||
pod['pod_id'], cons.RT_VOLUME
|
||||
)
|
||||
|
||||
b_ret_body = jsonutils.loads(resp.content)
|
||||
if b_ret_body.get('volumes'):
|
||||
for vol in b_ret_body['volumes']:
|
||||
|
||||
if not routings.get(vol['id']):
|
||||
b_ret_body['volumes'].remove(vol)
|
||||
continue
|
||||
|
||||
vol['availability_zone'] = pod['az_name']
|
||||
|
||||
ret.extend(b_ret_body['volumes'])
|
||||
return ret
|
||||
|
||||
@expose(generic=True, template='json')
|
||||
def delete(self, _id):
|
||||
context = t_context.extract_context_from_environ()
|
||||
|
||||
# TODO(joehuang): get the release of top and bottom
|
||||
t_release = 'MITATA'
|
||||
b_release = 'MITATA'
|
||||
|
||||
s_ctx = self._get_res_routing_ref(context, _id, request.url)
|
||||
if not s_ctx:
|
||||
return Response(_('Failed to find resource'), 404)
|
||||
|
||||
if s_ctx['b_url'] == '':
|
||||
return Response(_('bottom pod endpoint incorrect'), 404)
|
||||
|
||||
b_headers = self._convert_header(t_release,
|
||||
b_release,
|
||||
request.headers)
|
||||
|
||||
resp = hclient.forward_req(context, 'DELETE',
|
||||
b_headers,
|
||||
s_ctx['b_url'],
|
||||
request.body)
|
||||
|
||||
response.status = resp.status_code
|
||||
|
||||
# don't remove the resource routing for delete is async. operation
|
||||
# remove the routing when query is executed but not find
|
||||
|
||||
# No content in the resp actually
|
||||
return {}
|
||||
|
||||
# move to common function if other modules need
|
||||
def _get_res_routing_ref(self, context, _id, t_url):
|
||||
|
||||
pod = self._get_pod_by_top_id(context, _id)
|
||||
|
||||
if not pod:
|
||||
return None
|
||||
|
||||
pod_name = pod['pod_name']
|
||||
|
||||
s_ctx = hclient.get_pod_service_ctx(
|
||||
context,
|
||||
t_url,
|
||||
pod_name,
|
||||
s_type=cons.ST_CINDER)
|
||||
|
||||
if s_ctx['b_url'] == '':
|
||||
LOG.error(_LE("bottom pod endpoint incorrect %s") %
|
||||
pod_name)
|
||||
|
||||
return s_ctx
|
||||
|
||||
# move to common function if other modules need
|
||||
def _get_pod_by_top_id(self, context, _id):
|
||||
|
||||
mappings = db_api.get_bottom_mappings_by_top_id(
|
||||
context, _id,
|
||||
cons.RT_VOLUME)
|
||||
|
||||
if not mappings or len(mappings) != 1:
|
||||
return None
|
||||
|
||||
return mappings[0][0]
|
||||
|
||||
def _convert_header(self, from_release, to_release, header):
|
||||
|
||||
return header
|
||||
|
||||
def _convert_object(self, from_release, to_release, res_object,
|
||||
res_type=cons.RT_VOLUME):
|
||||
|
||||
return res_object
|
|
@ -13,10 +13,17 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from oslo_log import log as logging
|
||||
from oslo_utils import uuidutils
|
||||
|
||||
from tricircle.common.i18n import _LE
|
||||
|
||||
from tricircle.db import api as db_api
|
||||
from tricircle.db import core
|
||||
from tricircle.db import models
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_ag_az(context, ag_name, az_name):
|
||||
aggregate = core.create_resource(context, models.Aggregate,
|
||||
|
@ -100,3 +107,59 @@ def get_all_ag(context, filters=None, sorts=None):
|
|||
aggregate.update(extra_fields)
|
||||
|
||||
return aggregates
|
||||
|
||||
|
||||
def get_pod_by_az_tenant(context, az_name, tenant_id):
|
||||
pod_bindings = core.query_resource(context,
|
||||
models.PodBinding,
|
||||
[{'key': 'tenant_id',
|
||||
'comparator': 'eq',
|
||||
'value': tenant_id}],
|
||||
[])
|
||||
if pod_bindings:
|
||||
for pod_b in pod_bindings:
|
||||
pod = core.get_resource(context,
|
||||
models.Pod,
|
||||
pod_b['pod_id'])
|
||||
if pod['az_name'] == az_name:
|
||||
return pod
|
||||
|
||||
# TODO(joehuang): schedule one dynamicly in the future
|
||||
filters = [{'key': 'az_name', 'comparator': 'eq', 'value': az_name}]
|
||||
pods = db_api.list_pods(context, filters=filters)
|
||||
for pod in pods:
|
||||
if pod['pod_name'] != '' and az_name != '':
|
||||
try:
|
||||
with context.session.begin():
|
||||
core.create_resource(
|
||||
context, models.PodBinding,
|
||||
{'id': uuidutils.generate_uuid(),
|
||||
'tenant_id': tenant_id,
|
||||
'pod_id': pod['pod_id']})
|
||||
return pod
|
||||
except Exception as e:
|
||||
LOG.error(_LE('Fail to create pod binding: %(exception)s'),
|
||||
{'exception': e})
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def list_pods_by_tenant(context, tenant_id):
|
||||
|
||||
pod_bindings = core.query_resource(context,
|
||||
models.PodBinding,
|
||||
[{'key': 'tenant_id',
|
||||
'comparator': 'eq',
|
||||
'value': tenant_id}],
|
||||
[])
|
||||
|
||||
pods = []
|
||||
if pod_bindings:
|
||||
for pod_b in pod_bindings:
|
||||
pod = core.get_resource(context,
|
||||
models.Pod,
|
||||
pod_b['pod_id'])
|
||||
pods.append(pod)
|
||||
|
||||
return pods
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
# Copyright 2015 Huawei Technologies Co., Ltd.
|
||||
#
|
||||
# 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.
|
||||
|
||||
# service type
|
||||
ST_NOVA = 'nova'
|
||||
# only support cinder v2
|
||||
ST_CINDER = 'cinderv2'
|
||||
ST_NEUTRON = 'neutron'
|
||||
ST_GLANCE = 'glance'
|
||||
|
||||
# resource_type
|
||||
RT_SERVER = 'server'
|
||||
RT_VOLUME = 'volume'
|
||||
RT_BACKUP = 'backup'
|
||||
RT_SNAPSHOT = 'snapshot'
|
||||
RT_NETWORK = 'network'
|
||||
RT_SUBNET = 'subnet'
|
||||
RT_PORT = 'port'
|
||||
|
||||
# version list
|
||||
NOVA_VERSION_V21 = 'v2.1'
|
||||
CINDER_VERSION_V2 = 'v2'
|
||||
NEUTRON_VERSION_V2 = 'v2'
|
||||
|
||||
# supported release
|
||||
R_LIBERTY = 'liberty'
|
||||
R_MITAKA = 'mitaka'
|
|
@ -0,0 +1,138 @@
|
|||
# Copyright 2015 Huawei Technologies Co., Ltd.
|
||||
# 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.
|
||||
|
||||
import urlparse
|
||||
|
||||
from requests import Request
|
||||
from requests import Session
|
||||
|
||||
from tricircle.common import client
|
||||
from tricircle.common import constants as cons
|
||||
from tricircle.db import api as db_api
|
||||
|
||||
|
||||
# the url could be endpoint registered in the keystone
|
||||
# or url sent to tricircle service, which is stored in
|
||||
# pecan.request.url
|
||||
def get_version_from_url(url):
|
||||
|
||||
components = urlparse.urlsplit(url)
|
||||
|
||||
path = components.path
|
||||
pos = path.find('/')
|
||||
|
||||
ver = ''
|
||||
if pos == 0:
|
||||
path = path[1:]
|
||||
i = path.find('/')
|
||||
if i >= 0:
|
||||
ver = path[:i]
|
||||
else:
|
||||
ver = path
|
||||
elif pos > 0:
|
||||
ver = path[:pos]
|
||||
else:
|
||||
ver = path
|
||||
|
||||
return ver
|
||||
|
||||
|
||||
def get_bottom_url(t_ver, t_url, b_ver, b_endpoint):
|
||||
"""get_bottom_url
|
||||
|
||||
convert url received by Tricircle service to bottom OpenStack
|
||||
request url through the configured endpoint in the KeyStone
|
||||
|
||||
:param t_ver: version of top service
|
||||
:param t_url: request url to the top service
|
||||
:param b_ver: version of bottom service
|
||||
:param b_endpoint: endpoint registered in keystone for bottom service
|
||||
:return: request url to bottom service
|
||||
"""
|
||||
t_parse = urlparse.urlsplit(t_url)
|
||||
|
||||
after_ver = t_parse.path
|
||||
|
||||
remove_ver = '/' + t_ver + '/'
|
||||
pos = after_ver.find(remove_ver)
|
||||
|
||||
if pos == 0:
|
||||
after_ver = after_ver[len(remove_ver):]
|
||||
else:
|
||||
remove_ver = t_ver + '/'
|
||||
pos = after_ver.find(remove_ver)
|
||||
if pos == 0:
|
||||
after_ver = after_ver[len(remove_ver):]
|
||||
|
||||
if after_ver == t_parse.path:
|
||||
# wrong t_url
|
||||
return ''
|
||||
|
||||
b_parse = urlparse.urlsplit(b_endpoint)
|
||||
|
||||
scheme = b_parse.scheme
|
||||
netloc = b_parse.netloc
|
||||
path = '/' + b_ver + '/' + after_ver
|
||||
if b_ver == '':
|
||||
path = '/' + after_ver
|
||||
query = t_parse.query
|
||||
fragment = t_parse.fragment
|
||||
|
||||
b_url = urlparse.urlunsplit((scheme,
|
||||
netloc,
|
||||
path,
|
||||
query,
|
||||
fragment))
|
||||
return b_url
|
||||
|
||||
|
||||
def get_pod_service_endpoint(context, pod_name, st):
|
||||
|
||||
pod = db_api.get_pod_by_name(context, pod_name)
|
||||
|
||||
if pod:
|
||||
c = client.Client()
|
||||
return c.get_endpoint(context, pod['pod_id'], st)
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
def get_pod_service_ctx(context, t_url, pod_name, s_type=cons.ST_NOVA):
|
||||
t_ver = get_version_from_url(t_url)
|
||||
b_endpoint = get_pod_service_endpoint(context,
|
||||
pod_name,
|
||||
s_type)
|
||||
b_ver = get_version_from_url(b_endpoint)
|
||||
b_url = ''
|
||||
if b_endpoint != '':
|
||||
b_url = get_bottom_url(t_ver, t_url, b_ver, b_endpoint)
|
||||
|
||||
return {'t_ver': t_ver, 'b_ver': b_ver,
|
||||
't_url': t_url, 'b_url': b_url}
|
||||
|
||||
|
||||
def forward_req(context, action, b_headers, b_url, b_body):
|
||||
s = Session()
|
||||
req = Request(action, b_url,
|
||||
data=b_body,
|
||||
headers=b_headers)
|
||||
prepped = req.prepare()
|
||||
|
||||
# do something with prepped.body
|
||||
# do something with prepped.headers
|
||||
resp = s.send(prepped,
|
||||
timeout=60)
|
||||
|
||||
return resp
|
|
@ -26,8 +26,10 @@ from oslo_config import cfg
|
|||
from oslo_log import log as logging
|
||||
from requests import exceptions as r_exceptions
|
||||
|
||||
from tricircle.common import constants as cons
|
||||
from tricircle.common import exceptions
|
||||
|
||||
|
||||
client_opts = [
|
||||
cfg.IntOpt('cinder_timeout',
|
||||
default=60,
|
||||
|
@ -77,7 +79,7 @@ class ResourceHandle(object):
|
|||
|
||||
|
||||
class GlanceResourceHandle(ResourceHandle):
|
||||
service_type = 'glance'
|
||||
service_type = cons.ST_GLANCE
|
||||
support_resource = {'image': LIST | GET}
|
||||
|
||||
def _get_client(self, cxt):
|
||||
|
@ -113,7 +115,7 @@ class GlanceResourceHandle(ResourceHandle):
|
|||
|
||||
|
||||
class NeutronResourceHandle(ResourceHandle):
|
||||
service_type = 'neutron'
|
||||
service_type = cons.ST_NEUTRON
|
||||
support_resource = {'network': LIST | CREATE | DELETE | GET,
|
||||
'subnet': LIST | CREATE | DELETE | GET,
|
||||
'port': LIST | CREATE | DELETE | GET,
|
||||
|
@ -176,7 +178,7 @@ class NeutronResourceHandle(ResourceHandle):
|
|||
|
||||
|
||||
class NovaResourceHandle(ResourceHandle):
|
||||
service_type = 'nova'
|
||||
service_type = cons.ST_NOVA
|
||||
support_resource = {'flavor': LIST,
|
||||
'server': LIST | CREATE | GET,
|
||||
'aggregate': LIST | CREATE | DELETE | ACTION}
|
||||
|
@ -257,7 +259,7 @@ class NovaResourceHandle(ResourceHandle):
|
|||
|
||||
|
||||
class CinderResourceHandle(ResourceHandle):
|
||||
service_type = 'cinder'
|
||||
service_type = cons.ST_CINDER
|
||||
support_resource = {'volume': GET | ACTION,
|
||||
'transfer': CREATE | ACTION}
|
||||
|
||||
|
|
|
@ -97,6 +97,38 @@ def get_bottom_mappings_by_top_id(context, top_id, resource_type):
|
|||
return mappings
|
||||
|
||||
|
||||
def get_bottom_mappings_by_tenant_pod(context,
|
||||
tenant_id,
|
||||
pod_id,
|
||||
resource_type):
|
||||
"""Get resource routing for specific tenant and pod
|
||||
|
||||
:param context: context object
|
||||
:param tenant_id: tenant id to look up
|
||||
:param pod_id: pod to look up
|
||||
:param resource_type: specific resource
|
||||
:return: a dic {top_id : route}
|
||||
"""
|
||||
route_filters = [{'key': 'pod_id',
|
||||
'comparator': 'eq',
|
||||
'value': pod_id},
|
||||
{'key': 'project_id',
|
||||
'comparator': 'eq',
|
||||
'value': tenant_id},
|
||||
{'key': 'resource_type',
|
||||
'comparator': 'eq',
|
||||
'value': resource_type}]
|
||||
routings = {}
|
||||
with context.session.begin():
|
||||
routes = core.query_resource(
|
||||
context, models.ResourceRouting, route_filters, [])
|
||||
for _route in routes:
|
||||
if not _route['bottom_id']:
|
||||
continue
|
||||
routings[_route['top_id']] = _route
|
||||
return routings
|
||||
|
||||
|
||||
def get_next_bottom_pod(context, current_pod_id=None):
|
||||
pods = list_pods(context, sorts=[(models.Pod.pod_id, True)])
|
||||
# NOTE(zhiyuan) number of pods is small, just traverse to filter top pod
|
||||
|
@ -107,3 +139,30 @@ def get_next_bottom_pod(context, current_pod_id=None):
|
|||
if pod['pod_id'] == current_pod_id and index < len(pods) - 1:
|
||||
return pods[index + 1]
|
||||
return None
|
||||
|
||||
|
||||
def get_top_pod(context):
|
||||
|
||||
filters = [{'key': 'az_name', 'comparator': 'eq', 'value': ''}]
|
||||
pods = list_pods(context, filters=filters)
|
||||
|
||||
# only one should be searched
|
||||
for pod in pods:
|
||||
if (pod['pod_name'] != '') and \
|
||||
(pod['az_name'] == ''):
|
||||
return pod
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_pod_by_name(context, pod_name):
|
||||
|
||||
filters = [{'key': 'pod_name', 'comparator': 'eq', 'value': pod_name}]
|
||||
pods = list_pods(context, filters=filters)
|
||||
|
||||
# only one should be searched
|
||||
for pod in pods:
|
||||
if pod['pod_name'] == pod_name:
|
||||
return pod
|
||||
|
||||
return None
|
||||
|
|
|
@ -0,0 +1,460 @@
|
|||
# Copyright (c) 2015 Huawei Technologies Co., Ltd.
|
||||
# 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.
|
||||
|
||||
from mock import patch
|
||||
|
||||
import pecan
|
||||
from pecan.configuration import set_config
|
||||
from pecan.testing import load_test_app
|
||||
|
||||
from requests import Response
|
||||
|
||||
from oslo_config import cfg
|
||||
from oslo_config import fixture as fixture_config
|
||||
from oslo_serialization import jsonutils
|
||||
from oslo_utils import uuidutils
|
||||
|
||||
from tricircle.cinder_apigw import app
|
||||
|
||||
from tricircle.common import constants as cons
|
||||
from tricircle.common import context
|
||||
from tricircle.common import httpclient as hclient
|
||||
|
||||
from tricircle.db import api as db_api
|
||||
from tricircle.db import core
|
||||
|
||||
from tricircle.tests import base
|
||||
|
||||
|
||||
OPT_GROUP_NAME = 'keystone_authtoken'
|
||||
cfg.CONF.import_group(OPT_GROUP_NAME, "keystonemiddleware.auth_token")
|
||||
|
||||
FAKE_AZ = 'fake_az'
|
||||
fake_volumes = []
|
||||
|
||||
|
||||
def fake_volumes_forward_req(ctx, action, b_header, b_url, b_req_body):
|
||||
resp = Response()
|
||||
resp.status_code = 404
|
||||
|
||||
if action == 'POST':
|
||||
b_body = jsonutils.loads(b_req_body)
|
||||
if b_body.get('volume'):
|
||||
vol = b_body['volume']
|
||||
vol['id'] = uuidutils.generate_uuid()
|
||||
stored_vol = {
|
||||
'volume': vol,
|
||||
'url': b_url
|
||||
}
|
||||
fake_volumes.append(stored_vol)
|
||||
resp.status_code = 202
|
||||
vol_dict = {'volume': vol}
|
||||
|
||||
resp._content = jsonutils.dumps(vol_dict)
|
||||
# resp.json = vol_dict
|
||||
return resp
|
||||
|
||||
pos = b_url.rfind('/volumes')
|
||||
op = ''
|
||||
cmp_url = b_url
|
||||
if pos > 0:
|
||||
op = b_url[pos:]
|
||||
cmp_url = b_url[:pos] + '/volumes'
|
||||
op = op[len('/volumes'):]
|
||||
|
||||
if action == 'GET':
|
||||
if op == '' or op == '/detail':
|
||||
tenant_id = b_url[:pos]
|
||||
pos2 = tenant_id.rfind('/')
|
||||
if pos2 > 0:
|
||||
tenant_id = tenant_id[(pos2 + 1):]
|
||||
else:
|
||||
resp.status_code = 404
|
||||
return resp
|
||||
ret_vols = []
|
||||
for temp_vol in fake_volumes:
|
||||
if temp_vol['url'] != cmp_url:
|
||||
continue
|
||||
|
||||
if temp_vol['volume']['project_id'] == tenant_id:
|
||||
ret_vols.append(temp_vol['volume'])
|
||||
|
||||
vol_dicts = {'volumes': ret_vols}
|
||||
resp._content = jsonutils.dumps(vol_dicts)
|
||||
resp.status_code = 200
|
||||
return resp
|
||||
elif op != '':
|
||||
if op[0] == '/':
|
||||
_id = op[1:]
|
||||
for vol in fake_volumes:
|
||||
if vol['volume']['id'] == _id:
|
||||
vol_dict = {'volume': vol['volume']}
|
||||
resp._content = jsonutils.dumps(vol_dict)
|
||||
resp.status_code = 200
|
||||
return resp
|
||||
if action == 'DELETE':
|
||||
if op != '':
|
||||
if op[0] == '/':
|
||||
_id = op[1:]
|
||||
for vol in fake_volumes:
|
||||
if vol['volume']['id'] == _id:
|
||||
fake_volumes.remove(vol)
|
||||
resp.status_code = 202
|
||||
return resp
|
||||
else:
|
||||
resp.status_code = 404
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
class CinderVolumeFunctionalTest(base.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(CinderVolumeFunctionalTest, self).setUp()
|
||||
|
||||
self.addCleanup(set_config, {}, overwrite=True)
|
||||
|
||||
cfg.CONF.register_opts(app.common_opts)
|
||||
|
||||
self.CONF = self.useFixture(fixture_config.Config()).conf
|
||||
|
||||
self.CONF.set_override('auth_strategy', 'noauth')
|
||||
|
||||
self.app = self._make_app()
|
||||
|
||||
self._init_db()
|
||||
|
||||
def _make_app(self, enable_acl=False):
|
||||
self.config = {
|
||||
'app': {
|
||||
'root':
|
||||
'tricircle.cinder_apigw.controllers.root.RootController',
|
||||
'modules': ['tricircle.cinder_apigw'],
|
||||
'enable_acl': enable_acl,
|
||||
'errors': {
|
||||
400: '/error',
|
||||
'__force_dict__': True
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return load_test_app(self.config)
|
||||
|
||||
def _init_db(self):
|
||||
core.initialize()
|
||||
core.ModelBase.metadata.create_all(core.get_engine())
|
||||
# enforce foreign key constraint for sqlite
|
||||
core.get_engine().execute('pragma foreign_keys=on')
|
||||
self.context = context.Context()
|
||||
|
||||
pod_dict = {
|
||||
'pod_id': 'fake_pod_id',
|
||||
'pod_name': 'fake_pod_name',
|
||||
'az_name': FAKE_AZ
|
||||
}
|
||||
|
||||
config_dict = {
|
||||
'service_id': 'fake_service_id',
|
||||
'pod_id': 'fake_pod_id',
|
||||
'service_type': cons.ST_CINDER,
|
||||
'service_url': 'http://127.0.0.1:8774/v2/$(tenant_id)s'
|
||||
}
|
||||
|
||||
pod_dict2 = {
|
||||
'pod_id': 'fake_pod_id' + '2',
|
||||
'pod_name': 'fake_pod_name' + '2',
|
||||
'az_name': FAKE_AZ + '2'
|
||||
}
|
||||
|
||||
config_dict2 = {
|
||||
'service_id': 'fake_service_id' + '2',
|
||||
'pod_id': 'fake_pod_id' + '2',
|
||||
'service_type': cons.ST_CINDER,
|
||||
'service_url': 'http://10.0.0.2:8774/v2/$(tenant_id)s'
|
||||
}
|
||||
|
||||
top_pod = {
|
||||
'pod_id': 'fake_top_pod_id',
|
||||
'pod_name': 'RegionOne',
|
||||
'az_name': ''
|
||||
}
|
||||
|
||||
top_config = {
|
||||
'service_id': 'fake_top_service_id',
|
||||
'pod_id': 'fake_top_pod_id',
|
||||
'service_type': cons.ST_CINDER,
|
||||
'service_url': 'http://127.0.0.1:19998/v2/$(tenant_id)s'
|
||||
}
|
||||
|
||||
db_api.create_pod(self.context, pod_dict)
|
||||
db_api.create_pod(self.context, pod_dict2)
|
||||
db_api.create_pod(self.context, top_pod)
|
||||
db_api.create_pod_service_configuration(self.context, config_dict)
|
||||
db_api.create_pod_service_configuration(self.context, config_dict2)
|
||||
db_api.create_pod_service_configuration(self.context, top_config)
|
||||
|
||||
def tearDown(self):
|
||||
super(CinderVolumeFunctionalTest, self).tearDown()
|
||||
cfg.CONF.unregister_opts(app.common_opts)
|
||||
pecan.set_config({}, overwrite=True)
|
||||
core.ModelBase.metadata.drop_all(core.get_engine())
|
||||
|
||||
|
||||
class TestVolumeController(CinderVolumeFunctionalTest):
|
||||
|
||||
@patch.object(hclient, 'forward_req',
|
||||
new=fake_volumes_forward_req)
|
||||
def test_post_error_case(self):
|
||||
|
||||
volumes = [
|
||||
# no 'volume' parameter
|
||||
{
|
||||
"volume_xxx":
|
||||
{
|
||||
"name": 'vol_1',
|
||||
"size": 10,
|
||||
"project_id": 'my_tenant_id',
|
||||
"metadata": {}
|
||||
},
|
||||
"expected_error": 400
|
||||
},
|
||||
|
||||
# no AZ parameter
|
||||
{
|
||||
"volume":
|
||||
{
|
||||
"name": 'vol_1',
|
||||
"size": 10,
|
||||
"project_id": 'my_tenant_id',
|
||||
"metadata": {}
|
||||
},
|
||||
"expected_error": 400
|
||||
},
|
||||
|
||||
# incorrect AZ parameter
|
||||
{
|
||||
"volume":
|
||||
{
|
||||
"name": 'vol_1',
|
||||
"availability_zone": FAKE_AZ + FAKE_AZ,
|
||||
"size": 10,
|
||||
"project_id": 'my_tenant_id',
|
||||
"metadata": {}
|
||||
},
|
||||
"expected_error": 500
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
self._test_and_check(volumes, 'my_tenant_id')
|
||||
|
||||
@patch.object(hclient, 'forward_req',
|
||||
new=fake_volumes_forward_req)
|
||||
def test_post_one_and_get_one(self):
|
||||
|
||||
tenant1_volumes = [
|
||||
# normal volume with correct parameter
|
||||
{
|
||||
"volume":
|
||||
{
|
||||
"name": 'vol_1',
|
||||
"availability_zone": FAKE_AZ,
|
||||
"source_volid": '',
|
||||
"consistencygroup_id": '',
|
||||
"snapshot_id": '',
|
||||
"source_replica": '',
|
||||
"size": 10,
|
||||
"user_id": '',
|
||||
"imageRef": '',
|
||||
"attach_status": "detached",
|
||||
"volume_type": '',
|
||||
"project_id": 'my_tenant_id',
|
||||
"metadata": {}
|
||||
},
|
||||
"expected_error": 202
|
||||
},
|
||||
|
||||
# same tenant, multiple volumes
|
||||
{
|
||||
"volume":
|
||||
{
|
||||
"name": 'vol_2',
|
||||
"availability_zone": FAKE_AZ,
|
||||
"source_volid": '',
|
||||
"consistencygroup_id": '',
|
||||
"snapshot_id": '',
|
||||
"source_replica": '',
|
||||
"size": 20,
|
||||
"user_id": '',
|
||||
"imageRef": '',
|
||||
"attach_status": "detached",
|
||||
"volume_type": '',
|
||||
"project_id": 'my_tenant_id',
|
||||
"metadata": {}
|
||||
},
|
||||
"expected_error": 202
|
||||
},
|
||||
|
||||
# same tenant, different az
|
||||
{
|
||||
"volume":
|
||||
{
|
||||
"name": 'vol_3',
|
||||
"availability_zone": FAKE_AZ + '2',
|
||||
"source_volid": '',
|
||||
"consistencygroup_id": '',
|
||||
"snapshot_id": '',
|
||||
"source_replica": '',
|
||||
"size": 20,
|
||||
"user_id": '',
|
||||
"imageRef": '',
|
||||
"attach_status": "detached",
|
||||
"volume_type": '',
|
||||
"project_id": 'my_tenant_id',
|
||||
"metadata": {}
|
||||
},
|
||||
"expected_error": 202
|
||||
},
|
||||
]
|
||||
|
||||
tenant2_volumes = [
|
||||
# different tenant, same az
|
||||
{
|
||||
"volume":
|
||||
{
|
||||
"name": 'vol_4',
|
||||
"availability_zone": FAKE_AZ,
|
||||
"source_volid": '',
|
||||
"consistencygroup_id": '',
|
||||
"snapshot_id": '',
|
||||
"source_replica": '',
|
||||
"size": 20,
|
||||
"user_id": '',
|
||||
"imageRef": '',
|
||||
"attach_status": "detached",
|
||||
"volume_type": '',
|
||||
"project_id": 'my_tenant_id_2',
|
||||
"metadata": {}
|
||||
},
|
||||
"expected_error": 202
|
||||
},
|
||||
]
|
||||
|
||||
self._test_and_check(tenant1_volumes, 'my_tenant_id')
|
||||
self._test_and_check(tenant2_volumes, 'my_tenant_id_2')
|
||||
|
||||
self._test_detail_check('my_tenant_id', 3)
|
||||
self._test_detail_check('my_tenant_id_2', 1)
|
||||
|
||||
@patch.object(hclient, 'forward_req',
|
||||
new=fake_volumes_forward_req)
|
||||
def test_post_one_and_delete_one(self):
|
||||
|
||||
volumes = [
|
||||
# normal volume with correct parameter
|
||||
{
|
||||
"volume":
|
||||
{
|
||||
"name": 'vol_1',
|
||||
"availability_zone": FAKE_AZ,
|
||||
"source_volid": '',
|
||||
"consistencygroup_id": '',
|
||||
"snapshot_id": '',
|
||||
"source_replica": '',
|
||||
"size": 10,
|
||||
"user_id": '',
|
||||
"imageRef": '',
|
||||
"attach_status": "detached",
|
||||
"volume_type": '',
|
||||
"project_id": 'my_tenant_id',
|
||||
"metadata": {}
|
||||
},
|
||||
"expected_error": 202
|
||||
},
|
||||
]
|
||||
|
||||
self._test_and_check_delete(volumes, 'my_tenant_id')
|
||||
|
||||
@patch.object(hclient, 'forward_req',
|
||||
new=fake_volumes_forward_req)
|
||||
def test_get(self):
|
||||
response = self.app.get('/v2/my_tenant_id/volumes')
|
||||
self.assertEqual(response.status_int, 200)
|
||||
json_body = jsonutils.loads(response.body)
|
||||
vols = json_body.get('volumes')
|
||||
self.assertEqual(0, len(vols))
|
||||
|
||||
def _test_and_check(self, volumes, tenant_id):
|
||||
for test_vol in volumes:
|
||||
if test_vol.get('volume'):
|
||||
response = self.app.post_json(
|
||||
'/v2/' + tenant_id + '/volumes',
|
||||
dict(volume=test_vol['volume']),
|
||||
expect_errors=True)
|
||||
elif test_vol.get('volume_xxx'):
|
||||
response = self.app.post_json(
|
||||
'/v2/' + tenant_id + '/volumes',
|
||||
dict(volume=test_vol['volume_xxx']),
|
||||
expect_errors=True)
|
||||
else:
|
||||
return
|
||||
|
||||
self.assertEqual(response.status_int,
|
||||
test_vol['expected_error'])
|
||||
|
||||
if response.status_int == 202:
|
||||
json_body = jsonutils.loads(response.body)
|
||||
res_vol = json_body.get('volume')
|
||||
query_resp = self.app.get(
|
||||
'/v2/' + tenant_id + '/volumes/' + res_vol['id'])
|
||||
self.assertEqual(query_resp.status_int, 200)
|
||||
json_body = jsonutils.loads(query_resp.body)
|
||||
query_vol = json_body.get('volume')
|
||||
|
||||
self.assertEqual(res_vol['id'], query_vol['id'])
|
||||
self.assertEqual(res_vol['name'], query_vol['name'])
|
||||
self.assertEqual(res_vol['availability_zone'],
|
||||
query_vol['availability_zone'])
|
||||
self.assertIn(res_vol['availability_zone'],
|
||||
[FAKE_AZ, FAKE_AZ + '2'])
|
||||
|
||||
def _test_and_check_delete(self, volumes, tenant_id):
|
||||
for test_vol in volumes:
|
||||
if test_vol.get('volume'):
|
||||
response = self.app.post_json(
|
||||
'/v2/' + tenant_id + '/volumes',
|
||||
dict(volume=test_vol['volume']),
|
||||
expect_errors=True)
|
||||
self.assertEqual(response.status_int,
|
||||
test_vol['expected_error'])
|
||||
if response.status_int == 202:
|
||||
json_body = jsonutils.loads(response.body)
|
||||
_id = json_body.get('volume')['id']
|
||||
query_resp = self.app.get(
|
||||
'/v2/' + tenant_id + '/volumes/' + _id)
|
||||
self.assertEqual(query_resp.status_int, 200)
|
||||
|
||||
delete_resp = self.app.delete(
|
||||
'/v2/' + tenant_id + '/volumes/' + _id)
|
||||
self.assertEqual(delete_resp.status_int, 202)
|
||||
|
||||
def _test_detail_check(self, tenant_id, vol_size):
|
||||
resp = self.app.get(
|
||||
'/v2/' + tenant_id + '/volumes' + '/detail',
|
||||
expect_errors=True)
|
||||
self.assertEqual(resp.status_int, 200)
|
||||
json_body = jsonutils.loads(resp.body)
|
||||
ret_vols = json_body.get('volumes')
|
||||
self.assertEqual(len(ret_vols), vol_size)
|
|
@ -0,0 +1,169 @@
|
|||
# Copyright 2015 Huawei Technologies Co., Ltd.
|
||||
# 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.
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
from tricircle.common import az_ag
|
||||
from tricircle.common import context
|
||||
|
||||
from tricircle.db import api
|
||||
from tricircle.db import core
|
||||
from tricircle.db import models
|
||||
|
||||
|
||||
FAKE_AZ = 'fake_az'
|
||||
|
||||
FAKE_SITE_ID = 'fake_pod_id'
|
||||
FAKE_SITE_NAME = 'fake_pod_name'
|
||||
FAKE_SERVICE_ID = 'fake_service_id'
|
||||
|
||||
FAKE_SITE_ID_2 = 'fake_pod_id_2'
|
||||
FAKE_SITE_NAME_2 = 'fake_pod_name_2'
|
||||
FAKE_SERVICE_ID_2 = 'fake_service_id_2'
|
||||
|
||||
FAKE_TOP_NAME = 'RegionOne'
|
||||
FAKE_TOP_ID = 'fake_top_pod_id'
|
||||
FAKE_TOP_SERVICE_ID = 'fake_top_service_id'
|
||||
FAKE_TOP_ENDPOINT = 'http://127.0.0.1:8774/v2/$(tenant_id)s'
|
||||
|
||||
FAKE_TYPE = 'fake_type'
|
||||
FAKE_URL = 'http://127.0.0.1:12345'
|
||||
FAKE_URL_INVALID = 'http://127.0.0.1:23456'
|
||||
|
||||
FAKE_SERVICE_TYPE = 'cinder'
|
||||
FAKE_SERVICE_ENDPOINT = 'http://127.0.0.1:8774/v2.1/$(tenant_id)s'
|
||||
FAKE_SERVICE_ENDPOINT_2 = 'http://127.0.0.2:8774/v2.1/$(tenant_id)s'
|
||||
|
||||
FAKE_TENANT_ID = 'my tenant'
|
||||
|
||||
|
||||
class FakeException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AZAGTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
core.initialize()
|
||||
core.ModelBase.metadata.create_all(core.get_engine())
|
||||
# enforce foreign key constraint for sqlite
|
||||
core.get_engine().execute('pragma foreign_keys=on')
|
||||
self.context = context.Context()
|
||||
|
||||
top_pod = {
|
||||
'pod_id': FAKE_TOP_ID,
|
||||
'pod_name': FAKE_TOP_NAME,
|
||||
'az_name': ''
|
||||
}
|
||||
|
||||
config_dict_top = {
|
||||
'service_id': FAKE_TOP_SERVICE_ID,
|
||||
'pod_id': FAKE_TOP_ID,
|
||||
'service_type': FAKE_SERVICE_TYPE,
|
||||
'service_url': FAKE_TOP_ENDPOINT
|
||||
}
|
||||
|
||||
pod_dict = {
|
||||
'pod_id': FAKE_SITE_ID,
|
||||
'pod_name': FAKE_SITE_NAME,
|
||||
'az_name': FAKE_AZ
|
||||
}
|
||||
|
||||
pod_dict2 = {
|
||||
'pod_id': FAKE_SITE_ID_2,
|
||||
'pod_name': FAKE_SITE_NAME_2,
|
||||
'az_name': FAKE_AZ
|
||||
}
|
||||
|
||||
config_dict = {
|
||||
'service_id': FAKE_SERVICE_ID,
|
||||
'pod_id': FAKE_SITE_ID,
|
||||
'service_type': FAKE_SERVICE_TYPE,
|
||||
'service_url': FAKE_SERVICE_ENDPOINT
|
||||
}
|
||||
|
||||
config_dict2 = {
|
||||
'service_id': FAKE_SERVICE_ID_2,
|
||||
'pod_id': FAKE_SITE_ID_2,
|
||||
'service_type': FAKE_SERVICE_TYPE,
|
||||
'service_url': FAKE_SERVICE_ENDPOINT_2
|
||||
}
|
||||
|
||||
api.create_pod(self.context, pod_dict)
|
||||
api.create_pod(self.context, pod_dict2)
|
||||
api.create_pod(self.context, top_pod)
|
||||
api.create_pod_service_configuration(self.context, config_dict)
|
||||
api.create_pod_service_configuration(self.context, config_dict2)
|
||||
api.create_pod_service_configuration(self.context, config_dict_top)
|
||||
|
||||
def test_get_pod_by_az_tenant(self):
|
||||
|
||||
pod1 = az_ag.get_pod_by_az_tenant(self.context,
|
||||
FAKE_AZ + FAKE_AZ,
|
||||
FAKE_TENANT_ID)
|
||||
self.assertEqual(pod1, None)
|
||||
pods = az_ag.list_pods_by_tenant(self.context, FAKE_TENANT_ID)
|
||||
self.assertEqual(len(pods), 0)
|
||||
|
||||
# schedule one
|
||||
pod2 = az_ag.get_pod_by_az_tenant(self.context,
|
||||
FAKE_AZ,
|
||||
FAKE_TENANT_ID)
|
||||
|
||||
pod_bindings = core.query_resource(self.context,
|
||||
models.PodBinding,
|
||||
[{'key': 'tenant_id',
|
||||
'comparator': 'eq',
|
||||
'value': FAKE_TENANT_ID}],
|
||||
[])
|
||||
self.assertIsNotNone(pod_bindings)
|
||||
if pod_bindings[0]['pod_id'] == FAKE_SITE_ID:
|
||||
self.assertEqual(pod2['pod_name'], FAKE_SITE_NAME)
|
||||
self.assertEqual(pod2['pod_id'], FAKE_SITE_ID)
|
||||
self.assertEqual(pod2['az_name'], FAKE_AZ)
|
||||
else:
|
||||
self.assertEqual(pod2['pod_name'], FAKE_SITE_NAME_2)
|
||||
self.assertEqual(pod2['pod_id'], FAKE_SITE_ID_2)
|
||||
self.assertEqual(pod2['az_name'], FAKE_AZ)
|
||||
|
||||
# scheduled one should always be bound
|
||||
pod3 = az_ag.get_pod_by_az_tenant(self.context,
|
||||
FAKE_AZ,
|
||||
FAKE_TENANT_ID)
|
||||
|
||||
self.assertEqual(pod2['pod_name'], pod3['pod_name'])
|
||||
self.assertEqual(pod2['pod_id'], pod3['pod_id'])
|
||||
self.assertEqual(pod2['az_name'], pod3['az_name'])
|
||||
|
||||
def test_list_pods_by_tenant(self):
|
||||
|
||||
pod1 = az_ag.get_pod_by_az_tenant(self.context,
|
||||
FAKE_AZ + FAKE_AZ,
|
||||
FAKE_TENANT_ID)
|
||||
pods = az_ag.list_pods_by_tenant(self.context, FAKE_TENANT_ID)
|
||||
self.assertEqual(pod1, None)
|
||||
self.assertEqual(len(pods), 0)
|
||||
|
||||
# TODO(joehuang): tenant bound to multiple pods in one AZ
|
||||
|
||||
# schedule one
|
||||
pod2 = az_ag.get_pod_by_az_tenant(self.context,
|
||||
FAKE_AZ,
|
||||
FAKE_TENANT_ID)
|
||||
pods = az_ag.list_pods_by_tenant(self.context, FAKE_TENANT_ID)
|
||||
self.assertDictEqual(pods[0], pod2)
|
||||
|
||||
def tearDown(self):
|
||||
core.ModelBase.metadata.drop_all(core.get_engine())
|
|
@ -0,0 +1,215 @@
|
|||
# Copyright 2015 Huawei Technologies Co., Ltd.
|
||||
# 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.
|
||||
|
||||
from mock import patch
|
||||
|
||||
import unittest
|
||||
|
||||
from tricircle.common import constants as cons
|
||||
from tricircle.common import context
|
||||
from tricircle.common import httpclient as hclient
|
||||
|
||||
from tricircle.db import api
|
||||
from tricircle.db import core
|
||||
|
||||
|
||||
def fake_get_pod_service_endpoint(ctx, pod_name, st):
|
||||
|
||||
pod = api.get_pod_by_name(ctx, pod_name)
|
||||
if pod:
|
||||
f = [{'key': 'pod_id', 'comparator': 'eq',
|
||||
'value': pod['pod_id']},
|
||||
{'key': 'service_type', 'comparator': 'eq',
|
||||
'value': st}]
|
||||
pod_services = api.list_pod_service_configurations(
|
||||
ctx,
|
||||
filters=f,
|
||||
sorts=[])
|
||||
|
||||
if len(pod_services) != 1:
|
||||
return ''
|
||||
|
||||
return pod_services[0]['service_url']
|
||||
|
||||
return ''
|
||||
|
||||
|
||||
class HttpClientTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
core.initialize()
|
||||
core.ModelBase.metadata.create_all(core.get_engine())
|
||||
# enforce foreign key constraint for sqlite
|
||||
core.get_engine().execute('pragma foreign_keys=on')
|
||||
self.context = context.Context()
|
||||
|
||||
def test_get_version_from_url(self):
|
||||
url = 'http://127.0.0.1:8774/v2.1/$(tenant_id)s'
|
||||
ver = hclient.get_version_from_url(url)
|
||||
self.assertEqual(ver, 'v2.1')
|
||||
|
||||
url = 'http://127.0.0.1:8774/v2.1/'
|
||||
ver = hclient.get_version_from_url(url)
|
||||
self.assertEqual(ver, 'v2.1')
|
||||
|
||||
url = 'http://127.0.0.1:8774/v2.1/'
|
||||
ver = hclient.get_version_from_url(url)
|
||||
self.assertEqual(ver, 'v2.1')
|
||||
|
||||
url = 'https://127.0.0.1:8774/v2.1/'
|
||||
ver = hclient.get_version_from_url(url)
|
||||
self.assertEqual(ver, 'v2.1')
|
||||
|
||||
url = 'https://127.0.0.1/v2.1/'
|
||||
ver = hclient.get_version_from_url(url)
|
||||
self.assertEqual(ver, 'v2.1')
|
||||
|
||||
url = 'https://127.0.0.1/'
|
||||
ver = hclient.get_version_from_url(url)
|
||||
self.assertEqual(ver, '')
|
||||
|
||||
url = 'https://127.0.0.1/sss/'
|
||||
ver = hclient.get_version_from_url(url)
|
||||
self.assertEqual(ver, 'sss')
|
||||
|
||||
url = ''
|
||||
ver = hclient.get_version_from_url(url)
|
||||
self.assertEqual(ver, '')
|
||||
|
||||
def test_get_bottom_url(self):
|
||||
b_endpoint = 'http://127.0.0.1:8774/v2.1/$(tenant_id)s'
|
||||
t_url = 'http://127.0.0.1:8774/v2/my_tenant_id/volumes'
|
||||
t_ver = hclient.get_version_from_url(t_url)
|
||||
b_ver = hclient.get_version_from_url(b_endpoint)
|
||||
|
||||
self.assertEqual(t_ver, 'v2')
|
||||
self.assertEqual(b_ver, 'v2.1')
|
||||
|
||||
b_url = hclient.get_bottom_url(t_ver, t_url, b_ver, b_endpoint)
|
||||
self.assertEqual(b_url,
|
||||
'http://127.0.0.1:8774/v2.1/my_tenant_id/volumes')
|
||||
|
||||
b_endpoint = 'http://127.0.0.1:8774/'
|
||||
b_ver = hclient.get_version_from_url(b_endpoint)
|
||||
self.assertEqual(b_ver, '')
|
||||
|
||||
b_url = hclient.get_bottom_url(t_ver, t_url, b_ver, b_endpoint)
|
||||
self.assertEqual(b_url,
|
||||
'http://127.0.0.1:8774/my_tenant_id/volumes')
|
||||
|
||||
b_endpoint = 'http://127.0.0.1:8774/v2.1'
|
||||
b_ver = hclient.get_version_from_url(b_endpoint)
|
||||
self.assertEqual(b_ver, 'v2.1')
|
||||
|
||||
b_url = hclient.get_bottom_url(t_ver, t_url, b_ver, b_endpoint)
|
||||
self.assertEqual(b_url,
|
||||
'http://127.0.0.1:8774/v2.1/my_tenant_id/volumes')
|
||||
|
||||
b_endpoint = 'http://127.0.0.1:8774/v2.1/'
|
||||
b_ver = hclient.get_version_from_url(b_endpoint)
|
||||
self.assertEqual(b_ver, 'v2.1')
|
||||
|
||||
b_url = hclient.get_bottom_url(t_ver, t_url, b_ver, b_endpoint)
|
||||
self.assertEqual(b_url,
|
||||
'http://127.0.0.1:8774/v2.1/my_tenant_id/volumes')
|
||||
|
||||
@patch.object(hclient, 'get_pod_service_endpoint',
|
||||
new=fake_get_pod_service_endpoint)
|
||||
def test_get_pod_service_ctx(self):
|
||||
pod_dict = {
|
||||
'pod_id': 'fake_pod_id',
|
||||
'pod_name': 'fake_pod_name',
|
||||
'az_name': 'fake_az'
|
||||
}
|
||||
|
||||
config_dict = {
|
||||
'service_id': 'fake_service_id',
|
||||
'pod_id': 'fake_pod_id',
|
||||
'service_type': cons.ST_CINDER,
|
||||
'service_url': 'http://127.0.0.1:8774/v2.1/$(tenant_id)s'
|
||||
}
|
||||
t_url = 'http://127.0.0.1:8774/v2/my_tenant_id/volumes'
|
||||
api.create_pod(self.context, pod_dict)
|
||||
api.create_pod_service_configuration(self.context, config_dict)
|
||||
|
||||
b_url = 'http://127.0.0.1:8774/v2.1/my_tenant_id/volumes'
|
||||
|
||||
b_endpoint = hclient.get_pod_service_endpoint(self.context,
|
||||
pod_dict['pod_name'],
|
||||
cons.ST_CINDER)
|
||||
self.assertEqual(b_endpoint, config_dict['service_url'])
|
||||
|
||||
b_ctx = hclient.get_pod_service_ctx(self.context,
|
||||
t_url,
|
||||
pod_dict['pod_name'],
|
||||
cons.ST_CINDER)
|
||||
self.assertEqual(b_ctx['t_ver'], 'v2')
|
||||
self.assertEqual(b_ctx['t_url'], t_url)
|
||||
self.assertEqual(b_ctx['b_ver'], 'v2.1')
|
||||
self.assertEqual(b_ctx['b_url'], b_url)
|
||||
|
||||
# wrong pod name
|
||||
b_ctx = hclient.get_pod_service_ctx(self.context,
|
||||
t_url,
|
||||
pod_dict['pod_name'] + '1',
|
||||
cons.ST_CINDER)
|
||||
self.assertEqual(b_ctx['t_ver'], 'v2')
|
||||
self.assertEqual(b_ctx['t_url'], t_url)
|
||||
self.assertEqual(b_ctx['b_ver'], '')
|
||||
self.assertEqual(b_ctx['b_url'], '')
|
||||
|
||||
# wrong service_type
|
||||
b_ctx = hclient.get_pod_service_ctx(self.context,
|
||||
t_url,
|
||||
pod_dict['pod_name'],
|
||||
cons.ST_CINDER + '1')
|
||||
self.assertEqual(b_ctx['t_ver'], 'v2')
|
||||
self.assertEqual(b_ctx['t_url'], t_url)
|
||||
self.assertEqual(b_ctx['b_ver'], '')
|
||||
self.assertEqual(b_ctx['b_url'], '')
|
||||
|
||||
@patch.object(hclient, 'get_pod_service_endpoint',
|
||||
new=fake_get_pod_service_endpoint)
|
||||
def test_get_pod_and_endpoint_by_name(self):
|
||||
pod_dict = {
|
||||
'pod_id': 'fake_pod_id',
|
||||
'pod_name': 'fake_pod_name',
|
||||
'az_name': 'fake_az'
|
||||
}
|
||||
api.create_pod(self.context, pod_dict)
|
||||
|
||||
pod = api.get_pod_by_name(self.context, pod_dict['pod_name'] + '1')
|
||||
self.assertEqual(pod, None)
|
||||
|
||||
pod = api.get_pod_by_name(self.context, pod_dict['pod_name'])
|
||||
self.assertEqual(pod['pod_id'], pod_dict['pod_id'])
|
||||
self.assertEqual(pod['pod_name'], pod_dict['pod_name'])
|
||||
self.assertEqual(pod['az_name'], pod_dict['az_name'])
|
||||
|
||||
config_dict = {
|
||||
'service_id': 'fake_service_id',
|
||||
'pod_id': 'fake_pod_id',
|
||||
'service_type': cons.ST_CINDER,
|
||||
'service_url': 'http://127.0.0.1:8774/v2.1/$(tenant_id)s'
|
||||
}
|
||||
api.create_pod_service_configuration(self.context, config_dict)
|
||||
|
||||
endpoint = hclient.get_pod_service_endpoint(
|
||||
self.context,
|
||||
pod_dict['pod_name'],
|
||||
config_dict['service_type'])
|
||||
self.assertEqual(endpoint, config_dict['service_url'])
|
||||
|
||||
def tearDown(self):
|
||||
core.ModelBase.metadata.drop_all(core.get_engine())
|
|
@ -58,6 +58,103 @@ class APITest(unittest.TestCase):
|
|||
self.assertEqual('test_pod_uuid_1', mappings[0][0]['pod_id'])
|
||||
self.assertEqual('bottom_uuid_1', mappings[0][1])
|
||||
|
||||
def test_get_bottom_mappings_by_tenant_pod(self):
|
||||
for i in xrange(3):
|
||||
pod = {'pod_id': 'test_pod_uuid_%d' % i,
|
||||
'pod_name': 'test_pod_%d' % i,
|
||||
'az_name': 'test_az_uuid_%d' % i}
|
||||
api.create_pod(self.context, pod)
|
||||
routes = [
|
||||
{
|
||||
'route':
|
||||
{
|
||||
'top_id': 'top_uuid',
|
||||
'pod_id': 'test_pod_uuid_0',
|
||||
'project_id': 'test_project_uuid_0',
|
||||
'resource_type': 'port'
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
'route':
|
||||
{
|
||||
'top_id': 'top_uuid_0',
|
||||
'bottom_id': 'top_uuid_0',
|
||||
'pod_id': 'test_pod_uuid_0',
|
||||
'project_id': 'test_project_uuid_0',
|
||||
'resource_type': 'port'
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
'route':
|
||||
{
|
||||
'top_id': 'top_uuid_1',
|
||||
'bottom_id': 'top_uuid_1',
|
||||
'pod_id': 'test_pod_uuid_0',
|
||||
'project_id': 'test_project_uuid_0',
|
||||
'resource_type': 'port'
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
'route':
|
||||
{
|
||||
'top_id': 'top_uuid_2',
|
||||
'bottom_id': 'top_uuid_2',
|
||||
'pod_id': 'test_pod_uuid_0',
|
||||
'project_id': 'test_project_uuid_1',
|
||||
'resource_type': 'port'
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
'route':
|
||||
{
|
||||
'top_id': 'top_uuid_3',
|
||||
'bottom_id': 'top_uuid_3',
|
||||
'pod_id': 'test_pod_uuid_1',
|
||||
'project_id': 'test_project_uuid_1',
|
||||
'resource_type': 'port'
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with self.context.session.begin():
|
||||
for route in routes:
|
||||
core.create_resource(
|
||||
self.context, models.ResourceRouting, route['route'])
|
||||
|
||||
routings = api.get_bottom_mappings_by_tenant_pod(
|
||||
self.context,
|
||||
'test_project_uuid_0',
|
||||
'test_pod_uuid_0',
|
||||
'port'
|
||||
)
|
||||
self.assertEqual(len(routings), 2)
|
||||
self.assertEqual(routings['top_uuid_0']['top_id'], 'top_uuid_0')
|
||||
self.assertEqual(routings['top_uuid_1']['top_id'], 'top_uuid_1')
|
||||
|
||||
routings = api.get_bottom_mappings_by_tenant_pod(
|
||||
self.context,
|
||||
'test_project_uuid_1',
|
||||
'test_pod_uuid_0',
|
||||
'port'
|
||||
)
|
||||
self.assertEqual(len(routings), 1)
|
||||
self.assertEqual(routings['top_uuid_2']['top_id'], 'top_uuid_2')
|
||||
self.assertEqual(routings['top_uuid_2']['bottom_id'], 'top_uuid_2')
|
||||
|
||||
routings = api.get_bottom_mappings_by_tenant_pod(
|
||||
self.context,
|
||||
'test_project_uuid_1',
|
||||
'test_pod_uuid_1',
|
||||
'port'
|
||||
)
|
||||
self.assertEqual(len(routings), 1)
|
||||
self.assertEqual(routings['top_uuid_3']['top_id'], 'top_uuid_3')
|
||||
self.assertEqual(routings['top_uuid_3']['bottom_id'], 'top_uuid_3')
|
||||
|
||||
def test_get_next_bottom_pod(self):
|
||||
next_pod = api.get_next_bottom_pod(self.context)
|
||||
self.assertIsNone(next_pod)
|
||||
|
|
Loading…
Reference in New Issue