Add a set of native quantum resource types.

The properties schemas map directly to the Quantum REST API, which makes
the implementation (and documentation) simpler.

The base class QuantumResource contains some default methods and
common utility functions.

templates/Quantum.template can be run without any parameters and only creates
network resources, no instances.

More example templates and tests will come later.

Change-Id: Ia270294440eeec5163e35009f6be0b5db9ad78c1
This commit is contained in:
Steve Baker 2012-10-30 08:41:13 +13:00
parent b3a57062ad
commit 2162b8047b
12 changed files with 778 additions and 1 deletions

View File

@ -0,0 +1,77 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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 heat.openstack.common import log as logging
from heat.engine.resources.quantum import quantum
logger = logging.getLogger('heat.engine.quantum')
class FloatingIP(quantum.QuantumResource):
properties_schema = {'floating_network_id': {'Type': 'String',
'Required': True},
'value_specs': {'Type': 'Map',
'Default': {}},
'port_id': {'Type': 'String'},
'fixed_ip_address': {'Type': 'String'},
}
def handle_create(self):
props = self.prepare_properties(self.properties, self.name)
fip = self.quantum().create_floatingip({
'floatingip': props})['floatingip']
self.instance_id_set(fip['id'])
def handle_delete(self):
client = self.quantum()
try:
client.delete_floatingip(self.instance_id)
except:
pass
def FnGetAtt(self, key):
attributes = self.quantum().show_floatingip(
self.instance_id)['floatingip']
return self.handle_get_attributes(self.name, key, attributes)
class FloatingIPAssociation(quantum.QuantumResource):
properties_schema = {'floatingip_id': {'Type': 'String',
'Required': True},
'port_id': {'Type': 'String',
'Required': True},
'fixed_ip_address': {'Type': 'String'}
}
def __init__(self, name, json_snippet, stack):
super(FloatingIPAssociation, self).__init__(name, json_snippet, stack)
def handle_create(self):
props = self.prepare_properties(self.properties, self.name)
floatingip_id = props.pop('floatingip_id')
self.quantum().update_floatingip(floatingip_id, {
'floatingip': props})['floatingip']
self.instance_id_set('%s:%s' % (floatingip_id, props['port_id']))
def handle_delete(self):
client = self.quantum()
try:
(floatingip_id, port_id) = self.instance_id.split(':')
client.update_floatingip(floatingip_id,
{'floatingip': {'port_id': None}})
except:
pass

View File

@ -0,0 +1,47 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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 heat.openstack.common import log as logging
from heat.engine.resources.quantum import quantum
logger = logging.getLogger('heat.engine.quantum')
class Net(quantum.QuantumResource):
properties_schema = {'name': {'Type': 'String'},
'value_specs': {'Type': 'Map',
'Default': {}},
'admin_state_up': {'Default': True},
}
def __init__(self, name, json_snippet, stack):
super(Net, self).__init__(name, json_snippet, stack)
def handle_create(self):
props = self.prepare_properties(self.properties, self.name)
net = self.quantum().create_network({'network': props})['network']
self.instance_id_set(net['id'])
def handle_delete(self):
client = self.quantum()
try:
client.delete_network(self.instance_id)
except:
pass
def FnGetAtt(self, key):
attributes = self.quantum().show_network(
self.instance_id)['network']
return self.handle_get_attributes(self.name, key, attributes)

View File

@ -0,0 +1,59 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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 heat.openstack.common import log as logging
from heat.engine.resources.quantum import quantum
logger = logging.getLogger('heat.engine.quantum')
class Port(quantum.QuantumResource):
fixed_ip_schema = {'subnet_id': {'Type': 'String',
'Required': True},
'ip_address': {'Type': 'String',
'Required': True}}
properties_schema = {'network_id': {'Type': 'String',
'Required': True},
'name': {'Type': 'String'},
'value_specs': {'Type': 'Map',
'Default': {}},
'admin_state_up': {'Default': True},
'fixed_ips': {'Type': 'List',
'Schema': fixed_ip_schema},
'mac_address': {'Type': 'String'},
'device_id': {'Type': 'String'},
}
def __init__(self, name, json_snippet, stack):
super(Port, self).__init__(name, json_snippet, stack)
def handle_create(self):
props = self.prepare_properties(self.properties, self.name)
port = self.quantum().create_port({'port': props})['port']
self.instance_id_set(port['id'])
def handle_delete(self):
client = self.quantum()
try:
client.delete_port(self.instance_id)
except:
pass
def FnGetAtt(self, key):
attributes = self.quantum().show_port(
self.instance_id)['port']
return self.handle_get_attributes(self.name, key, attributes)

