From 43960ee4486925bff2b4da3af5db963b389f7dc2 Mon Sep 17 00:00:00 2001 From: Mark McClain Date: Wed, 27 Jun 2012 14:15:53 -0400 Subject: [PATCH] implement dhcp agent for quantum blueprint: quantum-dhcp This change adds an agent to manage DHCP for Quantum networks Change-Id: If3c62965550dc0b0a7982b01d3468e2e07e2b775 --- bin/quantum-dhcp-agent | 20 ++ etc/dhcp_agent.ini | 38 +++ quantum/agent/common/__init__.py | 16 + quantum/agent/common/config.py | 36 ++ quantum/agent/dhcp_agent.py | 361 ++++++++++++++++++++ quantum/agent/linux/dhcp.py | 270 +++++++++++++++ quantum/agent/linux/interface.py | 174 ++++++++++ quantum/agent/linux/ip_lib.py | 191 +++++++++++ quantum/common/exceptions.py | 12 + quantum/rootwrap/dhcp-agent.py | 26 ++ quantum/tests/unit/test_agent_config.py | 23 ++ quantum/tests/unit/test_agent_utils.py | 2 + quantum/tests/unit/test_dhcp_agent.py | 318 +++++++++++++++++ quantum/tests/unit/test_linux_dhcp.py | 376 +++++++++++++++++++++ quantum/tests/unit/test_linux_interface.py | 224 ++++++++++++ quantum/tests/unit/test_linux_ip_lib.py | 274 +++++++++++++++ setup.py | 6 +- tools/pip-requires | 2 + 18 files changed, 2368 insertions(+), 1 deletion(-) create mode 100755 bin/quantum-dhcp-agent create mode 100644 etc/dhcp_agent.ini create mode 100644 quantum/agent/common/__init__.py create mode 100644 quantum/agent/common/config.py create mode 100644 quantum/agent/dhcp_agent.py create mode 100644 quantum/agent/linux/dhcp.py create mode 100644 quantum/agent/linux/interface.py create mode 100644 quantum/agent/linux/ip_lib.py create mode 100644 quantum/rootwrap/dhcp-agent.py create mode 100644 quantum/tests/unit/test_agent_config.py create mode 100644 quantum/tests/unit/test_dhcp_agent.py create mode 100644 quantum/tests/unit/test_linux_dhcp.py create mode 100644 quantum/tests/unit/test_linux_interface.py create mode 100644 quantum/tests/unit/test_linux_ip_lib.py diff --git a/bin/quantum-dhcp-agent b/bin/quantum-dhcp-agent new file mode 100755 index 000000000..56d7e6ea2 --- /dev/null +++ b/bin/quantum-dhcp-agent @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2012 Openstack, LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from quantum.agent.dhcp_agent import main +main() diff --git a/etc/dhcp_agent.ini b/etc/dhcp_agent.ini new file mode 100644 index 000000000..7fea80a98 --- /dev/null +++ b/etc/dhcp_agent.ini @@ -0,0 +1,38 @@ +[DEFAULT] +# Show debugging output in log (sets DEBUG log level output) +# debug = true + +# Where to store dnsmasq state files. This directory must be writable by the +# user executing the agent. The value below is compatible with a default +# devstack installation. +state_path = /opt/stack/data + + +# The DHCP requires that an inteface driver be set. Choose the one that best +# matches you plugin. + +# OVS +interface_driver = quantum.agent.linux.interface.OVSInterfaceDriver +# LinuxBridge +#interface_driver = quantum.agent.linux.interface.BridgeInterfaceDriver + +# The agent can use other DHCP drivers. Dnsmasq is the simplest and requires +# no additional setup of the DHCP server. +dhcp_driver = quantum.agent.linux.dhcp.Dnsmasq + +# +# Temporary F2 variables until the Agent <> Quantum Server is reworked in F3 +# +# The database used by the OVS Quantum plugin +db_connection = mysql://root:password@localhost/ovs_quantum?charset=utf8 + +# The database used by the LinuxBridge Quantum plugin +#db_connection = mysql://root:password@localhost/quantum_linux_bridge + +# The Quantum user information for accessing the Quantum API. +auth_url = http://localhost:35357/v2.0 +auth_region = RegionOne +admin_tenant_name = service +admin_user = quantum +admin_password = password + diff --git a/quantum/agent/common/__init__.py b/quantum/agent/common/__init__.py new file mode 100644 index 000000000..304bb14ea --- /dev/null +++ b/quantum/agent/common/__init__.py @@ -0,0 +1,16 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# 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/quantum/agent/common/config.py b/quantum/agent/common/config.py new file mode 100644 index 000000000..cff3f9ece --- /dev/null +++ b/quantum/agent/common/config.py @@ -0,0 +1,36 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os + +from quantum.common import config +from quantum.openstack.common import cfg + + +def setup_conf(): + bind_opts = [ + cfg.StrOpt('state_path', + default='/var/lib/quantum', + help='Top-level directory for maintaining dhcp state'), + ] + + conf = cfg.CommonConfigOpts() + conf.register_opts(bind_opts) + return conf + +# add a logging setup method here for convenience +setup_logging = config.setup_logging diff --git a/quantum/agent/dhcp_agent.py b/quantum/agent/dhcp_agent.py new file mode 100644 index 000000000..36643a5de --- /dev/null +++ b/quantum/agent/dhcp_agent.py @@ -0,0 +1,361 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import collections +import logging +import socket +import sys +import time +import uuid + +from sqlalchemy.ext import sqlsoup + +from quantum.agent.common import config +from quantum.agent.linux import dhcp +from quantum.agent.linux import interface +from quantum.agent.linux import ip_lib +from quantum.common import exceptions +from quantum.openstack.common import cfg +from quantum.openstack.common import importutils +from quantum.version import version_string +from quantumclient.v2_0 import client + +LOG = logging.getLogger(__name__) + +State = collections.namedtuple('State', + ['networks', 'subnet_hashes', 'ipalloc_hashes']) + + +class DhcpAgent(object): + OPTS = [ + cfg.StrOpt('db_connection', default=''), + cfg.StrOpt('root_helper', default='sudo'), + cfg.StrOpt('dhcp_driver', + default='quantum.agent.linux.dhcp.Dnsmasq', + help="The driver used to manage the DHCP server."), + cfg.IntOpt('polling_interval', + default=3, + help="The time in seconds between state poll requests."), + cfg.IntOpt('reconnect_interval', + default=5, + help="The time in seconds between db reconnect attempts.") + ] + + def __init__(self, conf): + self.conf = conf + self.dhcp_driver_cls = importutils.import_class(conf.dhcp_driver) + self.db = None + self.polling_interval = conf.polling_interval + self.reconnect_interval = conf.reconnect_interval + self._run = True + self.prev_state = State(set(), set(), set()) + + def daemon_loop(self): + while self._run: + delta = self.get_network_state_delta() + if delta is None: + continue + + for network in delta.get('new', []): + self.call_driver('enable', network) + for network in delta.get('updated', []): + self.call_driver('reload_allocations', network) + for network in delta.get('deleted', []): + self.call_driver('disable', network) + + time.sleep(self.polling_interval) + + def _state_builder(self): + """Polls the Quantum database and returns a represenation + of the network state. + + The value returned is a State tuple that contains three sets: + networks, subnet_hashes, and ipalloc_hashes. + + The hash sets are a tuple that contains the computed signature of the + obejct's metadata and the network that owns it. Signatures are used + because the objects metadata can change. Python's built-in hash + function is used on the string repr to compute the metadata signature. + """ + try: + if self.db is None: + time.sleep(self.reconnect_interval) + self.db = sqlsoup.SqlSoup(self.conf.db_connection) + LOG.info("Connecting to database \"%s\" on %s" % + (self.db.engine.url.database, + self.db.engine.url.host)) + else: + # we have to commit to get the latest view + self.db.commit() + + subnets = {} + subnet_hashes = set() + for subnet in self.db.subnets.all(): + subnet_hashes.add((hash(str(subnet)), subnet.network_id)) + subnets[subnet.id] = subnet.network_id + + ipalloc_hashes = set([(hash(str(a)), subnets[a.subnet_id]) + for a in self.db.ipallocations.all()]) + + networks = set(subnets.itervalues()) + + return State(networks, subnet_hashes, ipalloc_hashes) + + except Exception, e: + LOG.warn('Unable to get network state delta. Exception: %s' % e) + self.db = None + return None + + def get_network_state_delta(self): + """Return a dict containing the sets of networks that are new, + updated, and deleted.""" + delta = {} + state = self._state_builder() + + if state is None: + return None + + # determine the new/deleted networks + delta['deleted'] = self.prev_state.networks - state.networks + delta['new'] = state.networks - self.prev_state.networks + + # Get the networks that have subnets added or deleted. + # The change candidates are the net_id portion of the symmetric diff + # between the sets of (subnet_hash,net_id) + candidates = set( + [h[1] for h in + (state.subnet_hashes ^ self.prev_state.subnet_hashes)] + ) + + # Update with the networks that have had allocations added/deleted. + # change candidates are the net_id portion of the symmetric diff + # between the sets of (alloc_hash,net_id) + candidates.update( + [h[1] for h in + (state.ipalloc_hashes ^ self.prev_state.ipalloc_hashes)] + ) + + # the updated set will contain new and deleted networks, so remove them + delta['updated'] = candidates - delta['new'] - delta['deleted'] + + self.prev_state = state + + return delta + + def call_driver(self, action, network_id): + """Invoke an action on a DHCP driver instance.""" + try: + # the Driver expects something that is duck typed similar to + # the base models. Augmenting will add support to the SqlSoup + # result, so that the Driver does have to concern itself with our + # db schema. + network = AugmentingWrapper( + self.db.networks.filter_by(id=network_id).one(), + self.db + ) + driver = self.dhcp_driver_cls(self.conf, + network, + self.conf.root_helper, + DeviceManager(self.conf, self.db)) + getattr(driver, action)() + + except Exception, e: + LOG.warn('Unable to %s dhcp. Exception: %s' % (action, e)) + + # Manipulate the state so the action will be attempted on next + # loop iteration. + if action == 'disable': + # adding to prev state means we'll try to delete it next time + self.prev_state.networks.add(network_id) + else: + # removing means it will look like new next time + self.prev_state.networks.remove(network_id) + + +class DeviceManager(object): + OPTS = [ + cfg.StrOpt('admin_user'), + cfg.StrOpt('admin_password'), + cfg.StrOpt('admin_tenant_name'), + cfg.StrOpt('auth_url'), + cfg.StrOpt('auth_strategy', default='keystone'), + cfg.StrOpt('auth_region'), + cfg.StrOpt('interface_driver', + help="The driver used to manage the virtual interface.") + ] + + def __init__(self, conf, db): + self.conf = conf + self.db = db + + if not conf.interface_driver: + LOG.error(_('You must specify an interface driver')) + self.driver = importutils.import_object(conf.interface_driver, conf) + + def get_interface_name(self, network): + return ('tap' + network.id)[:self.driver.DEV_NAME_LEN] + + def get_device_id(self, network): + # There could be more than one dhcp server per network, so create + # a device id that combines host and network ids + + host_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, socket.gethostname()) + return 'dhcp%s-%s' % (host_uuid, network.id) + + def setup(self, network, reuse_existing=False): + interface_name = self.get_interface_name(network) + port = self._get_or_create_port(network) + + if ip_lib.device_exists(interface_name): + if not reuse_existing: + raise exceptions.PreexistingDeviceFailure( + dev_name=interface_name) + + LOG.debug(_('Reusing existing device: %s.') % interface_name) + else: + self.driver.plug(network.id, + port.id, + interface_name, + port.mac_address) + self.driver.init_l3(port, interface_name) + + def destroy(self, network): + self.driver.unplug(self.get_interface_name(network)) + + def _get_or_create_port(self, network): + # todo (mark): reimplement using RPC + # Usage of client lib is a temporary measure. + + try: + device_id = self.get_device_id(network) + port_obj = self.db.ports.filter_by(device_id=device_id).one() + port = AugmentingWrapper(port_obj, self.db) + except sqlsoup.SQLAlchemyError, e: + port = self._create_port(network) + + return port + + def _create_port(self, network): + # todo (mark): reimplement using RPC + # Usage of client lib is a temporary measure. + + quantum = client.Client( + username=self.conf.admin_user, + password=self.conf.admin_password, + tenant_name=self.conf.admin_tenant_name, + auth_url=self.conf.auth_url, + auth_strategy=self.conf.auth_strategy, + auth_region=self.conf.auth_region + ) + + body = dict(port=dict( + admin_state_up=True, + device_id=self.get_device_id(network), + network_id=network.id, + tenant_id=network.tenant_id, + fixed_ips=[dict(subnet_id=s.id) for s in network.subnets])) + port_dict = quantum.create_port(body)['port'] + + # we have to call commit since the port was created in outside of + # our current transaction + self.db.commit() + + port = AugmentingWrapper( + self.db.ports.filter_by(id=port_dict['id']).one(), + self.db) + return port + + +class PortModel(object): + def __init__(self, port_dict): + self.__dict__.update(port_dict) + + +class AugmentingWrapper(object): + """A wrapper that augments Sqlsoup results so that they look like the + base v2 db model. + """ + + MAPPING = { + 'networks': {'subnets': 'subnets', 'ports': 'ports'}, + 'subnets': {'allocations': 'ipallocations'}, + 'ports': {'fixed_ips': 'ipallocations'}, + + } + + def __init__(self, obj, db): + self.obj = obj + self.db = db + + def __repr__(self): + return repr(self.obj) + + def __getattr__(self, name): + """Executes a dynamic lookup of attributes to make SqlSoup results + mimic the same structure as the v2 db models. + + The actual models could not be used because they're dependent on the + plugin and the agent is not tied to any plugin structure. + + If .subnet, is accessed, the wrapper will return a subnet + object if this instance has a subnet_id attribute. + + If the _id attribute does not exists then wrapper will check MAPPING + to see if a reverse relationship exists. If so, a wrapped result set + will be returned. + """ + + try: + return getattr(self.obj, name) + except: + pass + + id_attr = '%s_id' % name + if hasattr(self.obj, id_attr): + args = {'id': getattr(self.obj, id_attr)} + return AugmentingWrapper( + getattr(self.db, '%ss' % name).filter_by(**args).one(), + self.db + ) + try: + attr_name = self.MAPPING[self.obj._table.name][name] + arg_name = '%s_id' % self.obj._table.name[:-1] + args = {arg_name: self.obj.id} + + return [AugmentingWrapper(o, self.db) for o in + getattr(self.db, attr_name).filter_by(**args).all()] + except KeyError: + pass + + raise AttributeError + + +def main(): + conf = config.setup_conf() + conf.register_opts(DhcpAgent.OPTS) + conf.register_opts(DeviceManager.OPTS) + conf.register_opts(dhcp.OPTS) + conf.register_opts(interface.OPTS) + conf(sys.argv) + config.setup_logging(conf) + + mgr = DhcpAgent(conf) + mgr.daemon_loop() + + +if __name__ == '__main__': + main() diff --git a/quantum/agent/linux/dhcp.py b/quantum/agent/linux/dhcp.py new file mode 100644 index 000000000..ba5656370 --- /dev/null +++ b/quantum/agent/linux/dhcp.py @@ -0,0 +1,270 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import abc +import logging +import os +import re +import StringIO +import tempfile + +import netaddr + +from quantum.agent.linux import utils +from quantum.openstack.common import cfg +from quantum.openstack.common import importutils + +LOG = logging.getLogger(__name__) + +OPTS = [ + cfg.StrOpt('dhcp_confs', + default='$state_path/dhcp', + help='Location to store DHCP server config files'), + cfg.IntOpt('dhcp_lease_time', + default=120, + help='Lifetime of a DHCP lease in seconds'), + cfg.StrOpt('dhcp_domain', + default='openstacklocal', + help='Domain to use for building the hostnames'), + cfg.StrOpt('dnsmasq_config_file', + help='Override the default dnsmasq settings with this file'), + cfg.StrOpt('dnsmasq_dns_server', + help='Use another DNS server before any in /etc/resolv.conf.'), +] + +IPV4 = 4 +IPV6 = 6 +UDP = 'udp' +TCP = 'tcp' +DNS_PORT = 53 +DHCPV4_PORT = 67 +DHCPV6_PORT = 467 + + +class DhcpBase(object): + __metaclass__ = abc.ABCMeta + + def __init__(self, conf, network, root_helper='sudo', + device_delegate=None): + self.conf = conf + self.network = network + self.root_helper = root_helper + self.device_delegate = device_delegate + + @abc.abstractmethod + def enable(self): + """Enables DHCP for this network.""" + + @abc.abstractmethod + def disable(self): + """Disable dhcp for this network.""" + + def restart(self): + """Restart the dhcp service for the network.""" + self.disable() + self.enable() + + @abc.abstractproperty + def active(self): + """Boolean representing the running state of the DHCP server.""" + + @abc.abstractmethod + def reload_allocations(self): + """Force the DHCP server to reload the assignment database.""" + + +class DhcpLocalProcess(DhcpBase): + PORTS = [] + + def enable(self): + """Enables DHCP for this network by spawning a local process.""" + if self.active: + self.reload_allocations() + return + + self.device_delegate.setup(self.network, reuse_existing=True) + self.spawn_process() + + def disable(self): + """Disable DHCP for this network by killing the local process.""" + pid = self.pid + + if self.active: + utils.execute(['kill', '-9', pid], self.root_helper) + self.device_delegate.destroy(self.network) + elif pid: + LOG.debug(_('DHCP for %s pid %d is stale, ignoring command') % + (self.network.id, pid)) + else: + LOG.debug(_('No DHCP started for %s') % self.network.id) + + def get_conf_file_name(self, kind, ensure_conf_dir=False): + """Returns the file name for a given kind of config file.""" + confs_dir = os.path.abspath(os.path.normpath(self.conf.dhcp_confs)) + conf_dir = os.path.join(confs_dir, self.network.id) + if ensure_conf_dir: + if not os.path.isdir(conf_dir): + os.makedirs(conf_dir, 0755) + + return os.path.join(conf_dir, kind) + + def _get_value_from_conf_file(self, kind, converter=None): + """A helper function to read a value from one of the state files.""" + file_name = self.get_conf_file_name(kind) + msg = _('Error while reading %s') + + try: + with open(file_name, 'r') as f: + try: + return converter and converter(f.read()) or f.read() + except ValueError, e: + msg = _('Unable to convert value in %s') + except IOError, e: + msg = _('Unable to access %s') + + LOG.debug(msg % file_name) + return None + + @property + def pid(self): + """Last known pid for the DHCP process spawned for this network.""" + return self._get_value_from_conf_file('pid', int) + + @property + def active(self): + pid = self.pid + cmd = ['cat', '/proc/%s/cmdline' % pid] + try: + return self.network.id in utils.execute(cmd, self.root_helper) + except RuntimeError, e: + return False + + @abc.abstractmethod + def spawn_process(self): + pass + + +class Dnsmasq(DhcpLocalProcess): + # The ports that need to be opened when security policies are active + # on the Quantum port used for DHCP. These are provided as a convenience + # for users of this class. + PORTS = {IPV4: [(UDP, DNS_PORT), (TCP, DNS_PORT), (UDP, DHCPV4_PORT)], + IPV6: [(UDP, DNS_PORT), (TCP, DNS_PORT), (UDP, DHCPV6_PORT)], + } + + _TAG_PREFIX = 'tag%d' + + def spawn_process(self): + """Spawns a Dnsmasq process for the network.""" + interface_name = self.device_delegate.get_interface_name(self.network) + cmd = [ + 'NETWORK_ID=%s' % self.network.id, + # TODO (mark): this is dhcpbridge script we'll need to know + # when an IP address has been released + 'dnsmasq', + '--no-hosts', + '--no-resolv', + '--strict-order', + '--bind-interfaces', + '--interface=%s' % interface_name, + '--except-interface=lo', + '--domain=%s' % self.conf.dhcp_domain, + '--pid-file=%s' % self.get_conf_file_name('pid', + ensure_conf_dir=True), + #TODO (mark): calculate value from cidr (defaults to 150) + #'--dhcp-lease-max=%s' % ?, + '--dhcp-hostsfile=%s' % self._output_hosts_file(), + '--dhcp-optsfile=%s' % self._output_opts_file(), + '--leasefile-ro', + ] + + for i, subnet in enumerate(self.network.subnets): + if subnet.ip_version == 4: + mode = 'static' + else: + # TODO (mark): how do we indicate other options + # ra-only, slaac, ra-nameservers, and ra-stateless. + mode = 'static' + cmd.append('--dhcp-range=set:%s,%s,%s,%ss' % + (self._TAG_PREFIX % i, + netaddr.IPNetwork(subnet.cidr).network, + mode, + self.conf.dhcp_lease_time)) + + if self.conf.dnsmasq_config_file: + cmd.append('--conf-file=%s' % self.conf.dnsmasq_config_file) + if self.conf.dnsmasq_dns_server: + cmd.append('--server=%s' % self.conf.dnsmasq_dns_server) + + utils.execute(cmd, self.root_helper) + + def reload_allocations(self): + """Rebuilds the dnsmasq config and signal the dnsmasq to reload.""" + self._output_hosts_file() + self._output_opts_file() + utils.execute(['kill', '-HUP', self.pid], self.root_helper) + LOG.debug(_('Reloading allocations for network: %s') % self.network.id) + + def _output_hosts_file(self): + """Writes a dnsmasq compatible hosts file.""" + r = re.compile('[:.]') + buf = StringIO.StringIO() + + for port in self.network.ports: + for alloc in port.fixed_ips: + name = '%s.%s' % (r.sub('-', alloc.ip_address), + self.conf.dhcp_domain) + buf.write('%s,%s,%s\n' % + (port.mac_address, name, alloc.ip_address)) + + name = self.get_conf_file_name('host') + replace_file(name, buf.getvalue()) + return name + + def _output_opts_file(self): + """Write a dnsmasq compatible options file.""" + # TODO (mark): add support for nameservers + options = [] + for i, subnet in enumerate(self.network.subnets): + if subnet.ip_version == 6: + continue + else: + options.append((self._TAG_PREFIX % i, + 'option', + 'router', + subnet.gateway_ip)) + + name = self.get_conf_file_name('opts') + replace_file(name, '\n'.join(['tag:%s,%s:%s,%s' % o for o in options])) + return name + + +def replace_file(file_name, data): + """Replaces the contents of file_name with data in a safe manner. + + First write to a temp file and then rename. Since POSIX renames are + atomic, the file is unlikely to be corrupted by competing writes. + + We create the tempfile on the same device to ensure that it can be renamed. + """ + + base_dir = os.path.dirname(os.path.abspath(file_name)) + tmp_file = tempfile.NamedTemporaryFile('w+', dir=base_dir, delete=False) + tmp_file.write(data) + tmp_file.close() + os.chmod(tmp_file.name, 0644) + os.rename(tmp_file.name, file_name) diff --git a/quantum/agent/linux/interface.py b/quantum/agent/linux/interface.py new file mode 100644 index 000000000..aed549b75 --- /dev/null +++ b/quantum/agent/linux/interface.py @@ -0,0 +1,174 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import abc +import logging + +import netaddr + +from quantum.agent.linux import ip_lib +from quantum.agent.linux import ovs_lib +from quantum.agent.linux import utils +from quantum.common import exceptions +from quantum.openstack.common import cfg + +LOG = logging.getLogger(__name__) + +OPTS = [ + cfg.StrOpt('ovs_integration_bridge', + default='br-int', + help='Name of Open vSwitch bridge to use'), + cfg.StrOpt('network_device_mtu', + help='MTU setting for device.'), +] + + +class LinuxInterfaceDriver(object): + __metaclass__ = abc.ABCMeta + + # from linux IF_NAMESIZE + DEV_NAME_LEN = 14 + + def __init__(self, conf): + self.conf = conf + + def init_l3(self, port, device_name): + """Set the L3 settings for the interface using data from the port.""" + device = ip_lib.IPDevice(device_name, self.conf.root_helper) + + previous = {} + for address in device.addr.list(scope='global', filters=['permanent']): + previous[address['cidr']] = address['ip_version'] + + # add new addresses + for fixed_ip in port.fixed_ips: + subnet = fixed_ip.subnet + net = netaddr.IPNetwork(subnet.cidr) + ip_cidr = '%s/%s' % (fixed_ip.ip_address, net.prefixlen) + + if ip_cidr in previous: + del previous[ip_cidr] + continue + + device.addr.add(net.version, ip_cidr, str(net.broadcast)) + + # clean up any old addresses + for ip_cidr, ip_version in previous.items(): + device.addr.delete(ip_version, ip_cidr) + + def check_bridge_exists(self, bridge): + if not ip_lib.device_exists(bridge): + raise exception.BridgeDoesNotExist(bridge=bridge) + + @abc.abstractmethod + def plug(self, network_id, port_id, device_name, mac_address): + """Plug in the interface.""" + + @abc.abstractmethod + def unplug(self, device_name): + """Unplug the interface.""" + + +class NullDriver(LinuxInterfaceDriver): + def plug(self, network_id, port_id, device_name, mac_address): + pass + + def unplug(self, device_name): + pass + + +class OVSInterfaceDriver(LinuxInterfaceDriver): + """Driver for creating an OVS interface.""" + + def plug(self, network_id, port_id, device_name, mac_address): + """Plug in the interface.""" + bridge = self.conf.ovs_integration_bridge + + self.check_bridge_exists(bridge) + + if not ip_lib.device_exists(device_name): + utils.execute(['ovs-vsctl', + '--', '--may-exist', 'add-port', bridge, + device_name, + '--', 'set', 'Interface', device_name, + 'type=internal', + '--', 'set', 'Interface', device_name, + 'external-ids:iface-id=%s' % port_id, + '--', 'set', 'Interface', device_name, + 'external-ids:iface-status=active', + '--', 'set', 'Interface', device_name, + 'external-ids:attached-mac=%s' % + mac_address], + self.conf.root_helper) + + device = ip_lib.IPDevice(device_name, self.conf.root_helper) + device.link.set_address(mac_address) + if self.conf.network_device_mtu: + device.link.set_mtu(self.conf.network_device_mtu) + device.link.set_up() + else: + LOG.error(_('Device %s already exists') % device) + + def unplug(self, device_name): + """Unplug the interface.""" + bridge_name = self.conf.ovs_integration_bridge + + self.check_bridge_exists(bridge_name) + bridge = ovs_lib.OVSBridge(bridge_name, self.conf.root_helper) + bridge.delete_port(device_name) + + +class BridgeInterfaceDriver(LinuxInterfaceDriver): + """Driver for creating bridge interfaces.""" + + BRIDGE_NAME_PREFIX = 'brq' + + def plug(self, network_id, port_id, device_name, mac_address): + """Plugin the interface.""" + bridge = self.get_bridge(network_id) + + self.check_bridge_exists(bridge) + + if not ip_lib.device_exists(device_name): + device = ip_lib.IPDevice(device_name, self.conf.root_helper) + try: + # First, try with 'ip' + device.tuntap.add() + except RuntimeError, e: + # Second option: tunctl + utils.execute(['tunctl', '-b', '-t', device_name], + self.conf.root_helper) + + device.link.set_address(mac_address) + device.link.set_up() + else: + LOG.warn(_("Device %s already exists") % device_name) + + def unplug(self, device_name): + """Unplug the interface.""" + device = ip_lib.IPDevice(device_name, self.conf.root_helper) + try: + device.link.delete() + LOG.debug(_("Unplugged interface '%s'") % device_name) + except RuntimeError: + LOG.error(_("Failed unplugging interface '%s'") % + device_name) + + def get_bridge(self, network_id): + """Returns the name of the bridge interface.""" + bridge = self.BRIDGE_NAME_PREFIX + network_id[0:11] + return bridge diff --git a/quantum/agent/linux/ip_lib.py b/quantum/agent/linux/ip_lib.py new file mode 100644 index 000000000..099ae7879 --- /dev/null +++ b/quantum/agent/linux/ip_lib.py @@ -0,0 +1,191 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from quantum.agent.linux import utils + + +class IPDevice(object): + def __init__(self, name, root_helper=None): + self.name = name + self.root_helper = root_helper + self._commands = {} + + self.link = IpLinkCommand(self) + self.tuntap = IpTuntapCommand(self) + self.addr = IpAddrCommand(self) + + def __eq__(self, other): + return self.name == other.name + + @classmethod + def _execute(cls, options, command, args, root_helper=None): + opt_list = ['-%s' % o for o in options] + return utils.execute(['ip'] + opt_list + [command] + list(args), + root_helper=root_helper) + + @classmethod + def get_devices(cls): + retval = [] + for line in cls._execute('o', 'link', ('list',)).split('\n'): + if '<' not in line: + continue + index, name, attrs = line.split(':', 2) + retval.append(IPDevice(name.strip())) + return retval + + +class IpCommandBase(object): + COMMAND = '' + + def __init__(self, parent): + self._parent = parent + + @property + def name(self): + return self._parent.name + + def _run(self, *args, **kwargs): + return self._parent._execute(kwargs.get('options', []), + self.COMMAND, + args) + + def _as_root(self, *args, **kwargs): + if not self._parent.root_helper: + raise exceptions.SudoRequired() + return self._parent._execute(kwargs.get('options', []), + self.COMMAND, + args, + self._parent.root_helper) + + +class IpLinkCommand(IpCommandBase): + COMMAND = 'link' + + def set_address(self, mac_address): + self._as_root('set', self.name, 'address', mac_address) + + def set_mtu(self, mtu_size): + self._as_root('set', self.name, 'mtu', mtu_size) + + def set_up(self): + self._as_root('set', self.name, 'up') + + def set_down(self): + self._as_root('set', self.name, 'down') + + def delete(self): + self._as_root('delete', self.name) + + @property + def address(self): + return self.attributes.get('link/ether') + + @property + def state(self): + return self.attributes.get('state') + + @property + def mtu(self): + return self.attributes.get('mtu') + + @property + def qdisc(self): + return self.attributes.get('qdisc') + + @property + def qlen(self): + return self.attributes.get('qlen') + + @property + def attributes(self): + return self._parse_line(self._run('show', self.name, options='o')) + + def _parse_line(self, value): + device_name, settings = value.replace("\\", '').split('>', 1) + + tokens = settings.split() + keys = tokens[::2] + values = [int(v) if v.isdigit() else v for v in tokens[1::2]] + + retval = dict(zip(keys, values)) + return retval + + +class IpTuntapCommand(IpCommandBase): + COMMAND = 'tuntap' + + def add(self): + self._as_root('add', self.name, 'mode', 'tap') + + +class IpAddrCommand(IpCommandBase): + COMMAND = 'addr' + + def add(self, ip_version, cidr, broadcast, scope='global'): + self._as_root('add', + cidr, + 'brd', + broadcast, + 'scope', + scope, + 'dev', + self.name, + options=[ip_version]) + + def delete(self, ip_version, cidr): + self._as_root('del', + cidr, + 'dev', + self.name, + options=[ip_version]) + + def flush(self): + self._as_root('flush', self.name) + + def list(self, scope=None, to=None, filters=[]): + retval = [] + + if scope: + filters += ['scope', scope] + if to: + filters += ['to', to] + + for line in self._run('show', self.name, *filters).split('\n'): + line = line.strip() + if not line.startswith('inet'): + continue + parts = line.split() + if parts[0] == 'inet6': + version = 6 + scope = parts[3] + else: + version = 4 + scope = parts[5] + + retval.append(dict(cidr=parts[1], + scope=scope, + ip_version=version, + dynamic=('dynamic' == parts[-1]))) + return retval + + +def device_exists(device_name): + try: + address = IPDevice(device_name).link.address + except RuntimeError: + return False + + return True diff --git a/quantum/common/exceptions.py b/quantum/common/exceptions.py index 18df32071..a00cf8ee0 100644 --- a/quantum/common/exceptions.py +++ b/quantum/common/exceptions.py @@ -156,3 +156,15 @@ class MacAddressGenerationFailure(QuantumException): class IpAddressGenerationFailure(QuantumException): message = _("No more IP addresses available on network %(net_id)s.") + + +class BridgeDoesNotExist(QuantumException): + message = _("Bridge %(bridge)s does not exist.") + + +class PreexistingDeviceFailure(QuantumException): + message = _("Creation failed. %(dev_name)s already exists.") + + +class SudoRequired(QuantumException): + message = _("Sudo priviledge is required to run this command.") diff --git a/quantum/rootwrap/dhcp-agent.py b/quantum/rootwrap/dhcp-agent.py new file mode 100644 index 000000000..2ba63a176 --- /dev/null +++ b/quantum/rootwrap/dhcp-agent.py @@ -0,0 +1,26 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2012 Openstack, LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + + +from quantum.rootwrap import filters + +filterlist = [ + # quantum/agent/linux/dhcp.py: + # "dnsmasq", "--no-hosts", ... + filters.CommandFilter("/usr/sbin/dnsmasq", "root"), + filters.KillFilter("/bin/kill", "root", [''], ['/usr/sbin/dnsmasq']), +] diff --git a/quantum/tests/unit/test_agent_config.py b/quantum/tests/unit/test_agent_config.py new file mode 100644 index 000000000..73e10ed01 --- /dev/null +++ b/quantum/tests/unit/test_agent_config.py @@ -0,0 +1,23 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from quantum.agent.common import config + + +def test_setup_conf(): + conf = config.setup_conf() + assert conf.state_path.endswith('/var/lib/quantum') diff --git a/quantum/tests/unit/test_agent_utils.py b/quantum/tests/unit/test_agent_utils.py index 5859f1f4b..73053890a 100644 --- a/quantum/tests/unit/test_agent_utils.py +++ b/quantum/tests/unit/test_agent_utils.py @@ -17,6 +17,8 @@ import unittest +import mock + from quantum.agent.linux import utils diff --git a/quantum/tests/unit/test_dhcp_agent.py b/quantum/tests/unit/test_dhcp_agent.py new file mode 100644 index 000000000..9255b4c54 --- /dev/null +++ b/quantum/tests/unit/test_dhcp_agent.py @@ -0,0 +1,318 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import unittest + +import mock +from sqlalchemy.ext import sqlsoup + +from quantum.agent import dhcp_agent +from quantum.agent.common import config +from quantum.agent.linux import interface + + +class FakeModel: + def __init__(self, id_, **kwargs): + self.id = id_ + self.__dict__.update(kwargs) + + def __str__(self): + return str(self.__dict__) + + +class TestDhcpAgent(unittest.TestCase): + def setUp(self): + self.conf = config.setup_conf() + self.conf.register_opts(dhcp_agent.DhcpAgent.OPTS) + self.driver_cls_p = mock.patch( + 'quantum.agent.dhcp_agent.importutils.import_class') + self.driver = mock.Mock(name='driver') + self.driver_cls = self.driver_cls_p.start() + self.driver_cls.return_value = self.driver + self.dhcp = dhcp_agent.DhcpAgent(self.conf) + self.dhcp.polling_interval = 0 + + def tearDown(self): + self.driver_cls_p.stop() + + def test_daemon_loop_survives_get_network_state_delta_failure(self): + def stop_loop(*args): + self.dhcp._run = False + return None + + with mock.patch.object(self.dhcp, 'get_network_state_delta') as state: + state.side_effect = stop_loop + self.dhcp.daemon_loop() + + def test_daemon_loop_completes_single_pass(self): + with mock.patch.object(self.dhcp, 'get_network_state_delta') as state: + with mock.patch.object(self.dhcp, 'call_driver') as call_driver: + with mock.patch('quantum.agent.dhcp_agent.time') as time: + time.sleep = mock.Mock(side_effect=RuntimeError('stop')) + state.return_value = dict(new=['new_net'], + updated=['updated_net'], + deleted=['deleted_net']) + + self.assertRaises(RuntimeError, self.dhcp.daemon_loop) + call_driver.assert_has_calls( + [mock.call('enable', 'new_net'), + mock.call('reload_allocations', 'updated_net'), + mock.call('disable', 'deleted_net')]) + + def test_state_builder(self): + fake_subnet = [ + FakeModel(1, network_id=1), + FakeModel(2, network_id=2), + ] + + fake_allocation = [ + FakeModel(2, subnet_id=1) + ] + + db = mock.Mock() + db.subnets.all = mock.Mock(return_value=fake_subnet) + db.ipallocations.all = mock.Mock(return_value=fake_allocation) + self.dhcp.db = db + state = self.dhcp._state_builder() + + self.assertEquals(state.networks, set([1, 2])) + + expected_subnets = set([ + (hash(str(fake_subnet[0])), 1), + (hash(str(fake_subnet[1])), 2) + ]) + self.assertEquals(state.subnet_hashes, expected_subnets) + + expected_ipalloc = set([ + (hash(str(fake_allocation[0])), 1), + ]) + self.assertEquals(state.ipalloc_hashes, expected_ipalloc) + + def _network_state_helper(self, before, after): + with mock.patch.object(self.dhcp, '_state_builder') as state_builder: + state_builder.return_value = after + self.dhcp.prev_state = before + return self.dhcp.get_network_state_delta() + + def test_get_network_state_fresh(self): + new_state = dhcp_agent.State(set([1]), set([(3, 1)]), set([(11, 1)])) + + delta = self._network_state_helper(self.dhcp.prev_state, new_state) + self.assertEqual(delta, + dict(new=set([1]), deleted=set(), updated=set())) + + def test_get_network_state_new_subnet_on_known_network(self): + prev_state = dhcp_agent.State(set([1]), set([(3, 1)]), set([(11, 1)])) + new_state = dhcp_agent.State(set([1]), + set([(3, 1), (4, 1)]), + set([(11, 1)])) + + delta = self._network_state_helper(prev_state, new_state) + self.assertEqual(delta, + dict(new=set(), deleted=set(), updated=set([1]))) + + def test_get_network_state_new_ipallocation(self): + prev_state = dhcp_agent.State(set([1]), + set([(3, 1)]), + set([(11, 1)])) + new_state = dhcp_agent.State(set([1]), + set([(3, 1)]), + set([(11, 1), (12, 1)])) + + delta = self._network_state_helper(prev_state, new_state) + self.assertEqual(delta, + dict(new=set(), deleted=set(), updated=set([1]))) + + def test_get_network_state_delete_subnet_on_known_network(self): + prev_state = dhcp_agent.State(set([1]), + set([(3, 1), (4, 1)]), + set([(11, 1)])) + new_state = dhcp_agent.State(set([1]), + set([(3, 1)]), + set([(11, 1)])) + + delta = self._network_state_helper(prev_state, new_state) + self.assertEqual(delta, + dict(new=set(), deleted=set(), updated=set([1]))) + + def test_get_network_state_deleted_ipallocation(self): + prev_state = dhcp_agent.State(set([1]), + set([(3, 1)]), + set([(11, 1), (12, 1)])) + new_state = dhcp_agent.State(set([1]), + set([(3, 1)]), + set([(11, 1)])) + + delta = self._network_state_helper(prev_state, new_state) + self.assertEqual(delta, + dict(new=set(), deleted=set(), updated=set([1]))) + + def test_get_network_state_deleted_network(self): + prev_state = dhcp_agent.State(set([1]), + set([(3, 1)]), + set([(11, 1), (12, 1)])) + new_state = dhcp_agent.State(set(), set(), set()) + + delta = self._network_state_helper(prev_state, new_state) + self.assertEqual(delta, + dict(new=set(), deleted=set([1]), updated=set())) + + def test_get_network_state_changed_subnet_and_deleted_network(self): + prev_state = dhcp_agent.State(set([1, 2]), + set([(3, 1), (2, 2)]), + set([(11, 1), (12, 1)])) + new_state = dhcp_agent.State(set([1]), + set([(4, 1)]), + set([(11, 1), (12, 1)])) + + delta = self._network_state_helper(prev_state, new_state) + self.assertEqual(delta, + dict(new=set(), deleted=set([2]), updated=set([1]))) + + def test_call_driver(self): + with mock.patch.object(self.dhcp, 'db') as db: + db.networks = mock.Mock() + db.networks.filter_by = mock.Mock( + return_value=mock.Mock(return_value=FakeModel('1'))) + with mock.patch.object(dhcp_agent, 'DeviceManager') as dev_mgr: + self.dhcp.call_driver('foo', '1') + dev_mgr.assert_called() + self.driver.assert_called_once_with(self.conf, + mock.ANY, + 'sudo', + mock.ANY) + + +class TestDeviceManager(unittest.TestCase): + def setUp(self): + self.conf = config.setup_conf() + self.conf.register_opts(dhcp_agent.DeviceManager.OPTS) + self.conf.set_override('interface_driver', + 'quantum.agent.linux.interface.NullDriver') + + self.client_cls_p = mock.patch('quantumclient.v2_0.client.Client') + client_cls = self.client_cls_p.start() + self.client_inst = mock.Mock() + client_cls.return_value = self.client_inst + + self.device_exists_p = mock.patch( + 'quantum.agent.linux.ip_lib.device_exists') + self.device_exists = self.device_exists_p.start() + + self.dvr_cls_p = mock.patch('quantum.agent.linux.interface.NullDriver') + driver_cls = self.dvr_cls_p.start() + self.mock_driver = mock.MagicMock() + self.mock_driver.DEV_NAME_LEN = ( + interface.LinuxInterfaceDriver.DEV_NAME_LEN) + driver_cls.return_value = self.mock_driver + + def tearDown(self): + self.dvr_cls_p.stop() + self.device_exists_p.stop() + self.client_cls_p.stop() + + def test_setup(self): + fake_subnets = [FakeModel('12345678-aaaa-aaaa-1234567890ab'), + FakeModel('12345678-bbbb-bbbb-1234567890ab')] + + fake_network = FakeModel('12345678-1234-5678-1234567890ab', + tenant_id='aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa', + subnets=fake_subnets) + + fake_port = FakeModel('12345678-aaaa-aaaa-1234567890ab', + mac_address='aa:bb:cc:dd:ee:ff') + + port_dict = dict(mac_address='aa:bb:cc:dd:ee:ff', allocations=[], id=1) + + self.client_inst.create_port.return_value = dict(port=port_dict) + self.device_exists.return_value = False + + # fake the db + filter_by_result = mock.Mock() + filter_by_result.one = mock.Mock(return_value=fake_port) + + self.filter_called = False + + def get_filter_results(*args, **kwargs): + if self.filter_called: + return filter_by_result + else: + self.filter_called = True + raise sqlsoup.SQLAlchemyError() + + return filter_results.pop(0) + + mock_db = mock.Mock() + mock_db.ports = mock.Mock(name='ports2') + mock_db.ports.filter_by = mock.Mock( + name='filter_by', + side_effect=get_filter_results) + + dh = dhcp_agent.DeviceManager(self.conf, mock_db) + dh.setup(fake_network) + + self.client_inst.assert_has_calls([ + mock.call.create_port(mock.ANY)]) + + self.mock_driver.assert_has_calls([ + mock.call.plug('12345678-1234-5678-1234567890ab', + '12345678-aaaa-aaaa-1234567890ab', + 'tap12345678-12', + 'aa:bb:cc:dd:ee:ff'), + mock.call.init_l3(mock.ANY, 'tap12345678-12')] + ) + + def test_destroy(self): + fake_subnets = [FakeModel('12345678-aaaa-aaaa-1234567890ab'), + FakeModel('12345678-bbbb-bbbb-1234567890ab')] + + fake_network = FakeModel('12345678-1234-5678-1234567890ab', + tenant_id='aaaaaaaa-aaaa-aaaa-aaaaaaaaaaaa', + subnets=fake_subnets) + + with mock.patch('quantum.agent.linux.interface.NullDriver') as dvr_cls: + mock_driver = mock.MagicMock() + mock_driver.DEV_NAME_LEN = ( + interface.LinuxInterfaceDriver.DEV_NAME_LEN) + dvr_cls.return_value = mock_driver + + dh = dhcp_agent.DeviceManager(self.conf, None) + dh.destroy(fake_network) + + dvr_cls.assert_called_once_with(self.conf) + mock_driver.assert_has_calls( + [mock.call.unplug('tap12345678-12')]) + + +class TestAugmentingWrapper(unittest.TestCase): + def test_simple_wrap(self): + net = mock.Mock() + db = mock.Mock() + net.name = 'foo' + wrapped = dhcp_agent.AugmentingWrapper(net, db) + self.assertEqual(wrapped.name, 'foo') + self.assertEqual(repr(net), repr(wrapped)) + + +def test_dhcp_agent_main(): + with mock.patch('quantum.agent.dhcp_agent.DeviceManager') as dev_mgr: + with mock.patch('quantum.agent.dhcp_agent.DhcpAgent') as dhcp: + dhcp_agent.main() + dev_mgr.assert_called_once(mock.ANY, 'sudo') + dhcp.assert_has_calls([ + mock.call(mock.ANY), + mock.call().daemon_loop()]) diff --git a/quantum/tests/unit/test_linux_dhcp.py b/quantum/tests/unit/test_linux_dhcp.py new file mode 100644 index 000000000..37b0dbb06 --- /dev/null +++ b/quantum/tests/unit/test_linux_dhcp.py @@ -0,0 +1,376 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import tempfile +import unittest2 as unittest + +import mock + +from quantum.agent.linux import dhcp +from quantum.agent.common import config +from quantum.openstack.common import cfg + + +class FakeIPAllocation: + def __init__(self, address): + self.ip_address = address + + +class FakePort1: + id = 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee' + admin_state_up = True + fixed_ips = [FakeIPAllocation('192.168.0.2')] + mac_address = '00:00:80:aa:bb:cc' + + +class FakePort2: + id = 'ffffffff-ffff-ffff-ffff-ffffffffffff' + admin_state_up = False + fixed_ips = [FakeIPAllocation('fdca:3ba5:a17a:4ba3::2')] + mac_address = '00:00:f3:aa:bb:cc' + + +class FakePort3: + id = '44444444-4444-4444-4444-444444444444' + admin_state_up = True + fixed_ips = [FakeIPAllocation('192.168.0.3'), + FakeIPAllocation('fdca:3ba5:a17a:4ba3::3')] + mac_address = '00:00:0f:aa:bb:cc' + + +class FakeV4Subnet: + id = 'dddddddd-dddd-dddd-dddd-dddddddddddd' + ip_version = 4 + cidr = '192.168.0.0/24' + gateway_ip = '192.168.0.1' + + +class FakeV6Subnet: + id = 'ffffffff-ffff-ffff-ffff-ffffffffffff' + ip_version = 6 + cidr = 'fdca:3ba5:a17a:4ba3::/64' + gateway_ip = 'fdca:3ba5:a17a:4ba3::1' + + +class FakeV4Network: + id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + subnets = [FakeV4Subnet()] + ports = [FakePort1()] + + +class FakeV6Network: + id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' + subnets = [FakeV6Subnet()] + ports = [FakePort2()] + + +class FakeDualNetwork: + id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' + subnets = [FakeV4Subnet(), FakeV6Subnet()] + ports = [FakePort1(), FakePort2(), FakePort3()] + + +class TestDhcpBase(unittest.TestCase): + def test_base_abc_error(self): + self.assertRaises(TypeError, dhcp.DhcpBase, None) + + def test_replace_file(self): + # make file to replace + with mock.patch('tempfile.NamedTemporaryFile') as ntf: + ntf.return_value.name = '/baz' + with mock.patch('os.chmod') as chmod: + with mock.patch('os.rename') as rename: + dhcp.replace_file('/foo', 'bar') + + expected = [mock.call('w+', dir='/', delete=False), + mock.call().write('bar'), + mock.call().close()] + + ntf.assert_has_calls(expected) + chmod.assert_called_once_with('/baz', 0644) + rename.assert_called_once_with('/baz', '/foo') + + def test_restart(self): + class SubClass(dhcp.DhcpBase): + def __init__(self): + dhcp.DhcpBase.__init__(self, None, None) + self.called = [] + + def enable(self): + self.called.append('enable') + + def disable(self): + self.called.append('disable') + + def reload_allocations(self): + pass + + @property + def active(self): + return True + + c = SubClass() + c.restart() + self.assertEquals(c.called, ['disable', 'enable']) + + +class LocalChild(dhcp.DhcpLocalProcess): + PORTS = {4: [4], 6: [6]} + + def __init__(self, *args, **kwargs): + super(LocalChild, self).__init__(*args, **kwargs) + self.called = [] + + def reload_allocations(self): + self.called.append('reload') + + def spawn_process(self): + self.called.append('spawn') + + +class TestBase(unittest.TestCase): + def setUp(self): + root = os.path.dirname(os.path.dirname(__file__)) + args = ['--config-file', + os.path.join(root, 'etc', 'quantum.conf.test')] + self.conf = config.setup_conf() + self.conf.register_opts(dhcp.OPTS) + self.conf(args=args) + self.conf.set_override('state_path', '') + + self.replace_p = mock.patch('quantum.agent.linux.dhcp.replace_file') + self.execute_p = mock.patch('quantum.agent.linux.utils.execute') + self.safe = self.replace_p.start() + self.execute = self.execute_p.start() + + def tearDown(self): + self.execute_p.stop() + self.replace_p.stop() + + +class TestDhcpLocalProcess(TestBase): + def test_active(self): + dummy_cmd_line = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + self.execute.return_value = (dummy_cmd_line, '') + with mock.patch.object(LocalChild, 'pid') as pid: + pid.__get__ = mock.Mock(return_value=4) + lp = LocalChild(self.conf, FakeV4Network()) + self.assertTrue(lp.active) + self.execute.assert_called_once_with(['cat', '/proc/4/cmdline'], + 'sudo') + + def test_active_cmd_mismatch(self): + dummy_cmd_line = 'bbbbbbbb-bbbb-bbbb-aaaa-aaaaaaaaaaaa' + self.execute.return_value = (dummy_cmd_line, '') + with mock.patch.object(LocalChild, 'pid') as pid: + pid.__get__ = mock.Mock(return_value=4) + lp = LocalChild(self.conf, FakeV4Network()) + self.assertFalse(lp.active) + self.execute.assert_called_once_with(['cat', '/proc/4/cmdline'], + 'sudo') + + def test_get_conf_file_name(self): + tpl = '/dhcp/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/dev' + with mock.patch('os.path.isdir') as isdir: + isdir.return_value = False + with mock.patch('os.makedirs') as makedirs: + lp = LocalChild(self.conf, FakeV4Network()) + self.assertEqual(lp.get_conf_file_name('dev'), tpl) + self.assertFalse(makedirs.called) + + def test_get_conf_file_name_ensure_dir(self): + tpl = '/dhcp/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/dev' + with mock.patch('os.path.isdir') as isdir: + isdir.return_value = False + with mock.patch('os.makedirs') as makedirs: + lp = LocalChild(self.conf, FakeV4Network()) + self.assertEqual(lp.get_conf_file_name('dev', True), tpl) + self.assertTrue(makedirs.called) + + def test_enable_already_active(self): + with mock.patch.object(LocalChild, 'active') as patched: + patched.__get__ = mock.Mock(return_value=True) + lp = LocalChild(self.conf, FakeV4Network()) + lp.enable() + + self.assertEqual(lp.called, ['reload']) + + def test_enable(self): + delegate = mock.Mock(return_value='tap0') + attrs_to_mock = dict( + [(a, mock.DEFAULT) for a in + ['active', 'get_conf_file_name']] + ) + + with mock.patch.multiple(LocalChild, **attrs_to_mock) as mocks: + mocks['active'].__get__ = mock.Mock(return_value=False) + mocks['get_conf_file_name'].return_value = '/dir' + lp = LocalChild(self.conf, + FakeDualNetwork(), + device_delegate=delegate) + lp.enable() + + delegate.assert_has_calls( + [mock.call.setup(mock.ANY, reuse_existing=True)]) + self.assertEqual(lp.called, ['spawn']) + + def test_disable_not_active(self): + attrs_to_mock = dict([(a, mock.DEFAULT) for a in ['active', 'pid']]) + with mock.patch.multiple(LocalChild, **attrs_to_mock) as mocks: + mocks['active'].__get__ = mock.Mock(return_value=False) + mocks['pid'].__get__ = mock.Mock(return_value=5) + with mock.patch.object(dhcp.LOG, 'debug') as log: + lp = LocalChild(self.conf, FakeDualNetwork()) + lp.disable() + msg = log.call_args[0][0] + self.assertIn('stale', msg) + + def test_disable_unknown_network(self): + attrs_to_mock = dict([(a, mock.DEFAULT) for a in ['active', 'pid']]) + with mock.patch.multiple(LocalChild, **attrs_to_mock) as mocks: + mocks['active'].__get__ = mock.Mock(return_value=False) + mocks['pid'].__get__ = mock.Mock(return_value=None) + with mock.patch.object(dhcp.LOG, 'debug') as log: + lp = LocalChild(self.conf, FakeDualNetwork()) + lp.disable() + msg = log.call_args[0][0] + self.assertIn('No DHCP', msg) + + def test_disable(self): + attrs_to_mock = dict([(a, mock.DEFAULT) for a in + ['active', 'pid']]) + delegate = mock.Mock() + delegate.intreface_name = 'tap0' + network = FakeDualNetwork() + with mock.patch.multiple(LocalChild, **attrs_to_mock) as mocks: + mocks['active'].__get__ = mock.Mock(return_value=True) + mocks['pid'].__get__ = mock.Mock(return_value=5) + lp = LocalChild(self.conf, network, device_delegate=delegate) + lp.disable() + + delegate.assert_has_calls([mock.call.destroy(network)]) + self.execute.assert_called_once_with(['kill', '-9', 5], 'sudo') + + def test_pid(self): + with mock.patch('__builtin__.open') as mock_open: + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = mock.Mock() + mock_open.return_value.read.return_value = '5' + lp = LocalChild(self.conf, FakeDualNetwork()) + self.assertEqual(lp.pid, 5) + + def test_pid_no_an_int(self): + with mock.patch('__builtin__.open') as mock_open: + mock_open.return_value.__enter__ = lambda s: s + mock_open.return_value.__exit__ = mock.Mock() + mock_open.return_value.read.return_value = 'foo' + lp = LocalChild(self.conf, FakeDualNetwork()) + self.assertIsNone(lp.pid) + + def test_pid_invalid_file(self): + with mock.patch.object(LocalChild, 'get_conf_file_name') as conf_file: + conf_file.return_value = '.doesnotexist/pid' + lp = LocalChild(self.conf, FakeDualNetwork()) + self.assertIsNone(lp.pid) + + +class TestDnsmasq(TestBase): + def _test_spawn(self, extra_options): + def mock_get_conf_file_name(kind, ensure_conf_dir=False): + return '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/%s' % kind + + expected = [ + 'NETWORK_ID=cccccccc-cccc-cccc-cccc-cccccccccccc', + 'dnsmasq', + '--no-hosts', + '--no-resolv', + '--strict-order', + '--bind-interfaces', + '--interface=tap0', + '--except-interface=lo', + '--domain=openstacklocal', + '--pid-file=/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/pid', + '--dhcp-hostsfile=/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/host', + '--dhcp-optsfile=/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/opts', + '--leasefile-ro', + '--dhcp-range=set:tag0,192.168.0.0,static,120s', + '--dhcp-range=set:tag1,fdca:3ba5:a17a:4ba3::,static,120s' + ] + expected.extend(extra_options) + + self.execute.return_value = ('', '') + delegate = mock.Mock() + delegate.get_interface_name.return_value = 'tap0' + + attrs_to_mock = dict( + [(a, mock.DEFAULT) for a in + ['_output_opts_file', 'get_conf_file_name']] + ) + + with mock.patch.multiple(dhcp.Dnsmasq, **attrs_to_mock) as mocks: + mocks['get_conf_file_name'].side_effect = mock_get_conf_file_name + mocks['_output_opts_file'].return_value = ( + '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/opts' + ) + dm = dhcp.Dnsmasq(self.conf, FakeDualNetwork(), + device_delegate=delegate) + dm.spawn_process() + self.assertTrue(mocks['_output_opts_file'].called) + self.execute.assert_called_once_with(expected, 'sudo') + + def test_spawn(self): + self._test_spawn([]) + + def test_spawn_cfg_config_file(self): + self.conf.set_override('dnsmasq_config_file', '/foo') + self._test_spawn(['--conf-file=/foo']) + + def test_spawn_cfg_dns_server(self): + self.conf.set_override('dnsmasq_dns_server', '8.8.8.8') + self._test_spawn(['--server=8.8.8.8']) + + def test_output_opts_file(self): + expected = 'tag:tag0,option:router,192.168.0.1' + with mock.patch.object(dhcp.Dnsmasq, 'get_conf_file_name') as conf_fn: + conf_fn.return_value = '/foo/opts' + dm = dhcp.Dnsmasq(self.conf, FakeDualNetwork()) + dm._output_opts_file() + + self.safe.assert_called_once_with('/foo/opts', expected) + + def test_reload_allocations(self): + exp_host_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/host' + exp_host_data = """ +00:00:80:aa:bb:cc,192-168-0-2.openstacklocal,192.168.0.2 +00:00:f3:aa:bb:cc,fdca-3ba5-a17a-4ba3--2.openstacklocal,fdca:3ba5:a17a:4ba3::2 +00:00:0f:aa:bb:cc,192-168-0-3.openstacklocal,192.168.0.3 +00:00:0f:aa:bb:cc,fdca-3ba5-a17a-4ba3--3.openstacklocal,fdca:3ba5:a17a:4ba3::3 +""".lstrip() + exp_opt_name = '/dhcp/cccccccc-cccc-cccc-cccc-cccccccccccc/opts' + exp_opt_data = "tag:tag0,option:router,192.168.0.1" + + with mock.patch('os.path.isdir') as isdir: + isdir.return_value = True + with mock.patch.object(dhcp.Dnsmasq, 'pid') as pid: + pid.__get__ = mock.Mock(return_value=5) + dm = dhcp.Dnsmasq(self.conf, FakeDualNetwork()) + dm.reload_allocations() + + self.safe.assert_has_calls([mock.call(exp_host_name, exp_host_data), + mock.call(exp_opt_name, exp_opt_data)]) + self.execute.assert_called_once_with(['kill', '-HUP', 5], 'sudo') diff --git a/quantum/tests/unit/test_linux_interface.py b/quantum/tests/unit/test_linux_interface.py new file mode 100644 index 000000000..07591bed9 --- /dev/null +++ b/quantum/tests/unit/test_linux_interface.py @@ -0,0 +1,224 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import unittest + +import mock + +from quantum.agent.common import config +from quantum.agent.linux import interface +from quantum.agent.linux import ip_lib +from quantum.agent.linux import utils +from quantum.openstack.common import cfg + + +class BaseChild(interface.LinuxInterfaceDriver): + def plug(*args): + pass + + def unplug(*args): + pass + + +class FakeSubnet: + cidr = '192.168.1.1/24' + + +class FakeAllocation: + subnet = FakeSubnet() + ip_address = '192.168.1.2' + ip_version = 4 + + +class FakePort(object): + fixed_ips = [FakeAllocation] + device_id = 'cccccccc-cccc-cccc-cccc-cccccccccccc' + + +class TestBase(unittest.TestCase): + def setUp(self): + root_helper_opt = [ + cfg.StrOpt('root_helper', default='sudo'), + ] + self.conf = config.setup_conf() + self.conf.register_opts(interface.OPTS) + self.conf.register_opts(root_helper_opt) + self.ip_dev_p = mock.patch.object(ip_lib, 'IPDevice') + self.ip_dev = self.ip_dev_p.start() + self.device_exists_p = mock.patch.object(ip_lib, 'device_exists') + self.device_exists = self.device_exists_p.start() + + def tearDown(self): + # sometimes a test may turn this off + try: + self.device_exists_p.stop() + except RuntimeError, e: + pass + self.ip_dev_p.stop() + + +class TestABCDriver(TestBase): + def test_l3_init(self): + addresses = [dict(ip_version=4, scope='global', + dynamic=False, cidr='172.16.77.240/24')] + self.ip_dev().addr.list = mock.Mock(return_value=addresses) + + bc = BaseChild(self.conf) + bc.init_l3(FakePort(), 'tap0') + self.ip_dev.assert_has_calls( + [mock.call('tap0', 'sudo'), + mock.call().addr.list(scope='global', filters=['permanent']), + mock.call().addr.add(4, '192.168.1.2/24', '192.168.1.255'), + mock.call().addr.delete(4, '172.16.77.240/24')]) + + +class TestOVSInterfaceDriver(TestBase): + def test_plug(self, additional_expectation=[]): + def device_exists(dev, root_helper=None): + return dev == 'br-int' + + vsctl_cmd = ['ovs-vsctl', '--', '--may-exist', 'add-port', + 'br-int', 'tap0', '--', 'set', 'Interface', 'tap0', + 'type=internal', '--', 'set', 'Interface', 'tap0', + 'external-ids:iface-id=port-1234', '--', 'set', + 'Interface', 'tap0', + 'external-ids:iface-status=active', '--', 'set', + 'Interface', 'tap0', + 'external-ids:attached-mac=aa:bb:cc:dd:ee:ff'] + + with mock.patch.object(utils, 'execute') as execute: + ovs = interface.OVSInterfaceDriver(self.conf) + self.device_exists.side_effect = device_exists + ovs.plug('01234567-1234-1234-99', + 'port-1234', + 'tap0', + 'aa:bb:cc:dd:ee:ff') + execute.assert_called_once_with(vsctl_cmd, 'sudo') + + expected = [mock.call('tap0', 'sudo'), + mock.call().link.set_address('aa:bb:cc:dd:ee:ff')] + + expected.extend(additional_expectation) + expected.append(mock.call().link.set_up()) + self.ip_dev.assert_has_calls(expected) + + def test_plug_mtu(self): + self.conf.set_override('network_device_mtu', 9000) + self.test_plug([mock.call().link.set_mtu(9000)]) + + def test_unplug(self): + with mock.patch('quantum.agent.linux.ovs_lib.OVSBridge') as ovs_br: + ovs = interface.OVSInterfaceDriver(self.conf) + ovs.unplug('tap0') + ovs_br.assert_has_calls([mock.call('br-int', 'sudo'), + mock.call().delete_port('tap0')]) + + +class TestBridgeInterfaceDriver(TestBase): + def test_get_bridge(self): + br = interface.BridgeInterfaceDriver(self.conf) + self.assertEqual('brq12345678-11', br.get_bridge('12345678-1122-3344')) + + def test_plug(self): + def device_exists(device, root_helper=None): + return device.startswith('brq') + + expected = [mock.call(c, 'sudo') for c in [ + ['ip', 'tuntap', 'add', 'tap0', 'mode', 'tap'], + ['ip', 'link', 'set', 'tap0', 'address', 'aa:bb:cc:dd:ee:ff'], + ['ip', 'link', 'set', 'tap0', 'up']] + ] + + self.device_exists.side_effect = device_exists + br = interface.BridgeInterfaceDriver(self.conf) + br.plug('01234567-1234-1234-99', + 'port-1234', + 'tap0', + 'aa:bb:cc:dd:ee:ff') + + self.ip_dev.assert_has_calls( + [mock.call('tap0', 'sudo'), + mock.call().tuntap.add(), + mock.call().link.set_address('aa:bb:cc:dd:ee:ff'), + mock.call().link.set_up()]) + + def test_plug_dev_exists(self): + self.device_exists.return_value = True + with mock.patch('quantum.agent.linux.interface.LOG.warn') as log: + br = interface.BridgeInterfaceDriver(self.conf) + br.plug('01234567-1234-1234-99', + 'port-1234', + 'tap0', + 'aa:bb:cc:dd:ee:ff') + self.ip_dev.assert_has_calls([]) + self.assertEquals(log.call_count, 1) + + def test_tunctl_failback(self): + def device_exists(dev, root_helper=None): + return dev.startswith('brq') + + expected = [mock.call(c, 'sudo') for c in [ + ['ip', 'tuntap', 'add', 'tap0', 'mode', 'tap'], + ['tunctl', '-b', '-t', 'tap0'], + ['ip', 'link', 'set', 'tap0', 'address', 'aa:bb:cc:dd:ee:ff'], + ['ip', 'link', 'set', 'tap0', 'up']] + ] + + self.device_exists.side_effect = device_exists + self.ip_dev().tuntap.add.side_effect = RuntimeError + self.ip_dev.reset_calls() + with mock.patch.object(utils, 'execute') as execute: + br = interface.BridgeInterfaceDriver(self.conf) + br.plug('01234567-1234-1234-99', + 'port-1234', + 'tap0', + 'aa:bb:cc:dd:ee:ff') + execute.assert_called_once_with(['tunctl', '-b', '-t', 'tap0'], + 'sudo') + self.ip_dev.assert_has_calls( + [mock.call('tap0', 'sudo'), + mock.call().tuntap.add(), + mock.call().link.set_address('aa:bb:cc:dd:ee:ff'), + mock.call().link.set_up()]) + + def test_unplug(self): + self.device_exists.return_value = True + with mock.patch('quantum.agent.linux.interface.LOG.debug') as log: + br = interface.BridgeInterfaceDriver(self.conf) + br.unplug('tap0') + log.assert_called_once() + self.execute.assert_has_calls( + [mock.call(['ip', 'link', 'delete', 'tap0'], 'sudo')]) + + def test_unplug_no_device(self): + self.device_exists.return_value = False + self.ip_dev().link.delete.side_effect = RuntimeError + with mock.patch('quantum.agent.linux.interface.LOG') as log: + br = interface.BridgeInterfaceDriver(self.conf) + br.unplug('tap0') + [mock.call(), mock.call('tap0', 'sudo'), mock.call().link.delete()] + self.assertEqual(log.error.call_count, 1) + + def test_unplug(self): + self.device_exists.return_value = True + with mock.patch('quantum.agent.linux.interface.LOG.debug') as log: + br = interface.BridgeInterfaceDriver(self.conf) + br.unplug('tap0') + log.assert_called_once() + + self.ip_dev.assert_has_calls([mock.call('tap0', 'sudo'), + mock.call().link.delete()]) diff --git a/quantum/tests/unit/test_linux_ip_lib.py b/quantum/tests/unit/test_linux_ip_lib.py new file mode 100644 index 000000000..13617fd8e --- /dev/null +++ b/quantum/tests/unit/test_linux_ip_lib.py @@ -0,0 +1,274 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 OpenStack LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import unittest + +import mock + +from quantum.agent.linux import ip_lib +from quantum.agent.linux import utils + + +LINK_SAMPLE = [ + '1: lo: mtu 16436 qdisc noqueue state UNKNOWN \\' + 'link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00', + '2: eth0: mtu 1500 qdisc mq state UP ' + 'qlen 1000\ link/ether cc:dd:ee:ff:ab:cd brd ff:ff:ff:ff:ff:ff', + '3: br-int: mtu 1500 qdisc noop state DOWN ' + '\ link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff', + '4: gw-ddc717df-49: mtu 1500 qdisc noop ' + 'state DOWN \ link/ether fe:dc:ba:fe:dc:ba brd ff:ff:ff:ff:ff:ff'] + +ADDR_SAMPLE = (""" +2: eth0: mtu 1500 qdisc mq state UP qlen 1000 + link/ether dd:cc:aa:b9:76:ce brd ff:ff:ff:ff:ff:ff + inet 172.16.77.240/24 brd 172.16.77.255 scope global eth0 + inet6 2001:470:9:1224:5595:dd51:6ba2:e788/64 scope global temporary dynamic + valid_lft 14187sec preferred_lft 3387sec + inet6 2001:470:9:1224:fd91:272:581e:3a32/64 scope global temporary """ + """deprecated dynamic + valid_lft 14187sec preferred_lft 0sec + inet6 2001:470:9:1224:4508:b885:5fb:740b/64 scope global temporary """ + """deprecated dynamic + valid_lft 14187sec preferred_lft 0sec + inet6 2001:470:9:1224:dfcc:aaff:feb9:76ce/64 scope global dynamic + valid_lft 14187sec preferred_lft 3387sec + inet6 fe80::dfcc:aaff:feb9:76ce/64 scope link + valid_lft forever preferred_lft forever +""") + + +class TestIPDevice(unittest.TestCase): + def test_execute_wrapper(self): + with mock.patch('quantum.agent.linux.utils.execute') as execute: + ip_lib.IPDevice._execute('o', 'link', ('list',), 'sudo') + + execute.assert_called_once_with(['ip', '-o', 'link', 'list'], + root_helper='sudo') + + def test_execute_wrapper_int_options(self): + with mock.patch('quantum.agent.linux.utils.execute') as execute: + ip_lib.IPDevice._execute([4], 'link', ('list',)) + + execute.assert_called_once_with(['ip', '-4', 'link', 'list'], + root_helper=None) + + def test_execute_wrapper_no_options(self): + with mock.patch('quantum.agent.linux.utils.execute') as execute: + ip_lib.IPDevice._execute([], 'link', ('list',)) + + execute.assert_called_once_with(['ip', 'link', 'list'], + root_helper=None) + + def test_get_devices(self): + with mock.patch.object(ip_lib.IPDevice, '_execute') as _execute: + _execute.return_value = '\n'.join(LINK_SAMPLE) + retval = ip_lib.IPDevice.get_devices() + self.assertEquals(retval, + [ip_lib.IPDevice('lo'), + ip_lib.IPDevice('eth0'), + ip_lib.IPDevice('br-int'), + ip_lib.IPDevice('gw-ddc717df-49')]) + + _execute.assert_called_once_with('o', 'link', ('list',)) + + +class TestIPCommandBase(unittest.TestCase): + def setUp(self): + self.ip_dev = mock.Mock() + self.ip_dev.name = 'eth0' + self.ip_dev.root_helper = 'sudo' + self.ip_dev._execute = mock.Mock(return_value='executed') + self.ip_cmd = ip_lib.IpCommandBase(self.ip_dev) + self.ip_cmd.COMMAND = 'foo' + + def test_run(self): + self.assertEqual(self.ip_cmd._run('link', 'show'), 'executed') + self.ip_dev._execute.assert_called_once_with([], 'foo', + ('link', 'show')) + + def test_run_with_options(self): + self.assertEqual(self.ip_cmd._run('link', options='o'), 'executed') + self.ip_dev._execute.assert_called_once_with('o', 'foo', ('link',)) + + def test_as_root(self): + self.assertEqual(self.ip_cmd._as_root('link'), 'executed') + self.ip_dev._execute.assert_called_once_with([], 'foo', + ('link',), 'sudo') + + def test_as_root_with_options(self): + self.assertEqual(self.ip_cmd._as_root('link', options='o'), 'executed') + self.ip_dev._execute.assert_called_once_with('o', 'foo', + ('link',), 'sudo') + + def test_name_property(self): + self.assertEqual(self.ip_cmd.name, 'eth0') + + +class TestIPCmdBase(unittest.TestCase): + def setUp(self): + self.parent = mock.Mock() + self.parent.name = 'eth0' + self.parent.root_helper = 'sudo' + + def _assert_call(self, options, args): + self.parent.assert_has_calls([ + mock.call._execute(options, self.command, args)]) + + def _assert_sudo(self, options, args): + self.parent.assert_has_calls([ + mock.call._execute(options, self.command, args, 'sudo')]) + + +class TestIpLinkCommand(TestIPCmdBase): + def setUp(self): + super(TestIpLinkCommand, self).setUp() + self.command = 'link' + self.link_cmd = ip_lib.IpLinkCommand(self.parent) + + def test_set_address(self): + self.link_cmd.set_address('aa:bb:cc:dd:ee:ff') + self._assert_sudo([], ('set', 'eth0', 'address', 'aa:bb:cc:dd:ee:ff')) + + def test_set_mtu(self): + self.link_cmd.set_mtu(1500) + self._assert_sudo([], ('set', 'eth0', 'mtu', 1500)) + + def test_set_up(self): + self.link_cmd.set_up() + self._assert_sudo([], ('set', 'eth0', 'up')) + + def test_set_down(self): + self.link_cmd.set_down() + self._assert_sudo([], ('set', 'eth0', 'down')) + + def test_delete(self): + self.link_cmd.delete() + self._assert_sudo([], ('delete', 'eth0')) + + def test_address_property(self): + self.parent._execute = mock.Mock(return_value=LINK_SAMPLE[1]) + self.assertEqual(self.link_cmd.address, 'cc:dd:ee:ff:ab:cd') + + def test_mtu_property(self): + self.parent._execute = mock.Mock(return_value=LINK_SAMPLE[1]) + self.assertEqual(self.link_cmd.mtu, 1500) + + def test_qdisc_property(self): + self.parent._execute = mock.Mock(return_value=LINK_SAMPLE[1]) + self.assertEqual(self.link_cmd.qdisc, 'mq') + + def test_qlen_property(self): + self.parent._execute = mock.Mock(return_value=LINK_SAMPLE[1]) + self.assertEqual(self.link_cmd.qlen, 1000) + + def test_settings_property(self): + expected = {'mtu': 1500, + 'qlen': 1000, + 'state': 'UP', + 'qdisc': 'mq', + 'brd': 'ff:ff:ff:ff:ff:ff', + 'link/ether': 'cc:dd:ee:ff:ab:cd'} + self.parent._execute = mock.Mock(return_value=LINK_SAMPLE[1]) + self.assertEquals(self.link_cmd.attributes, expected) + self._assert_call('o', ('show', 'eth0')) + + +class TestIpTuntapCommand(TestIPCmdBase): + def setUp(self): + super(TestIpTuntapCommand, self).setUp() + self.parent.name = 'tap0' + self.command = 'tuntap' + self.tuntap_cmd = ip_lib.IpTuntapCommand(self.parent) + + def test_add_tap(self): + self.tuntap_cmd.add() + self._assert_sudo([], ('add', 'tap0', 'mode', 'tap')) + + +class TestIpAddrCommand(TestIPCmdBase): + def setUp(self): + super(TestIpAddrCommand, self).setUp() + self.parent.name = 'tap0' + self.command = 'addr' + self.addr_cmd = ip_lib.IpAddrCommand(self.parent) + + def test_add_address(self): + self.addr_cmd.add(4, '192.168.45.100/24', '192.168.45.255') + self._assert_sudo([4], + ('add', '192.168.45.100/24', 'brd', '192.168.45.255', + 'scope', 'global', 'dev', 'tap0')) + + def test_add_address_scoped(self): + self.addr_cmd.add(4, '192.168.45.100/24', '192.168.45.255', + scope='link') + self._assert_sudo([4], + ('add', '192.168.45.100/24', 'brd', '192.168.45.255', + 'scope', 'link', 'dev', 'tap0')) + + def test_del_address(self): + self.addr_cmd.delete(4, '192.168.45.100/24') + self._assert_sudo([4], + ('del', '192.168.45.100/24', 'dev', 'tap0')) + + def test_flush(self): + self.addr_cmd.flush() + self._assert_sudo([], ('flush', 'tap0')) + + def test_list(self): + expected = [ + dict(ip_version=4, scope='global', + dynamic=False, cidr='172.16.77.240/24'), + dict(ip_version=6, scope='global', + dynamic=True, cidr='2001:470:9:1224:5595:dd51:6ba2:e788/64'), + dict(ip_version=6, scope='global', + dynamic=True, cidr='2001:470:9:1224:fd91:272:581e:3a32/64'), + dict(ip_version=6, scope='global', + dynamic=True, cidr='2001:470:9:1224:4508:b885:5fb:740b/64'), + dict(ip_version=6, scope='global', + dynamic=True, cidr='2001:470:9:1224:dfcc:aaff:feb9:76ce/64'), + dict(ip_version=6, scope='link', + dynamic=False, cidr='fe80::dfcc:aaff:feb9:76ce/64')] + + self.parent._execute = mock.Mock(return_value=ADDR_SAMPLE) + self.assertEquals(self.addr_cmd.list(), expected) + self._assert_call([], ('show', 'tap0')) + + def test_list_filtered(self): + expected = [ + dict(ip_version=4, scope='global', + dynamic=False, cidr='172.16.77.240/24')] + + output = '\n'.join(ADDR_SAMPLE.split('\n')[0:4]) + self.parent._execute = mock.Mock(return_value=output) + self.assertEquals(self.addr_cmd.list('global', filters=['permanent']), + expected) + self._assert_call([], ('show', 'tap0', 'permanent', 'scope', 'global')) + + +class TestDeviceExists(unittest.TestCase): + def test_device_exists(self): + with mock.patch.object(ip_lib.IPDevice, '_execute') as _execute: + _execute.return_value = LINK_SAMPLE[1] + self.assertTrue(ip_lib.device_exists('eth0')) + _execute.assert_called_once_with('o', 'link', ('show', 'eth0')) + + def test_device_does_not_exist(self): + with mock.patch.object(ip_lib.IPDevice, '_execute') as _execute: + _execute.return_value = '' + _execute.side_effect = RuntimeError + self.assertFalse(ip_lib.device_exists('eth0')) diff --git a/setup.py b/setup.py index 5cd63da39..60caf27d0 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,10 @@ ryu_plugin_config_path = 'etc/quantum/plugins/ryu' DataFiles = [ (config_path, - ['etc/quantum.conf', 'etc/api-paste.ini', 'etc/policy.json']), + ['etc/quantum.conf', + 'etc/api-paste.ini', + 'etc/policy.json', + 'etc/dhcp_agent.ini']), (init_path, ['etc/init.d/quantum-server']), (ovs_plugin_config_path, ['etc/quantum/plugins/openvswitch/ovs_quantum_plugin.ini']), @@ -89,6 +92,7 @@ setuptools.setup( eager_resources=EagerResources, entry_points={ 'console_scripts': [ + 'quantum-dhcp-agent = quantum.agent.dhcp_agent:main', 'quantum-linuxbridge-agent =' 'quantum.plugins.linuxbridge.agent.linuxbridge_quantum_agent:main', 'quantum-openvswitch-agent =' diff --git a/tools/pip-requires b/tools/pip-requires index 18fdd91e6..adfa8c614 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -2,8 +2,10 @@ Paste PasteDeploy==1.5.0 Routes>=1.12.3 eventlet>=0.9.12 +httplib2 lxml netaddr python-gflags==1.3 +python-quantumclient>=0.1,<0.2 sqlalchemy>0.6.4 webob==1.2.0