diff --git a/etc/neutron.conf b/etc/neutron.conf index 77f8d92f4d6..dc889486059 100644 --- a/etc/neutron.conf +++ b/etc/neutron.conf @@ -706,6 +706,9 @@ # exit - Exits the agent # check_child_processes_action = respawn +# Availability zone of this node. +# availability_zone = nova + # =========== items for agent management extension ============= # seconds between nodes reporting state to server; should be less than # agent_down_time, best if it is half or less than agent_down_time diff --git a/neutron/agent/common/config.py b/neutron/agent/common/config.py index 100ef34edb2..6235c23b088 100644 --- a/neutron/agent/common/config.py +++ b/neutron/agent/common/config.py @@ -16,6 +16,7 @@ import os from oslo_config import cfg +from oslo_config import types from oslo_log import log as logging from neutron.common import config @@ -75,6 +76,37 @@ PROCESS_MONITOR_OPTS = [ ] +# TODO(hichihara): Remove these two classes, once oslo fixes types.string +# and cfg.StrOpt. +class LengthString(types.String): + def __init__(self, maxlen=None): + super(LengthString, self).__init__() + self.maxlen = maxlen + + def __call__(self, value): + value = super(LengthString, self).__call__(value) + if self.maxlen and len(value) > self.maxlen: + raise ValueError(_("String value '%(value)s' exceeds max length " + "%(len)d") % {'value': value, + 'len': self.maxlen}) + return value + + +class LengthStrOpt(cfg.Opt): + def __init__(self, name, maxlen=None, **kwargs): + super(LengthStrOpt, self).__init__(name, + type=LengthString(maxlen=maxlen), + **kwargs) + + +AVAILABILITY_ZONE_OPTS = [ + # The default AZ name "nova" is selected to match the default + # AZ name in Nova and Cinder. + LengthStrOpt('availability_zone', maxlen=255, default='nova', + help=_("Availability zone of this node")), +] + + def get_log_args(conf, log_file_name, **kwargs): cmd_args = [] if conf.debug: @@ -128,6 +160,10 @@ def register_process_monitor_opts(conf): conf.register_opts(PROCESS_MONITOR_OPTS, 'AGENT') +def register_availability_zone_opts_helper(conf): + conf.register_opts(AVAILABILITY_ZONE_OPTS, 'AGENT') + + def get_root_helper(conf): return conf.AGENT.root_helper diff --git a/neutron/agent/dhcp/agent.py b/neutron/agent/dhcp/agent.py index 4e4da5036ae..f077d4fe129 100644 --- a/neutron/agent/dhcp/agent.py +++ b/neutron/agent/dhcp/agent.py @@ -548,6 +548,7 @@ class DhcpAgentWithStateReport(DhcpAgent): self.agent_state = { 'binary': 'neutron-dhcp-agent', 'host': host, + 'availability_zone': self.conf.AGENT.availability_zone, 'topic': topics.DHCP_AGENT, 'configurations': { 'dhcp_driver': self.conf.dhcp_driver, diff --git a/neutron/agent/dhcp_agent.py b/neutron/agent/dhcp_agent.py index 968a3d1c5b2..634b3b6d3c9 100644 --- a/neutron/agent/dhcp_agent.py +++ b/neutron/agent/dhcp_agent.py @@ -32,6 +32,7 @@ def register_options(conf): config.register_interface_driver_opts_helper(conf) config.register_use_namespaces_opts_helper(conf) config.register_agent_state_opts_helper(conf) + config.register_availability_zone_opts_helper(conf) conf.register_opts(dhcp_config.DHCP_AGENT_OPTS) conf.register_opts(dhcp_config.DHCP_OPTS) conf.register_opts(dhcp_config.DNSMASQ_OPTS) diff --git a/neutron/agent/l3/agent.py b/neutron/agent/l3/agent.py index 6f2b9077253..570a6c420f6 100644 --- a/neutron/agent/l3/agent.py +++ b/neutron/agent/l3/agent.py @@ -615,6 +615,7 @@ class L3NATAgentWithStateReport(L3NATAgent): self.agent_state = { 'binary': 'neutron-l3-agent', 'host': host, + 'availability_zone': self.conf.AGENT.availability_zone, 'topic': topics.L3_AGENT, 'configurations': { 'agent_mode': self.conf.agent_mode, diff --git a/neutron/agent/l3_agent.py b/neutron/agent/l3_agent.py index bee060181c9..8c34fe1ac44 100644 --- a/neutron/agent/l3_agent.py +++ b/neutron/agent/l3_agent.py @@ -40,6 +40,7 @@ def register_opts(conf): config.register_agent_state_opts_helper(conf) conf.register_opts(interface.OPTS) conf.register_opts(external_process.OPTS) + config.register_availability_zone_opts_helper(conf) def main(manager='neutron.agent.l3.agent.L3NATAgentWithStateReport'): diff --git a/neutron/db/agents_db.py b/neutron/db/agents_db.py index 9417d5e3c37..c704f884b84 100644 --- a/neutron/db/agents_db.py +++ b/neutron/db/agents_db.py @@ -20,6 +20,7 @@ from oslo_log import log as logging import oslo_messaging from oslo_serialization import jsonutils from oslo_utils import timeutils +import six import sqlalchemy as sa from sqlalchemy.orm import exc from sqlalchemy import sql @@ -29,6 +30,7 @@ from neutron.common import constants from neutron.db import model_base from neutron.db import models_v2 from neutron.extensions import agent as ext_agent +from neutron.extensions import availability_zone as az_ext from neutron.i18n import _LE, _LI, _LW from neutron import manager @@ -81,6 +83,7 @@ class Agent(model_base.BASEV2, models_v2.HasId): topic = sa.Column(sa.String(255), nullable=False) # TOPIC.host is a target topic host = sa.Column(sa.String(255), nullable=False) + availability_zone = sa.Column(sa.String(255)) admin_state_up = sa.Column(sa.Boolean, default=True, server_default=sql.true(), nullable=False) # the time when first report came from agents @@ -101,7 +104,60 @@ class Agent(model_base.BASEV2, models_v2.HasId): return not AgentDbMixin.is_agent_down(self.heartbeat_timestamp) -class AgentDbMixin(ext_agent.AgentPluginBase): +class AgentAvailabilityZoneMixin(az_ext.AvailabilityZonePluginBase): + """Mixin class to add availability_zone extension to AgentDbMixin.""" + + def _list_availability_zones(self, context, filters=None): + result = {} + query = self._get_collection_query(context, Agent, filters=filters) + for agent in query.group_by(Agent.admin_state_up, + Agent.availability_zone, + Agent.agent_type): + if not agent.availability_zone: + continue + if agent.agent_type == constants.AGENT_TYPE_DHCP: + resource = 'network' + elif agent.agent_type == constants.AGENT_TYPE_L3: + resource = 'router' + else: + continue + key = (agent.availability_zone, resource) + result[key] = agent.admin_state_up or result.get(key, False) + return result + + def get_availability_zones(self, context, filters=None, fields=None, + sorts=None, limit=None, marker=None, + page_reverse=False): + """Return a list of availability zones.""" + # NOTE(hichihara): 'tenant_id' is dummy for policy check. + # it is not visible via API. + return [{'state': 'available' if v else 'unavailable', + 'name': k[0], 'resource': k[1], + 'tenant_id': context.tenant_id} + for k, v in six.iteritems(self._list_availability_zones( + context, filters))] + + def validate_availability_zones(self, context, resource_type, + availability_zones): + """Verify that the availability zones exist.""" + if not availability_zones: + return + if resource_type == 'network': + agent_type = constants.AGENT_TYPE_DHCP + elif resource_type == 'router': + agent_type = constants.AGENT_TYPE_L3 + else: + return + query = context.session.query(Agent.availability_zone).filter_by( + agent_type=agent_type).group_by(Agent.availability_zone) + query = query.filter(Agent.availability_zone.in_(availability_zones)) + azs = [item[0] for item in query] + diff = set(availability_zones) - set(azs) + if diff: + raise az_ext.AvailabilityZoneNotFound(availability_zone=diff.pop()) + + +class AgentDbMixin(ext_agent.AgentPluginBase, AgentAvailabilityZoneMixin): """Mixin class to add agent extension to db_base_plugin_v2.""" def _get_agent(self, context, id): @@ -162,6 +218,7 @@ class AgentDbMixin(ext_agent.AgentPluginBase): res['alive'] = not AgentDbMixin.is_agent_down( res['heartbeat_timestamp']) res['configurations'] = self.get_configuration_dict(agent) + res['availability_zone'] = agent['availability_zone'] return self._fields(res, fields) def delete_agent(self, context, id): @@ -222,7 +279,8 @@ class AgentDbMixin(ext_agent.AgentPluginBase): with context.session.begin(subtransactions=True): res_keys = ['agent_type', 'binary', 'host', 'topic'] res = dict((k, agent_state[k]) for k in res_keys) - + if 'availability_zone' in agent_state: + res['availability_zone'] = agent_state['availability_zone'] configurations_dict = agent_state.get('configurations', {}) res['configurations'] = jsonutils.dumps(configurations_dict) res['load'] = self._get_agent_load(agent_state) diff --git a/neutron/db/migration/alembic_migrations/versions/mitaka/expand/59cb5b6cf4d_availability_zone.py b/neutron/db/migration/alembic_migrations/versions/mitaka/expand/59cb5b6cf4d_availability_zone.py new file mode 100644 index 00000000000..d2c5482db4c --- /dev/null +++ b/neutron/db/migration/alembic_migrations/versions/mitaka/expand/59cb5b6cf4d_availability_zone.py @@ -0,0 +1,33 @@ +# +# 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. +# + +"""Add availability zone + +Revision ID: 59cb5b6cf4d +Revises: 34af2b5c5a59 +Create Date: 2015-01-20 14:38:47.156574 + +""" + +# revision identifiers, used by Alembic. +revision = '59cb5b6cf4d' +down_revision = '34af2b5c5a59' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('agents', + sa.Column('availability_zone', sa.String(length=255))) diff --git a/neutron/extensions/agent.py b/neutron/extensions/agent.py index c8e40a7c6c5..c83d3fb4987 100644 --- a/neutron/extensions/agent.py +++ b/neutron/extensions/agent.py @@ -108,6 +108,10 @@ class Agent(extensions.ExtensionDescriptor): return [ex] + def update_attributes_map(self, attributes): + super(Agent, self).update_attributes_map( + attributes, extension_attrs_map=RESOURCE_ATTRIBUTE_MAP) + def get_extended_resources(self, version): if version == "2.0": return RESOURCE_ATTRIBUTE_MAP diff --git a/neutron/extensions/availability_zone.py b/neutron/extensions/availability_zone.py new file mode 100644 index 00000000000..2c06c18337e --- /dev/null +++ b/neutron/extensions/availability_zone.py @@ -0,0 +1,107 @@ +# +# 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 abc + +from neutron.api import extensions +from neutron.api.v2 import attributes as attr +from neutron.api.v2 import base +from neutron.common import exceptions +from neutron import manager + + +# Attribute Map +RESOURCE_NAME = 'availability_zone' +AVAILABILITY_ZONES = 'availability_zones' +# name: name of availability zone (string) +# resource: type of resource: 'network' or 'router' +# state: state of availability zone: 'available' or 'unavailable' +# It means whether users can use the availability zone. +RESOURCE_ATTRIBUTE_MAP = { + AVAILABILITY_ZONES: { + 'name': {'is_visible': True}, + 'resource': {'is_visible': True}, + 'state': {'is_visible': True} + } +} + +EXTENDED_ATTRIBUTES_2_0 = { + 'agents': { + RESOURCE_NAME: {'allow_post': False, 'allow_put': False, + 'is_visible': True} + } +} + + +class AvailabilityZoneNotFound(exceptions.NotFound): + message = _("AvailabilityZone %(availability_zone)s could not be found.") + + +class Availability_zone(extensions.ExtensionDescriptor): + """Availability zone extension.""" + + @classmethod + def get_name(cls): + return "Availability Zone" + + @classmethod + def get_alias(cls): + return "availability_zone" + + @classmethod + def get_description(cls): + return "The availability zone extension." + + @classmethod + def get_updated(cls): + return "2015-01-01T10:00:00-00:00" + + def get_required_extensions(self): + return ["agent"] + + @classmethod + def get_resources(cls): + """Returns Ext Resources.""" + my_plurals = [(key, key[:-1]) for key in RESOURCE_ATTRIBUTE_MAP.keys()] + attr.PLURALS.update(dict(my_plurals)) + plugin = manager.NeutronManager.get_plugin() + params = RESOURCE_ATTRIBUTE_MAP.get(AVAILABILITY_ZONES) + controller = base.create_resource(AVAILABILITY_ZONES, + RESOURCE_NAME, plugin, params) + + ex = extensions.ResourceExtension(AVAILABILITY_ZONES, controller) + + return [ex] + + def get_extended_resources(self, version): + if version == "2.0": + return dict(list(EXTENDED_ATTRIBUTES_2_0.items()) + + list(RESOURCE_ATTRIBUTE_MAP.items())) + else: + return {} + + +class AvailabilityZonePluginBase(object): + """REST API to operate the Availability Zone.""" + + @abc.abstractmethod + def get_availability_zones(self, context, filters=None, fields=None, + sorts=None, limit=None, marker=None, + page_reverse=False): + pass + + @abc.abstractmethod + def validate_availability_zones(self, context, resource_type, + availability_zones): + pass diff --git a/neutron/plugins/ml2/plugin.py b/neutron/plugins/ml2/plugin.py index d6f98f26cf6..3d2cbdeaf53 100644 --- a/neutron/plugins/ml2/plugin.py +++ b/neutron/plugins/ml2/plugin.py @@ -118,7 +118,8 @@ class Ml2Plugin(db_base_plugin_v2.NeutronDbPluginV2, "multi-provider", "allowed-address-pairs", "extra_dhcp_opt", "subnet_allocation", "net-mtu", "vlan-transparent", - "address-scope", "dns-integration"] + "address-scope", "dns-integration", + "availability_zone"] @property def supported_extension_aliases(self): diff --git a/neutron/tests/common/helpers.py b/neutron/tests/common/helpers.py index 80473cf56a8..484c0216580 100644 --- a/neutron/tests/common/helpers.py +++ b/neutron/tests/common/helpers.py @@ -25,6 +25,7 @@ from neutron.db import agents_db from neutron.db import common_db_mixin HOST = 'localhost' +DEFAULT_AZ = 'nova' def find_file(filename, path): @@ -47,12 +48,14 @@ class FakePlugin(common_db_mixin.CommonDbMixin, def _get_l3_agent_dict(host, agent_mode, internal_only=True, - ext_net_id='', ext_bridge='', router_id=None): + ext_net_id='', ext_bridge='', router_id=None, + az=DEFAULT_AZ): return { 'agent_type': constants.AGENT_TYPE_L3, 'binary': 'neutron-l3-agent', 'host': host, 'topic': topics.L3_AGENT, + 'availability_zone': az, 'configurations': {'agent_mode': agent_mode, 'handle_internal_only_routers': internal_only, 'external_network_bridge': ext_bridge, @@ -71,18 +74,19 @@ def _register_agent(agent): def register_l3_agent(host=HOST, agent_mode=constants.L3_AGENT_MODE_LEGACY, internal_only=True, ext_net_id='', ext_bridge='', - router_id=None): + router_id=None, az=DEFAULT_AZ): agent = _get_l3_agent_dict(host, agent_mode, internal_only, ext_net_id, - ext_bridge, router_id) + ext_bridge, router_id, az) return _register_agent(agent) -def _get_dhcp_agent_dict(host, networks=0): +def _get_dhcp_agent_dict(host, networks=0, az=DEFAULT_AZ): agent = { 'binary': 'neutron-dhcp-agent', 'host': host, 'topic': topics.DHCP_AGENT, 'agent_type': constants.AGENT_TYPE_DHCP, + 'availability_zone': az, 'configurations': {'dhcp_driver': 'dhcp_driver', 'use_namespaces': True, 'networks': networks}} @@ -90,9 +94,9 @@ def _get_dhcp_agent_dict(host, networks=0): def register_dhcp_agent(host=HOST, networks=0, admin_state_up=True, - alive=True): + alive=True, az=DEFAULT_AZ): agent = _register_agent( - _get_dhcp_agent_dict(host, networks)) + _get_dhcp_agent_dict(host, networks, az=az)) if not admin_state_up: set_agent_admin_state(agent['id']) diff --git a/neutron/tests/unit/agent/l3/test_agent.py b/neutron/tests/unit/agent/l3/test_agent.py index 3be71f5c07e..ea9409917c5 100644 --- a/neutron/tests/unit/agent/l3/test_agent.py +++ b/neutron/tests/unit/agent/l3/test_agent.py @@ -70,6 +70,7 @@ class BasicRouterOperationsFramework(base.BaseTestCase): agent_config.register_interface_driver_opts_helper(self.conf) agent_config.register_use_namespaces_opts_helper(self.conf) agent_config.register_process_monitor_opts(self.conf) + agent_config.register_availability_zone_opts_helper(self.conf) self.conf.register_opts(interface.OPTS) self.conf.register_opts(external_process.OPTS) self.conf.set_override('router_id', 'fake_id') diff --git a/neutron/tests/unit/extensions/test_availability_zone.py b/neutron/tests/unit/extensions/test_availability_zone.py new file mode 100644 index 00000000000..d68ea90b977 --- /dev/null +++ b/neutron/tests/unit/extensions/test_availability_zone.py @@ -0,0 +1,98 @@ +# +# 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_log import log as logging + +from neutron import context +from neutron.db import agents_db +from neutron.db import db_base_plugin_v2 +from neutron.extensions import agent +from neutron.extensions import availability_zone as az_ext +from neutron.tests.common import helpers +from neutron.tests.unit.db import test_db_base_plugin_v2 + + +LOG = logging.getLogger(__name__) + + +class AZExtensionManager(object): + + def get_resources(self): + agent.RESOURCE_ATTRIBUTE_MAP['agents'].update( + az_ext.EXTENDED_ATTRIBUTES_2_0['agents']) + return (az_ext.Availability_zone.get_resources() + + agent.Agent.get_resources()) + + def get_actions(self): + return [] + + def get_request_extensions(self): + return [] + + +# This plugin class is just for testing +class AZTestPlugin(db_base_plugin_v2.NeutronDbPluginV2, + agents_db.AgentDbMixin): + supported_extension_aliases = ["agent", "availability_zone"] + + +class AZTestCommon(test_db_base_plugin_v2.NeutronDbPluginV2TestCase): + def _register_azs(self): + self.agent1 = helpers.register_dhcp_agent(host='host1', az='nova1') + self.agent2 = helpers.register_dhcp_agent(host='host2', az='nova2') + self.agent3 = helpers.register_l3_agent(host='host2', az='nova2') + self.agent4 = helpers.register_l3_agent(host='host3', az='nova3') + self.agent5 = helpers.register_l3_agent(host='host4', az='nova2') + + +class TestAZAgentCase(AZTestCommon): + def setUp(self): + plugin = ('neutron.tests.unit.extensions.' + 'test_availability_zone.AZTestPlugin') + ext_mgr = AZExtensionManager() + super(TestAZAgentCase, self).setUp(plugin=plugin, ext_mgr=ext_mgr) + + def test_list_availability_zones(self): + self._register_azs() + helpers.set_agent_admin_state(self.agent3['id'], admin_state_up=False) + helpers.set_agent_admin_state(self.agent4['id'], admin_state_up=False) + expected = [ + {'name': 'nova1', 'resource': 'network', 'state': 'available'}, + {'name': 'nova2', 'resource': 'network', 'state': 'available'}, + {'name': 'nova2', 'resource': 'router', 'state': 'available'}, + {'name': 'nova3', 'resource': 'router', 'state': 'unavailable'}] + res = self._list('availability_zones') + azs = res['availability_zones'] + self.assertItemsEqual(expected, azs) + # not admin case + ctx = context.Context('', 'noadmin') + res = self._list('availability_zones', neutron_context=ctx) + azs = res['availability_zones'] + self.assertItemsEqual(expected, azs) + + def test_list_agent_with_az(self): + helpers.register_dhcp_agent(host='host1', az='nova1') + res = self._list('agents') + self.assertEqual('nova1', + res['agents'][0]['availability_zone']) + + def test_validate_availability_zones(self): + self._register_azs() + ctx = context.Context('', 'tenant_id') + self.plugin.validate_availability_zones(ctx, 'network', + ['nova1', 'nova2']) + self.plugin.validate_availability_zones(ctx, 'router', + ['nova2', 'nova3']) + self.assertRaises(az_ext.AvailabilityZoneNotFound, + self.plugin.validate_availability_zones, + ctx, 'router', ['nova1'])