Site create API

Implement site create API. This patch only covers database model
creation and aggregate creation.

Partially implements: blueprint implement-api

Change-Id: I299f367900b7b15ea992fe6f0eaf614f83a1a70e
This commit is contained in:
zhiyuan_cai 2015-09-30 11:59:24 +08:00
parent a5fa51f6e4
commit 0caaa2b979
13 changed files with 462 additions and 73 deletions

View File

@ -77,8 +77,15 @@ function configure_tricircle_cascade_api {
iniset $TRICIRCLE_CASCADE_API_CONF DEFAULT debug $ENABLE_DEBUG_LOG_LEVEL
iniset $TRICIRCLE_CASCADE_API_CONF DEFAULT verbose True
iniset $TRICIRCLE_CASCADE_API_CONF DEFAULT use_syslog $SYSLOG
iniset $TRICIRCLE_CASCADE_API_CONF database connection `database_connection_url tricircle`
setup_colorized_logging $TRICIRCLE_CASCADE_API_CONF DEFAULT
iniset $TRICIRCLE_CASCADE_API_CONF client admin_username admin
iniset $TRICIRCLE_CASCADE_API_CONF client admin_password $ADMIN_PASSWORD
iniset $TRICIRCLE_CASCADE_API_CONF client admin_tenant demo
iniset $TRICIRCLE_CASCADE_API_CONF client auto_refresh_endpoint True
iniset $TRICIRCLE_CASCADE_API_CONF client top_site_name $OS_REGION_NAME
setup_colorized_logging $TRICIRCLE_CASCADE_API_CONF DEFAULT tenant_name
if is_service_enabled keystone; then

118
doc/source/api_v1.rst Normal file
View File

@ -0,0 +1,118 @@
================
Tricircle API v1
================
This API describes the ways of interacting with Tricircle(Cascade) service via
HTTP protocol using Representational State Transfer(ReST).
Application Root [/]
====================
Application Root provides links to all possible API methods for Tricircle. URLs
for other resources described below are relative to Application Root.
API v1 Root [/v1/]
==================
All API v1 URLs are relative to API v1 root.
Site [/sites/{site_id}]
=======================
A site represents a region in Keystone. When operating a site, Tricircle
decides the correct endpoints to send request based on the region of the site.
Considering the 2-layers architecture of Tricircle, we also have 2 kinds of
sites: top site and bottom site. A site has the following attributes:
- site_id
- site_name
- az_id
**site_id** is automatically generated when creating a site. **site_name** is
specified by user but **MUST** match the region name registered in Keystone.
When creating a bottom site, Tricircle automatically creates a host aggregate
and assigns the new availability zone id to **az_id**. Top site doesn't need a
host aggregate so **az_id** is left empty.
URL Parameters
--------------
- site_id: Site id
Models
------
::
{
"site_id": "302e02a6-523c-4a92-a8d1-4939b31a788c",
"site_name": "Site1",
"az_id": "az_Site1"
}
Retrieve Site List [GET]
------------------------
- URL: /sites
- Status: 200
- Returns: List of Sites
Response
::
{
"sites": [
{
"site_id": "f91ca3a5-d5c6-45d6-be4c-763f5a2c4aa3",
"site_name": "RegionOne",
"az_id": ""
},
{
"site_id": "302e02a6-523c-4a92-a8d1-4939b31a788c",
"site_name": "Site1",
"az_id": "az_Site1"
}
]
}
Retrieve a Single Site [GET]
----------------------------
- URL: /sites/site_id
- Status: 200
- Returns: Site
Response
::
{
"site": {
"site_id": "302e02a6-523c-4a92-a8d1-4939b31a788c",
"site_name": "Site1",
"az_id": "az_Site1"
}
}
Create a Site [POST]
--------------------
- URL: /sites
- Status: 201
- Returns: Created Site
Request (application/json)
.. csv-table::
:header: "Parameter", "Type", "Description"
name, string, name of the Site
top, bool, "indicate whether it's a top Site, optional, default false"
::
{
"name": "RegionOne"
"top": true
}
Response
::
{
"site": {
"site_id": "f91ca3a5-d5c6-45d6-be4c-763f5a2c4aa3",
"site_name": "RegionOne",
"az_id": ""
}
}

View File

@ -179,6 +179,45 @@
# If set, use this value for pool_timeout with sqlalchemy
# pool_timeout = 10
[client]
# Keystone authentication URL
# auth_url = http://127.0.0.1:5000/v3
# Keystone service URL
# identity_url = http://127.0.0.1:35357/v3
# If set to True, endpoint will be automatically refreshed if timeout
# accessing endpoint.
# auto_refresh_endpoint = False
# Name of top site which client needs to access
# top_site_name =
# Username of admin account for synchronizing endpoint with Keystone
# admin_username =
# Password of admin account for synchronizing endpoint with Keystone
# admin_password =
# Tenant name of admin account for synchronizing endpoint with Keystone
# admin_tenant =
# User domain name of admin account for synchronizing endpoint with Keystone
# admin_user_domain_name = default
# Tenant domain name of admin account for synchronizing endpoint with Keystone
# admin_tenant_domain_name = default
# Timeout for glance client in seconds
# glance_timeout = 60
# Timeout for neutron client in seconds
# neutron_timeout = 60
# Timeout for nova client in seconds
# nova_timeout = 60
[oslo_concurrency]
# Directory to use for lock files. For security, the specified directory should

View File

@ -13,9 +13,20 @@
# License for the specific language governing permissions and limitations
# under the License.
import uuid
import oslo_log.log as logging
import pecan
from pecan import request
from pecan import rest
import tricircle.context as t_context
from tricircle.db import client
from tricircle.db import exception
from tricircle.db import models
LOG = logging.getLogger(__name__)
def expose(*args, **kwargs):
kwargs.setdefault('content_type', 'application/json')
@ -81,22 +92,101 @@ class V1Controller(object):
}
def _extract_context_from_environ(environ):
context_paras = {'auth_token': 'HTTP_X_AUTH_TOKEN',
'user': 'HTTP_X_USER_ID',
'tenant': 'HTTP_X_TENANT_ID',
'user_name': 'HTTP_X_USER_NAME',
'tenant_name': 'HTTP_X_PROJECT_NAME',
'domain': 'HTTP_X_DOMAIN_ID',
'user_domain': 'HTTP_X_USER_DOMAIN_ID',
'project_domain': 'HTTP_X_PROJECT_DOMAIN_ID',
'request_id': 'openstack.request_id'}
for key in context_paras:
context_paras[key] = environ.get(context_paras[key])
role = environ.get('HTTP_X_ROLE')
# TODO(zhiyuan): replace with policy check
context_paras['is_admin'] = role == 'admin'
return t_context.Context(**context_paras)
def _get_environment():
return request.environ
class SitesController(rest.RestController):
"""ReST controller to handle CRUD operations of site resource"""
@expose(generic=True)
def index(self):
if pecan.request.method != 'GET':
pecan.abort(405)
return {'message': 'GET'}
@when(index, method='PUT')
def put(self, **kw):
@expose()
def put(self, site_id, **kw):
return {'message': 'PUT'}
@when(index, method='POST')
def post(self, **kw):
return {'message': 'POST'}
@expose()
def get_one(self, site_id):
context = _extract_context_from_environ(_get_environment())
try:
return {'site': models.get_site(context, site_id)}
except exception.ResourceNotFound:
pecan.abort(404, 'Site with id %s not found' % site_id)
@when(index, method='DELETE')
def delete(self):
@expose()
def get_all(self):
context = _extract_context_from_environ(_get_environment())
sites = models.list_sites(context, [])
return {'sites': sites}
@expose()
def post(self, **kw):
context = _extract_context_from_environ(_get_environment())
if not context.is_admin:
pecan.abort(400, 'Admin role required to create sites')
return
site_name = kw.get('name')
is_top_site = kw.get('top', False)
if not site_name:
pecan.abort(400, 'Name of site required')
return
site_filters = [{'key': 'site_name', 'comparator': 'eq',
'value': site_name}]
sites = models.list_sites(context, site_filters)
if sites:
pecan.abort(409, 'Site with name %s exists' % site_name)
return
ag_name = 'ag_%s' % site_name
# top site doesn't need az
az_name = 'az_%s' % site_name if not is_top_site else ''
try:
site_dict = {'site_id': str(uuid.uuid4()),
'site_name': site_name,
'az_id': az_name}
site = models.create_site(context, site_dict)
except Exception as e:
LOG.debug(e.message)
pecan.abort(500, 'Fail to create site')
return
# top site doesn't need aggregate
if is_top_site:
pecan.response.status = 201
return {'site': site}
else:
try:
top_client = client.Client()
top_client.create_aggregates(context, ag_name, az_name)
except Exception as e:
LOG.debug(e.message)
# delete previously created site
models.delete_site(context, site['site_id'])
pecan.abort(500, 'Fail to create aggregate')
return
pecan.response.status = 201
return {'site': site}
@expose()
def delete(self, site_id):
return {'message': 'DELETE'}

