]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
add metadata proxy support for Quantum Networks
authorMark McClain <mark.mcclain@dreamhost.com>
Fri, 16 Nov 2012 04:08:52 +0000 (23:08 -0500)
committerMark McClain <mark.mcclain@dreamhost.com>
Thu, 29 Nov 2012 00:09:16 +0000 (19:09 -0500)
blueprint metadata-overlapping-networks

Adds Metadata for guest VMs running on Qunatum networks.  This requires
a companion patchset for Nova to test.

Change-Id: I524c6fdcd6a44e46da08395fd84c1288052a69ea

17 files changed:
bin/quantum-metadata-agent [new file with mode: 0755]
bin/quantum-ns-metadata-proxy [new file with mode: 0755]
etc/l3_agent.ini
etc/metadata_agent.ini [new file with mode: 0644]
etc/quantum/rootwrap.d/l3.filters
quantum/agent/l3_agent.py
quantum/agent/linux/daemon.py [new file with mode: 0644]
quantum/agent/linux/external_process.py [new file with mode: 0644]
quantum/agent/metadata/__init__.py [new file with mode: 0644]
quantum/agent/metadata/agent.py [new file with mode: 0644]
quantum/agent/metadata/namespace_proxy.py [new file with mode: 0644]
quantum/tests/unit/test_l3_agent.py
quantum/tests/unit/test_linux_daemon.py [new file with mode: 0644]
quantum/tests/unit/test_linux_external_process.py [new file with mode: 0644]
quantum/tests/unit/test_metadata_agent.py [new file with mode: 0644]
quantum/tests/unit/test_metadata_namespace_proxy.py [new file with mode: 0644]
setup.py

diff --git a/bin/quantum-metadata-agent b/bin/quantum-metadata-agent
new file mode 100755 (executable)
index 0000000..5d0ff8f
--- /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.metadata.agent import main
+main()
diff --git a/bin/quantum-ns-metadata-proxy b/bin/quantum-ns-metadata-proxy
new file mode 100755 (executable)
index 0000000..fa65c5f
--- /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.metadata.namespace_proxy import main
+main()
index c76b1af26b3d91014fa277d67796423bc9bbb1b1..f495436d043887a47bd04a574a54213a7f284cd6 100644 (file)
@@ -27,6 +27,12 @@ root_helper = sudo
 # use_namespaces = True
 
 # If use_namespaces is set as False then the agent can only configure one router.
+
+# Where to store metadata 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/quantum
+
 # This is done by setting the specific router_id.
 # router_id =
 
@@ -46,11 +52,8 @@ root_helper = sudo
 # empty value for the linux bridge
 # external_network_bridge = br-ex
 
-# IP address used by Nova metadata server
-# metadata_ip =
-
-# TCP Port used by Nova metadata server
-# metadata_port = 8775
+# TCP Port used by Quantum metadata server
+# metadata_port = 9697
 
 # The time in seconds between state poll requests
 # polling_interval = 3
diff --git a/etc/metadata_agent.ini b/etc/metadata_agent.ini
new file mode 100644 (file)
index 0000000..bb5aac1
--- /dev/null
@@ -0,0 +1,32 @@
+[DEFAULT]
+# Show debugging output in log (sets DEBUG log level output)
+# debug = True
+
+# The Quantum user information for accessing the Quantum API.
+auth_url = http://localhost:35357/v2.0
+auth_region = RegionOne
+admin_tenant_name = %SERVICE_TENANT_NAME%
+admin_user = %SERVICE_USER%
+admin_password = %SERVICE_PASSWORD%
+
+# Use "sudo quantum-rootwrap /etc/quantum/rootwrap.conf" to use the real
+# root filter facility.
+# Change to "sudo" to skip the filtering and just run the comand directly
+root_helper = sudo
+
+# Where to store metadata 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/quantum
+
+# IP address used by Nova metadata server
+# nova_metadata_ip = 127.0.0.1
+
+# TCP Port used by Nova metadata server
+# nova_metadata_port = 8775
+
+# When proxying metadata requests, Quantum signs the Instance-ID header with a
+# shared secret to prevent spoofing.  You may select any string for a secret,
+# but it must match here and in the configuration used by the Nova Metadata
+# Server. NOTE: Nova uses a different key: quantum_metadata_proxy_shared_secret
+# metadata_proxy_shared_secret =
index a84800067cc36115d860d75f8ee0a7bcadadfe14..9e0e5619592e7ce94e1f358675b8a99692984887 100644 (file)
@@ -16,6 +16,11 @@ arping_sbin: CommandFilter, /sbin/arping, root
 sysctl: CommandFilter, /sbin/sysctl, root
 route: CommandFilter, /sbin/route, root
 
+# metadata proxy
+metadata_proxy: CommandFilter, /usr/local/bin/quantum-ns-metadata-proxy, root
+kill_metadata7: KillFilter, root, /usr/bin/python2.7, -9
+kill_metadata6: KillFilter, root, /usr/bin/python2.6, -9
+
 # ip_lib
 ip: IpFilter, /sbin/ip, root
 ip_usr: IpFilter, /usr/sbin/ip, root
index 154d7c4fce321d3e1001ec14e2b2b8a2eee94a6c..e9c339306efc31b19cb281db262319e819eddfee 100644 (file)
@@ -25,6 +25,7 @@ import time
 import netaddr
 
 from quantum.agent.common import config
+from quantum.agent.linux import external_process
 from quantum.agent.linux import interface
 from quantum.agent.linux import ip_lib
 from quantum.agent.linux import iptables_manager
@@ -78,11 +79,9 @@ class L3NATAgent(object):
         cfg.IntOpt('polling_interval',
                    default=3,
                    help="The time in seconds between state poll requests."),