View File

@ -0,0 +1,92 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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 heat.common import exception
from heat.engine.resources import resource
from heat.openstack.common import log as logging
logger = logging.getLogger('heat.engine.quantum')
class QuantumResource(resource.Resource):
def __init__(self, name, json_snippet, stack):
super(QuantumResource, self).__init__(name, json_snippet, stack)
def validate(self):
'''
Validate any of the provided params
'''
res = super(QuantumResource, self).validate()
if res:
return res
return self.validate_properties(self.properties)
@staticmethod
def validate_properties(properties):
'''
Validates to ensure nothing in value_specs overwrites
any key that exists in the schema.
Also ensures that shared and tenant_id is not specified
in value_specs.
'''
if 'value_specs' in properties.keys():
vs = properties.get('value_specs')
banned_keys = set(['shared', 'tenant_id']).union(
properties.keys())
for k in banned_keys.intersection(vs.keys()):
return '%s not allowed in value_specs' % k
@staticmethod
def prepare_properties(properties, name):
'''
Prepares the property values so that they can be passed directly to
the Quantum call.
Removes None values and value_specs, merges value_specs with the main
values.
'''
props = dict((k, v) for k, v in properties.items()
if v is not None and k != 'value_specs')
if 'name' in properties.keys():
props.setdefault('name', name)
if 'value_specs' in properties.keys():
props.update(properties.get('value_specs'))
return props
@staticmethod
def handle_get_attributes(name, key, attributes):
'''
Support method for responding to FnGetAtt
'''
if key == 'show':
return attributes
if key in attributes.keys():
return attributes[key]
raise exception.InvalidTemplateAttribute(resource=name,
key=key)
def handle_update(self):
return self.UPDATE_REPLACE
def FnGetRefId(self):
return unicode(self.instance_id)

View File

@ -0,0 +1,102 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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 heat.engine.resources.quantum import quantum
from heat.openstack.common import log as logging
logger = logging.getLogger('heat.engine.quantum')
class Router(quantum.QuantumResource):
properties_schema = {'name': {'Type': 'String'},
'value_specs': {'Type': 'Map',
'Default': {}},
'admin_state_up': {'Type': 'Boolean',
'Default': True},
}
def __init__(self, name, json_snippet, stack):
super(Router, self).__init__(name, json_snippet, stack)
def handle_create(self):
props = self.prepare_properties(self.properties, self.name)
router = self.quantum().create_router({'router': props})['router']
self.instance_id_set(router['id'])
def handle_delete(self):
client = self.quantum()
try:
client.delete_router(self.instance_id)
except:
pass
def FnGetAtt(self, key):
attributes = self.quantum().show_router(
self.instance_id)['router']
return self.handle_get_attributes(self.name, key, attributes)
class RouterInterface(quantum.QuantumResource):
properties_schema = {'router_id': {'Type': 'String',
'Required': True},
'subnet_id': {'Type': 'String',
'Required': True},
}
def __init__(self, name, json_snippet, stack):
super(RouterInterface, self).__init__(name, json_snippet, stack)
def handle_create(self):
router_id = self.properties.get('router_id')
subnet_id = self.properties.get('subnet_id')
self.quantum().add_interface_router(router_id,
{'subnet_id': subnet_id})
self.instance_id_set('%s:%s' % (router_id, subnet_id))
def handle_delete(self):
client = self.quantum()
try:
(router_id, subnet_id) = self.instance_id.split(':')
client.remove_interface_router(router_id,
{'subnet_id': subnet_id})
except:
pass
class RouterGateway(quantum.QuantumResource):
properties_schema = {'router_id': {'Type': 'String',
'Required': True},
'network_id': {'Type': 'String',
'Required': True},
}
def __init__(self, name, json_snippet, stack):
super(RouterGateway, self).__init__(name, json_snippet, stack)
def handle_create(self):
router_id = self.properties.get('router_id')
network_id = self.properties.get('network_id')
self.quantum().add_gateway_router(router_id,
{'network_id': network_id})
self.instance_id_set('%s:%s' % (router_id, network_id))
def handle_delete(self):
client = self.quantum()
try:
(router_id, network_id) = self.instance_id.split(':')
client.remove_gateway_router(router_id)
except:
pass

