diff --git a/bin/quantum-metadata-agent b/bin/quantum-metadata-agent new file mode 100755 index 0000000000..5d0ff8f6c2 --- /dev/null +++ b/bin/quantum-metadata-agent @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2012 Openstack, LLC. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from quantum.agent.metadata.agent import main +main() diff --git a/bin/quantum-ns-metadata-proxy b/bin/quantum-ns-metadata-proxy new file mode 100755 index 0000000000..fa65c5f489 --- /dev/null +++ b/bin/quantum-ns-metadata-proxy @@ -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() diff --git a/etc/l3_agent.ini b/etc/l3_agent.ini index c76b1af26b..f495436d04 100644 --- a/etc/l3_agent.ini +++ b/etc/l3_agent.ini @@ -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 index 0000000000..bb5aac160b --- /dev/null +++ b/etc/metadata_agent.ini @@ -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 = diff --git a/etc/quantum/rootwrap.d/l3.filters b/etc/quantum/rootwrap.d/l3.filters index a84800067c..9e0e561959 100644 --- a/etc/quantum/rootwrap.d/l3.filters +++ b/etc/quantum/rootwrap.d/l3.filters @@ -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 diff --git a/quantum/agent/l3_agent.py b/quantum/agent/l3_agent.py index 154d7c4fce..e9c339306e 100644 --- a/quantum/agent/l3_agent.py +++ b/quantum/agent/l3_agent.py @@ -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 index 0000000000..e73d40a754 --- /dev/null +++ b/quantum/agent/linux/daemon.py @@ -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 index 0000000000..b96956c5d0 --- /dev/null +++ b/quantum/agent/linux/external_process.py @@ -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 index 0000000000..6e2c062056 --- /dev/null +++ b/quantum/agent/metadata/__init__.py @@ -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 index 0000000000..fe8529904e --- /dev/null +++ b/quantum/agent/metadata/agent.py @@ -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 = ('', 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 index 0000000000..ec0b65ed8a --- /dev/null +++ b/quantum/agent/metadata/namespace_proxy.py @@ -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() diff --git a/quantum/tests/unit/test_l3_agent.py b/quantum/tests/unit/test_l3_agent.py index 70b336c476..44f16ba9d7 100644 --- a/quantum/tests/unit/test_l3_agent.py +++ b/quantum/tests/unit/test_l3_agent.py @@ -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 index 0000000000..0cc6fff544 --- /dev/null +++ b/quantum/tests/unit/test_linux_daemon.py @@ -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 index 0000000000..86040adba0 --- /dev/null +++ b/quantum/tests/unit/test_linux_external_process.py @@ -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 index 0000000000..634fa6680d --- /dev/null +++ b/quantum/tests/unit/test_metadata_agent.py @@ -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, ('', 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 index 0000000000..09979f7ad2 --- /dev/null +++ b/quantum/tests/unit/test_metadata_namespace_proxy.py @@ -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()] + ) diff --git a/setup.py b/setup.py index 7e410bfaed..9e2f89ee45 100644 --- 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 = '