-        cfg.StrOpt('metadata_ip', default='',
-                   help="IP address used by Nova metadata server."),
         cfg.IntOpt('metadata_port',
-                   default=8775,
-                   help="TCP Port used by Nova metadata server."),
+                   default=9697,
+                   help="TCP Port used by Quantum metadata namespace proxy."),
         cfg.IntOpt('send_arp_for_ha',
                    default=3,
                    help="Send this many gratuitous ARPs for HA setup, "
@@ -244,6 +243,7 @@ class L3NATAgent(object):
         for c, r in self.metadata_nat_rules():
             ri.iptables_manager.ipv4['nat'].add_rule(c, r)
         ri.iptables_manager.apply()
+        self._spawn_metadata_agent(ri)
 
     def _router_removed(self, router_id):
         ri = self.router_info[router_id]
@@ -252,9 +252,32 @@ class L3NATAgent(object):
         for c, r in self.metadata_nat_rules():
             ri.iptables_manager.ipv4['nat'].remove_rule(c, r)
         ri.iptables_manager.apply()
+        self._destroy_metadata_agent(ri)
         del self.router_info[router_id]
         self._destroy_router_namespace(ri.ns_name())
 
+    def _spawn_metadata_agent(self, router_info):
+        def callback(pid_file):
+            return ['quantum-ns-metadata-proxy',
+                    '--pid_file=%s' % pid_file,
+                    '--router_id=%s' % router_info.router_id,
+                    '--state_path=%s' % self.conf.state_path]
+
+        pm = external_process.ProcessManager(
+            self.conf,
+            router_info.router_id,
+            self.conf.root_helper,
+            router_info.ns_name())
+        pm.enable(callback)
+
+    def _destroy_metadata_agent(self, router_info):
+        pm = external_process.ProcessManager(
+            self.conf,
+            router_info.router_id,
+            self.conf.root_helper,
+            router_info.ns_name())
+        pm.disable()
+
     def _set_subnet_info(self, port):
         ips = port['fixed_ips']
         if not ips:
@@ -437,20 +460,16 @@ class L3NATAgent(object):
 
     def metadata_filter_rules(self):
         rules = []
-        if self.conf.metadata_ip:
-            rules.append(('INPUT', '-s 0.0.0.0/0 -d %s '
-                          '-p tcp -m tcp --dport %s '
-                          '-j ACCEPT' %
-                         (self.conf.metadata_ip, self.conf.metadata_port)))
+        rules.append(('INPUT', '-s 0.0.0.0/0 -d 127.0.0.1 '
+                      '-p tcp -m tcp --dport %s '
+                      '-j ACCEPT' % self.conf.metadata_port))
         return rules
 
     def metadata_nat_rules(self):
         rules = []
-        if self.conf.metadata_ip:
-            rules.append(('PREROUTING', '-s 0.0.0.0/0 -d 169.254.169.254/32 '
-                         '-p tcp -m tcp --dport 80 -j DNAT '
-                         '--to-destination %s:%s' %
-                         (self.conf.metadata_ip, self.conf.metadata_port)))
+        rules.append(('PREROUTING', '-s 0.0.0.0/0 -d 169.254.169.254/32 '
+                     '-p tcp -m tcp --dport 80 -j REDIRECT '
+                     '--to-port %s' % self.conf.metadata_port))
         return rules
 
     def external_gateway_nat_rules(self, ex_gw_ip, internal_cidrs,
@@ -502,9 +521,6 @@ class L3NATAgent(object):
     def internal_network_nat_rules(self, ex_gw_ip, internal_cidr):
         rules = [('snat', '-s %s -j SNAT --to-source %s' %
                  (internal_cidr, ex_gw_ip))]
-        if self.conf.metadata_ip:
-            rules.append(('POSTROUTING', '-s %s -d %s/32 -j ACCEPT' %
-                          (internal_cidr, self.conf.metadata_ip)))
         return rules
 
     def floating_ip_added(self, ri, ex_gw_port, floating_ip, fixed_ip):
@@ -548,6 +564,7 @@ def main():
     conf = config.setup_conf()
     conf.register_opts(L3NATAgent.OPTS)
     conf.register_opts(interface.OPTS)
+    conf.register_opts(external_process.OPTS)
     conf(sys.argv)
     config.setup_logging(conf)
 
diff --git a/quantum/agent/linux/daemon.py b/quantum/agent/linux/daemon.py
new file mode 100644 (file)
index 0000000..e73d40a
--- /dev/null
@@ -0,0 +1,148 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
+
+import atexit
+import fcntl
+import os
+from signal import SIGTERM
+import sys
+import time
+
+from quantum.agent.linux import utils
+from quantum.openstack.common import log as logging
+
+LOG = logging.getLogger(__name__)
+
+
+class Pidfile(object):
+    def __init__(self, pidfile, procname, root_helper='sudo'):
+        try:
+            self.fd = os.open(pidfile, os.O_CREAT | os.O_RDWR)
+        except IOError, e:
+            LOG.exception(_("Failed to open pidfile: %s") % pidfile)
+            sys.exit(1)
+        self.pidfile = pidfile
+        self.procname = procname
+        self.root_helper = root_helper
+        if not not fcntl.flock(self.fd, fcntl.LOCK_EX):
+            raise IOError(_('Unable to lock pid file'))
+
+    def __str__(self):
+        return self.pidfile
+
+    def unlock(self):
+        if not not fcntl.flock(self.fd, fcntl.LOCK_UN):
+            raise IOError(_('Unable to unlock pid file'))
+
+    def write(self, pid):
+        os.ftruncate(self.fd, 0)
+        os.write(self.fd, "%d" % pid)
+        os.fsync(self.fd)
+
+    def read(self):
+        try:
+            pid = int(os.read(self.fd, 128))
+            os.lseek(self.fd, 0, os.SEEK_SET)
+            return pid
+        except ValueError:
+            return
+
+    def is_running(self):
+        pid = self.read()
+        if not pid:
+            return False
+
+        cmd = ['cat', '/proc/%s/cmdline' % pid]
+        try:
+            return self.procname in utils.execute(cmd, self.root_helper)
+        except RuntimeError, e:
+            return False
+
+
+class Daemon(object):
+    """
+    A generic daemon class.
+
+    Usage: subclass the Daemon class and override the run() method
+    """
+    def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null',
+                 stderr='/dev/null', procname='python', root_helper='sudo'):
+        self.stdin = stdin
+        self.stdout = stdout
+        self.stderr = stderr
+        self.procname = procname
+        self.pidfile = Pidfile(pidfile, procname, root_helper)
+
+    def _fork(self):
+        try:
+            pid = os.fork()
+            if pid > 0:
+                sys.exit(0)
+        except OSError, e:
+            LOG.exception(_('Fork failed'))
+            sys.exit(1)
+
+    def daemonize(self):
+        """Daemonize process by doing Stevens double fork."""
+        # fork first time
+        self._fork()
+
+        # decouple from parent environment
+        os.chdir("/")
+        os.setsid()
+        os.umask(0)
+
+        # fork second time
+        self._fork()
+
+        # redirect standard file descriptors
+        sys.stdout.flush()
+        sys.stderr.flush()
+        stdin = open(self.stdin, 'r')
+        stdout = open(self.stdout, 'a+')
+        stderr = open(self.stderr, 'a+', 0)
+        os.dup2(stdin.fileno(), sys.stdin.fileno())
+        os.dup2(stdout.fileno(), sys.stdout.fileno())
+        os.dup2(stderr.fileno(), sys.stderr.fileno())
+
+        # write pidfile
+        atexit.register(self.delete_pid)
+        self.pidfile.write(os.getpid())
+
+    def delete_pid(self):
+        os.remove(str(self.pidfile))
+
+    def start(self):
+        """ Start the daemon """
+
+        if self.pidfile.is_running():
+            self.pidfile.unlock()
+            message = _('pidfile %s already exist. Daemon already running?\n')
+            LOG.error(message % self.pidfile)
+            sys.exit(1)
+
+        # Start the daemon
+        self.daemonize()
+        self.run()
+
+    def run(self):
+        """Override this method when subclassing Daemon.
+
+        start() will call this method after the process has daemonized.
+        """
+        pass
diff --git a/quantum/agent/linux/external_process.py b/quantum/agent/linux/external_process.py
new file mode 100644 (file)
index 0000000..b96956c
--- /dev/null
@@ -0,0 +1,112 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
+
+import os
+import tempfile
+
+from quantum.agent.linux import ip_lib
+from quantum.agent.linux import utils
+from quantum.openstack.common import cfg
+from quantum.openstack.common import log as logging
+
+LOG = logging.getLogger(__name__)
+
+OPTS = [
+    cfg.StrOpt('external_pids',
+               default='$state_path/external/pids',
+               help='Location to store child pid files'),
+]
+
+cfg.CONF.register_opts(OPTS)
+
+
+class ProcessManager(object):
+    """An external process manager for Quantum spawned processes.
+
+    Note: The manager expects uuid to be in cmdline.
+    """
+    def __init__(self, conf, uuid, root_helper='sudo', namespace=None):
+        self.conf = conf
+        self.uuid = uuid
+        self.root_helper = root_helper
+        self.namespace = namespace
+
+    def enable(self, cmd_callback):
+        if not self.active:
+            cmd = cmd_callback(self.get_pid_file_name(ensure_pids_dir=True))
+
+            if self.namespace:
+                ip_wrapper = ip_lib.IPWrapper(self.root_helper, self.namespace)
+                ip_wrapper.netns.execute(cmd)
+            else:
+                # For normal sudo prepend the env vars before command
+                utils.execute(cmd, self.root_helper)
+
+    def disable(self):
+        pid = self.pid
+
+        if self.active:
+            cmd = ['kill', '-9', pid]
+            if self.namespace:
+                ip_wrapper = ip_lib.IPWrapper(self.root_helper, self.namespace)
+                ip_wrapper.netns.execute(cmd)
+            else:
+                utils.execute(cmd, self.root_helper)
+
+        elif pid:
+            LOG.debug(_('Process for %(uuid)s pid %(pid)d is stale, ignoring '
+                        'command') % {'uuid': self.uuid, 'pid': pid})
+        else:
+            LOG.debug(_('No process started for %s') % self.uuid)
+
+    def get_pid_file_name(self, ensure_pids_dir=False):
+        """Returns the file name for a given kind of config file."""
+        pids_dir = os.path.abspath(os.path.normpath(self.conf.external_pids))
+        if ensure_pids_dir and not os.path.isdir(pids_dir):
+            os.makedirs(pids_dir, 0755)
+
+        return os.path.join(pids_dir, self.uuid + '.pid')
+
+    @property
+    def pid(self):
+        """Last known pid for this external process spawned for this uuid."""
+        file_name = self.get_pid_file_name()
+        msg = _('Error while reading %s')
+
+        try:
+            with open(file_name, 'r') as f:
+                return int(f.read())
+        except IOError, e:
+            msg = _('Unable to access %s')
+        except ValueError, e:
+            msg = _('Unable to convert value in %s')
+
+        LOG.debug(msg % file_name)
+        return None
+
+    @property
+    def active(self):
+        pid = self.pid
+        if pid is None:
+            return False
+
+        cmd = ['cat', '/proc/%s/cmdline' % pid]
+        try:
+            return self.uuid in utils.execute(cmd, self.root_helper)
+        except RuntimeError, e:
+            return False
diff --git a/quantum/agent/metadata/__init__.py b/quantum/agent/metadata/__init__.py
new file mode 100644 (file)
index 0000000..6e2c062
--- /dev/null
@@ -0,0 +1,17 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
diff --git a/quantum/agent/metadata/agent.py b/quantum/agent/metadata/agent.py
new file mode 100644 (file)
index 0000000..fe85299
--- /dev/null
@@ -0,0 +1,214 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
+
+import hashlib
+import hmac
+import os
+import socket
+import sys
+import urlparse
+
+import eventlet
+import httplib2
+from quantumclient.v2_0 import client
+import webob
+
+from quantum.common import config
+from quantum.openstack.common import cfg
+from quantum.openstack.common import log as logging
+from quantum import wsgi
+
+LOG = logging.getLogger(__name__)
+
+DEVICE_OWNER_ROUTER_INTF = "network:router_interface"
+
+
+class MetadataProxyHandler(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('nova_metadata_ip', default='127.0.0.1',
+                   help="IP address used by Nova metadata server."),
+        cfg.IntOpt('nova_metadata_port',
+                   default=8775,
+                   help="TCP Port used by Nova metadata server."),
+        cfg.StrOpt('metadata_proxy_shared_secret',
+                   default='',
+                   help='Shared secret to sign instance-id request')
+    ]
+
+    def __init__(self, conf):
+        self.conf = conf
+
+        self.qclient = 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,
+            region_name=self.conf.auth_region
+        )
+
+    @webob.dec.wsgify(RequestClass=wsgi.Request)
+    def __call__(self, req):
+        try:
+            LOG.debug(_("Request: %s"), req)
+
+            instance_id = self._get_instance_id(req)
+            if instance_id:
+                return self._proxy_request(instance_id, req)
+            else:
+                return webob.exc.HTTPNotFound()
+
+        except Exception, e:
+            LOG.exception(_("Unexpected error."))
+            msg = _('An unknown error has occurred. '
+                    'Please try your request again.')
+            return webob.exc.HTTPInternalServerError(explanation=unicode(msg))
+
+    def _get_instance_id(self, req):
+        remote_address = req.headers.get('X-Forwarded-For')
+        network_id = req.headers.get('X-Quantum-Network-ID')
+        router_id = req.headers.get('X-Quantum-Router-ID')
+
+        if network_id:
+            networks = [network_id]
+        else:
+            internal_ports = self.qclient.list_ports(
+                device_id=router_id,
+                device_owner=DEVICE_OWNER_ROUTER_INTF)['ports']
+
+            networks = [p['network_id'] for p in internal_ports]
+
+        ports = self.qclient.list_ports(
+            network_id=networks,
+            fixed_ips=['ip_address=%s' % remote_address])['ports']
+
+        if len(ports) == 1:
+            return ports[0]['device_id']
+
+    def _proxy_request(self, instance_id, req):
+        headers = {
+            'X-Instance-ID': instance_id,
+            'X-Instance-ID-Signature': self._sign_instance_id(instance_id)
+        }
+
+        url = urlparse.urlunsplit((
+            'http',
+            '%s:%s' % (self.conf.nova_metadata_ip,
+                       self.conf.nova_metadata_port),
+            req.path_info,
+            req.query_string,
+            ''))
+
+        h = httplib2.Http()
+        resp, content = h.request(url, headers=headers)
+
+        if resp.status == 200:
+            LOG.debug(str(resp))
+            return content
+        elif resp.status == 403:
+            msg = _(
+                'The remote metadata server responded with Forbidden. This '
+                'response usually occurs when shared secrets do not match.'
+            )
+            LOG.warn(msg)
+            return webob.exc.HTTPForbidden()
+        elif resp.status == 404:
+            return webob.exc.HTTPNotFound()
+        elif resp.status == 500:
+            msg = _(
+                'Remote metadata server experienced an internal server error.'
+            )
+            LOG.warn(msg)
+            return webob.exc.HTTPInternalServerError(explanation=unicode(msg))
+        else:
+            raise Exception(_('Unexpected response code: %s') % resp.status)
+
+    def _sign_instance_id(self, instance_id):
+        return hmac.new(self.conf.metadata_proxy_shared_secret,
+                        instance_id,
+                        hashlib.sha256).hexdigest()
+
+
+class UnixDomainHttpProtocol(eventlet.wsgi.HttpProtocol):
+    def __init__(self, request, client_address, server):
+        if client_address == '':
+            client_address = ('<local>', 0)
+        # base class is old-style, no super does not work properly
+        eventlet.wsgi.HttpProtocol.__init__(self, request, client_address,
+                                            server)
+
+
+class UnixDomainWSGIServer(wsgi.Server):
+    def start(self, application, file_socket, backlog=128):
+        sock = eventlet.listen(file_socket,
+                               family=socket.AF_UNIX,
+                               backlog=backlog)
+        self.pool.spawn_n(self._run, application, sock)
+
+    def _run(self, application, socket):
+        """Start a WSGI service in a new green thread."""
+        logger = logging.getLogger('eventlet.wsgi.server')
+        eventlet.wsgi.server(socket,
+                             application,
+                             custom_pool=self.pool,
+                             protocol=UnixDomainHttpProtocol,
+                             log=logging.WritableLogger(logger))
+
+
+class UnixDomainMetadataProxy(object):
+    OPTS = [
+        cfg.StrOpt('metadata_proxy_socket',
+                   default='$state_path/metadata_proxy',
+                   help='Location for Metadata Proxy UNIX domain socket')
+    ]
+
+    def __init__(self, conf):
+        self.conf = conf
+
+        dirname = os.path.dirname(cfg.CONF.metadata_proxy_socket)
+        if os.path.isdir(dirname):
+            try:
+                os.unlink(cfg.CONF.metadata_proxy_socket)
+            except OSError:
+                if os.path.exists(cfg.CONF.metadata_proxy_socket):
+                    raise
+        else:
+            os.makedirs(dirname, 0755)
+
+    def run(self):
+        server = UnixDomainWSGIServer('quantum-metadata-agent')
+        server.start(MetadataProxyHandler(self.conf),
+                     self.conf.metadata_proxy_socket)
+        server.wait()
+
+
+def main():
+    eventlet.monkey_patch()
+    cfg.CONF.register_opts(UnixDomainMetadataProxy.OPTS)
+    cfg.CONF.register_opts(MetadataProxyHandler.OPTS)
+    cfg.CONF(args=sys.argv, project='quantum')
+    config.setup_logging(cfg.CONF)
+
+    proxy = UnixDomainMetadataProxy(cfg.CONF)
+    proxy.run()
diff --git a/quantum/agent/metadata/namespace_proxy.py b/quantum/agent/metadata/namespace_proxy.py
new file mode 100644 (file)
index 0000000..ec0b65e
--- /dev/null
@@ -0,0 +1,165 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
+
+import httplib
+import os
+import socket
+import sys
+import urlparse
+
+import eventlet
+import httplib2
+import webob
+
+from quantum.agent.linux import daemon
+from quantum.common import config
+from quantum.openstack.common import cfg
+from quantum.openstack.common import log as logging
+from quantum import wsgi
+
+proxy_socket = cfg.StrOpt('metadata_proxy_socket',
+                          default='$state_path/metadata_proxy',
+                          help='Location of Metadata Proxy UNIX domain socket')
+
+cfg.CONF.register_opt(proxy_socket)
+
+LOG = logging.getLogger(__name__)
+
+
+class UnixDomainHTTPConnection(httplib.HTTPConnection):
+    """Connection class for HTTP over UNIX domain socket."""
+    def __init__(self, host, port=None, strict=None, timeout=None,
+                 proxy_info=None):
+        httplib.HTTPConnection.__init__(self, host, port, strict)
+        self.timeout = timeout
+
+    def connect(self):
+        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        if self.timeout:
+            self.sock.settimeout(self.timeout)
+        self.sock.connect(cfg.CONF.metadata_proxy_socket)
+
+
+class NetworkMetadataProxyHandler(object):
+    """Proxy AF_INET metadata request through Unix Domain socket.
+
+       The Unix domain socket allows the proxy access resource that are not
+       accessible within the isolated tenant context.
+    """
+
+    def __init__(self, network_id=None, router_id=None):
+        self.network_id = network_id
+        self.router_id = router_id
+
+        if network_id is None and router_id is None:
+            msg = _('network_id and router_id are None. One must be provided.')
+            raise ValueError(msg)
+
+    @webob.dec.wsgify(RequestClass=wsgi.Request)
+    def __call__(self, req):
+        LOG.debug(_("Request: %s"), req)
+        try:
+            return self._proxy_request(req.remote_addr,
+                                       req.path_info,
+                                       req.query_string)
+        except Exception, e:
+            LOG.exception(_("Unexpected error."))
+            msg = _('An unknown error has occurred. '
+                    'Please try your request again.')
+            return webob.exc.HTTPInternalServerError(explanation=unicode(msg))
+
+    def _proxy_request(self, remote_address, path_info, query_string):
+        headers = {
+            'X-Forwarded-For': remote_address,
+        }
+
+        if self.router_id:
+            headers['X-Quantum-Router-ID'] = self.router_id
+        else:
+            headers['X-Quantum-Network-ID'] = self.network_id
+
+        url = urlparse.urlunsplit((
+            'http',
+            '169.254.169.254',  # a dummy value to make the request proper
+            path_info,
+            query_string,
+            ''))
+
+        h = httplib2.Http()
+        resp, content = h.request(
+            url,
+            headers=headers,
+            connection_type=UnixDomainHTTPConnection)
+
+        if resp.status == 200:
+            LOG.debug(resp)
+            LOG.debug(content)
+            return content
+        elif resp.status == 404:
+            return webob.exc.HTTPNotFound()
+        elif resp.status == 500:
+            msg = _(
+                'Remote metadata server experienced an internal server error.'
+            )
+            LOG.debug(msg)
+            return webob.exc.HTTPInternalServerError(explanation=unicode(msg))
+        else:
+            raise Exception(_('Unexpected response code: %s') % resp.status)
+
+
+class ProxyDaemon(daemon.Daemon):
+    def __init__(self, pidfile, port, network_id=None, router_id=None):
+        super(ProxyDaemon, self).__init__(pidfile)
+        self.network_id = network_id
+        self.router_id = router_id
+        self.port = port
+
+    def run(self):
+        handler = NetworkMetadataProxyHandler(
+            self.network_id,
+            self.router_id)
+        proxy = wsgi.Server('quantum-network-metadata-proxy')
+        proxy.start(handler, self.port)
+        proxy.wait()
+
+
+def main():
+    eventlet.monkey_patch()
+    opts = [
+        cfg.StrOpt('network_id'),
+        cfg.StrOpt('router_id'),
+        cfg.StrOpt('pid_file'),
+        cfg.BoolOpt('daemonize', default=True),
+        cfg.IntOpt('metadata_port',
+                   default=9697,
+                   help="TCP Port to listen for metadata server requests."),
+    ]
+
+    cfg.CONF.register_opts(opts)
+    cfg.CONF(args=sys.argv, project='quantum')
+    config.setup_logging(cfg.CONF)
+
+    proxy = ProxyDaemon(cfg.CONF.pid_file,
+                        cfg.CONF.metadata_port,
+                        network_id=cfg.CONF.network_id,
+                        router_id=cfg.CONF.router_id)
+
+    if cfg.CONF.daemonize:
+        proxy.start()
+    else:
+        proxy.run()
index 70b336c4764ae7a57dc61808c4440eb0171d1891..44f16ba9d708b3bc071a8b2b201080e595b8c174 100644 (file)
@@ -49,6 +49,10 @@ class TestBasicRouterOperations(unittest.TestCase):
             'quantum.agent.linux.utils.execute')
         self.utils_exec = self.utils_exec_p.start()
 