View File

@ -0,0 +1,65 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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 heat.common import exception
from heat.openstack.common import log as logging
from heat.engine.resources.quantum import quantum
logger = logging.getLogger('heat.engine.quantum')
class Subnet(quantum.QuantumResource):
allocation_schema = {'start': {'Type': 'String',
'Required': True},
'end': {'Type': 'String',
'Required': True}}
properties_schema = {'network_id': {'Type': 'String',
'Required': True},
'cidr': {'Type': 'String',
'Required': True},
'value_specs': {'Type': 'Map',
'Default': {}},
'name': {'Type': 'String'},
'admin_state_up': {'Default': True},
'ip_version': {'Type': 'Integer',
'AllowedValues': [4, 6],
'Default': 4},
'gateway_ip': {'Type': 'String'},
'allocation_pools': {'Type': 'List',
'Schema': allocation_schema}
}
def __init__(self, name, json_snippet, stack):
super(Subnet, self).__init__(name, json_snippet, stack)
def handle_create(self):
props = self.prepare_properties(self.properties, self.name)
subnet = self.quantum().create_subnet({'subnet': props})['subnet']
self.instance_id_set(subnet['id'])
def handle_delete(self):
client = self.quantum()
try:
client.delete_subnet(self.instance_id)
except:
pass
def FnGetAtt(self, key):
attributes = self.quantum().show_subnet(
self.instance_id)['subnet']
return self.handle_get_attributes(self.name, key, attributes)

View File

