diff --git a/etc/neutron/plugins/ml2/ml2_conf_arista.ini b/etc/neutron/plugins/ml2/ml2_conf_arista.ini new file mode 100644 index 000000000..a4cfee0cd --- /dev/null +++ b/etc/neutron/plugins/ml2/ml2_conf_arista.ini @@ -0,0 +1,45 @@ +# Defines configuration options specific for Arista ML2 Mechanism driver + +[ml2_arista] +# (StrOpt) EOS IP address. This is required field. If not set, all +# communications to Arista EOS will fail +# +# eapi_host = +# Example: eapi_host = 192.168.0.1 +# +# (StrOpt) EOS command API username. This is required field. +# if not set, all communications to Arista EOS will fail. +# +# eapi_username = +# Example: arista_eapi_username = admin +# +# (StrOpt) EOS command API password. This is required field. +# if not set, all communications to Arista EOS will fail. +# +# eapi_password = +# Example: eapi_password = my_password +# +# (StrOpt) Defines if hostnames are sent to Arista EOS as FQDNs +# ("node1.domain.com") or as short names ("node1"). This is +# optional. If not set, a value of "True" is assumed. +# +# use_fqdn = +# Example: use_fqdn = True +# +# (IntOpt) Sync interval in seconds between Neutron plugin and EOS. +# This field defines how often the synchronization is performed. +# This is an optional field. If not set, a value of 180 seconds +# is assumed. +# +# sync_interval = +# Example: sync_interval = 60 +# +# (StrOpt) Defines Region Name that is assigned to this OpenStack Controller. +# This is useful when multiple OpenStack/Neutron controllers are +# managing the same Arista HW clusters. Note that this name must +# match with the region name registered (or known) to keystone +# service. Authentication with Keysotne is performed by EOS. +# This is optional. If not set, a value of "RegionOne" is assumed. +# +# region_name = +# Example: region_name = RegionOne diff --git a/neutron/db/migration/alembic_migrations/versions/14f24494ca31_arista_ml2.py b/neutron/db/migration/alembic_migrations/versions/14f24494ca31_arista_ml2.py new file mode 100644 index 000000000..a02ea1909 --- /dev/null +++ b/neutron/db/migration/alembic_migrations/versions/14f24494ca31_arista_ml2.py @@ -0,0 +1,78 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# +# Copyright 2013 OpenStack Foundation +# +# 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. +# + +"""DB Migration for Arista ml2 mechanism driver + +Revision ID: 14f24494ca31 +Revises: 2a3bae1ceb8 +Create Date: 2013-08-15 18:54:16.083640 + +""" + +# revision identifiers, used by Alembic. +revision = '14f24494ca31' +down_revision = '2a3bae1ceb8' + +# Change to ['*'] if this migration applies to all plugins + +migration_for_plugins = [ + 'neutron.plugins.ml2.plugin.Ml2Plugin' +] + +from alembic import op +import sqlalchemy as sa + +from neutron.db import migration + + +def upgrade(active_plugins=None, options=None): + if not migration.should_run(active_plugins, migration_for_plugins): + return + + op.create_table( + 'arista_provisioned_nets', + sa.Column('tenant_id', sa.String(length=255), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('network_id', sa.String(length=36), nullable=True), + sa.Column('segmentation_id', sa.Integer(), + autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id')) + + op.create_table( + 'arista_provisioned_vms', + sa.Column('tenant_id', sa.String(length=255), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('vm_id', sa.String(length=36), nullable=True), + sa.Column('host_id', sa.String(length=255), nullable=True), + sa.Column('port_id', sa.String(length=36), nullable=True), + sa.Column('network_id', sa.String(length=36), nullable=True), + sa.PrimaryKeyConstraint('id')) + + op.create_table( + 'arista_provisioned_tenants', + sa.Column('tenant_id', sa.String(length=255), nullable=True), + sa.Column('id', sa.String(length=36), nullable=False), + sa.PrimaryKeyConstraint('id')) + + +def downgrade(active_plugins=None, options=None): + if not migration.should_run(active_plugins, migration_for_plugins): + return + + op.drop_table('arista_provisioned_tenants') + op.drop_table('arista_provisioned_vms') + op.drop_table('arista_provisioned_nets') diff --git a/neutron/plugins/ml2/drivers/mech_arista/README b/neutron/plugins/ml2/drivers/mech_arista/README new file mode 100644 index 000000000..bcdec5e54 --- /dev/null +++ b/neutron/plugins/ml2/drivers/mech_arista/README @@ -0,0 +1,9 @@ + +Arista Neutron ML2 Mechanism Driver + +This mechanism driver implements ML2 Driver API and is used to manage the virtual and physical networks using Arista Hardware. + +Note: Initial verison of this driver support VLANs only. + +For more details on use please refer to: +https://wiki.openstack.org/wiki/Arista-neutron-ml2-driver diff --git a/neutron/plugins/ml2/drivers/mech_arista/__init__.py b/neutron/plugins/ml2/drivers/mech_arista/__init__.py new file mode 100644 index 000000000..788cea1f7 --- /dev/null +++ b/neutron/plugins/ml2/drivers/mech_arista/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2013 OpenStack Foundation +# 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. diff --git a/neutron/plugins/ml2/drivers/mech_arista/config.py b/neutron/plugins/ml2/drivers/mech_arista/config.py new file mode 100644 index 000000000..5e3c60f86 --- /dev/null +++ b/neutron/plugins/ml2/drivers/mech_arista/config.py @@ -0,0 +1,69 @@ +# Copyright (c) 2013 OpenStack, LLC. +# +# 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 oslo.config import cfg + +""" Arista ML2 Mechanism driver specific configuration knobs. + +Following are user configurable options for Arista ML2 Mechanism +driver. The eapi_username, eapi_password, and eapi_host are +required options. Region Name must be the same that is used by +Keystone service. This option is available to support multiple +OpenStack/Neutron controllers. +""" + +ARISTA_DRIVER_OPTS = [ + cfg.StrOpt('eapi_username', + default='', + help=_('Username for Arista EOS. This is required field.' + 'if not set, all communications to Arista EOS' + 'will fail')), + cfg.StrOpt('eapi_password', + default='', + secret=True, # do not expose value in the logs + help=_('Password for Arista EOS. This is required field.' + 'if not set, all communications to Arista EOS' + 'will fail')), + cfg.StrOpt('eapi_host', + default='', + help=_('Arista EOS IP address. This is required field.' + 'If not set, all communications to Arista EOS' + 'will fail')), + cfg.BoolOpt('use_fqdn', + default=True, + help=_('Defines if hostnames are sent to Arista EOS as FQDNs' + '("node1.domain.com") or as short names ("node1").' + 'This is optional. If not set, a value of "True"' + 'is assumed.')), + cfg.IntOpt('sync_interval', + default=180, + help=_('Sync interval in seconds between Neutron plugin and' + 'EOS. This interval defines how often the' + 'synchronization is performed. This is an optional' + 'field. If not set, a value of 180 seconds is assumed')), + cfg.StrOpt('region_name', + default='RegionOne', + help=_('Defines Region Name that is assigned to this OpenStack' + 'Controller. This is useful when multiple' + 'OpenStack/Neutron controllers are managing the same' + 'Arista HW clusters. Note that this name must match with' + 'the region name registered (or known) to keystone' + 'service. Authentication with Keysotne is performed by' + 'EOS. This is optional. If not set, a value of' + '"RegionOne" is assumed')) +] + +cfg.CONF.register_opts(ARISTA_DRIVER_OPTS, "ml2_arista") diff --git a/neutron/plugins/ml2/drivers/mech_arista/db.py b/neutron/plugins/ml2/drivers/mech_arista/db.py new file mode 100644 index 000000000..b13e1aa83 --- /dev/null +++ b/neutron/plugins/ml2/drivers/mech_arista/db.py @@ -0,0 +1,424 @@ +# Copyright (c) 2013 OpenStack, LLC. +# +# 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 sqlalchemy as sa + +from neutron import context as nctx +import neutron.db.api as db +from neutron.db import db_base_plugin_v2 +from neutron.db import model_base +from neutron.db import models_v2 + +VLAN_SEGMENTATION = 'vlan' + +UUID_LEN = 36 +STR_LEN = 255 + + +class AristaProvisionedNets(model_base.BASEV2, models_v2.HasId, + models_v2.HasTenant): + """Stores networks provisioned on Arista EOS. + + Saves the segmentation ID for each network that is provisioned + on EOS. This information is used during synchronization between + Neutron and EOS. + """ + __tablename__ = 'arista_provisioned_nets' + + network_id = sa.Column(sa.String(UUID_LEN)) + segmentation_id = sa.Column(sa.Integer) + + def eos_network_representation(self, segmentation_type): + return {u'networkId': self.network_id, + u'segmentationTypeId': self.segmentation_id, + u'segmentationType': segmentation_type} + + +class AristaProvisionedVms(model_base.BASEV2, models_v2.HasId, + models_v2.HasTenant): + """Stores VMs provisioned on Arista EOS. + + All VMs launched on physical hosts connected to Arista + Switches are remembered + """ + __tablename__ = 'arista_provisioned_vms' + + vm_id = sa.Column(sa.String(UUID_LEN)) + host_id = sa.Column(sa.String(STR_LEN)) + port_id = sa.Column(sa.String(UUID_LEN)) + network_id = sa.Column(sa.String(UUID_LEN)) + + def eos_vm_representation(self): + return {u'vmId': self.vm_id, + u'host': self.host_id, + u'ports': {self.port_id: [{u'portId': self.port_id, + u'networkId': self.network_id}]}} + + def eos_port_representation(self): + return {u'vmId': self.vm_id, + u'host': self.host_id, + u'portId': self.port_id, + u'networkId': self.network_id} + + +class AristaProvisionedTenants(model_base.BASEV2, models_v2.HasId, + models_v2.HasTenant): + """Stores Tenants provisioned on Arista EOS. + + Tenants list is maintained for sync between Neutron and EOS. + """ + __tablename__ = 'arista_provisioned_tenants' + + def eos_tenant_representation(self): + return {u'tenantId': self.tenant_id} + + +def remember_tenant(tenant_id): + """Stores a tenant information in repository. + + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + tenant = (session.query(AristaProvisionedTenants). + filter_by(tenant_id=tenant_id).first()) + + if not tenant: + tenant = AristaProvisionedTenants( + tenant_id=tenant_id) + session.add(tenant) + + +def forget_tenant(tenant_id): + """Removes a tenant information from repository. + + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + (session.query(AristaProvisionedTenants). + filter_by(tenant_id=tenant_id). + delete()) + + +def get_all_tenants(): + """Returns a list of all tenants stored in repository.""" + session = db.get_session() + with session.begin(): + return session.query(AristaProvisionedTenants).all() + + +def num_provisioned_tenants(): + """Returns number of tenants stored in repository.""" + session = db.get_session() + with session.begin(): + return session.query(AristaProvisionedTenants).count() + + +def remember_vm(vm_id, host_id, port_id, network_id, tenant_id): + """Stores all relevent information about a VM in repository. + + :param vm_id: globally unique identifier for VM instance + :param host_id: ID of the host where the VM is placed + :param port_id: globally unique port ID that connects VM to network + :param network_id: globally unique neutron network identifier + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + vm = (session.query(AristaProvisionedVms). + filter_by(vm_id=vm_id, host_id=host_id, + port_id=port_id, tenant_id=tenant_id, + network_id=network_id).first()) + + if not vm: + vm = AristaProvisionedVms( + vm_id=vm_id, + host_id=host_id, + port_id=port_id, + network_id=network_id, + tenant_id=tenant_id) + session.add(vm) + + +def forget_vm(vm_id, host_id, port_id, network_id, tenant_id): + """Removes all relevent information about a VM from repository. + + :param vm_id: globally unique identifier for VM instance + :param host_id: ID of the host where the VM is placed + :param port_id: globally unique port ID that connects VM to network + :param network_id: globally unique neutron network identifier + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + (session.query(AristaProvisionedVms). + filter_by(vm_id=vm_id, host_id=host_id, + port_id=port_id, tenant_id=tenant_id, + network_id=network_id).delete()) + + +def remember_network(tenant_id, network_id, segmentation_id): + """Stores all relevent information about a Network in repository. + + :param tenant_id: globally unique neutron tenant identifier + :param network_id: globally unique neutron network identifier + :param segmentation_id: VLAN ID that is assigned to the network + """ + session = db.get_session() + with session.begin(): + net = (session.query(AristaProvisionedNets). + filter_by(tenant_id=tenant_id, + network_id=network_id).first()) + + if not net: + net = AristaProvisionedNets( + tenant_id=tenant_id, + network_id=network_id, + segmentation_id=segmentation_id) + session.add(net) + + +def forget_network(tenant_id, network_id): + """Deletes all relevent information about a Network from repository. + + :param tenant_id: globally unique neutron tenant identifier + :param network_id: globally unique neutron network identifier + """ + session = db.get_session() + with session.begin(): + (session.query(AristaProvisionedNets). + filter_by(tenant_id=tenant_id, network_id=network_id). + delete()) + + +def get_segmentation_id(tenant_id, network_id): + """Returns Segmentation ID (VLAN) associated with a network. + + :param tenant_id: globally unique neutron tenant identifier + :param network_id: globally unique neutron network identifier + """ + session = db.get_session() + with session.begin(): + net = (session.query(AristaProvisionedNets). + filter_by(tenant_id=tenant_id, + network_id=network_id).first()) + return net and net.segmentation_id or None + + +def is_vm_provisioned(vm_id, host_id, port_id, + network_id, tenant_id): + """Checks if a VM is already known to EOS + + :returns: True, if yes; False otherwise. + :param vm_id: globally unique identifier for VM instance + :param host_id: ID of the host where the VM is placed + :param port_id: globally unique port ID that connects VM to network + :param network_id: globally unique neutron network identifier + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + num_vm = (session.query(AristaProvisionedVms). + filter_by(tenant_id=tenant_id, + vm_id=vm_id, + port_id=port_id, + network_id=network_id, + host_id=host_id).count()) + return num_vm > 0 + + +def is_network_provisioned(tenant_id, network_id, seg_id=None): + """Checks if a networks is already known to EOS + + :returns: True, if yes; False otherwise. + :param tenant_id: globally unique neutron tenant identifier + :param network_id: globally unique neutron network identifier + :param seg_id: Optionally matches the segmentation ID (VLAN) + """ + session = db.get_session() + with session.begin(): + if not seg_id: + num_nets = (session.query(AristaProvisionedNets). + filter_by(tenant_id=tenant_id, + network_id=network_id).count()) + else: + num_nets = (session.query(AristaProvisionedNets). + filter_by(tenant_id=tenant_id, + network_id=network_id, + segmentation_id=seg_id).count()) + return num_nets > 0 + + +def is_tenant_provisioned(tenant_id): + """Checks if a tenant is already known to EOS + + :returns: True, if yes; False otherwise. + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + num_tenants = (session.query(AristaProvisionedTenants). + filter_by(tenant_id=tenant_id).count()) + return num_tenants > 0 + + +def num_nets_provisioned(tenant_id): + """Returns number of networks for a given tennat. + + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + return (session.query(AristaProvisionedNets). + filter_by(tenant_id=tenant_id).count()) + + +def num_vms_provisioned(tenant_id): + """Returns number of VMs for a given tennat. + + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + return (session.query(AristaProvisionedVms). + filter_by(tenant_id=tenant_id).count()) + + +def get_networks(tenant_id): + """Returns all networks for a given tenant in EOS-compatible format. + + See AristaRPCWrapper.get_network_list() for return value format. + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + model = AristaProvisionedNets + # hack for pep8 E711: comparison to None should be + # 'if cond is not None' + none = None + all_nets = (session.query(model). + filter(model.tenant_id == tenant_id, + model.segmentation_id != none)) + res = dict( + (net.network_id, net.eos_network_representation( + VLAN_SEGMENTATION)) + for net in all_nets + ) + return res + + +def get_vms(tenant_id): + """Returns all VMs for a given tenant in EOS-compatible format. + + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + model = AristaProvisionedVms + # hack for pep8 E711: comparison to None should be + # 'if cond is not None' + none = None + all_vms = (session.query(model). + filter(model.tenant_id == tenant_id, + model.host_id != none, + model.vm_id != none, + model.network_id != none, + model.port_id != none)) + res = dict( + (vm.vm_id, vm.eos_vm_representation()) + for vm in all_vms + ) + return res + + +def get_ports(tenant_id): + """Returns all ports of VMs in EOS-compatible format. + + :param tenant_id: globally unique neutron tenant identifier + """ + session = db.get_session() + with session.begin(): + model = AristaProvisionedVms + # hack for pep8 E711: comparison to None should be + # 'if cond is not None' + none = None + all_ports = (session.query(model). + filter(model.tenant_id == tenant_id, + model.host_id != none, + model.vm_id != none, + model.network_id != none, + model.port_id != none)) + res = dict( + (port.port_id, port.eos_port_representation()) + for port in all_ports + ) + return res + + +def get_tenants(): + """Returns list of all tenants in EOS-compatible format.""" + session = db.get_session() + with session.begin(): + model = AristaProvisionedTenants + all_tenants = session.query(model) + res = dict( + (tenant.tenant_id, tenant.eos_tenant_representation()) + for tenant in all_tenants + ) + return res + + +class NeutronNets(db_base_plugin_v2.NeutronDbPluginV2): + """Access to Neutron DB. + + Provides access to the Neutron Data bases for all provisioned + networks as well ports. This data is used during the synchronization + of DB between ML2 Mechanism Driver and Arista EOS + Names of the networks and ports are not stroed in Arista repository + They are pulled from Neutron DB. + """ + + def __init__(self): + self.admin_ctx = nctx.get_admin_context() + + def get_network_name(self, tenant_id, network_id): + network = self._get_network(tenant_id, network_id) + network_name = None + if network: + network_name = network[0]['name'] + return network_name + + def get_all_networks_for_tenant(self, tenant_id): + filters = {'tenant_id': [tenant_id]} + return super(NeutronNets, + self).get_networks(self.admin_ctx, filters=filters) or [] + + def get_all_ports_for_tenant(self, tenant_id): + filters = {'tenant_id': [tenant_id]} + return super(NeutronNets, + self).get_ports(self.admin_ctx, filters=filters) or [] + + def get_all_ports_for_vm(self, tenant_id, vm_id): + filters = {'tenant_id': [tenant_id], + 'device_id': [vm_id]} + return super(NeutronNets, + self).get_ports(self.admin_ctx, filters=filters) or [] + + def _get_network(self, tenant_id, network_id): + filters = {'tenant_id': [tenant_id], + 'id': [network_id]} + return super(NeutronNets, + self).get_networks(self.admin_ctx, filters=filters) or [] diff --git a/neutron/plugins/ml2/drivers/mech_arista/exceptions.py b/neutron/plugins/ml2/drivers/mech_arista/exceptions.py new file mode 100644 index 000000000..65c1b5464 --- /dev/null +++ b/neutron/plugins/ml2/drivers/mech_arista/exceptions.py @@ -0,0 +1,27 @@ +# Copyright (c) 2013 OpenStack, LLC. +# +# 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. + + +"""Exceptions used by Arista ML2 Mechanism Driver.""" + +from neutron.common import exceptions + + +class AristaRpcError(exceptions.NeutronException): + message = _('%(msg)s') + + +class AristaConfigError(exceptions.NeutronException): + message = _('%(msg)s') diff --git a/neutron/plugins/ml2/drivers/mech_arista/mechanism_arista.py b/neutron/plugins/ml2/drivers/mech_arista/mechanism_arista.py new file mode 100644 index 000000000..c72358b63 --- /dev/null +++ b/neutron/plugins/ml2/drivers/mech_arista/mechanism_arista.py @@ -0,0 +1,697 @@ +# Copyright (c) 2013 OpenStack, LLC. +# +# 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 threading + +import jsonrpclib +from oslo.config import cfg + +from neutron.openstack.common import log as logging +from neutron.plugins.ml2.common import exceptions as ml2_exc +from neutron.plugins.ml2 import driver_api +from neutron.plugins.ml2.drivers.mech_arista import config # noqa +from neutron.plugins.ml2.drivers.mech_arista import db +from neutron.plugins.ml2.drivers.mech_arista import exceptions as arista_exc + +LOG = logging.getLogger(__name__) + +EOS_UNREACHABLE_MSG = 'Unable to reach EOS' + + +class AristaRPCWrapper(object): + """Wraps Arista JSON RPC. + + All communications between Neutron and EOS are over JSON RPC. + EOS - operating system used on Arista hardware + Command API - JSON RPC API provided by Arista EOS + """ + required_options = ['eapi_username', + 'eapi_password', + 'eapi_host'] + + def __init__(self): + self._server = jsonrpclib.Server(self._eapi_host_url()) + self.keystone_conf = cfg.CONF.keystone_authtoken + self.region = cfg.CONF.ml2_arista.region_name + + def _keystone_url(self): + keystone_auth_url = ('%s://%s:%s/v2.0/' % + (self.keystone_conf.auth_protocol, + self.keystone_conf.auth_host, + self.keystone_conf.auth_port)) + return keystone_auth_url + + def get_tenants(self): + """Returns dict of all tanants known by EOS. + + :returns: dictionary containing the networks per tenant + and VMs allocated per tenant + """ + cmds = ['show openstack config region %s' % self.region] + command_output = self._run_openstack_cmds(cmds) + tenants = command_output[0]['tenants'] + + return tenants + + def plug_host_into_network(self, vm_id, host, port_id, + network_id, tenant_id, port_name): + """Creates VLAN between TOR and compute host. + + :param vm_id: globally unique identifier for VM instance + :param host: ID of the host where the VM is placed + :param port_id: globally unique port ID that connects VM to network + :param network_id: globally unique neutron network identifier + :param tenant_id: globally unique neutron tenant identifier + :param port_name: Name of the port - for display purposes + """ + cmds = ['tenant %s' % tenant_id, + 'vm id %s hostid %s' % (vm_id, host)] + if port_name: + cmds.append('port id %s name %s network-id %s' % + (port_id, port_name, network_id)) + else: + cmds.append('port id %s network-id %s' % + (port_id, network_id)) + cmds.append('exit') + cmds.append('exit') + self._run_openstack_cmds(cmds) + + def unplug_host_from_network(self, vm_id, host, port_id, + network_id, tenant_id): + """Removes previously configured VLAN between TOR and a host. + + :param vm_id: globally unique identifier for VM instance + :param host: ID of the host where the VM is placed + :param port_id: globally unique port ID that connects VM to network + :param network_id: globally unique neutron network identifier + :param tenant_id: globally unique neutron tenant identifier + """ + cmds = ['tenant %s' % tenant_id, + 'vm id %s host %s' % (vm_id, host), + 'no port id %s network-id %s' % (port_id, network_id), + 'exit', + 'exit'] + self._run_openstack_cmds(cmds) + + def create_network(self, tenant_id, network_id, network_name, seg_id): + """Creates a network on Arista Hardware + + :param tenant_id: globally unique neutron tenant identifier + :param network_id: globally unique neutron network identifier + :param network_name: Network name - for display purposes + :param seg_id: Segment ID of the network + """ + cmds = ['tenant %s' % tenant_id] + if network_name: + cmds.append('network id %s name %s' % (network_id, network_name)) + else: + cmds.append('network id %s' % network_id) + cmds.append('segment 1 type vlan id %d' % seg_id) + cmds.append('exit') + cmds.append('exit') + cmds.append('exit') + + self._run_openstack_cmds(cmds) + + def create_network_segments(self, tenant_id, network_id, + network_name, segments): + """Creates a network on Arista Hardware + + Note: This method is not used at the moment. create_network() + is used instead. This will be used once the support for + multiple segments is added in Neutron. + + :param tenant_id: globally unique neutron tenant identifier + :param network_id: globally unique neutron network identifier + :param network_name: Network name - for display purposes + :param segments: List of segments in a given network + """ + if segments: + cmds = ['tenant %s' % tenant_id, + 'network id %s name %s' % (network_id, network_name)] + seg_num = 1 + for seg in segments: + cmds.append('segment %d type %s id %d' % (seg_num, + seg['network_type'], seg['segmentation_id'])) + seg_num += 1 + cmds.append('exit') # exit for segment mode + cmds.append('exit') # exit for network mode + cmds.append('exit') # exit for tenant mode + + self._run_openstack_cmds(cmds) + + def delete_network(self, tenant_id, network_id): + """Deletes a specified network for a given tenant + + :param tenant_id: globally unique neutron tenant identifier + :param network_id: globally unique neutron network identifier + """ + cmds = ['tenant %s' % tenant_id, + 'no network id %s' % network_id, + 'exit', + 'exit'] + self._run_openstack_cmds(cmds) + + def delete_vm(self, tenant_id, vm_id): + """Deletes a VM from EOS for a given tenant + + :param tenant_id : globally unique neutron tenant identifier + :param vm_id : id of a VM that needs to be deleted. + """ + cmds = ['tenant %s' % tenant_id, + 'no vm id %s' % vm_id, + 'exit', + 'exit'] + self._run_openstack_cmds(cmds) + + def delete_tenant(self, tenant_id): + """Deletes a given tenant and all its networks and VMs from EOS. + + :param tenant_id: globally unique neutron tenant identifier + """ + cmds = ['no tenant %s' % tenant_id, 'exit'] + self._run_openstack_cmds(cmds) + + def delete_this_region(self): + """Deletes this entire region from EOS. + + This is equivalent of unregistering this Neurtron stack from EOS + All networks for all tenants are removed. + """ + cmds = [] + self._run_openstack_cmds(cmds, deleteRegion=True) + + def _register_with_eos(self): + """This is the registration request with EOS. + + This the initial handshake between Neutron and EOS. + critical end-point information is registered with EOS. + """ + cmds = ['auth url %s user %s password %s' % + (self._keystone_url(), + self.keystone_conf.admin_user, + self.keystone_conf.admin_password)] + + self._run_openstack_cmds(cmds) + + def _run_openstack_cmds(self, commands, deleteRegion=None): + """Execute/sends a CAPI (Command API) command to EOS. + + In this method, list of commands is appended with prefix and + postfix commands - to make is understandble by EOS. + + :param commands : List of command to be executed on EOS. + :param deleteRegion : True/False - to delte entire region from EOS + """ + command_start = ['enable', 'configure', 'management openstack'] + if deleteRegion: + command_start.append('no region %s' % self.region) + else: + command_start.append('region %s' % self.region) + command_end = ['exit', 'exit'] + full_command = command_start + commands + command_end + + LOG.info(_('Executing command on Arista EOS: %s'), full_command) + + try: + # this returns array of return values for every command in + # full_command list + ret = self._server.runCmds(version=1, cmds=full_command) + + # Remove return values for 'configure terminal', + # 'management openstack' and 'exit' commands + ret = ret[len(command_start):-len(command_end)] + except Exception as error: + host = cfg.CONF.ml2_arista.eapi_host + msg = ('Error %s while trying to execute commands %s on EOS %s' % + (error, full_command, host)) + LOG.exception(_("%s"), msg) + raise arista_exc.AristaRpcError(msg=msg) + + return ret + + def _eapi_host_url(self): + self._validate_config() + + user = cfg.CONF.ml2_arista.eapi_username + pwd = cfg.CONF.ml2_arista.eapi_password + host = cfg.CONF.ml2_arista.eapi_host + + eapi_server_url = ('https://%s:%s@%s/command-api' % + (user, pwd, host)) + return eapi_server_url + + def _validate_config(self): + for option in self.required_options: + if cfg.CONF.ml2_arista.get(option) is None: + msg = _('Required option %s is not set') % option + LOG.error(msg) + raise arista_exc.AristaConfigError(msg=msg) + + +class SyncService(object): + """Synchronizatin of information between Neutron and EOS + + Periodically (through configuration option), this service + ensures that Networks and VMs configured on EOS/Arista HW + are always in sync with Neutron DB. + """ + def __init__(self, rpc_wrapper, neutron_db): + self._rpc = rpc_wrapper + self._ndb = neutron_db + + def synchronize(self): + """Sends data to EOS which differs from neutron DB.""" + + LOG.info('Syncing Neutron <-> EOS') + try: + eos_tenants = self._rpc.get_tenants() + except arista_exc.AristaRpcError: + msg = _('EOS is not available, will try sync later') + LOG.warning(msg) + return + + db_tenants = db.get_tenants() + + if not db_tenants and eos_tenants: + # No tenants configured in Neutron. Clear all EOS state + try: + self._rpc.delete_this_region() + msg = _('No Tenants configured in Neutron DB. But %d ' + 'tenants disovered in EOS during synchronization.' + 'Enitre EOS region is cleared') % len(eos_tenants) + except arista_exc.AristaRpcError: + msg = _('EOS is not available, failed to delete this region') + LOG.warning(msg) + return + + if len(eos_tenants) > len(db_tenants): + # EOS has extra tenants configured which should not be there. + for tenant in eos_tenants: + if tenant not in db_tenants: + try: + self._rpc.delete_tenant(tenant) + except arista_exc.AristaRpcError: + msg = _('EOS is not available,' + 'failed to delete tenant %s') % tenant + LOG.warning(msg) + return + + # EOS and Neutron has matching set of tenants. Now check + # to ensure that networks and VMs match on both sides for + # each tenant. + for tenant in db_tenants: + db_nets = db.get_networks(tenant) + db_vms = db.get_vms(tenant) + eos_nets = self._get_eos_networks(eos_tenants, tenant) + eos_vms = self._get_eos_vms(eos_tenants, tenant) + + # Check for the case if everything is already in sync. + if eos_nets == db_nets: + # Net list is same in both Neutron and EOS. + # check the vM list + if eos_vms == db_vms: + # Nothing to do. Everything is in sync for this tenant + break + + # Neutron DB and EOS reruires synchronization. + # First delete anything which should not be EOS + # delete VMs from EOS if it is not present in neutron DB + for vm_id in eos_vms: + if vm_id not in db_vms: + try: + self._rpc.delete_vm(tenant, vm_id) + except arista_exc.AristaRpcError: + msg = _('EOS is not available,' + 'failed to delete vm %s') % vm_id + LOG.warning(msg) + return + + # delete network from EOS if it is not present in neutron DB + for net_id in eos_nets: + if net_id not in db_nets: + try: + self._rpc.delete_network(tenant, net_id) + except arista_exc.AristaRpcError: + msg = _('EOS is not available,' + 'failed to delete network %s') % net_id + LOG.warning(msg) + return + + # update networks in EOS if it is present in neutron DB + for net_id in db_nets: + if net_id not in eos_nets: + vlan_id = db_nets[net_id]['segmentationTypeId'] + net_name = self._ndb.get_network_name(tenant, net_id) + try: + self._rpc.create_network(tenant, net_id, + net_name, + vlan_id) + except arista_exc.AristaRpcError: + msg = _('EOS is not available, failed to create' + 'network id %s') % net_id + LOG.warning(msg) + return + + # Update VMs in EOS if it is present in neutron DB + for vm_id in db_vms: + if vm_id not in eos_vms: + vm = db_vms[vm_id] + ports = self._ndb.get_all_ports_for_vm(tenant, vm_id) + for port in ports: + port_id = port['id'] + network_id = port['network_id'] + port_name = port['name'] + try: + self._rpc.plug_host_into_network(vm['vmId'], + vm['host'], + port_id, + network_id, + tenant, + port_name) + except arista_exc.AristaRpcError: + msg = _('EOS is not available, failed to create' + 'vm id %s') % vm['vmId'] + LOG.warning(msg) + + def _get_eos_networks(self, eos_tenants, tenant): + networks = {} + if eos_tenants: + networks = eos_tenants[tenant]['tenantNetworks'] + return networks + + def _get_eos_vms(self, eos_tenants, tenant): + vms = {} + if eos_tenants: + vms = eos_tenants[tenant]['tenantVmInstances'] + return vms + + +class AristaDriver(driver_api.MechanismDriver): + """Ml2 Mechanism driver for Arista networking hardware. + + Remebers all networks and VMs that are provisioned on Arista Hardware. + Does not send network provisioning request if the network has already been + provisioned before for the given port. + """ + def __init__(self, rpc=None): + + self.rpc = rpc or AristaRPCWrapper() + self.ndb = db.NeutronNets() + + confg = cfg.CONF.ml2_arista + self.segmentation_type = db.VLAN_SEGMENTATION + self.timer = None + self.eos = SyncService(self.rpc, self.ndb) + self.sync_timeout = confg['sync_interval'] + self.eos_sync_lock = threading.Lock() + + self._synchronization_thread() + + def initialize(self): + self.rpc._register_with_eos() + self._cleanupDb() + + def create_network_precommit(self, context): + """Remember the tenant, and network information.""" + + network = context.current + segments = context.network_segments + network_id = network['id'] + tenant_id = network['tenant_id'] + segmentation_id = segments[0]['segmentation_id'] + with self.eos_sync_lock: + db.remember_tenant(tenant_id) + db.remember_network(tenant_id, + network_id, + segmentation_id) + + def create_network_postcommit(self, context): + """Provision the network on the Arista Hardware.""" + + network = context.current + network_id = network['id'] + network_name = network['name'] + tenant_id = network['tenant_id'] + segments = context.network_segments + vlan_id = segments[0]['segmentation_id'] + with self.eos_sync_lock: + if db.is_network_provisioned(tenant_id, network_id): + try: + self.rpc.create_network(tenant_id, + network_id, + network_name, + vlan_id) + except arista_exc.AristaRpcError: + LOG.info(EOS_UNREACHABLE_MSG) + raise ml2_exc.MechanismDriverError() + else: + msg = _('Network %s is not created as it is not found in' + 'Arista DB') % network_id + LOG.info(msg) + + def update_network_precommit(self, context): + """At the moment we only support network name change + + Any other change in network is not supprted at this time. + We do not store the network names, therefore, no DB store + action is performed here. + """ + new_network = context.current + orig_network = context.original + if new_network['name'] != orig_network['name']: + msg = _('Network name changed to %s') % new_network['name'] + LOG.info(msg) + + def update_network_postcommit(self, context): + """At the moment we only support network name change + + If network name is changed, a new network create request is + sent to the Arista Hardware. + """ + new_network = context.current + orig_network = context.original + if new_network['name'] != orig_network['name']: + network_id = new_network['id'] + network_name = new_network['name'] + tenant_id = new_network['tenant_id'] + vlan_id = new_network['provider:segmentation_id'] + with self.eos_sync_lock: + if db.is_network_provisioned(tenant_id, network_id): + try: + self.rpc.create_network(tenant_id, + network_id, + network_name, + vlan_id) + except arista_exc.AristaRpcError: + LOG.info(EOS_UNREACHABLE_MSG) + raise ml2_exc.MechanismDriverError() + else: + msg = _('Network %s is not updated as it is not found in' + 'Arista DB') % network_id + LOG.info(msg) + + def delete_network_precommit(self, context): + """Delete the network infromation from the DB.""" + network = context.current + network_id = network['id'] + tenant_id = network['tenant_id'] + with self.eos_sync_lock: + if db.is_network_provisioned(tenant_id, network_id): + db.forget_network(tenant_id, network_id) + # if necessary, delete tenant as well. + self.delete_tenant(tenant_id) + + def delete_network_postcommit(self, context): + """Send network delete request to Arista HW.""" + network = context.current + network_id = network['id'] + tenant_id = network['tenant_id'] + with self.eos_sync_lock: + + # Succeed deleting network in case EOS is not accessible. + # EOS state will be updated by sync thread once EOS gets + # alive. + try: + self.rpc.delete_network(tenant_id, network_id) + except arista_exc.AristaRpcError: + LOG.info(EOS_UNREACHABLE_MSG) + raise ml2_exc.MechanismDriverError() + + def create_port_precommit(self, context): + """Remember the infromation about a VM and its ports + + A VM information, along with the physical host information + is saved. + """ + port = context.current + device_id = port['device_id'] + device_owner = port['device_owner'] + + # TODO(sukhdev) revisit this once port biniding support is implemented + host = port['binding:host_id'] + + # device_id and device_owner are set on VM boot + is_vm_boot = device_id and device_owner + if host and is_vm_boot: + port_id = port['id'] + network_id = port['network_id'] + tenant_id = port['tenant_id'] + with self.eos_sync_lock: + db.remember_vm(device_id, host, port_id, + network_id, tenant_id) + + def create_port_postcommit(self, context): + """Plug a physical host into a network. + + Send provisioning request to Arista Hardware to plug a host + into appropriate network. + """ + port = context.current + device_id = port['device_id'] + device_owner = port['device_owner'] + + # TODO(sukhdev) revisit this once port biniding support is implemented + host = port['binding:host_id'] + + # device_id and device_owner are set on VM boot + is_vm_boot = device_id and device_owner + if host and is_vm_boot: + port_id = port['id'] + port_name = port['name'] + network_id = port['network_id'] + tenant_id = port['tenant_id'] + with self.eos_sync_lock: + hostname = self._host_name(host) + segmentation_id = db.get_segmentation_id(tenant_id, + network_id) + vm_provisioned = db.is_vm_provisioned(device_id, + host, + port_id, + network_id, + tenant_id) + net_provisioned = db.is_network_provisioned(tenant_id, + network_id, + segmentation_id) + if vm_provisioned and net_provisioned: + try: + self.rpc.plug_host_into_network(device_id, + hostname, + port_id, + network_id, + tenant_id, + port_name) + except arista_exc.AristaRpcError: + LOG.info(EOS_UNREACHABLE_MSG) + raise ml2_exc.MechanismDriverError() + else: + msg = _('VM %s is not created as it is not found in' + 'Arista DB') % device_id + LOG.info(msg) + + def update_port_precommit(self, context): + # TODO(sukhdev) revisit once the port binding support is implemented + return + + def update_port_postcommit(self, context): + # TODO(sukhdev) revisit once the port binding support is implemented + return + + def delete_port_precommit(self, context): + """Delete information about a VM and host from the DB.""" + port = context.current + + # TODO(sukhdev) revisit this once port biniding support is implemented + host_id = port['binding:host_id'] + device_id = port['device_id'] + tenant_id = port['tenant_id'] + network_id = port['network_id'] + port_id = port['id'] + with self.eos_sync_lock: + if db.is_vm_provisioned(device_id, host_id, port_id, + network_id, tenant_id): + db.forget_vm(device_id, host_id, port_id, + network_id, tenant_id) + # if necessary, delete tenant as well. + self.delete_tenant(tenant_id) + + def delete_port_postcommit(self, context): + """unPlug a physical host from a network. + + Send provisioning request to Arista Hardware to unplug a host + from appropriate network. + """ + port = context.current + device_id = port['device_id'] + + # TODO(sukhdev) revisit this once port biniding support is implemented + host = port['binding:host_id'] + + port_id = port['id'] + network_id = port['network_id'] + tenant_id = port['tenant_id'] + + try: + with self.eos_sync_lock: + hostname = self._host_name(host) + self.rpc.unplug_host_from_network(device_id, + hostname, + port_id, + network_id, + tenant_id) + except arista_exc.AristaRpcError: + LOG.info(EOS_UNREACHABLE_MSG) + raise ml2_exc.MechanismDriverError() + + def delete_tenant(self, tenant_id): + """delete a tenant from DB. + + A tenant is deleted only if there is no network or VM configured + configured for this tenant. + """ + objects_for_tenant = (db.num_nets_provisioned(tenant_id) + + db.num_vms_provisioned(tenant_id)) + if not objects_for_tenant: + db.forget_tenant(tenant_id) + + def _host_name(self, hostname): + fqdns_used = cfg.CONF.ml2_arista['use_fqdn'] + return hostname if fqdns_used else hostname.split('.')[0] + + def _synchronization_thread(self): + with self.eos_sync_lock: + self.eos.synchronize() + + self.timer = threading.Timer(self.sync_timeout, + self._synchronization_thread) + self.timer.start() + + def stop_synchronization_thread(self): + if self.timer: + self.timer.cancel() + self.timer = None + + def _cleanupDb(self): + """Clean up any uncessary entries in our DB.""" + db_tenants = db.get_tenants() + for tenant in db_tenants: + neutron_nets = self.ndb.get_all_networks_for_tenant(tenant) + neutron_nets_id = [] + for net in neutron_nets: + neutron_nets_id.append(net['id']) + db_nets = db.get_networks(tenant) + for net_id in db_nets.keys(): + if net_id not in neutron_nets_id: + db.forget_network(tenant, net_id) diff --git a/neutron/tests/unit/ml2/drivers/test_arista_mechanism_driver.py b/neutron/tests/unit/ml2/drivers/test_arista_mechanism_driver.py new file mode 100644 index 000000000..431dc6510 --- /dev/null +++ b/neutron/tests/unit/ml2/drivers/test_arista_mechanism_driver.py @@ -0,0 +1,535 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# Copyright (c) 2013 OpenStack, LLC. +# +# 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 oslo.config import cfg + +import neutron.db.api as ndb +from neutron.plugins.ml2.drivers.mech_arista import db +from neutron.plugins.ml2.drivers.mech_arista import exceptions as arista_exc +from neutron.plugins.ml2.drivers.mech_arista import mechanism_arista as arista +from neutron.tests import base + + +def setup_arista_wrapper_config(value=None): + cfg.CONF.keystone_authtoken = fake_keystone_info_class() + for opt in arista.AristaRPCWrapper.required_options: + cfg.CONF.set_override(opt, value, "ml2_arista") + + +def setup_valid_config(): + # Config is not valid if value is not set + setup_arista_wrapper_config('value') + + +class AristaProvisionedVlansStorageTestCase(base.BaseTestCase): + """Test storing and retriving functionality of Arista mechanism driver. + + Tests all methods of this class by invoking them seperately as well + as a goup. + """ + + def setUp(self): + super(AristaProvisionedVlansStorageTestCase, self).setUp() + ndb.configure_db() + + def test_tenant_is_remembered(self): + tenant_id = 'test' + + db.remember_tenant(tenant_id) + net_provisioned = db.is_tenant_provisioned(tenant_id) + self.assertTrue(net_provisioned, 'Tenant must be provisioned') + + def test_tenant_is_removed(self): + tenant_id = 'test' + + db.remember_tenant(tenant_id) + db.forget_tenant(tenant_id) + net_provisioned = db.is_tenant_provisioned(tenant_id) + self.assertFalse(net_provisioned, 'The Tenant should be deleted') + + def test_network_is_remembered(self): + tenant_id = 'test' + network_id = '123' + segmentation_id = 456 + + db.remember_network(tenant_id, network_id, segmentation_id) + net_provisioned = db.is_network_provisioned(tenant_id, + network_id) + self.assertTrue(net_provisioned, 'Network must be provisioned') + + def test_network_is_removed(self): + tenant_id = 'test' + network_id = '123' + + db.remember_network(tenant_id, network_id, '123') + db.forget_network(tenant_id, network_id) + net_provisioned = db.is_network_provisioned(tenant_id, network_id) + self.assertFalse(net_provisioned, 'The network should be deleted') + + def test_vm_is_remembered(self): + vm_id = 'VM-1' + tenant_id = 'test' + network_id = '123' + port_id = 456 + host_id = 'ubuntu1' + + db.remember_vm(vm_id, host_id, port_id, network_id, tenant_id) + vm_provisioned = db.is_vm_provisioned(vm_id, host_id, port_id, + network_id, tenant_id) + self.assertTrue(vm_provisioned, 'VM must be provisioned') + + def test_vm_is_removed(self): + vm_id = 'VM-1' + tenant_id = 'test' + network_id = '123' + port_id = 456 + host_id = 'ubuntu1' + + db.remember_vm(vm_id, host_id, port_id, network_id, tenant_id) + db.forget_vm(vm_id, host_id, port_id, network_id, tenant_id) + vm_provisioned = db.is_vm_provisioned(vm_id, host_id, port_id, + network_id, tenant_id) + self.assertFalse(vm_provisioned, 'The vm should be deleted') + + def test_remembers_multiple_networks(self): + tenant_id = 'test' + expected_num_nets = 100 + nets = ['id%s' % n for n in range(expected_num_nets)] + for net_id in nets: + db.remember_network(tenant_id, net_id, 123) + + num_nets_provisioned = db.num_nets_provisioned(tenant_id) + self.assertEqual(expected_num_nets, num_nets_provisioned, + 'There should be %d nets, not %d' % + (expected_num_nets, num_nets_provisioned)) + + def test_removes_all_networks(self): + tenant_id = 'test' + num_nets = 100 + old_nets = db.num_nets_provisioned(tenant_id) + nets = ['id_%s' % n for n in range(num_nets)] + for net_id in nets: + db.remember_network(tenant_id, net_id, 123) + for net_id in nets: + db.forget_network(tenant_id, net_id) + + num_nets_provisioned = db.num_nets_provisioned(tenant_id) + expected = old_nets + self.assertEqual(expected, num_nets_provisioned, + 'There should be %d nets, not %d' % + (expected, num_nets_provisioned)) + + def test_remembers_multiple_tenants(self): + expected_num_tenants = 100 + tenants = ['id%s' % n for n in range(expected_num_tenants)] + for tenant_id in tenants: + db.remember_tenant(tenant_id) + + num_tenants_provisioned = db.num_provisioned_tenants() + self.assertEqual(expected_num_tenants, num_tenants_provisioned, + 'There should be %d tenants, not %d' % + (expected_num_tenants, num_tenants_provisioned)) + + def test_removes_multiple_tenants(self): + num_tenants = 100 + tenants = ['id%s' % n for n in range(num_tenants)] + for tenant_id in tenants: + db.remember_tenant(tenant_id) + for tenant_id in tenants: + db.forget_tenant(tenant_id) + + num_tenants_provisioned = db.num_provisioned_tenants() + expected = 0 + self.assertEqual(expected, num_tenants_provisioned, + 'There should be %d tenants, not %d' % + (expected, num_tenants_provisioned)) + + def test_num_vm_is_valid(self): + tenant_id = 'test' + network_id = '123' + port_id = 456 + host_id = 'ubuntu1' + + vm_to_remember = ['vm1', 'vm2', 'vm3'] + vm_to_forget = ['vm2', 'vm1'] + + for vm in vm_to_remember: + db.remember_vm(vm, host_id, port_id, network_id, tenant_id) + for vm in vm_to_forget: + db.forget_vm(vm, host_id, port_id, network_id, tenant_id) + + num_vms = len(db.get_vms(tenant_id)) + expected = len(vm_to_remember) - len(vm_to_forget) + + self.assertEqual(expected, num_vms, + 'There should be %d records, ' + 'got %d records' % (expected, num_vms)) + # clean up afterwards + db.forget_vm('vm3', host_id, port_id, network_id, tenant_id) + + def test_get_network_list_returns_eos_compatible_data(self): + tenant = u'test-1' + segm_type = 'vlan' + network_id = u'123' + network2_id = u'1234' + vlan_id = 123 + vlan2_id = 1234 + expected_eos_net_list = {network_id: {u'networkId': network_id, + u'segmentationTypeId': vlan_id, + u'segmentationType': segm_type}, + network2_id: {u'networkId': network2_id, + u'segmentationTypeId': vlan2_id, + u'segmentationType': segm_type}} + + db.remember_network(tenant, network_id, vlan_id) + db.remember_network(tenant, network2_id, vlan2_id) + + net_list = db.get_networks(tenant) + self.assertNotEqual(net_list != expected_eos_net_list, ('%s != %s' % + (net_list, expected_eos_net_list))) + + +class PositiveRPCWrapperValidConfigTestCase(base.BaseTestCase): + """Test cases to test the RPC between Arista Driver and EOS. + + Tests all methods used to send commands between Arista Driver and EOS + """ + + def setUp(self): + super(PositiveRPCWrapperValidConfigTestCase, self).setUp() + setup_valid_config() + self.drv = arista.AristaRPCWrapper() + self.region = 'RegionOne' + self.drv._server = mock.MagicMock() + + def test_no_exception_on_correct_configuration(self): + self.assertNotEqual(self.drv, None) + + def test_plug_host_into_network(self): + tenant_id = 'ten-1' + vm_id = 'vm-1' + port_id = 123 + network_id = 'net-id' + host = 'host' + port_name = '123-port' + + self.drv.plug_host_into_network(vm_id, host, port_id, + network_id, tenant_id, port_name) + cmds = ['enable', 'configure', 'management openstack', + 'region RegionOne', + 'tenant ten-1', 'vm id vm-1 hostid host', + 'port id 123 name 123-port network-id net-id', + 'exit', 'exit', 'exit', 'exit'] + + self.drv._server.runCmds.assert_called_once_with(version=1, cmds=cmds) + + def test_unplug_host_from_network(self): + tenant_id = 'ten-1' + vm_id = 'vm-1' + port_id = 123 + network_id = 'net-id' + host = 'host' + self.drv.unplug_host_from_network(vm_id, host, port_id, + network_id, tenant_id) + cmds = ['enable', 'configure', 'management openstack', + 'region RegionOne', + 'tenant ten-1', 'vm id vm-1 host host', + 'no port id 123 network-id net-id', + 'exit', 'exit', 'exit', 'exit'] + self.drv._server.runCmds.assert_called_once_with(version=1, cmds=cmds) + + def test_create_network(self): + tenant_id = 'ten-1' + network_id = 'net-id' + network_name = 'net-name' + vlan_id = 123 + self.drv.create_network(tenant_id, network_id, network_name, vlan_id) + cmds = ['enable', 'configure', 'management openstack', + 'region RegionOne', + 'tenant ten-1', 'network id net-id name net-name', + 'segment 1 type vlan id 123', + 'exit', 'exit', 'exit', 'exit', 'exit'] + self.drv._server.runCmds.assert_called_once_with(version=1, cmds=cmds) + + def test_delete_network(self): + tenant_id = 'ten-1' + network_id = 'net-id' + self.drv.delete_network(tenant_id, network_id) + cmds = ['enable', 'configure', 'management openstack', + 'region RegionOne', + 'tenant ten-1', 'no network id net-id', + 'exit', 'exit', 'exit', 'exit'] + self.drv._server.runCmds.assert_called_once_with(version=1, cmds=cmds) + + def test_delete_vm(self): + tenant_id = 'ten-1' + vm_id = 'vm-id' + self.drv.delete_vm(tenant_id, vm_id) + cmds = ['enable', 'configure', 'management openstack', + 'region RegionOne', + 'tenant ten-1', 'no vm id vm-id', + 'exit', 'exit', 'exit', 'exit'] + self.drv._server.runCmds.assert_called_once_with(version=1, cmds=cmds) + + def test_delete_tenant(self): + tenant_id = 'ten-1' + self.drv.delete_tenant(tenant_id) + cmds = ['enable', 'configure', 'management openstack', + 'region RegionOne', 'no tenant ten-1', + 'exit', 'exit', 'exit'] + self.drv._server.runCmds.assert_called_once_with(version=1, cmds=cmds) + + def test_get_network_info_returns_none_when_no_such_net(self): + expected = [] + self.drv.get_tenants = mock.MagicMock() + self.drv.get_tenants.return_value = [] + + net_info = self.drv.get_tenants() + + self.drv.get_tenants.assert_called_once_with() + self.assertEqual(net_info, expected, ('Network info must be "None"' + 'for unknown network')) + + def test_get_network_info_returns_info_for_available_net(self): + valid_network_id = '12345' + valid_net_info = {'network_id': valid_network_id, + 'some_info': 'net info'} + known_nets = valid_net_info + + self.drv.get_tenants = mock.MagicMock() + self.drv.get_tenants.return_value = known_nets + + net_info = self.drv.get_tenants() + self.assertEqual(net_info, valid_net_info, + ('Must return network info for a valid net')) + + +class AristaRPCWrapperInvalidConfigTestCase(base.BaseTestCase): + """Negative test cases to test the Arista Driver configuration.""" + + def setUp(self): + super(AristaRPCWrapperInvalidConfigTestCase, self).setUp() + self.setup_invalid_config() # Invalid config, required options not set + + def setup_invalid_config(self): + setup_arista_wrapper_config(None) + + def test_raises_exception_on_wrong_configuration(self): + self.assertRaises(arista_exc.AristaConfigError, + arista.AristaRPCWrapper) + + +class NegativeRPCWrapperTestCase(base.BaseTestCase): + """Negative test cases to test the RPC between Arista Driver and EOS.""" + + def setUp(self): + super(NegativeRPCWrapperTestCase, self).setUp() + setup_valid_config() + + def test_exception_is_raised_on_json_server_error(self): + drv = arista.AristaRPCWrapper() + + drv._server = mock.MagicMock() + drv._server.runCmds.side_effect = Exception('server error') + self.assertRaises(arista_exc.AristaRpcError, drv.get_tenants) + + +class RealNetStorageAristaDriverTestCase(base.BaseTestCase): + """Main test cases for Arista Mechanism driver. + + Tests all mechanism driver APIs supported by Arista Driver. It invokes + all the APIs as they would be invoked in real world scenarios and + verifies the functionality. + """ + def setUp(self): + super(RealNetStorageAristaDriverTestCase, self).setUp() + self.fake_rpc = mock.MagicMock() + ndb.configure_db() + self.drv = arista.AristaDriver(self.fake_rpc) + + def tearDown(self): + super(RealNetStorageAristaDriverTestCase, self).tearDown() + self.drv.stop_synchronization_thread() + + def test_create_and_delete_network(self): + tenant_id = 'ten-1' + network_id = 'net1-id' + segmentation_id = 1001 + + network_context = self._get_network_context(tenant_id, + network_id, + segmentation_id) + self.drv.create_network_precommit(network_context) + net_provisioned = db.is_network_provisioned(tenant_id, network_id) + self.assertTrue(net_provisioned, 'The network should be created') + + expected_num_nets = 1 + num_nets_provisioned = db.num_nets_provisioned(tenant_id) + self.assertEqual(expected_num_nets, num_nets_provisioned, + 'There should be %d nets, not %d' % + (expected_num_nets, num_nets_provisioned)) + + #Now test the delete network + self.drv.delete_network_precommit(network_context) + net_provisioned = db.is_network_provisioned(tenant_id, network_id) + self.assertFalse(net_provisioned, 'The network should be created') + + expected_num_nets = 0 + num_nets_provisioned = db.num_nets_provisioned(tenant_id) + self.assertEqual(expected_num_nets, num_nets_provisioned, + 'There should be %d nets, not %d' % + (expected_num_nets, num_nets_provisioned)) + + def test_create_and_delete_multiple_networks(self): + tenant_id = 'ten-1' + expected_num_nets = 100 + segmentation_id = 1001 + nets = ['id%s' % n for n in range(expected_num_nets)] + for net_id in nets: + network_context = self._get_network_context(tenant_id, + net_id, + segmentation_id) + self.drv.create_network_precommit(network_context) + + num_nets_provisioned = db.num_nets_provisioned(tenant_id) + self.assertEqual(expected_num_nets, num_nets_provisioned, + 'There should be %d nets, not %d' % + (expected_num_nets, num_nets_provisioned)) + + #now test the delete networks + for net_id in nets: + network_context = self._get_network_context(tenant_id, + net_id, + segmentation_id) + self.drv.delete_network_precommit(network_context) + + num_nets_provisioned = db.num_nets_provisioned(tenant_id) + expected_num_nets = 0 + self.assertEqual(expected_num_nets, num_nets_provisioned, + 'There should be %d nets, not %d' % + (expected_num_nets, num_nets_provisioned)) + + def test_create_and_delete_ports(self): + tenant_id = 'ten-1' + network_id = 'net1-id' + segmentation_id = 1001 + vms = ['vm1', 'vm2', 'vm3'] + + network_context = self._get_network_context(tenant_id, + network_id, + segmentation_id) + self.drv.create_network_precommit(network_context) + + for vm_id in vms: + port_context = self._get_port_context(tenant_id, + network_id, + vm_id, + network_context) + self.drv.create_port_precommit(port_context) + + vm_list = db.get_vms(tenant_id) + provisioned_vms = len(vm_list) + expected_vms = len(vms) + self.assertEqual(expected_vms, provisioned_vms, + 'There should be %d ' + 'hosts, not %d' % (expected_vms, provisioned_vms)) + + # Now test the delete ports + for vm_id in vms: + port_context = self._get_port_context(tenant_id, + network_id, + vm_id, + network_context) + self.drv.delete_port_precommit(port_context) + + vm_list = db.get_vms(tenant_id) + provisioned_vms = len(vm_list) + expected_vms = 0 + self.assertEqual(expected_vms, provisioned_vms, + 'There should be %d ' + 'VMs, not %d' % (expected_vms, provisioned_vms)) + + def _get_network_context(self, tenant_id, net_id, seg_id): + network = {'id': net_id, + 'tenant_id': tenant_id} + network_segments = [{'segmentation_id': seg_id}] + return FakeNetworkContext(network, network_segments, network) + + def _get_port_context(self, tenant_id, net_id, vm_id, network): + port = {'device_id': vm_id, + 'device_owner': 'compute', + 'binding:host_id': 'ubuntu1', + 'tenant_id': tenant_id, + 'id': 101, + 'network_id': net_id + } + return FakePortContext(port, port, network) + + +class fake_keystone_info_class(object): + """To generate fake Keystone Authentification token information + + Arista Driver expects Keystone auth info. This fake information + is for testing only + """ + auth_protocol = 'abc' + auth_host = 'host' + auth_port = 5000 + admin_user = 'neutron' + admin_password = 'fun' + + +class FakeNetworkContext(object): + """To generate network context for testing purposes only.""" + + def __init__(self, network, segments=None, original_network=None): + self._network = network + self._original_network = original_network + self._segments = segments + + @property + def current(self): + return self._network + + @property + def original(self): + return self._original_network + + @property + def network_segments(self): + return self._segments + + +class FakePortContext(object): + """To generate port context for testing purposes only.""" + + def __init__(self, port, original_port, network): + self._port = port + self._original_port = original_port + self._network_context = network + + @property + def current(self): + return self._port + + @property + def original(self): + return self._original_port + + @property + def network(self): + return self._network_context diff --git a/requirements.txt b/requirements.txt index ef4de91a8..24cbc549d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ greenlet>=0.3.2 httplib2 requests>=1.1 iso8601>=0.1.4 +jsonrpclib kombu>=2.4.8 netaddr python-neutronclient>=2.2.3,<3 diff --git a/setup.cfg b/setup.cfg index e453e2b89..e5f73eb5c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -50,6 +50,7 @@ data_files = etc/neutron/plugins/metaplugin = etc/neutron/plugins/metaplugin/metaplugin.ini etc/neutron/plugins/midonet = etc/neutron/plugins/midonet/midonet.ini etc/neutron/plugins/ml2 = etc/neutron/plugins/ml2/ml2_conf.ini + etc/neutron/plugins/ml2/ml2_conf_arista.ini etc/neutron/plugins/mlnx = etc/neutron/plugins/mlnx/mlnx_conf.ini etc/neutron/plugins/nec = etc/neutron/plugins/nec/nec.ini etc/neutron/plugins/nicira = etc/neutron/plugins/nicira/nvp.ini @@ -117,6 +118,7 @@ neutron.ml2.mechanism_drivers = logger = neutron.tests.unit.ml2.drivers.mechanism_logger:LoggerMechanismDriver test = neutron.tests.unit.ml2.drivers.mechanism_test:TestMechanismDriver ncs = neutron.plugins.ml2.drivers.mechanism_ncs:NCSMechanismDriver + arista = neutron.plugins.ml2.drivers.mech_arista.mechanism_arista:AristaDriver [build_sphinx] all_files = 1