View File

@ -156,7 +156,7 @@ class Client(object):
return region_service_endpoint_map
def _get_config_with_retry(self, cxt, filters, site, service, retry):
conf_list = models.list_site_service_configuration(cxt, filters)
conf_list = models.list_site_service_configurations(cxt, filters)
if len(conf_list) > 1:
raise exception.EndpointNotUnique(site, service)
if len(conf_list) == 0:
@ -204,12 +204,12 @@ class Client(object):
endpoint_map = self._get_endpoint_from_keystone(admin_context)
else:
endpoint_map = self._get_endpoint_from_keystone(cxt)
for region in endpoint_map:
# use region name to query site
site_filters = [{'key': 'site_name', 'comparator': 'eq',
'value': region}]
site_list = models.list_sites(cxt, site_filters)
# skip region/site not registered in cascade service
if len(site_list) != 1:
continue
@ -219,7 +219,7 @@ class Client(object):
'value': site_id},
{'key': 'service_type', 'comparator': 'eq',
'value': service}]
config_list = models.list_site_service_configuration(
config_list = models.list_site_service_configurations(
cxt, config_filters)
if len(config_list) > 1:
@ -234,7 +234,6 @@ class Client(object):
config_dict = {
'service_id': str(uuid.uuid4()),
'site_id': site_id,
'service_name': '%s_%s' % (region, service),
'service_type': service,
'service_url': endpoint_map[region][service]
}

View File

@ -74,6 +74,9 @@ def _get_resource(context, model, pk_value):
def create_resource(context, model, res_dict):
res_obj = model.from_dict(res_dict)
context.session.add(res_obj)
context.session.flush()
# retrieve auto-generated fields
context.session.refresh(res_obj)
return res_obj.to_dict()

View File

@ -34,18 +34,10 @@ def upgrade(migrate_engine):
'cascaded_site_service_configuration', meta,
sql.Column('service_id', sql.String(length=64), primary_key=True),
sql.Column('site_id', sql.String(length=64), nullable=False),
sql.Column('service_name', sql.String(length=64), unique=True,
nullable=False),
sql.Column('service_type', sql.String(length=64), nullable=False),
sql.Column('service_url', sql.String(length=512), nullable=False),
mysql_engine='InnoDB',
mysql_charset='utf8')
cascaded_service_types = sql.Table(
'cascaded_service_types', meta,
sql.Column('id', sql.Integer, primary_key=True),
sql.Column('service_type', sql.String(length=64), unique=True),
mysql_engine='InnoDB',
mysql_charset='utf8')
cascaded_site_services = sql.Table(
'cascaded_site_services', meta,
sql.Column('site_id', sql.String(length=64), primary_key=True),
@ -53,20 +45,15 @@ def upgrade(migrate_engine):
mysql_charset='utf8')
tables = [cascaded_sites, cascaded_site_service_configuration,
cascaded_service_types, cascaded_site_services]
cascaded_site_services]
for table in tables:
table.create()
fkeys = [
{'columns': [cascaded_site_service_configuration.c.site_id],
'references': [cascaded_sites.c.site_id]},
{'columns': [cascaded_site_service_configuration.c.service_type],
'references': [cascaded_service_types.c.service_type]}
]
for fkey in fkeys:
migrate.ForeignKeyConstraint(columns=fkey['columns'],
refcolumns=fkey['references'],
name=fkey.get('name')).create()
fkey = {'columns': [cascaded_site_service_configuration.c.site_id],
'references': [cascaded_sites.c.site_id]}
migrate.ForeignKeyConstraint(columns=fkey['columns'],
refcolumns=fkey['references'],
name=fkey.get('name')).create()
def downgrade(migrate_engine):

