]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
implement dhcp agent for quantum
authorMark McClain <mark.mcclain@dreamhost.com>
Wed, 27 Jun 2012 18:15:53 +0000 (14:15 -0400)
committerMark McClain <mark.mcclain@dreamhost.com>
Sun, 8 Jul 2012 18:50:49 +0000 (14:50 -0400)
blueprint: quantum-dhcp

This change adds an agent to manage DHCP for Quantum networks

Change-Id: If3c62965550dc0b0a7982b01d3468e2e07e2b775

18 files changed:
bin/quantum-dhcp-agent [new file with mode: 0755]
etc/dhcp_agent.ini [new file with mode: 0644]
quantum/agent/common/__init__.py [new file with mode: 0644]
quantum/agent/common/config.py [new file with mode: 0644]
quantum/agent/dhcp_agent.py [new file with mode: 0644]
quantum/agent/linux/dhcp.py [new file with mode: 0644]
quantum/agent/linux/interface.py [new file with mode: 0644]
quantum/agent/linux/ip_lib.py [new file with mode: 0644]
quantum/common/exceptions.py
quantum/rootwrap/dhcp-agent.py [new file with mode: 0644]
quantum/tests/unit/test_agent_config.py [new file with mode: 0644]
quantum/tests/unit/test_agent_utils.py
quantum/tests/unit/test_dhcp_agent.py [new file with mode: 0644]
quantum/tests/unit/test_linux_dhcp.py [new file with mode: 0644]
quantum/tests/unit/test_linux_interface.py [new file with mode: 0644]
quantum/tests/unit/test_linux_ip_lib.py [new file with mode: 0644]
setup.py
tools/pip-requires

diff --git a/bin/quantum-dhcp-agent b/bin/quantum-dhcp-agent
new file mode 100755 (executable)
index 0000000..56d7e6e
--- /dev/null
@@ -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 (file)
index 0000000..7fea80a
--- /dev/null
@@ -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 (file)
index 0000000..304bb14
--- /dev/null
@@ -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 (file)
index 0000000..cff3f9e
--- /dev/null
@@ -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 (file)
index 0000000..36643a5
--- /dev/null
@@ -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 (file)
index 0000000..ba56563
--- /dev/null
@@ -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 (file)
index 0000000..aed549b
--- /dev/null
@@ -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 (file)
index 0000000..099ae78
--- /dev/null
@@ -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
index 18df32071570278d1841396a08e7c657806cef85..a00cf8ee089bbb27995670f93c02eeb5d1bd9953 100644 (file)
@@ -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 (file)
index 0000000..2ba63a1
--- /dev/null
@@ -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 (file)
index 0000000..73e10ed
--- /dev/null
@@ -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')
index 5859f1f4b485fa335c1e8415ae4262f96731c4b5..73053890a5ae6dcd8b0e704133912f9ecdce6d9c 100644 (file)
@@ -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 (file)
index 0000000..9255b4c
--- /dev/null
@@ -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 (file)
index 0000000..37b0dbb
--- /dev/null
@@ -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 (file)
index 0000000..07591be
--- /dev/null
@@ -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 (file)
index 0000000..13617fd
--- /dev/null
@@ -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: <LOOPBACK,UP,LOWER_UP> mtu 16436 qdisc noqueue state UNKNOWN \\'
+    'link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00',
+    '2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> 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: <BROADCAST,MULTICAST> 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: <BROADCAST,MULTICAST> 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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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'))
index 5cd63da3930575c5470b451bdade69781db28588..60caf27d0e92fa80e5472c60bb1fb29b1ef8c7b2 100644 (file)
--- 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 ='
index 18fdd91e6462b01bb2dc17cd8e3756f2177aebd8..adfa8c614b09d7230c5680001b5d7a7966d6063e 100644 (file)
@@ -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