+        self.external_process_p = mock.patch(
+            'quantum.agent.linux.external_process.ProcessManager')
+        self.external_process = self.external_process_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()
@@ -72,6 +76,7 @@ class TestBasicRouterOperations(unittest.TestCase):
         self.ip_cls_p.stop()
         self.dvr_cls_p.stop()
         self.utils_exec_p.stop()
+        self.external_process_p.stop()
 
     def testRouterInfoCreate(self):
         id = _uuid()
@@ -254,19 +259,25 @@ class TestBasicRouterOperations(unittest.TestCase):
 
     def testSingleLoopRouterRemoval(self):
         agent = l3_agent.L3NATAgent(self.conf)
+        router_id = _uuid()
 
         self.client_inst.list_ports.return_value = {'ports': []}
 
         self.client_inst.list_networks.return_value = {'networks': []}
 
         self.client_inst.list_routers.return_value = {'routers': [
-            {'id': _uuid(),
+            {'id': router_id,
              'admin_state_up': True,
              'external_gateway_info': {}}]}
         agent.do_single_loop()
 
         self.client_inst.list_routers.return_value = {'routers': []}
         agent.do_single_loop()
+        self.external_process.assert_has_calls(
+            [mock.call(agent.conf, router_id, 'sudo', 'qrouter-' + router_id),
+             mock.call().enable(mock.ANY),
+             mock.call(agent.conf, router_id, 'sudo', 'qrouter-' + router_id),
+             mock.call().disable()])
 
         # verify that remove is called
         self.assertEquals(self.mock_ip.get_devices.call_count, 1)
diff --git a/quantum/tests/unit/test_linux_daemon.py b/quantum/tests/unit/test_linux_daemon.py
new file mode 100644 (file)
index 0000000..0cc6fff
--- /dev/null
@@ -0,0 +1,179 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
+
+import os
+
+import mock
+import unittest2 as unittest
+
+from quantum.agent.linux import daemon
+
+FAKE_FD = 8
+
+
+class TestPidfile(unittest.TestCase):
+    def setUp(self):
+        self.os_p = mock.patch.object(daemon, 'os')
+        self.os = self.os_p.start()
+        self.os.open.return_value = FAKE_FD
+
+        self.fcntl_p = mock.patch.object(daemon, 'fcntl')
+        self.fcntl = self.fcntl_p.start()
+        self.fcntl.flock.return_value = 0
+
+    def tearDown(self):
+        self.fcntl_p.stop()
+        self.os_p.stop()
+
+    def test_init(self):
+        self.os.O_CREAT = os.O_CREAT
+        self.os.O_RDWR = os.O_RDWR
+
+        p = daemon.Pidfile('thefile', 'python')
+        self.os.open.assert_called_once_with('thefile', os.O_CREAT | os.O_RDWR)
+        self.fcntl.flock.assert_called_once_with(FAKE_FD, self.fcntl.LOCK_EX)
+
+    def test_init_open_fail(self):
+        self.os.open.side_effect = IOError
+
+        with mock.patch.object(daemon.sys, 'stderr') as stderr:
+            with self.assertRaises(SystemExit):
+                p = daemon.Pidfile('thefile', 'python')
+                sys.assert_has_calls([
+                    mock.call.stderr.write(mock.ANY),
+                    mock.call.exit(1)]
+                )
+
+    def test_unlock(self):
+        p = daemon.Pidfile('thefile', 'python')
+        p.unlock()
+        self.fcntl.flock.assert_has_calls([
+            mock.call(FAKE_FD, self.fcntl.LOCK_EX),
+            mock.call(FAKE_FD, self.fcntl.LOCK_UN)]
+        )
+
+    def test_write(self):
+        p = daemon.Pidfile('thefile', 'python')
+        p.write(34)
+
+        self.os.assert_has_calls([
+            mock.call.ftruncate(FAKE_FD, 0),
+            mock.call.write(FAKE_FD, '34'),
+            mock.call.fsync(FAKE_FD)]
+        )
+
+    def test_read(self):
+        self.os.read.return_value = '34'
+        p = daemon.Pidfile('thefile', 'python')
+        self.assertEqual(34, p.read())
+
+    def test_is_running(self):
+        with mock.patch('quantum.agent.linux.utils.execute') as execute:
+            execute.return_value = 'python'
+            p = daemon.Pidfile('thefile', 'python')
+
+            with mock.patch.object(p, 'read') as read:
+                read.return_value = 34
+                self.assertTrue(p.is_running())
+
+            execute.assert_called_once_with(
+                ['cat', '/proc/34/cmdline'], 'sudo')
+
+
+class TestDaemon(unittest.TestCase):
+    def setUp(self):
+        self.os_p = mock.patch.object(daemon, 'os')
+        self.os = self.os_p.start()
+
+        self.pidfile_p = mock.patch.object(daemon, 'Pidfile')
+        self.pidfile = self.pidfile_p.start()
+
+    def tearDown(self):
+        self.pidfile_p.stop()
+        self.os_p.stop()
+
+    def test_init(self):
+        d = daemon.Daemon('pidfile')
+        self.assertEqual(d.procname, 'python')
+
+    def test_fork_parent(self):
+        self.os.fork.return_value = 1
+        with self.assertRaises(SystemExit):
+            d = daemon.Daemon('pidfile')
+            d._fork()
+
+    def test_fork_child(self):
+        self.os.fork.return_value = 0
+        d = daemon.Daemon('pidfile')
+        self.assertIsNone(d._fork())
+
+    def test_fork_error(self):
+        self.os.fork.side_effect = lambda: OSError(1)
+        with mock.patch.object(daemon.sys, 'stderr') as stderr:
+            with self.assertRaises(SystemExit):
+                d = daemon.Daemon('pidfile', 'stdin')
+                d._fork()
+
+    def test_daemonize(self):
+        d = daemon.Daemon('pidfile')
+        with mock.patch.object(d, '_fork') as fork:
+            with mock.patch.object(daemon, 'atexit') as atexit:
+                with mock.patch.object(daemon, 'sys') as sys:
+                    sys.stdin.fileno.return_value = 0
+                    sys.stdout.fileno.return_value = 1
+                    sys.stderr.fileno.return_value = 2
+                    d.daemonize()
+                    atexit.register.assert_called_once_with(d.delete_pid)
+
+            fork.assert_has_calls([mock.call(), mock.call()])
+
+        self.os.assert_has_calls([
+            mock.call.chdir('/'),
+            mock.call.setsid(),
+            mock.call.umask(0),
+            mock.call.dup2(mock.ANY, 0),
+            mock.call.dup2(mock.ANY, 1),
+            mock.call.dup2(mock.ANY, 2),
+            mock.call.getpid()]
+        )
+
+    def test_delete_pid(self):
+        self.pidfile.return_value.__str__.return_value = 'pidfile'
+        d = daemon.Daemon('pidfile')
+        d.delete_pid()
+        self.os.remove.assert_called_once_with('pidfile')
+
+    def test_start(self):
+        self.pidfile.return_value.is_running.return_value = False
+        d = daemon.Daemon('pidfile')
+
+        with mock.patch.object(d, 'daemonize') as daemonize:
+            with mock.patch.object(d, 'run') as run:
+                d.start()
+                run.assert_called_once_with()
+                daemonize.assert_called_once_with()
+
+    def test_start_running(self):
+        self.pidfile.return_value.is_running.return_value = True
+        d = daemon.Daemon('pidfile')
+
+        with mock.patch.object(daemon.sys, 'stderr') as stderr:
+            with mock.patch.object(d, 'daemonize') as daemonize:
+                with self.assertRaises(SystemExit):
+                    d.start()
+                self.assertFalse(daemonize.called)
diff --git a/quantum/tests/unit/test_linux_external_process.py b/quantum/tests/unit/test_linux_external_process.py
new file mode 100644 (file)
index 0000000..86040ad
--- /dev/null
@@ -0,0 +1,200 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
+
+import os
+
+import mock
+import unittest2 as unittest
+
+from quantum.agent.linux import external_process as ep
+
+
+class TestProcessManager(unittest.TestCase):
+    def setUp(self):
+        self.execute_p = mock.patch('quantum.agent.linux.utils.execute')
+        self.execute = self.execute_p.start()
+        self.conf = mock.Mock()
+        self.conf.external_pids = '/var/path'
+
+    def tearDown(self):
+        self.execute_p.stop()
+
+    def test_enable_no_namespace(self):
+        callback = mock.Mock()
+        callback.return_value = ['the', 'cmd']
+
+        with mock.patch.object(ep.ProcessManager, 'get_pid_file_name') as name:
+            name.return_value = 'pidfile'
+            with mock.patch.object(ep.ProcessManager, 'active') as active:
+                active.__get__ = mock.Mock(return_value=False)
+
+                manager = ep.ProcessManager(self.conf, 'uuid')
+                manager.enable(callback)
+                callback.assert_called_once_with('pidfile')
+                name.assert_called_once_with(ensure_pids_dir=True)
+                self.execute.assert_called_once_with(['the', 'cmd'], 'sudo')
+
+    def test_enable_with_namespace(self):
+        callback = mock.Mock()
+        callback.return_value = ['the', 'cmd']
+
+        with mock.patch.object(ep.ProcessManager, 'get_pid_file_name') as name:
+            name.return_value = 'pidfile'
+            with mock.patch.object(ep.ProcessManager, 'active') as active:
+                active.__get__ = mock.Mock(return_value=False)
+
+                manager = ep.ProcessManager(self.conf, 'uuid', namespace='ns')
+                with mock.patch.object(ep, 'ip_lib') as ip_lib:
+                    manager.enable(callback)
+                    callback.assert_called_once_with('pidfile')
+                    name.assert_called_once_with(ensure_pids_dir=True)
+                    ip_lib.assert_has_calls([
+                        mock.call.IPWrapper('sudo', 'ns'),
+                        mock.call.IPWrapper().netns.execute(['the', 'cmd'])]
+                    )
+
+    def test_enable_with_namespace_process_active(self):
+        callback = mock.Mock()
+        callback.return_value = ['the', 'cmd']
+
+        with mock.patch.object(ep.ProcessManager, 'active') as active:
+            active.__get__ = mock.Mock(return_value=True)
+
+            manager = ep.ProcessManager(self.conf, 'uuid', namespace='ns')
+            with mock.patch.object(ep, 'ip_lib') as ip_lib:
+                manager.enable(callback)
+                self.assertFalse(callback.called)
+
+    def test_disable_no_namespace(self):
+        with mock.patch.object(ep.ProcessManager, 'pid') as pid:
+            pid.__get__ = mock.Mock(return_value=4)
+            with mock.patch.object(ep.ProcessManager, 'active') as active:
+                active.__get__ = mock.Mock(return_value=True)
+
+                manager = ep.ProcessManager(self.conf, 'uuid')
+                manager.disable()
+                self.execute(['kill', '-9', 4], 'sudo')
+
+    def test_disable_namespace(self):
+        with mock.patch.object(ep.ProcessManager, 'pid') as pid:
+            pid.__get__ = mock.Mock(return_value=4)
+            with mock.patch.object(ep.ProcessManager, 'active') as active:
+                active.__get__ = mock.Mock(return_value=True)
+
+                manager = ep.ProcessManager(self.conf, 'uuid', namespace='ns')
+
+                with mock.patch.object(ep, 'ip_lib') as ip_lib:
+                    manager.disable()
+                    ip_lib.assert_has_calls([
+                        mock.call.IPWrapper('sudo', 'ns'),
+                        mock.call.IPWrapper().netns.execute(['kill', '-9', 4])]
+                    )
+
+    def test_disable_not_active(self):
+        with mock.patch.object(ep.ProcessManager, 'pid') as pid:
+            pid.__get__ = mock.Mock(return_value=4)
+            with mock.patch.object(ep.ProcessManager, 'active') as active:
+                active.__get__ = mock.Mock(return_value=False)
+                with mock.patch.object(ep.LOG, 'debug') as debug:
+                    manager = ep.ProcessManager(self.conf, 'uuid')
+                    manager.disable()
+                    debug.assert_called_once_with(mock.ANY)
+
+    def test_disable_no_pid(self):
+        with mock.patch.object(ep.ProcessManager, 'pid') as pid:
+            pid.__get__ = mock.Mock(return_value=None)
+            with mock.patch.object(ep.ProcessManager, 'active') as active:
+                active.__get__ = mock.Mock(return_value=False)
+                with mock.patch.object(ep.LOG, 'debug') as debug:
+                    manager = ep.ProcessManager(self.conf, 'uuid')
+                    manager.disable()
+                    debug.assert_called_once_with(mock.ANY)
+
+    def test_get_pid_file_name_existing(self):
+        with mock.patch.object(ep.os.path, 'isdir') as isdir:
+            isdir.return_value = True
+            manager = ep.ProcessManager(self.conf, 'uuid')
+            retval = manager.get_pid_file_name(ensure_pids_dir=True)
+            self.assertEqual(retval, '/var/path/uuid.pid')
+
+    def test_get_pid_file_name_not_existing(self):
+        with mock.patch.object(ep.os.path, 'isdir') as isdir:
+            with mock.patch.object(ep.os, 'makedirs') as makedirs:
+                isdir.return_value = False
+                manager = ep.ProcessManager(self.conf, 'uuid')
+                retval = manager.get_pid_file_name(ensure_pids_dir=True)
+                self.assertEqual(retval, '/var/path/uuid.pid')
+                makedirs.assert_called_once_with('/var/path', 0755)
+
+    def test_get_pid_file_name_default(self):
+        with mock.patch.object(ep.os.path, 'isdir') as isdir:
+            isdir.return_value = True
+            manager = ep.ProcessManager(self.conf, 'uuid')
+            retval = manager.get_pid_file_name(ensure_pids_dir=False)
+            self.assertEqual(retval, '/var/path/uuid.pid')
+            self.assertFalse(isdir.called)
+
+    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'
+            manager = ep.ProcessManager(self.conf, 'uuid')
+            self.assertEqual(manager.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'
+            manager = ep.ProcessManager(self.conf, 'uuid')
+            self.assertIsNone(manager.pid, 5)
+
+    def test_pid_invalid_file(self):
+        with mock.patch.object(ep.ProcessManager, 'get_pid_file_name') as name:
+            name.return_value = '.doesnotexist/pid'
+            manager = ep.ProcessManager(self.conf, 'uuid')
+            self.assertIsNone(manager.pid)
+
+    def test_active(self):
+        dummy_cmd_line = 'python foo --router_id=uuid'
+        self.execute.return_value = dummy_cmd_line
+        with mock.patch.object(ep.ProcessManager, 'pid') as pid:
+            pid.__get__ = mock.Mock(return_value=4)
+            manager = ep.ProcessManager(self.conf, 'uuid')
+            self.assertTrue(manager.active)
+            self.execute.assert_called_once_with(['cat', '/proc/4/cmdline'],
+                                                 'sudo')
+
+    def test_active_none(self):
+        dummy_cmd_line = 'python foo --router_id=uuid'
+        self.execute.return_value = dummy_cmd_line
+        with mock.patch.object(ep.ProcessManager, 'pid') as pid:
+            pid.__get__ = mock.Mock(return_value=None)
+            manager = ep.ProcessManager(self.conf, 'uuid')
+            self.assertFalse(manager.active)
+
+    def test_active_cmd_mismatch(self):
+        dummy_cmd_line = 'python foo --router_id=anotherid'
+        self.execute.return_value = dummy_cmd_line
+        with mock.patch.object(ep.ProcessManager, 'pid') as pid:
+            pid.__get__ = mock.Mock(return_value=4)
+            manager = ep.ProcessManager(self.conf, 'uuid')
+            self.assertFalse(manager.active)
+            self.execute.assert_called_once_with(['cat', '/proc/4/cmdline'],
+                                                 'sudo')
diff --git a/quantum/tests/unit/test_metadata_agent.py b/quantum/tests/unit/test_metadata_agent.py
new file mode 100644 (file)
index 0000000..634fa66
--- /dev/null
@@ -0,0 +1,432 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
+
+import socket
+
+import mock
+import unittest2 as unittest
+import webob
+
+from quantum.agent.metadata import agent
+
+
+class FakeConf(object):
+    admin_user = 'quantum'
+    admin_password = 'password'
+    admin_tenant_name = 'tenant'
+    auth_url = 'http://127.0.0.1'
+    auth_strategy = 'keystone'
+    auth_region = 'region'
+    nova_metadata_ip = '9.9.9.9'
+    nova_metadata_port = 8775
+    metadata_proxy_shared_secret = 'secret'
+
+
+class TestMetadataProxyHandler(unittest.TestCase):
+    def setUp(self):
+        self.qclient_p = mock.patch('quantumclient.v2_0.client.Client')
+        self.qclient = self.qclient_p.start()
+
+        self.log_p = mock.patch.object(agent, 'LOG')
+        self.log = self.log_p.start()
+
+        self.handler = agent.MetadataProxyHandler(FakeConf)
+
+    def tearDown(self):
+        self.log_p.stop()
+        self.qclient_p.stop()
+
+    def test_call(self):
+        req = mock.Mock()
+        with mock.patch.object(self.handler, '_get_instance_id') as get_id:
+            get_id.return_value = 'id'
+            with mock.patch.object(self.handler, '_proxy_request') as proxy:
+                proxy.return_value = 'value'
+
+                retval = self.handler(req)
+                self.assertEqual(retval, 'value')
+
+    def test_call_no_instance_match(self):
+        req = mock.Mock()
+        with mock.patch.object(self.handler, '_get_instance_id') as get_id:
+            get_id.return_value = None
+            retval = self.handler(req)
+            self.assertIsInstance(retval, webob.exc.HTTPNotFound)
+
+    def test_call_internal_server_error(self):
+        req = mock.Mock()
+        with mock.patch.object(self.handler, '_get_instance_id') as get_id:
+            get_id.side_effect = Exception
+            retval = self.handler(req)
+            self.assertIsInstance(retval, webob.exc.HTTPInternalServerError)
+            self.assertEqual(len(self.log.mock_calls), 2)
+
+    def _get_instance_id_helper(self, headers, list_ports_retval,
+                                networks=None, router_id=None):
+        headers['X-Forwarded-For'] = '192.168.1.1'
+        req = mock.Mock(headers=headers)
+
+        def mock_list_ports(*args, **kwargs):
+            return {'ports': list_ports_retval.pop(0)}
+
+        self.qclient.return_value.list_ports.side_effect = mock_list_ports
+        retval = self.handler._get_instance_id(req)
+
+        expected = [
+            mock.call(
+                username=FakeConf.admin_user,
+                tenant_name=FakeConf.admin_tenant_name,
+                region_name=FakeConf.auth_region,
+                auth_url=FakeConf.auth_url,
+                password=FakeConf.admin_password,
+                auth_strategy=FakeConf.auth_strategy)
+        ]
+
+        if router_id:
+            expected.append(
+                mock.call().list_ports(
+                    device_id=router_id,
+                    device_owner='network:router_interface'
+                )
+            )
+
+        expected.append(
+            mock.call().list_ports(
+                network_id=networks or [],
+                fixed_ips=['ip_address=192.168.1.1'])
+        )
+
+        self.qclient.assert_has_calls(expected)
+
+        return retval
+
+    def test_get_instance_id_router_id(self):
+        router_id = 'the_id'
+        headers = {
+            'X-Quantum-Router-ID': router_id
+        }
+
+        networks = ['net1', 'net2']
+        ports = [
+            [{'network_id': 'net1'}, {'network_id': 'net2'}],
+            [{'device_id': 'device_id'}]
+        ]
+
+        self.assertEqual(
+            self._get_instance_id_helper(headers, ports, networks=networks,
+                                         router_id=router_id),
+            'device_id'
+        )
+
+    def test_get_instance_id_router_id_no_match(self):
+        router_id = 'the_id'
+        headers = {
+            'X-Quantum-Router-ID': router_id
+        }
+
+        networks = ['net1', 'net2']
+        ports = [
+            [{'network_id': 'net1'}, {'network_id': 'net2'}],
+            []
+        ]
+
+        self.assertIsNone(
+            self._get_instance_id_helper(headers, ports, networks=networks,
+                                         router_id=router_id),
+        )
+
+    def test_get_instance_id_network_id(self):
+        network_id = 'the_id'
+        headers = {
+            'X-Quantum-Network-ID': network_id
+        }
+
+        ports = [
+            [{'device_id': 'device_id'}]
+        ]
+
+        self.assertEqual(
+            self._get_instance_id_helper(headers, ports, networks=['the_id']),
+            'device_id'
+        )
+
+    def test_get_instance_id_network_id_no_match(self):
+        network_id = 'the_id'
+        headers = {
+            'X-Quantum-Network-ID': network_id
+        }
+
+        ports = [[]]
+
+        self.assertIsNone(
+            self._get_instance_id_helper(headers, ports, networks=['the_id'])
+        )
+
+    def test_proxy_request_200(self):
+        req = mock.Mock(path_info='/the_path', query_string='')
+        resp = mock.Mock(status=200)
+        with mock.patch.object(self.handler, '_sign_instance_id') as sign:
+            sign.return_value = 'signed'
+            with mock.patch('httplib2.Http') as mock_http:
+                mock_http.return_value.request.return_value = (resp, 'content')
+
+                retval = self.handler._proxy_request('the_id', req)
+                mock_http.assert_has_calls([
+                    mock.call().request(
+                        'http://9.9.9.9:8775/the_path',
+                        headers={
+                            'X-Instance-ID-Signature': 'signed',
+                            'X-Instance-ID': 'the_id'
+                        }
+                    )]
+                )
+
+                self.assertEqual(retval, 'content')
+
+    def test_proxy_request_403(self):
+        req = mock.Mock(path_info='/the_path', query_string='')
+        resp = mock.Mock(status=403)
+        with mock.patch.object(self.handler, '_sign_instance_id') as sign:
+            sign.return_value = 'signed'
+            with mock.patch('httplib2.Http') as mock_http:
+                mock_http.return_value.request.return_value = (resp, 'content')
+
+                retval = self.handler._proxy_request('the_id', req)
+                mock_http.assert_has_calls([
+                    mock.call().request(
+                        'http://9.9.9.9:8775/the_path',
+                        headers={
+                            'X-Instance-ID-Signature': 'signed',
+                            'X-Instance-ID': 'the_id'
+                        }
+                    )]
+                )
+
+                self.assertIsInstance(retval, webob.exc.HTTPForbidden)
+
+    def test_proxy_request_404(self):
+        req = mock.Mock(path_info='/the_path', query_string='')
+        resp = mock.Mock(status=404)
+        with mock.patch.object(self.handler, '_sign_instance_id') as sign:
+            sign.return_value = 'signed'
+            with mock.patch('httplib2.Http') as mock_http:
+                mock_http.return_value.request.return_value = (resp, 'content')
+
+                retval = self.handler._proxy_request('the_id', req)
+                mock_http.assert_has_calls([
+                    mock.call().request(
+                        'http://9.9.9.9:8775/the_path',
+                        headers={
+                            'X-Instance-ID-Signature': 'signed',
+                            'X-Instance-ID': 'the_id'
+                        }
+                    )]
+                )
+
+                self.assertIsInstance(retval, webob.exc.HTTPNotFound)
+
+    def test_proxy_request_500(self):
+        req = mock.Mock(path_info='/the_path', query_string='')
+        resp = mock.Mock(status=500)
+        with mock.patch.object(self.handler, '_sign_instance_id') as sign:
+            sign.return_value = 'signed'
+            with mock.patch('httplib2.Http') as mock_http:
+                mock_http.return_value.request.return_value = (resp, 'content')
+
+                retval = self.handler._proxy_request('the_id', req)
+                mock_http.assert_has_calls([
+                    mock.call().request(
+                        'http://9.9.9.9:8775/the_path',
+                        headers={
+                            'X-Instance-ID-Signature': 'signed',
+                            'X-Instance-ID': 'the_id'
+                        }
+                    )]
+                )
+
+                self.assertIsInstance(
+                    retval,
+                    webob.exc.HTTPInternalServerError)
+
+    def test_proxy_request_other_code(self):
+        req = mock.Mock(path_info='/the_path', query_string='')
+        resp = mock.Mock(status=302)
+        with mock.patch.object(self.handler, '_sign_instance_id') as sign:
+            sign.return_value = 'signed'
+            with mock.patch('httplib2.Http') as mock_http:
+                mock_http.return_value.request.return_value = (resp, 'content')
+
+                with self.assertRaises(Exception) as e:
+                    self.handler._proxy_request('the_id', req)
+                    self.assertIn('302', str(e))
+
+                    mock_http.assert_has_calls([
+                        mock.call().request(
+                            'http://9.9.9.9:8775/the_path',
+                            headers={
+                                'X-Instance-ID-Signature': 'signed',
+                                'X-Instance-ID': 'the_id'
+                            }
+                        )]
+                    )
+
+    def test_sign_instance_id(self):
+        self.assertEqual(
+            self.handler._sign_instance_id('foo'),
+            '773ba44693c7553d6ee20f61ea5d2757a9a4f4a44d2841ae4e95b52e4cd62db4'
+        )
+
+
+class TestUnixDomainHttpProtocol(unittest.TestCase):
+    def test_init_empty_client(self):
+        u = agent.UnixDomainHttpProtocol(mock.Mock(), '', mock.Mock())
+        self.assertEqual(u.client_address, ('<local>', 0))
+
+    def test_init_with_client(self):
+        u = agent.UnixDomainHttpProtocol(mock.Mock(), 'foo', mock.Mock())
+        self.assertEqual(u.client_address, 'foo')
+
+
+class TestUnixDomainWSGIServer(unittest.TestCase):
+    def setUp(self):
+        self.eventlet_p = mock.patch.object(agent, 'eventlet')
+        self.eventlet = self.eventlet_p.start()
+        self.server = agent.UnixDomainWSGIServer('test')
+
+    def tearDown(self):
+        self.eventlet_p.stop()
+
+    def test_start(self):
+        mock_app = mock.Mock()
+        with mock.patch.object(self.server, 'pool') as pool:
+            self.server.start(mock_app, '/the/path')
+            self.eventlet.assert_has_calls([
+                mock.call.listen(
+                    '/the/path',
+                    family=socket.AF_UNIX,
+                    backlog=128
+                )]
+            )
+            pool.spawn_n.assert_called_once_with(
+                self.server._run,
+                mock_app,
+                self.eventlet.listen.return_value
+            )
+
+    def test_run(self):
+        with mock.patch.object(agent, 'logging') as logging:
+            self.server._run('app', 'sock')
+
+            self.eventlet.wsgi.server.called_once_with(
+                'sock',
+                'app',
+                self.server.pool,
+                agent.UnixDomainHttpProtocol,
+                mock.ANY
+            )
+            self.assertTrue(len(logging.mock_calls))
+
+
+class TestUnixDomainMetadataProxy(unittest.TestCase):
+    def setUp(self):
+        self.cfg_p = mock.patch.object(agent, 'cfg')
+        self.cfg = self.cfg_p.start()
+        self.cfg.CONF.metadata_proxy_socket = '/the/path'
+
+    def tearDown(self):
+        self.cfg_p.stop()
+
+    def test_init_doesnot_exists(self):
+        with mock.patch('os.path.isdir') as isdir:
+            with mock.patch('os.makedirs') as makedirs:
+                isdir.return_value = False
+                p = agent.UnixDomainMetadataProxy(mock.Mock())
+
+                isdir.assert_called_once_with('/the')
+                makedirs.assert_called_once_with('/the', 0755)
+
+    def test_init_exists(self):
+        with mock.patch('os.path.isdir') as isdir:
+            with mock.patch('os.unlink') as unlink:
+                isdir.return_value = True
+                p = agent.UnixDomainMetadataProxy(mock.Mock())
+
+                isdir.assert_called_once_with('/the')
+                unlink.assert_called_once_with('/the/path')
+
+    def test_init_exists_unlink_no_file(self):
+        with mock.patch('os.path.isdir') as isdir:
+            with mock.patch('os.unlink') as unlink:
+                with mock.patch('os.path.exists') as exists:
+                    isdir.return_value = True
+                    exists.return_value = False
+                    unlink.side_effect = OSError
+
+                    p = agent.UnixDomainMetadataProxy(mock.Mock())
+
+                    isdir.assert_called_once_with('/the')
+                    unlink.assert_called_once_with('/the/path')
+                    exists.assert_called_once_with('/the/path')
+
+    def test_init_exists_unlink_fails_file_still_exists(self):
+        with mock.patch('os.path.isdir') as isdir:
+            with mock.patch('os.unlink') as unlink:
+                with mock.patch('os.path.exists') as exists:
+                    isdir.return_value = True
+                    exists.return_value = True
+                    unlink.side_effect = OSError
+
+                    with self.assertRaises(OSError):
+                        p = agent.UnixDomainMetadataProxy(mock.Mock())
+
+                    isdir.assert_called_once_with('/the')
+                    unlink.assert_called_once_with('/the/path')
+                    exists.assert_called_once_with('/the/path')
+
+    def test_run(self):
+        with mock.patch.object(agent, 'MetadataProxyHandler') as handler:
+            with mock.patch.object(agent, 'UnixDomainWSGIServer') as server:
+                with mock.patch('os.path.isdir') as isdir:
+                    with mock.patch('os.makedirs') as makedirs:
+                        isdir.return_value = False
+
+                        p = agent.UnixDomainMetadataProxy(self.cfg.CONF)
+                        p.run()
+
+                        isdir.assert_called_once_with('/the')
+                        makedirs.assert_called_once_with('/the', 0755)
+                        server.assert_has_calls([
+                            mock.call('quantum-metadata-agent'),
+                            mock.call().start(handler.return_value,
+                                              '/the/path'),
+                            mock.call().wait()]
+                        )
+
+    def test_main(self):
+        with mock.patch.object(agent, 'UnixDomainMetadataProxy') as proxy:
+            with mock.patch('eventlet.monkey_patch') as eventlet:
+                with mock.patch.object(agent, 'config') as config:
+                    with mock.patch.object(agent, 'cfg') as cfg:
+                        agent.main()
+
+                        self.assertTrue(eventlet.called)
+                        self.assertTrue(config.setup_logging.called)
+                        proxy.assert_has_calls([
+                            mock.call(cfg.CONF),
+                            mock.call().run()]
+                        )
diff --git a/quantum/tests/unit/test_metadata_namespace_proxy.py b/quantum/tests/unit/test_metadata_namespace_proxy.py
new file mode 100644 (file)
index 0000000..09979f7
--- /dev/null
@@ -0,0 +1,292 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+#
+# Copyright 2012 New Dream Network, LLC (DreamHost)
+#
+#    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.
+#
+# @author: Mark McClain, DreamHost
+
+import socket
+
+import mock
+import unittest2 as unittest
+import webob
+
+from quantum.agent.metadata import namespace_proxy as ns_proxy
+
+
+class FakeConf(object):
+    admin_user = 'quantum'
+    admin_password = 'password'
+    admin_tenant_name = 'tenant'
+    auth_url = 'http://127.0.0.1'
+    auth_strategy = 'keystone'
+    auth_region = 'region'
+    nova_metadata_ip = '9.9.9.9'
+    nova_metadata_port = 8775
+    metadata_proxy_shared_secret = 'secret'
+
+
+class TestUnixDomainHttpConnection(unittest.TestCase):
+    def test_connect(self):
+        with mock.patch.object(ns_proxy, 'cfg') as cfg:
+            cfg.CONF.metadata_proxy_socket = '/the/path'
+            with mock.patch('socket.socket') as socket_create:
+                conn = ns_proxy.UnixDomainHTTPConnection('169.254.169.254',
+                                                         timeout=3)
+
+                conn.connect()
+
+                socket_create.assert_has_calls([
+                    mock.call(socket.AF_UNIX, socket.SOCK_STREAM),
+                    mock.call().settimeout(3),
+                    mock.call().connect('/the/path')]
+                )
+                self.assertEqual(conn.timeout, 3)
+
+
+class TestNetworkMetadataProxyHandler(unittest.TestCase):
+    def setUp(self):
+        self.log_p = mock.patch.object(ns_proxy, 'LOG')
+        self.log = self.log_p.start()
+
+        self.handler = ns_proxy.NetworkMetadataProxyHandler('router_id')
+
+    def tearDown(self):
+        self.log_p.stop()
+
+    def test_call(self):
+        req = mock.Mock(headers={})
+        with mock.patch.object(self.handler, '_proxy_request') as proxy_req:
+            proxy_req.return_value = 'value'
+
+            retval = self.handler(req)
+            self.assertEqual(retval, 'value')
+            proxy_req.assert_called_once_with(req.remote_addr,
+                                              req.path_info,
+                                              req.query_string)
+
+    def test_no_argument_passed_to_init(self):
+        with self.assertRaises(ValueError):
+            ns_proxy.NetworkMetadataProxyHandler()
+
+    def test_call_internal_server_error(self):
+        req = mock.Mock(headers={})
+        with mock.patch.object(self.handler, '_proxy_request') as proxy_req:
+            proxy_req.side_effect = Exception
+            retval = self.handler(req)
+            self.assertIsInstance(retval, webob.exc.HTTPInternalServerError)
+            self.assertEqual(len(self.log.mock_calls), 2)
+            self.assertTrue(proxy_req.called)
+
+    def test_proxy_request_router_200(self):
+        self.handler.router_id = 'router_id'
+
+        resp = mock.Mock(status=200)
+        with mock.patch('httplib2.Http') as mock_http:
+            mock_http.return_value.request.return_value = (resp, 'content')
+
+            retval = self.handler._proxy_request('192.168.1.1',
+                                                 '/latest/meta-data',
+                                                 '')
+
+            mock_http.assert_has_calls([
+                mock.call().request(
+                    'http://169.254.169.254/latest/meta-data',
+                    headers={
+                        'X-Forwarded-For': '192.168.1.1',
+                        'X-Quantum-Router-ID': 'router_id'
+                    },
+                    connection_type=ns_proxy.UnixDomainHTTPConnection
+                )]
+            )
+
+            self.assertEqual(retval, 'content')
+
+    def test_proxy_request_network_200(self):
+        self.handler.network_id = 'network_id'
+
+        resp = mock.Mock(status=200)
+        with mock.patch('httplib2.Http') as mock_http:
+            mock_http.return_value.request.return_value = (resp, 'content')
+
+            retval = self.handler._proxy_request('192.168.1.1',
+                                                 '/latest/meta-data',
+                                                 '')
+
+            mock_http.assert_has_calls([
+                mock.call().request(
+                    'http://169.254.169.254/latest/meta-data',
+                    headers={
+                        'X-Forwarded-For': '192.168.1.1',
+                        'X-Quantum-Network-ID': 'network_id'
+                    },
+                    connection_type=ns_proxy.UnixDomainHTTPConnection
+                )]
+            )
+
+            self.assertEqual(retval, 'content')
+
+    def test_proxy_request_network_404(self):
+        self.handler.network_id = 'network_id'
+
+        resp = mock.Mock(status=404)
+        with mock.patch('httplib2.Http') as mock_http:
+            mock_http.return_value.request.return_value = (resp, '')
+
+            retval = self.handler._proxy_request('192.168.1.1',
+                                                 '/latest/meta-data',
+                                                 '')
+
+            mock_http.assert_has_calls([
+                mock.call().request(
+                    'http://169.254.169.254/latest/meta-data',
+                    headers={
+                        'X-Forwarded-For': '192.168.1.1',
+                        'X-Quantum-Network-ID': 'network_id'
+                    },
+                    connection_type=ns_proxy.UnixDomainHTTPConnection
+                )]
+            )
+
+            self.assertIsInstance(retval, webob.exc.HTTPNotFound)
+
+    def test_proxy_request_network_500(self):
+        self.handler.network_id = 'network_id'
+
+        resp = mock.Mock(status=500)
+        with mock.patch('httplib2.Http') as mock_http:
+            mock_http.return_value.request.return_value = (resp, '')
+
+            retval = self.handler._proxy_request('192.168.1.1',
+                                                 '/latest/meta-data',
+                                                 '')
+
+            mock_http.assert_has_calls([
+                mock.call().request(
+                    'http://169.254.169.254/latest/meta-data',
+                    headers={
+                        'X-Forwarded-For': '192.168.1.1',
+                        'X-Quantum-Network-ID': 'network_id'
+                    },
+                    connection_type=ns_proxy.UnixDomainHTTPConnection
+                )]
+            )
+
+            self.assertIsInstance(retval, webob.exc.HTTPInternalServerError)
+
+    def test_proxy_request_network_418(self):
+        self.handler.network_id = 'network_id'
+
+        resp = mock.Mock(status=418)
+        with mock.patch('httplib2.Http') as mock_http:
+            mock_http.return_value.request.return_value = (resp, '')
+
+            with self.assertRaises(Exception):
+                self.handler._proxy_request('192.168.1.1',
+                                            '/latest/meta-data',
+                                            '')
+
+            mock_http.assert_has_calls([
+                mock.call().request(
+                    'http://169.254.169.254/latest/meta-data',
+                    headers={
+                        'X-Forwarded-For': '192.168.1.1',
+                        'X-Quantum-Network-ID': 'network_id'
+                    },
+                    connection_type=ns_proxy.UnixDomainHTTPConnection
+                )]
+            )
+
+    def test_proxy_request_network_exception(self):
+        self.handler.network_id = 'network_id'
+
+        resp = mock.Mock(status=500)
+        with mock.patch('httplib2.Http') as mock_http:
+            mock_http.return_value.request.side_effect = Exception
+
+            with self.assertRaises(Exception):
+                self.handler._proxy_request('192.168.1.1',
+                                            '/latest/meta-data',
+                                            '')
+
+            mock_http.assert_has_calls([
+                mock.call().request(
+                    'http://169.254.169.254/latest/meta-data',
+                    headers={
+                        'X-Forwarded-For': '192.168.1.1',
+                        'X-Quantum-Network-ID': 'network_id'
+                    },
+                    connection_type=ns_proxy.UnixDomainHTTPConnection
+                )]
+            )
+
+
+class TestProxyDaemon(unittest.TestCase):
+    def test_init(self):
+        with mock.patch('quantum.agent.linux.daemon.Pidfile') as pf:
+            pd = ns_proxy.ProxyDaemon('pidfile', 9697, 'net_id', 'router_id')
+            self.assertEqual(pd.router_id, 'router_id')
+            self.assertEqual(pd.network_id, 'net_id')
+
+    def test_run(self):
+        with mock.patch('quantum.agent.linux.daemon.Pidfile') as pf:
+            with mock.patch('quantum.wsgi.Server') as Server:
+                pd = ns_proxy.ProxyDaemon('pidfile', 9697, 'net_id',
+                                          'router_id')
+                pd.run()
+                Server.assert_has_calls([
+                    mock.call('quantum-network-metadata-proxy'),
+                    mock.call().start(mock.ANY, 9697),
+                    mock.call().wait()]
+                )
+
+    def test_main(self):
+        with mock.patch.object(ns_proxy, 'ProxyDaemon') as daemon:
+            with mock.patch('eventlet.monkey_patch') as eventlet:
+                with mock.patch.object(ns_proxy, 'config') as config:
+                    with mock.patch.object(ns_proxy, 'cfg') as cfg:
+                        cfg.CONF.router_id = 'router_id'
+                        cfg.CONF.network_id = None
+                        cfg.CONF.metadata_port = 9697
+                        cfg.CONF.pid_file = 'pidfile'
+                        cfg.CONF.daemonize = True
+                        ns_proxy.main()
+
+                        self.assertTrue(eventlet.called)
+                        self.assertTrue(config.setup_logging.called)
+                        daemon.assert_has_calls([
+                            mock.call('pidfile', 9697, router_id='router_id',
+                                      network_id=None),
+                            mock.call().start()]
+                        )
+
+    def test_main_dont_fork(self):
+        with mock.patch.object(ns_proxy, 'ProxyDaemon') as daemon:
+            with mock.patch('eventlet.monkey_patch') as eventlet:
+                with mock.patch.object(ns_proxy, 'config') as config:
+                    with mock.patch.object(ns_proxy, 'cfg') as cfg:
+                        cfg.CONF.router_id = 'router_id'
+                        cfg.CONF.network_id = None
+                        cfg.CONF.metadata_port = 9697
+                        cfg.CONF.pid_file = 'pidfile'
+                        cfg.CONF.daemonize = False
+                        ns_proxy.main()
+
+                        self.assertTrue(eventlet.called)
+                        self.assertTrue(config.setup_logging.called)
+                        daemon.assert_has_calls([
+                            mock.call('pidfile', 9697, router_id='router_id',
+                                      network_id=None),
+                            mock.call().run()]
+                        )
index 7e410bfaed4aaffe385d6af47c51fc93dabbe380..9e2f89ee457f3f3ed568b111234186a4c0495f0a 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -123,6 +123,10 @@ setuptools.setup(
             'quantum-l3-agent = quantum.agent.l3_agent:main',
             'quantum-linuxbridge-agent ='
             'quantum.plugins.linuxbridge.agent.linuxbridge_quantum_agent:main',
+            'quantum-metadata-agent ='
+            'quantum.agent.metadata.agent:main',
+            'quantum-ns-metadata-proxy ='
+            'quantum.agent.metadata.namespace_proxy:main',
             'quantum-openvswitch-agent ='
             'quantum.plugins.openvswitch.agent.ovs_quantum_agent:main',
             'quantum-ryu-agent = '