View File

@ -44,11 +44,6 @@ def update_site(context, site_id, update_dict):
return core.update_resource(context, Site, site_id, update_dict)
def create_service_type(context, type_dict):
with context.session.begin():
return core.create_resource(context, ServiceType, type_dict)
def create_site_service_configuration(context, config_dict):
with context.session.begin():
return core.create_resource(context, SiteServiceConfiguration,
@ -61,7 +56,7 @@ def delete_site_service_configuration(context, config_id):
SiteServiceConfiguration, config_id)
def list_site_service_configuration(context, filters):
def list_site_service_configurations(context, filters):
with context.session.begin():
return core.query_resource(context, SiteServiceConfiguration, filters)
@ -83,31 +78,18 @@ class Site(core.ModelBase, core.DictBase):
class SiteServiceConfiguration(core.ModelBase, core.DictBase):
__tablename__ = 'cascaded_site_service_configuration'
attributes = ['service_id', 'site_id', 'service_name',
'service_type', 'service_url']
attributes = ['service_id', 'site_id', 'service_type', 'service_url']
service_id = sql.Column('service_id', sql.String(length=64),
primary_key=True)
site_id = sql.Column('site_id', sql.String(length=64),
sql.ForeignKey('cascaded_sites.site_id'),
nullable=False)
service_name = sql.Column('service_name', sql.String(length=64),
unique=True, nullable=False)
service_type = sql.Column(
'service_type', sql.String(length=64),
sql.ForeignKey('cascaded_service_types.service_type'),
nullable=False)
service_type = sql.Column('service_type', sql.String(length=64),
nullable=False)
service_url = sql.Column('service_url', sql.String(length=512),
nullable=False)
class ServiceType(core.ModelBase, core.DictBase):
__tablename__ = 'cascaded_service_types'
attributes = ['id', 'service_type']
id = sql.Column('id', sql.Integer, primary_key=True)
service_type = sql.Column('service_type', sql.String(length=64),
unique=True)
class SiteService(core.ModelBase, core.DictBase):
__tablename__ = 'cascaded_site_services'
attributes = ['site_id']