@ -29,6 +29,11 @@ from heat.engine.resources import stack
from heat.engine.resources import user from heat.engine.resources import user
from heat.engine.resources import volume from heat.engine.resources import volume
from heat.engine.resources import wait_condition from heat.engine.resources import wait_condition
from heat.engine.resources.quantum import floatingip
from heat.engine.resources.quantum import net
from heat.engine.resources.quantum import port
from heat.engine.resources.quantum import router
from heat.engine.resources.quantum import subnet
_resource_classes = { _resource_classes = {
@ -52,6 +57,14 @@ _resource_classes = {
'AWS::AutoScaling::AutoScalingGroup': autoscaling.AutoScalingGroup, 'AWS::AutoScaling::AutoScalingGroup': autoscaling.AutoScalingGroup,
'AWS::AutoScaling::ScalingPolicy': autoscaling.ScalingPolicy, 'AWS::AutoScaling::ScalingPolicy': autoscaling.ScalingPolicy,
'AWS::RDS::DBInstance': dbinstance.DBInstance, 'AWS::RDS::DBInstance': dbinstance.DBInstance,
'OS::Quantum::FloatingIP': floatingip.FloatingIP,
'OS::Quantum::FloatingIPAssociation': floatingip.FloatingIPAssociation,
'OS::Quantum::Net': net.Net,
'OS::Quantum::Port': port.Port,
'OS::Quantum::Router': router.Router,
'OS::Quantum::RouterInterface': router.RouterInterface,
'OS::Quantum::RouterGateway': router.RouterGateway,
'OS::Quantum::Subnet': subnet.Subnet,
} }

View File

@ -26,6 +26,13 @@ try:
swiftclient_present = True swiftclient_present = True
except ImportError: except ImportError:
swiftclient_present = False swiftclient_present = False
# quantumclient not available in all distributions - make quantum an optional
# feature
try:
from quantumclient.v2_0 import client as quantumclient
quantumclient_present = True
except ImportError:
quantumclient_present = False
from heat.common import exception from heat.common import exception
from heat.common import config from heat.common import config
@ -124,6 +131,7 @@ class Resource(object):
self._nova = {} self._nova = {}
self._keystone = None self._keystone = None
self._swift = None self._swift = None
self._quantum = None
def __eq__(self, other): def __eq__(self, other):
'''Allow == comparison of two resources''' '''Allow == comparison of two resources'''
@ -281,6 +289,38 @@ class Resource(object):
self._swift = swiftclient.Connection(**args) self._swift = swiftclient.Connection(**args)
return self._swift return self._swift
def quantum(self):
if quantumclient_present == False:
return None
if self._quantum:
logger.debug('using existing _quantum')
return self._quantum
con = self.context
args = {
'auth_url': con.auth_url,
'service_type': 'network',
}
if con.password is not None:
args['username'] = con.username
args['password'] = con.password
args['tenant_name'] = con.tenant
elif con.auth_token is not None:
args['username'] = con.service_user
args['password'] = con.service_password
args['tenant_name'] = con.service_tenant
args['token'] = con.auth_token
else:
logger.error("Quantum connection failed, "
"no password or auth_token!")
return None
logger.debug('quantum args %s', args)
self._quantum = quantumclient.Client(**args)
return self._quantum
def calculate_properties(self): def calculate_properties(self):
for p, v in self.parsed_template('Properties').items(): for p, v in self.parsed_template('Properties').items():
self.properties[p] = v self.properties[p] = v

182
heat/tests/test_quantum.py Normal file
View File

@ -0,0 +1,182 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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 sys
import os
import nose
import unittest
import mox
import json
from nose.plugins.attrib import attr
from heat.common import exception
from heat.engine import checkeddict
from heat.engine.resources.quantum import net
from heat.engine.resources.quantum.quantum import QuantumResource as qr
from heat.engine import parser
from utils import skip_if
try:
from quantumclient.v2_0 import client as quantumclient
except:
skip_test = True
else:
skip_test = False
class FakeQuantum():
def create_network(self, name):
return {"network": {
"status": "ACTIVE",
"subnets": [],
"name": "name",
"admin_state_up": False,
"shared": False,
"tenant_id": "c1210485b2424d48804aad5d39c61b8f",
"id": "fc68ea2c-b60b-4b4f-bd82-94ec81110766"
}}
def show_network(self, id):
return {"network": {
"status": "ACTIVE",
"subnets": [],
"name": "name",
"admin_state_up": False,
"shared": False,
"tenant_id": "c1210485b2424d48804aad5d39c61b8f",
"id": "fc68ea2c-b60b-4b4f-bd82-94ec81110766"
}}
@attr(tag=['unit', 'resource'])
@attr(speed='fast')
class QuantumTest(unittest.TestCase):
@skip_if(skip_test, 'unable to import quantumclient')
def setUp(self):
self.m = mox.Mox()
self.m.CreateMock(quantumclient)
self.m.StubOutWithMock(net.Net, 'quantum')
def tearDown(self):
self.m.UnsetStubs()
print "QuantumTest teardown complete"
def load_template(self):
self.path = os.path.dirname(os.path.realpath(__file__)).\
replace('heat/tests', 'templates')
f = open("%s/Quantum.template" % self.path)
t = json.loads(f.read())
f.close()
return t
def parse_stack(self, t):
class DummyContext():
tenant = 'test_tenant'
username = 'test_username'
password = 'password'
auth_url = 'http://localhost:5000/v2.0'
stack = parser.Stack(DummyContext(), 'test_stack', parser.Template(t),
stack_id=-1, parameters={'external_network': 'abcd1234'})
return stack
def create_net(self, t, stack, resource_name):
resource = net.Net('test_net',
t['Resources'][resource_name],
stack)
self.assertEqual(None, resource.create())
self.assertEqual(net.Net.CREATE_COMPLETE, resource.state)
return resource
def test_validate_properties(self):
p = checkeddict.Properties('foo', net.Net.properties_schema)
vs = {'router:external': True}
p.update({
'admin_state_up': False,
'value_specs': vs
})
self.assertEqual(None, qr.validate_properties(p))
vs['shared'] = True
self.assertEqual('shared not allowed in value_specs',
qr.validate_properties(p))
vs.pop('shared')
vs['name'] = 'foo'
self.assertEqual('name not allowed in value_specs',
qr.validate_properties(p))
vs.pop('name')
vs['tenant_id'] = '1234'
self.assertEqual('tenant_id not allowed in value_specs',
qr.validate_properties(p))
vs.pop('tenant_id')
vs['foo'] = '1234'
self.assertEqual(None, qr.validate_properties(p))
def test_prepare_properties(self):
p = checkeddict.Properties('foo', net.Net.properties_schema)
p.update({
'admin_state_up': False,
'value_specs': {'router:external': True}
})
props = qr.prepare_properties(p, 'resource_name')
self.assertEqual({
'name': 'resource_name',
'router:external': True,
'admin_state_up': False
}, props)
@skip_if(skip_test, 'unable to import quantumclient')
def test_net(self):
fq = FakeQuantum()
net.Net.quantum().MultipleTimes().AndReturn(fq)
self.m.ReplayAll()
t = self.load_template()
stack = self.parse_stack(t)
resource = self.create_net(t, stack, 'network')
resource.validate()
ref_id = resource.FnGetRefId()
self.assertEqual('fc68ea2c-b60b-4b4f-bd82-94ec81110766', ref_id)
self.assertEqual('ACTIVE', resource.FnGetAtt('status'))
try:
resource.FnGetAtt('Foo')
raise Exception('Expected InvalidTemplateAttribute')
except exception.InvalidTemplateAttribute:
pass
try:
resource.FnGetAtt('id')
raise Exception('Expected InvalidTemplateAttribute')
except exception.InvalidTemplateAttribute:
pass
self.assertEqual(net.Net.UPDATE_REPLACE, resource.handle_update())
resource.delete()
self.m.VerifyAll()
# allows testing of the test directly, shown below
if __name__ == '__main__':
sys.argv.append(__file__)
nose.main()

100
templates/Quantum.template Normal file
View File

@ -0,0 +1,100 @@
{
"AWSTemplateFormatVersion" : "2010-09-09",
"Description" : "Template to test Quantum resources",
"Parameters" : {
},
"Resources" : {
"network": {
"Type": "OS::Quantum::Net",
"Properties": {
"name": "the_network"
}
},
"unnamed_network": {
"Type": "OS::Quantum::Net"
},
"admin_down_network": {
"Type": "OS::Quantum::Net",
"Properties": {
"admin_state_up": false
}
},
"subnet": {
"Type": "OS::Quantum::Subnet",
"Properties": {
"network_id": { "Ref" : "network" },
"ip_version": 4,
"cidr": "10.0.3.0/24",
"allocation_pools": [{"start": "10.0.3.20", "end": "10.0.3.150"}]
}
},
"port": {
"Type": "OS::Quantum::Port",
"Properties": {
"device_id": "d6b4d3a5-c700-476f-b609-1493dd9dadc0",
"name": "port1",
"network_id": { "Ref" : "network" },
"fixed_ips": [{
"subnet_id": { "Ref" : "subnet" },
"ip_address": "10.0.3.21"
}]
}
},
"router": {
"Type": "OS::Quantum::Router"
},
"router_interface": {
"Type": "OS::Quantum::RouterInterface",
"Properties": {
"router_id": { "Ref" : "router" },
"subnet_id": { "Ref" : "subnet" }
}
}
},
"Outputs" : {
"the_network_status" : {
"Value" : { "Fn::GetAtt" : [ "network", "status" ]},
"Description" : "Status of network"
},
"port_device_owner" : {
"Value" : { "Fn::GetAtt" : [ "port", "device_owner" ]},
"Description" : "Device owner of the port"
},
"port_fixed_ips" : {
"Value" : { "Fn::GetAtt" : [ "port", "fixed_ips" ]},
"Description" : "Fixed IPs of the port"
},
"port_mac_address" : {
"Value" : { "Fn::GetAtt" : [ "port", "mac_address" ]},
"Description" : "MAC address of the port"
},
"port_status" : {
"Value" : { "Fn::GetAtt" : [ "port", "status" ]},
"Description" : "Status of the port"
},
"port_show" : {
"Value" : { "Fn::GetAtt" : [ "port", "show" ]},
"Description" : "All attributes for port"
},
"subnet_show" : {
"Value" : { "Fn::GetAtt" : [ "subnet", "show" ]},
"Description" : "All attributes for subnet"
},
"network_show" : {
"Value" : { "Fn::GetAtt" : [ "network", "show" ]},
"Description" : "All attributes for network"
},
"router_show" : {
"Value" : { "Fn::GetAtt" : [ "router", "show" ]},
"Description" : "All attributes for router"
}
}
}

View File

@ -27,4 +27,4 @@ python-keystoneclient
glance glance
python-memcached python-memcached
python-swiftclient python-swiftclient
python-quantumclient