View File

View File

@ -0,0 +1,150 @@
# 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 mock
from mock import patch
import unittest
import pecan
import tricircle.api.controllers.root as root_controller
from tricircle import context
from tricircle.db import client
from tricircle.db import core
from tricircle.db import models
class ControllerTest(unittest.TestCase):
def setUp(self):
core.initialize()
core.ModelBase.metadata.create_all(core.get_engine())
self.context = context.Context()
self.context.is_admin = True
root_controller._get_environment = mock.Mock(return_value={})
root_controller._extract_context_from_environ = mock.Mock(
return_value=self.context)
pecan.abort = mock.Mock()
pecan.response = mock.Mock()
def tearDown(self):
core.ModelBase.metadata.drop_all(core.get_engine())
class SitesControllerTest(ControllerTest):
def setUp(self):
super(SitesControllerTest, self).setUp()
self.controller = root_controller.SitesController()
def test_post_top_site(self):
kw = {'name': 'TopSite', 'top': True}
site_id = self.controller.post(**kw)['site']['site_id']
site = models.get_site(self.context, site_id)
self.assertEqual(site['site_name'], 'TopSite')
self.assertEqual(site['az_id'], '')
@patch.object(client.Client, 'create_resources')
def test_post_bottom_site(self, mock_method):
kw = {'name': 'BottomSite'}
site_id = self.controller.post(**kw)['site']['site_id']
site = models.get_site(self.context, site_id)
self.assertEqual(site['site_name'], 'BottomSite')
self.assertEqual(site['az_id'], 'az_BottomSite')
mock_method.assert_called_once_with('aggregate', self.context,
'ag_BottomSite', 'az_BottomSite')
def test_post_site_name_missing(self):
kw = {'top': True}
self.controller.post(**kw)
pecan.abort.assert_called_once_with(400, 'Name of site required')
def test_post_conflict(self):
kw = {'name': 'TopSite', 'top': True}
self.controller.post(**kw)
self.controller.post(**kw)
pecan.abort.assert_called_once_with(409,
'Site with name TopSite exists')
def test_post_not_admin(self):
self.context.is_admin = False
kw = {'name': 'TopSite', 'top': True}
self.controller.post(**kw)
pecan.abort.assert_called_once_with(
400, 'Admin role required to create sites')
@patch.object(client.Client, 'create_resources')
def test_post_decide_top(self, mock_method):
# 'top' default to False
# top site
kw = {'name': 'Site1', 'top': True}
self.controller.post(**kw)
# bottom site
kw = {'name': 'Site2', 'top': False}
self.controller.post(**kw)
kw = {'name': 'Site3'}
self.controller.post(**kw)
calls = [mock.call('aggregate', self.context, 'ag_Site%d' % i,
'az_Site%d' % i) for i in xrange(2, 4)]
mock_method.assert_has_calls(calls)
@patch.object(models, 'create_site')
def test_post_create_site_exception(self, mock_method):
mock_method.side_effect = Exception
kw = {'name': 'BottomSite'}
self.controller.post(**kw)
pecan.abort.assert_called_once_with(500, 'Fail to create site')
@patch.object(client.Client, 'create_resources')
def test_post_create_aggregate_exception(self, mock_method):
mock_method.side_effect = Exception
kw = {'name': 'BottomSite'}
self.controller.post(**kw)
pecan.abort.assert_called_once_with(500, 'Fail to create aggregate')
# make sure site is deleted
site_filter = [{'key': 'site_name',
'comparator': 'eq',
'value': 'BottomSite'}]
sites = models.list_sites(self.context, site_filter)
self.assertEqual(len(sites), 0)
def test_get_one(self):
kw = {'name': 'TopSite', 'top': True}
site_id = self.controller.post(**kw)['site']['site_id']
return_site = self.controller.get_one(site_id)['site']
self.assertEqual(return_site, {'site_id': site_id,
'site_name': 'TopSite',
'az_id': ''})
def test_get_one_not_found(self):
self.controller.get_one('fake_id')
pecan.abort.assert_called_once_with(404,
'Site with id fake_id not found')
@patch.object(client.Client, 'create_resources', new=mock.Mock)
def test_get_all(self):
kw1 = {'name': 'TopSite', 'top': True}
kw2 = {'name': 'BottomSite'}
self.controller.post(**kw1)
self.controller.post(**kw2)
sites = self.controller.get_all()
actual_result = [(site['site_name'],
site['az_id']) for site in sites['sites']]
expect_result = [('BottomSite', 'az_BottomSite'), ('TopSite', '')]
self.assertItemsEqual(actual_result, expect_result)
def tearDown(self):
core.ModelBase.metadata.drop_all(core.get_engine())

View File

@ -15,6 +15,7 @@
import unittest
import uuid
import mock
from oslo_config import cfg
@ -31,7 +32,6 @@ FAKE_RESOURCE = 'fake_res'
FAKE_SITE_ID = 'fake_site_id'
FAKE_SITE_NAME = 'fake_site_name'
FAKE_SERVICE_ID = 'fake_service_id'
FAKE_SERVICE_NAME = 'fake_service_name'
FAKE_TYPE = 'fake_type'
FAKE_URL = 'http://127.0.0.1:12345'
FAKE_URL_INVALID = 'http://127.0.0.1:23456'
@ -105,6 +105,8 @@ class ClientTest(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()
site_dict = {
@ -112,19 +114,13 @@ class ClientTest(unittest.TestCase):
'site_name': FAKE_SITE_NAME,
'az_id': FAKE_AZ
}
type_dict = {
'id': 1,
'service_type': FAKE_TYPE
}
config_dict = {
'service_id': FAKE_SERVICE_ID,
'site_id': FAKE_SITE_ID,
'service_name': FAKE_SERVICE_NAME,
'service_type': FAKE_TYPE,
'service_url': FAKE_URL
}
models.create_site(self.context, site_dict)
models.create_service_type(self.context, type_dict)
models.create_site_service_configuration(self.context, config_dict)
global FAKE_RESOURCES
@ -207,7 +203,6 @@ class ClientTest(unittest.TestCase):
config_dict = {
'service_id': FAKE_SERVICE_ID + '_new',
'site_id': FAKE_SITE_ID,
'service_name': FAKE_SERVICE_NAME + '_new',
'service_type': FAKE_TYPE,
'service_url': FAKE_URL
}
@ -249,6 +244,31 @@ class ClientTest(unittest.TestCase):
FAKE_RESOURCE, self.context, [])
self.assertEqual(resources, [{'name': 'res1'}, {'name': 'res2'}])
def test_update_endpoint_from_keystone(self):
self.client._get_admin_token = mock.Mock()
self.client._get_endpoint_from_keystone = mock.Mock()
self.client._get_endpoint_from_keystone.return_value = {
FAKE_SITE_NAME: {FAKE_TYPE: FAKE_URL,
'another_fake_type': 'http://127.0.0.1:34567'},
'not_registered_site': {FAKE_TYPE: FAKE_URL}
}
models.create_site_service_configuration = mock.Mock()
models.update_site_service_configuration = mock.Mock()
uuid.uuid4 = mock.Mock()
uuid.uuid4.return_value = 'another_fake_service_id'
self.client.update_endpoint_from_keystone(self.context)
update_dict = {'service_url': FAKE_URL}
create_dict = {'service_id': 'another_fake_service_id',
'site_id': FAKE_SITE_ID,
'service_type': 'another_fake_type',
'service_url': 'http://127.0.0.1:34567'}
# not registered site is skipped
models.update_site_service_configuration.assert_called_once_with(
self.context, FAKE_SERVICE_ID, update_dict)
models.create_site_service_configuration.assert_called_once_with(
self.context, create_dict)
def test_get_endpoint(self):
cfg.CONF.set_override(name='auto_refresh_endpoint', override=False,
group='client')

View File

@ -43,15 +43,9 @@ class ModelsTest(unittest.TestCase):
site_ret = models.create_site(self.context, site)
self.assertEqual(site_ret, site)
service_type = {'id': 1,
'service_type': 'nova'}
type_ret = models.create_service_type(self.context, service_type)
self.assertEqual(type_ret, service_type)
configuration = {
'service_id': 'test_config_uuid',
'site_id': 'test_site_uuid',
'service_name': 'nova_service',
'service_type': 'nova',
'service_url': 'http://test_url'
}