diff --git a/.gitignore b/.gitignore index 92078f6f0..a99705a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,11 @@ build .tox cover venv +.venv *.sublime-workspace *.sqlite var/* etc/*.conf +etc/*.ini AUTHORS ChangeLog diff --git a/README.md b/README.md index 3c406bab2..de8578053 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,40 @@ +# Introduction -TODOs: +Moniker is an OpenStack inspired DNSaaS. + +# Developer Guide: + +NOTE: This is probably incomplete! + +## Install Dependencies + +1. `apt-get install python-pip python-virtualenv python-setuptools-git` +1. `apt-get install rabbitmq-server bind9` +1. `apt-get build-dep python-lxml` + +## Install Moniker + +1. `virtualenv .venv` +1. `source .venv/bin/activate` +1. `python setup.py develop` +1. create config files (See `*.sample` in the `etc` folder) +1. Ensure the user you intend to run moniker as has passwordless sudo rights: + * `echo "$USER ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/90-moniker-$USER` + * `chmod 0440 /etc/sudoers.d/90-moniker-$USER` +1. Tell bind to load our zones: + * Open `/etc/bind/named.conf` + * Add `include "$CHECKOUT_PATH/var/bind9/zones.config";` to the end of the file + * `sudo service bind9 restart` + +## Run + +1. Open 3 consoles/screen sessions for each command: + * `./bin/moniker-api` + * `./bin/moniker-central` + * `./bin/moniker-agent-bind9` +1. Make use of the API.. + +# TODOs: * Documentation! * Fixup Bind9 agent implementation so it could be considered even remotely reliable @@ -7,4 +42,9 @@ TODOs: * Database migrations * Unit Tests!! * Integration with other OS servers eg Nova and Quantum -* Introduce Server Groups + * Listen for floating IP allocation/deallocation events - giving user access to + the necessary PTR record. + * Listen for server create/destroy events - creating DNS records as needed. + * Listen for server add/remove from security group events - creating "load balancing" DNS RR records as needed. +* Introduce Server Pools + * Server pools will allow a provider to 'schedule' a end users domain to one of many available DNS server pools diff --git a/bin/moniker-agent-bind9 b/bin/moniker-agent-bind9 index ec79894d9..6c7a256ec 100755 --- a/bin/moniker-agent-bind9 +++ b/bin/moniker-agent-bind9 @@ -32,7 +32,5 @@ cfg.CONF(sys.argv[1:], project='moniker', prog='moniker-agent-bind9', logging.setup('moniker') -serv = bind9_service.Service(cfg.CONF.host, cfg.CONF.agent_topic) - -launcher = service.launch(serv) +launcher = service.launch(bind9_service.Service()) launcher.wait() diff --git a/bin/moniker-api b/bin/moniker-api index 094bc7d87..e266d3779 100755 --- a/bin/moniker-api +++ b/bin/moniker-api @@ -15,29 +15,22 @@ # License for the specific language governing permissions and limitations # under the License. import sys +import eventlet from moniker.openstack.common import cfg from moniker.openstack.common import log as logging -from moniker.api import app +from moniker.openstack.common import service +from moniker.api import service as api_service -config_files = cfg.find_config_files(project='moniker', prog='moniker-api') +eventlet.monkey_patch() + +config_files = cfg.find_config_files(project='moniker', + prog='moniker-api') config_files.append('./etc/moniker-api.conf') -# Jerry Rig the keystone middleware. -from keystone.middleware import auth_token -auth_token.CONF = cfg.CONF -cfg.CONF.register_opts(auth_token.opts, group='keystone_authtoken') - cfg.CONF(sys.argv[1:], project='moniker', prog='moniker-api', default_config_files=config_files) logging.setup('moniker') -if cfg.CONF.verbose or cfg.CONF.debug: - app.debug = True - -if cfg.CONF.enable_keystone: - # Add Keystone Middleware - middleware_conf = {'delay_auth_decision': False} - app.wsgi_app = auth_token.AuthProtocol(app.wsgi_app, middleware_conf) - -app.run(host=cfg.CONF.api_host, port=cfg.CONF.api_port) +launcher = service.launch(api_service.Service()) +launcher.wait() diff --git a/bin/moniker-central b/bin/moniker-central index a98852955..520fc7ca0 100755 --- a/bin/moniker-central +++ b/bin/moniker-central @@ -32,7 +32,5 @@ cfg.CONF(sys.argv[1:], project='moniker', prog='moniker-central', logging.setup('moniker') -serv = central_service.Service(cfg.CONF.host, cfg.CONF.central_topic) - -launcher = service.launch(serv) +launcher = service.launch(central_service.Service()) launcher.wait() diff --git a/etc/moniker-api-paste.ini.sample b/etc/moniker-api-paste.ini.sample new file mode 100644 index 000000000..b9d1c5ca2 --- /dev/null +++ b/etc/moniker-api-paste.ini.sample @@ -0,0 +1,29 @@ +[composite:osapi_dns] +use = egg:Paste#urlmap +/v1: osapi_dns_api_v1 + +[composite:osapi_dns_api_v1] +use = call:moniker.api.auth:pipeline_factory +noauth = noauth osapi_dns_app_v1 +keystone = authtoken keystonecontext osapi_dns_app_v1 + +[app:osapi_dns_app_v1] +paste.app_factory = moniker.api.v1:factory + +[filter:noauth] +paste.filter_factory = moniker.api.auth:NoAuthMiddleware.factory + +[filter:keystonecontext] +paste.filter_factory = moniker.api.auth:KeystoneContextMiddleware.factory + +[filter:authtoken] +paste.filter_factory = keystone.middleware.auth_token:filter_factory +service_protocol = http +service_host = 127.0.0.1 +service_port = 5000 +auth_host = 127.0.0.1 +auth_port = 35357 +auth_protocol = http +admin_tenant_name = %SERVICE_TENANT_NAME% +admin_user = %SERVICE_USER% +admin_password = %SERVICE_PASSWORD% diff --git a/etc/moniker-api.conf.sample b/etc/moniker-api.conf.sample index 3de866c4d..e9763731e 100644 --- a/etc/moniker-api.conf.sample +++ b/etc/moniker-api.conf.sample @@ -20,10 +20,5 @@ control_exchange = moniker # allowed_rpc_exception_modules = moniker.exceptions, moniker.openstack.common.exception -[keystone_authtoken] -auth_host = 127.0.0.1 -auth_port = 35357 -auth_protocol = http -admin_tenant_name = admin -admin_user = admin -admin_password = password +# +auth_strategy = noauth diff --git a/moniker/__init__.py b/moniker/__init__.py index 3fe6094e3..5342d3ba6 100644 --- a/moniker/__init__.py +++ b/moniker/__init__.py @@ -28,12 +28,4 @@ cfg.CONF.register_opts([ help='Templates Path'), cfg.StrOpt('templates-path', default='/usr/share/moniker/templates', help='Templates Path'), - - # Temp Config Options - cfg.BoolOpt('enable-keystone', default=False, - help='Disable Keystone Integration'), - cfg.StrOpt('default-tenant', default='12345', - help='Tenant to use when keystone is disabled'), - cfg.StrOpt('default-user', default='12345', - help='User to use when keystone is disabled'), ]) diff --git a/moniker/agent/bind9.py b/moniker/agent/bind9.py index 7ac99971d..119efd8c2 100644 --- a/moniker/agent/bind9.py +++ b/moniker/agent/bind9.py @@ -18,7 +18,6 @@ import subprocess from jinja2 import Template from moniker.openstack.common import cfg from moniker.openstack.common import log as logging -from moniker.openstack.common import rpc from moniker.openstack.common.rpc import service as rpc_service from moniker.openstack.common.context import get_admin_context from moniker.central import api as central_api @@ -36,6 +35,11 @@ cfg.CONF.register_opts([ class Service(rpc_service.Service): def __init__(self, *args, **kwargs): + kwargs.update( + host=cfg.CONF.host, + topic=cfg.CONF.agent_topic + ) + super(Service, self).__init__(*args, **kwargs) # TODO: This is a hack to ensure the data dir is 100% up to date diff --git a/moniker/api/__init__.py b/moniker/api/__init__.py index 20ca1da6d..d0dce6fcd 100644 --- a/moniker/api/__init__.py +++ b/moniker/api/__init__.py @@ -16,37 +16,20 @@ import flask from moniker.openstack.common import cfg from moniker.openstack.common import jsonutils -from moniker.openstack.common.context import RequestContext -from moniker import central -from moniker.api import v1 -from moniker.api import debug -# Allows us to serialize datetime's etc -flask.helpers.json = jsonutils cfg.CONF.register_opts([ cfg.StrOpt('api_host', default='0.0.0.0', help='API Host'), cfg.IntOpt('api_port', default=9001, help='API Port Number'), + cfg.StrOpt('api_paste_config', default='moniker-api-paste.ini', + help='File name for the paste.deploy config for moniker-api'), + cfg.StrOpt('auth_strategy', default='noauth', + help='The strategy to use for auth. Supports noauth or ' + 'keystone'), ]) -app = flask.Flask('moniker.api') -# Blueprints -app.register_blueprint(v1.blueprint, url_prefix='/v1') -app.register_blueprint(debug.blueprint, url_prefix='/debug') - - -@app.before_request -def attach_context(): - request = flask.request - headers = request.headers - - if cfg.CONF.enable_keystone: - request.context = RequestContext(auth_tok=headers.get('X-Auth-Token'), - user=headers.get('X-User-ID'), - tenant=headers.get('X-Tenant-ID')) - else: - request.context = RequestContext(user=cfg.CONF.default_user, - tenant=cfg.CONF.default_tenant) +# Allows us to serialize datetime's etc +flask.helpers.json = jsonutils diff --git a/moniker/api/auth.py b/moniker/api/auth.py new file mode 100644 index 000000000..0fca3b0ec --- /dev/null +++ b/moniker/api/auth.py @@ -0,0 +1,51 @@ +# Copyright 2012 Managed I.T. +# +# Author: Kiall Mac Innes +# +# 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 moniker.openstack.common.context import RequestContext +from moniker.openstack.common import cfg +from moniker.openstack.common import log as logging +from moniker import wsgi + +LOG = logging.getLogger(__name__) + + +def pipeline_factory(loader, global_conf, **local_conf): + """ + A paste pipeline replica that keys off of auth_strategy. + + Code nabbed from cinder. + """ + pipeline = local_conf[cfg.CONF.auth_strategy] + pipeline = pipeline.split() + filters = [loader.get_filter(n) for n in pipeline[:-1]] + app = loader.get_app(pipeline[-1]) + filters.reverse() + for filter in filters: + app = filter(app) + return app + + +class KeystoneContextMiddleware(wsgi.Middleware): + def process_request(self, request): + headers = request.headers + context = RequestContext(auth_tok=headers.get('X-Auth-Token'), + user=headers.get('X-User-ID'), + tenant=headers.get('X-Tenant-ID')) + request.environ['context'] = context + + +class NoAuthMiddleware(wsgi.Middleware): + def process_request(self, request): + request.environ['context'] = RequestContext() diff --git a/moniker/api/service.py b/moniker/api/service.py new file mode 100644 index 000000000..dfa6644d7 --- /dev/null +++ b/moniker/api/service.py @@ -0,0 +1,43 @@ +# Copyright 2012 Managed I.T. +# +# Author: Kiall Mac Innes +# +# 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 paste import deploy +from moniker.openstack.common import log as logging +from moniker.openstack.common import wsgi +from moniker.openstack.common import cfg +from moniker import utils + + +LOG = logging.getLogger(__name__) + + +class Service(wsgi.Service): + def __init__(self, backlog=128, threads=1000): + super(Service, self).__init__(threads) + + self.host = cfg.CONF.api_host + self.port = cfg.CONF.api_port + self.backlog = backlog + + config_path = cfg.CONF.api_paste_config + config_path = utils.find_config(config_path) + + self.application = deploy.loadapp("config:%s" % config_path, + name='osapi_dns') + + def start(self): + return super(Service, self).start(application=self.application, + port=self.port, host=self.host, + backlog=self.backlog) diff --git a/moniker/api/v1/__init__.py b/moniker/api/v1/__init__.py index 1638196c8..1ac6df20f 100644 --- a/moniker/api/v1/__init__.py +++ b/moniker/api/v1/__init__.py @@ -20,3 +20,10 @@ blueprint = flask.Blueprint('v1', __name__) import moniker.api.v1.servers import moniker.api.v1.domains import moniker.api.v1.records + + +def factory(global_config, **local_conf): + app = flask.Flask('moniker.api.v1') + app.register_blueprint(blueprint) + + return app diff --git a/moniker/api/v1/domains.py b/moniker/api/v1/domains.py index 7df2a51b0..e240f19f2 100644 --- a/moniker/api/v1/domains.py +++ b/moniker/api/v1/domains.py @@ -43,7 +43,7 @@ def get_domains_schema(): @blueprint.route('/domains', methods=['POST']) def create_domain(): - context = flask.request.context + context = flask.request.environ.get('context') values = flask.request.json try: @@ -67,7 +67,7 @@ def create_domain(): @blueprint.route('/domains', methods=['GET']) def get_domains(): - context = flask.request.context + context = flask.request.environ.get('context') domains = central_api.get_domains(context) @@ -78,7 +78,7 @@ def get_domains(): @blueprint.route('/domains/', methods=['GET']) def get_domain(domain_id): - context = flask.request.context + context = flask.request.environ.get('context') try: domain = central_api.get_domain(context, domain_id) @@ -94,7 +94,7 @@ def get_domain(domain_id): @blueprint.route('/domains/', methods=['PUT']) def update_domain(domain_id): - context = flask.request.context + context = flask.request.environ.get('context') values = flask.request.json try: @@ -116,7 +116,7 @@ def update_domain(domain_id): @blueprint.route('/domains/', methods=['DELETE']) def delete_domain(domain_id): - context = flask.request.context + context = flask.request.environ.get('context') try: central_api.delete_domain(context, domain_id) diff --git a/moniker/api/v1/records.py b/moniker/api/v1/records.py index 443531cf9..6019e8e1a 100644 --- a/moniker/api/v1/records.py +++ b/moniker/api/v1/records.py @@ -44,7 +44,7 @@ def get_records_schema(): @blueprint.route('/domains//records', methods=['POST']) def create_record(domain_id): - context = flask.request.context + context = flask.request.environ.get('context') values = flask.request.json try: @@ -69,7 +69,7 @@ def create_record(domain_id): @blueprint.route('/domains//records', methods=['GET']) def get_records(domain_id): - context = flask.request.context + context = flask.request.environ.get('context') records = central_api.get_records(context, domain_id) @@ -78,7 +78,7 @@ def get_records(domain_id): @blueprint.route('/domains//records/', methods=['GET']) def get_record(domain_id, record_id): - context = flask.request.context + context = flask.request.environ.get('context') try: record = central_api.get_record(context, domain_id, record_id) @@ -94,7 +94,7 @@ def get_record(domain_id, record_id): @blueprint.route('/domains//records/', methods=['PUT']) def update_record(domain_id, record_id): - context = flask.request.context + context = flask.request.environ.get('context') values = flask.request.json try: @@ -118,7 +118,7 @@ def update_record(domain_id, record_id): @blueprint.route('/domains//records/', methods=['DELETE']) def delete_record(domain_id, record_id): - context = flask.request.context + context = flask.request.environ.get('context') try: central_api.delete_record(context, domain_id, record_id) diff --git a/moniker/api/v1/servers.py b/moniker/api/v1/servers.py index fe41cf912..bcd8a14fc 100644 --- a/moniker/api/v1/servers.py +++ b/moniker/api/v1/servers.py @@ -42,7 +42,7 @@ def get_servers_schema(): @blueprint.route('/servers', methods=['POST']) def create_server(): - context = flask.request.context + context = flask.request.environ.get('context') values = flask.request.json try: @@ -66,7 +66,7 @@ def create_server(): @blueprint.route('/servers', methods=['GET']) def get_servers(): - context = flask.request.context + context = flask.request.environ.get('context') servers = central_api.get_servers(context) @@ -77,7 +77,7 @@ def get_servers(): @blueprint.route('/servers/', methods=['GET']) def get_server(server_id): - context = flask.request.context + context = flask.request.environ.get('context') try: server = central_api.get_server(context, server_id) @@ -93,7 +93,7 @@ def get_server(server_id): @blueprint.route('/servers/', methods=['PUT']) def update_server(server_id): - context = flask.request.context + context = flask.request.environ.get('context') values = flask.request.json try: @@ -115,7 +115,7 @@ def update_server(server_id): @blueprint.route('/servers/', methods=['DELETE']) def delete_server(server_id): - context = flask.request.context + context = flask.request.environ.get('context') try: central_api.delete_server(context, server_id) diff --git a/moniker/central/service.py b/moniker/central/service.py index a455f78a5..297feb088 100644 --- a/moniker/central/service.py +++ b/moniker/central/service.py @@ -15,9 +15,7 @@ # under the License. from moniker.openstack.common import cfg from moniker.openstack.common import log as logging -from moniker.openstack.common import rpc from moniker.openstack.common.rpc import service as rpc_service -from moniker import exceptions from moniker import database from moniker import utils from moniker.agent import api as agent_api @@ -27,8 +25,12 @@ LOG = logging.getLogger(__name__) class Service(rpc_service.Service): - def __init__(self, *args, **kwargs): + kwargs.update( + host=cfg.CONF.host, + topic=cfg.CONF.central_topic + ) + super(Service, self).__init__(*args, **kwargs) self.init_database() diff --git a/moniker/database/__init__.py b/moniker/database/__init__.py index d1904204e..aa29833b0 100644 --- a/moniker/database/__init__.py +++ b/moniker/database/__init__.py @@ -73,3 +73,9 @@ def get_driver(*args, **kwargs): from moniker.database.sqlalchemy import Sqlalchemy return Sqlalchemy(*args, **kwargs) + + +def reinitialize(*args, **kwargs): + """ Reset the DB to default - Used for testing purposes """ + from moniker.database.sqlalchemy.session import reset_session + reset_session(*args, **kwargs) diff --git a/moniker/database/sqlalchemy/__init__.py b/moniker/database/sqlalchemy/__init__.py index daa7bcb13..0b613016c 100644 --- a/moniker/database/sqlalchemy/__init__.py +++ b/moniker/database/sqlalchemy/__init__.py @@ -14,7 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound +from sqlalchemy.orm.exc import NoResultFound from moniker.openstack.common import cfg from moniker.openstack.common import log as logging from moniker import exceptions @@ -22,6 +22,7 @@ from moniker.database import BaseDatabase from moniker.database.sqlalchemy import models from moniker.database.sqlalchemy.session import get_session + LOG = logging.getLogger(__name__) cfg.CONF.register_opts([ @@ -33,7 +34,11 @@ cfg.CONF.register_opts([ class Sqlalchemy(BaseDatabase): def __init__(self): self.session = get_session() - models.Base.metadata.create_all(self.session.bind) # HACK: Remove me + self._initialize_database() # HACK: Remove me + + def _initialize_database(self): + """ Semi-Private Method to create the database schema """ + models.Base.metadata.create_all(self.session.bind) # Server Methods def create_server(self, context, values): diff --git a/moniker/database/sqlalchemy/models.py b/moniker/database/sqlalchemy/models.py index 5bcb44646..1bf8b3f65 100644 --- a/moniker/database/sqlalchemy/models.py +++ b/moniker/database/sqlalchemy/models.py @@ -14,8 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. from uuid import uuid4 -from sqlalchemy import (Column, DateTime, Boolean, String, Integer, ForeignKey, - Enum) +from sqlalchemy import Column, DateTime, String, Integer, ForeignKey, Enum from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import relationship, backref, object_mapper from sqlalchemy.ext.declarative import declarative_base @@ -112,13 +111,13 @@ class Server(Base): name = Column(String, nullable=False, unique=True) ipv4 = Column(Inet, nullable=False, unique=True) - ipv6 = Column(Inet, default=None, unique=True) + ipv6 = Column(Inet, default=None, nullable=True, unique=True) class Domain(Base): __tablename__ = 'domains' - tenant_id = Column(String, nullable=False) + tenant_id = Column(String, default=None, nullable=True) name = Column(String, nullable=False, unique=True) email = Column(String, nullable=False) diff --git a/moniker/database/sqlalchemy/session.py b/moniker/database/sqlalchemy/session.py index 813b8921d..c02170a7a 100644 --- a/moniker/database/sqlalchemy/session.py +++ b/moniker/database/sqlalchemy/session.py @@ -52,3 +52,10 @@ def get_engine(): engine.connect() return engine + + +def reset_session(): + global _ENGINE, _SESSION + + _ENGINE = None + _SESSION = None diff --git a/moniker/exceptions.py b/moniker/exceptions.py index 62f0659a2..ee4a45182 100644 --- a/moniker/exceptions.py +++ b/moniker/exceptions.py @@ -19,6 +19,10 @@ class Base(Exception): pass +class ConfigNotFound(Base): + pass + + class InvalidObject(Base): pass diff --git a/moniker/openstack/common/rpc/common.py b/moniker/openstack/common/rpc/common.py index 02bf3c35f..e4e10cb13 100644 --- a/moniker/openstack/common/rpc/common.py +++ b/moniker/openstack/common/rpc/common.py @@ -162,7 +162,9 @@ class Connection(object): raise NotImplementedError() def consume_in_thread_group(self, thread_group): - """Spawn a thread to handle incoming messages in the supplied ThreadGroup. + """ + Spawn a thread to handle incoming messages in the supplied + ThreadGroup. Spawn a thread that will be responsible for handling all incoming messages for consumers that were set up on this connection. diff --git a/moniker/openstack/common/wsgi.py b/moniker/openstack/common/wsgi.py new file mode 100644 index 000000000..84034d6c9 --- /dev/null +++ b/moniker/openstack/common/wsgi.py @@ -0,0 +1,728 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +"""Utility methods for working with WSGI servers.""" + +import datetime +import eventlet +import eventlet.wsgi + +eventlet.patcher.monkey_patch(all=False, socket=True) + +import routes +import routes.middleware +import sys +import webob.dec +import webob.exc +from xml.dom import minidom +from xml.parsers import expat + +from moniker.openstack.common import exception +from moniker.openstack.common.gettextutils import _ +from moniker.openstack.common import log as logging +from moniker.openstack.common import jsonutils +from moniker.openstack.common import service + + +LOG = logging.getLogger(__name__) + + +def run_server(application, port): + """Run a WSGI server with the given application.""" + sock = eventlet.listen(('0.0.0.0', port)) + eventlet.wsgi.server(sock, application) + + +class Service(service.Service): + """ + Provides a Service API for wsgi servers. + + This gives us the ability to launch wsgi servers with the + Launcher classes in service.py. + """ + + def __init__(self, threads=1000): + super(Service, self).__init__() + self.pool = eventlet.GreenPool(threads) + + def start(self, application, port, host='0.0.0.0', backlog=128): + """Start serving this service using the provided server instance. + + :returns: None + + """ + super(Service, self).start() + socket = eventlet.listen((host, port), backlog=backlog) + self.pool.spawn_n(self._run, application, socket) + + def stop(self): + """Stop serving this API. + + :returns: None + + """ + super(Service, self).stop() + + def wait(self): + """Wait until all servers have completed running.""" + super(Service, self).wait() + try: + self.pool.waitall() + except KeyboardInterrupt: + pass + + def _run(self, application, socket): + """Start a WSGI server in a new green thread.""" + logger = logging.getLogger('eventlet.wsgi.server') + eventlet.wsgi.server(socket, application, custom_pool=self.pool, + log=logging.WritableLogger(logger)) + + +class Middleware(object): + """ + Base WSGI middleware wrapper. These classes require an application to be + initialized that will be called next. By default the middleware will + simply call its wrapped app, or you can override __call__ to customize its + behavior. + """ + + def __init__(self, application): + self.application = application + + def process_request(self, req): + """ + Called on each request. + + If this returns None, the next application down the stack will be + executed. If it returns a response then that response will be returned + and execution will stop here. + """ + return None + + def process_response(self, response): + """Do whatever you'd like to the response.""" + return response + + @webob.dec.wsgify + def __call__(self, req): + response = self.process_request(req) + if response: + return response + response = req.get_response(self.application) + return self.process_response(response) + + +class Debug(Middleware): + """ + Helper class that can be inserted into any WSGI application chain + to get information about the request and response. + """ + + @webob.dec.wsgify + def __call__(self, req): + print ("*" * 40) + " REQUEST ENVIRON" + for key, value in req.environ.items(): + print key, "=", value + print + resp = req.get_response(self.application) + + print ("*" * 40) + " RESPONSE HEADERS" + for (key, value) in resp.headers.iteritems(): + print key, "=", value + print + + resp.app_iter = self.print_generator(resp.app_iter) + + return resp + + @staticmethod + def print_generator(app_iter): + """ + Iterator that prints the contents of a wrapper string iterator + when iterated. + """ + print ("*" * 40) + " BODY" + for part in app_iter: + sys.stdout.write(part) + sys.stdout.flush() + yield part + print + + +class Router(object): + + """ + WSGI middleware that maps incoming requests to WSGI apps. + """ + + def __init__(self, mapper): + """ + Create a router for the given routes.Mapper. + + Each route in `mapper` must specify a 'controller', which is a + WSGI app to call. You'll probably want to specify an 'action' as + well and have your controller be a wsgi.Controller, who will route + the request to the action method. + + Examples: + mapper = routes.Mapper() + sc = ServerController() + + # Explicit mapping of one route to a controller+action + mapper.connect(None, "/svrlist", controller=sc, action="list") + + # Actions are all implicitly defined + mapper.resource("server", "servers", controller=sc) + + # Pointing to an arbitrary WSGI app. You can specify the + # {path_info:.*} parameter so the target app can be handed just that + # section of the URL. + mapper.connect(None, "/v1.0/{path_info:.*}", controller=BlogApp()) + """ + self.map = mapper + self._router = routes.middleware.RoutesMiddleware(self._dispatch, + self.map) + + @webob.dec.wsgify + def __call__(self, req): + """ + Route the incoming request to a controller based on self.map. + If no match, return a 404. + """ + return self._router + + @staticmethod + @webob.dec.wsgify + def _dispatch(req): + """ + Called by self._router after matching the incoming request to a route + and putting the information into req.environ. Either returns 404 + or the routed WSGI app's response. + """ + match = req.environ['wsgiorg.routing_args'][1] + if not match: + return webob.exc.HTTPNotFound() + app = match['controller'] + return app + + +class Request(webob.Request): + """Add some Openstack API-specific logic to the base webob.Request.""" + + default_request_content_types = ('application/json', 'application/xml') + default_accept_types = ('application/json', 'application/xml') + default_accept_type = 'application/json' + + def best_match_content_type(self, supported_content_types=None): + """Determine the requested response content-type. + + Based on the query extension then the Accept header. + Defaults to default_accept_type if we don't find a preference + + """ + supported_content_types = (supported_content_types or + self.default_accept_types) + + parts = self.path.rsplit('.', 1) + if len(parts) > 1: + ctype = 'application/{0}'.format(parts[1]) + if ctype in supported_content_types: + return ctype + + bm = self.accept.best_match(supported_content_types) + return bm or self.default_accept_type + + def get_content_type(self, allowed_content_types=None): + """Determine content type of the request body. + + Does not do any body introspection, only checks header + + """ + if not "Content-Type" in self.headers: + return None + + content_type = self.content_type + allowed_content_types = (allowed_content_types or + self.default_request_content_types) + + if content_type not in allowed_content_types: + raise exception.InvalidContentType(content_type=content_type) + return content_type + + +class Resource(object): + """ + WSGI app that handles (de)serialization and controller dispatch. + + Reads routing information supplied by RoutesMiddleware and calls + the requested action method upon its deserializer, controller, + and serializer. Those three objects may implement any of the basic + controller action methods (create, update, show, index, delete) + along with any that may be specified in the api router. A 'default' + method may also be implemented to be used in place of any + non-implemented actions. Deserializer methods must accept a request + argument and return a dictionary. Controller methods must accept a + request argument. Additionally, they must also accept keyword + arguments that represent the keys returned by the Deserializer. They + may raise a webob.exc exception or return a dict, which will be + serialized by requested content type. + """ + def __init__(self, controller, deserializer=None, serializer=None): + """ + :param controller: object that implement methods created by routes lib + :param deserializer: object that supports webob request deserialization + through controller-like actions + :param serializer: object that supports webob response serialization + through controller-like actions + """ + self.controller = controller + self.serializer = serializer or ResponseSerializer() + self.deserializer = deserializer or RequestDeserializer() + + @webob.dec.wsgify(RequestClass=Request) + def __call__(self, request): + """WSGI method that controls (de)serialization and method dispatch.""" + + try: + action, action_args, accept = self.deserialize_request(request) + except exception.InvalidContentType: + msg = _("Unsupported Content-Type") + return webob.exc.HTTPUnsupportedMediaType(explanation=msg) + except exception.MalformedRequestBody: + msg = _("Malformed request body") + return webob.exc.HTTPBadRequest(explanation=msg) + + action_result = self.execute_action(action, request, **action_args) + try: + return self.serialize_response(action, action_result, accept) + # return unserializable result (typically a webob exc) + except Exception: + return action_result + + def deserialize_request(self, request): + return self.deserializer.deserialize(request) + + def serialize_response(self, action, action_result, accept): + return self.serializer.serialize(action_result, accept, action) + + def execute_action(self, action, request, **action_args): + return self.dispatch(self.controller, action, request, **action_args) + + def dispatch(self, obj, action, *args, **kwargs): + """Find action-specific method on self and call it.""" + try: + method = getattr(obj, action) + except AttributeError: + method = getattr(obj, 'default') + + return method(*args, **kwargs) + + def get_action_args(self, request_environment): + """Parse dictionary created by routes library.""" + try: + args = request_environment['wsgiorg.routing_args'][1].copy() + except Exception: + return {} + + try: + del args['controller'] + except KeyError: + pass + + try: + del args['format'] + except KeyError: + pass + + return args + + +class ActionDispatcher(object): + """Maps method name to local methods through action name.""" + + def dispatch(self, *args, **kwargs): + """Find and call local method.""" + action = kwargs.pop('action', 'default') + action_method = getattr(self, str(action), self.default) + return action_method(*args, **kwargs) + + def default(self, data): + raise NotImplementedError() + + +class DictSerializer(ActionDispatcher): + """Default request body serialization""" + + def serialize(self, data, action='default'): + return self.dispatch(data, action=action) + + def default(self, data): + return "" + + +class JSONDictSerializer(DictSerializer): + """Default JSON request body serialization""" + + def default(self, data): + def sanitizer(obj): + if isinstance(obj, datetime.datetime): + _dtime = obj - datetime.timedelta(microseconds=obj.microsecond) + return _dtime.isoformat() + return obj + return jsonutils.dumps(data, default=sanitizer) + + +class XMLDictSerializer(DictSerializer): + + def __init__(self, metadata=None, xmlns=None): + """ + :param metadata: information needed to deserialize xml into + a dictionary. + :param xmlns: XML namespace to include with serialized xml + """ + super(XMLDictSerializer, self).__init__() + self.metadata = metadata or {} + self.xmlns = xmlns + + def default(self, data): + # We expect data to contain a single key which is the XML root. + root_key = data.keys()[0] + doc = minidom.Document() + node = self._to_xml_node(doc, self.metadata, root_key, data[root_key]) + + return self.to_xml_string(node) + + def to_xml_string(self, node, has_atom=False): + self._add_xmlns(node, has_atom) + return node.toprettyxml(indent=' ', encoding='UTF-8') + + #NOTE (ameade): the has_atom should be removed after all of the + # xml serializers and view builders have been updated to the current + # spec that required all responses include the xmlns:atom, the has_atom + # flag is to prevent current tests from breaking + def _add_xmlns(self, node, has_atom=False): + if self.xmlns is not None: + node.setAttribute('xmlns', self.xmlns) + if has_atom: + node.setAttribute('xmlns:atom', "http://www.w3.org/2005/Atom") + + def _to_xml_node(self, doc, metadata, nodename, data): + """Recursive method to convert data members to XML nodes.""" + result = doc.createElement(nodename) + + # Set the xml namespace if one is specified + # TODO(justinsb): We could also use prefixes on the keys + xmlns = metadata.get('xmlns', None) + if xmlns: + result.setAttribute('xmlns', xmlns) + + #TODO(bcwaldon): accomplish this without a type-check + if type(data) is list: + collections = metadata.get('list_collections', {}) + if nodename in collections: + metadata = collections[nodename] + for item in data: + node = doc.createElement(metadata['item_name']) + node.setAttribute(metadata['item_key'], str(item)) + result.appendChild(node) + return result + singular = metadata.get('plurals', {}).get(nodename, None) + if singular is None: + if nodename.endswith('s'): + singular = nodename[:-1] + else: + singular = 'item' + for item in data: + node = self._to_xml_node(doc, metadata, singular, item) + result.appendChild(node) + #TODO(bcwaldon): accomplish this without a type-check + elif type(data) is dict: + collections = metadata.get('dict_collections', {}) + if nodename in collections: + metadata = collections[nodename] + for k, v in data.items(): + node = doc.createElement(metadata['item_name']) + node.setAttribute(metadata['item_key'], str(k)) + text = doc.createTextNode(str(v)) + node.appendChild(text) + result.appendChild(node) + return result + attrs = metadata.get('attributes', {}).get(nodename, {}) + for k, v in data.items(): + if k in attrs: + result.setAttribute(k, str(v)) + else: + node = self._to_xml_node(doc, metadata, k, v) + result.appendChild(node) + else: + # Type is atom + node = doc.createTextNode(str(data)) + result.appendChild(node) + return result + + def _create_link_nodes(self, xml_doc, links): + link_nodes = [] + for link in links: + link_node = xml_doc.createElement('atom:link') + link_node.setAttribute('rel', link['rel']) + link_node.setAttribute('href', link['href']) + if 'type' in link: + link_node.setAttribute('type', link['type']) + link_nodes.append(link_node) + return link_nodes + + +class ResponseHeadersSerializer(ActionDispatcher): + """Default response headers serialization""" + + def serialize(self, response, data, action): + self.dispatch(response, data, action=action) + + def default(self, response, data): + response.status_int = 200 + + +class ResponseSerializer(object): + """Encode the necessary pieces into a response object""" + + def __init__(self, body_serializers=None, headers_serializer=None): + self.body_serializers = { + 'application/xml': XMLDictSerializer(), + 'application/json': JSONDictSerializer(), + } + self.body_serializers.update(body_serializers or {}) + + self.headers_serializer = (headers_serializer or + ResponseHeadersSerializer()) + + def serialize(self, response_data, content_type, action='default'): + """Serialize a dict into a string and wrap in a wsgi.Request object. + + :param response_data: dict produced by the Controller + :param content_type: expected mimetype of serialized response body + + """ + response = webob.Response() + self.serialize_headers(response, response_data, action) + self.serialize_body(response, response_data, content_type, action) + return response + + def serialize_headers(self, response, data, action): + self.headers_serializer.serialize(response, data, action) + + def serialize_body(self, response, data, content_type, action): + response.headers['Content-Type'] = content_type + if data is not None: + serializer = self.get_body_serializer(content_type) + response.body = serializer.serialize(data, action) + + def get_body_serializer(self, content_type): + try: + return self.body_serializers[content_type] + except (KeyError, TypeError): + raise exception.InvalidContentType(content_type=content_type) + + +class RequestHeadersDeserializer(ActionDispatcher): + """Default request headers deserializer""" + + def deserialize(self, request, action): + return self.dispatch(request, action=action) + + def default(self, request): + return {} + + +class RequestDeserializer(object): + """Break up a Request object into more useful pieces.""" + + def __init__(self, body_deserializers=None, headers_deserializer=None, + supported_content_types=None): + + self.supported_content_types = supported_content_types + + self.body_deserializers = { + 'application/xml': XMLDeserializer(), + 'application/json': JSONDeserializer(), + } + self.body_deserializers.update(body_deserializers or {}) + + self.headers_deserializer = (headers_deserializer or + RequestHeadersDeserializer()) + + def deserialize(self, request): + """Extract necessary pieces of the request. + + :param request: Request object + :returns tuple of expected controller action name, dictionary of + keyword arguments to pass to the controller, the expected + content type of the response + + """ + action_args = self.get_action_args(request.environ) + action = action_args.pop('action', None) + + action_args.update(self.deserialize_headers(request, action)) + action_args.update(self.deserialize_body(request, action)) + + accept = self.get_expected_content_type(request) + + return (action, action_args, accept) + + def deserialize_headers(self, request, action): + return self.headers_deserializer.deserialize(request, action) + + def deserialize_body(self, request, action): + if not len(request.body) > 0: + LOG.debug(_("Empty body provided in request")) + return {} + + try: + content_type = request.get_content_type() + except exception.InvalidContentType: + LOG.debug(_("Unrecognized Content-Type provided in request")) + raise + + if content_type is None: + LOG.debug(_("No Content-Type provided in request")) + return {} + + try: + deserializer = self.get_body_deserializer(content_type) + except exception.InvalidContentType: + LOG.debug(_("Unable to deserialize body as provided Content-Type")) + raise + + return deserializer.deserialize(request.body, action) + + def get_body_deserializer(self, content_type): + try: + return self.body_deserializers[content_type] + except (KeyError, TypeError): + raise exception.InvalidContentType(content_type=content_type) + + def get_expected_content_type(self, request): + return request.best_match_content_type(self.supported_content_types) + + def get_action_args(self, request_environment): + """Parse dictionary created by routes library.""" + try: + args = request_environment['wsgiorg.routing_args'][1].copy() + except Exception: + return {} + + try: + del args['controller'] + except KeyError: + pass + + try: + del args['format'] + except KeyError: + pass + + return args + + +class TextDeserializer(ActionDispatcher): + """Default request body deserialization""" + + def deserialize(self, datastring, action='default'): + return self.dispatch(datastring, action=action) + + def default(self, datastring): + return {} + + +class JSONDeserializer(TextDeserializer): + + def _from_json(self, datastring): + try: + return jsonutils.loads(datastring) + except ValueError: + msg = _("cannot understand JSON") + raise exception.MalformedRequestBody(reason=msg) + + def default(self, datastring): + return {'body': self._from_json(datastring)} + + +class XMLDeserializer(TextDeserializer): + + def __init__(self, metadata=None): + """ + :param metadata: information needed to deserialize xml into + a dictionary. + """ + super(XMLDeserializer, self).__init__() + self.metadata = metadata or {} + + def _from_xml(self, datastring): + plurals = set(self.metadata.get('plurals', {})) + + try: + node = minidom.parseString(datastring).childNodes[0] + return {node.nodeName: self._from_xml_node(node, plurals)} + except expat.ExpatError: + msg = _("cannot understand XML") + raise exception.MalformedRequestBody(reason=msg) + + def _from_xml_node(self, node, listnames): + """Convert a minidom node to a simple Python type. + + :param listnames: list of XML node names whose subnodes should + be considered list items. + + """ + + if len(node.childNodes) == 1 and node.childNodes[0].nodeType == 3: + return node.childNodes[0].nodeValue + elif node.nodeName in listnames: + return [self._from_xml_node(n, listnames) for n in node.childNodes] + else: + result = dict() + for attr in node.attributes.keys(): + result[attr] = node.attributes[attr].nodeValue + for child in node.childNodes: + if child.nodeType != node.TEXT_NODE: + result[child.nodeName] = self._from_xml_node(child, + listnames) + return result + + def find_first_child_named(self, parent, name): + """Search a nodes children for the first child with a given name""" + for node in parent.childNodes: + if node.nodeName == name: + return node + return None + + def find_children_named(self, parent, name): + """Return all of a nodes children who have the given name""" + for node in parent.childNodes: + if node.nodeName == name: + yield node + + def extract_text(self, node): + """Get the text field contained by the given node""" + if len(node.childNodes) == 1: + child = node.childNodes[0] + if child.nodeType == child.TEXT_NODE: + return child.nodeValue + return "" + + def default(self, datastring): + return {'body': self._from_xml(datastring)} diff --git a/moniker/schema.py b/moniker/schema.py index ab6d2d9ba..bc84ec9de 100644 --- a/moniker/schema.py +++ b/moniker/schema.py @@ -88,6 +88,9 @@ class CollectionSchema(object): self.item_schema = item_schema def filter(self, obj): + if not obj: + return [] + return [self.item_schema.filter(o) for o in obj] def raw(self): diff --git a/moniker/tests/__init__.py b/moniker/tests/__init__.py index d02504ea4..9468c83d1 100644 --- a/moniker/tests/__init__.py +++ b/moniker/tests/__init__.py @@ -15,13 +15,33 @@ # under the License. import unittest import mox +from moniker.openstack.common import cfg +from moniker.openstack.common.context import RequestContext, get_admin_context +from moniker.database import reinitialize as reinitialize_database +from moniker.database import sqlalchemy # Import for sql_connection cfg def. class TestCase(unittest.TestCase): def setUp(self): super(TestCase, self).setUp() self.mox = mox.Mox() + self.config(database_driver='sqlalchemy', sql_connection='sqlite://', + rpc_backend='moniker.openstack.common.rpc.impl_fake', + notification_driver=[]) + reinitialize_database() def tearDown(self): + cfg.CONF.reset() self.mox.UnsetStubs() super(TestCase, self).tearDown() + + def config(self, **kwargs): + group = kwargs.pop('group', None) + for k, v in kwargs.iteritems(): + cfg.CONF.set_override(k, v, group) + + def get_context(self, **kwargs): + return RequestContext(**kwargs) + + def get_admin_context(self): + return get_admin_context() diff --git a/moniker/tests/central/__init__.py b/moniker/tests/central/__init__.py index e69de29bb..7c01bfcd5 100644 --- a/moniker/tests/central/__init__.py +++ b/moniker/tests/central/__init__.py @@ -0,0 +1,23 @@ +# Copyright 2012 Managed I.T. +# +# Author: Kiall Mac Innes +# +# 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 moniker.openstack.common import cfg +from moniker.central import service as central_service +from moniker.tests import TestCase + + +class CentralTestCase(TestCase): + def get_central_service(self): + return central_service.Service() diff --git a/moniker/tests/central/test_service.py b/moniker/tests/central/test_service.py index 7dac84d66..005cedaac 100644 --- a/moniker/tests/central/test_service.py +++ b/moniker/tests/central/test_service.py @@ -13,9 +13,337 @@ # 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 moniker.tests import TestCase +import random +from moniker.openstack.common import log as logging +from moniker.tests.central import CentralTestCase +from moniker import exceptions + +LOG = logging.getLogger(__name__) -class ServiceTest(TestCase): - def test_something(self): - assert(True) +class ServiceTest(CentralTestCase): + def setUp(self): + super(ServiceTest, self).setUp() + self.config(rpc_backend='moniker.openstack.common.rpc.impl_fake') + + def test_init(self): + self.get_central_service() + + def create_server(self, **kwargs): + context = kwargs.pop('context', self.get_admin_context()) + service = kwargs.pop('service', self.get_central_service()) + + values = dict( + name='ns1.example.org', + ipv4='192.0.2.1', + ipv6='2001:db8::1', + ) + + values.update(kwargs) + + return service.create_server(context, values=values) + + def create_domain(self, **kwargs): + context = kwargs.pop('context', self.get_admin_context()) + service = kwargs.pop('service', self.get_central_service()) + + values = dict( + name='example.com', + email='info@example.com', + ) + + values.update(kwargs) + + return service.create_domain(context, values=values) + + def create_record(self, domain_id, **kwargs): + context = kwargs.pop('context', self.get_admin_context()) + service = kwargs.pop('service', self.get_central_service()) + + values = dict( + name='www.example.com', + type='A', + data='127.0.0.1' + ) + + values.update(kwargs) + + return service.create_record(context, domain_id, values=values) + + # Server Tests + def test_create_server(self): + context = self.get_admin_context() + service = self.get_central_service() + + values = dict( + name='ns1.example.org', + ipv4='192.0.2.1', + ipv6='2001:db8::1', + ) + + # Create a server + server = service.create_server(context, values=values) + + # Ensure all values have been set correctly + self.assertIsNotNone(server['id']) + self.assertEqual(server['name'], values['name']) + self.assertEqual(str(server['ipv4']), values['ipv4']) + self.assertEqual(str(server['ipv6']), values['ipv6']) + + def test_get_servers(self): + context = self.get_admin_context() + service = self.get_central_service() + + # Ensure we have no servers to start with. + servers = service.get_servers(context) + self.assertEqual(len(servers), 0) + + # Create a single server (using default values) + self.create_server() + + # Ensure we can retrieve the newly created server + servers = service.get_servers(context) + self.assertEqual(len(servers), 1) + self.assertEqual(servers[0]['name'], 'ns1.example.org') + + # Create a second server + self.create_server(name='ns2.example.org', ipv4='192.0.2.2', + ipv6='2001:db8::2') + + # Ensure we can retrieve both servers + servers = service.get_servers(context) + self.assertEqual(len(servers), 2) + self.assertEqual(servers[0]['name'], 'ns1.example.org') + self.assertEqual(servers[1]['name'], 'ns2.example.org') + + def test_get_server(self): + context = self.get_admin_context() + service = self.get_central_service() + + # Create a server + server_name = 'ns%d.example.org' % random.randint(10, 1000) + expected_server = self.create_server(name=server_name) + + # Retrieve it, and ensure it's the same + server = service.get_server(context, expected_server['id']) + self.assertEqual(server['id'], expected_server['id']) + self.assertEqual(server['name'], expected_server['name']) + self.assertEqual(str(server['ipv4']), expected_server['ipv4']) + self.assertEqual(str(server['ipv6']), expected_server['ipv6']) + + def test_update_server(self): + context = self.get_admin_context() + service = self.get_central_service() + + # Create a server + expected_server = self.create_server() + + # Update the server + values = dict(ipv4='127.0.0.1') + service.update_server(context, expected_server['id'], values=values) + + # Fetch the server again + server = service.get_server(context, expected_server['id']) + + # Ensure the server was updated correctly + self.assertEqual(str(server['ipv4']), '127.0.0.1') + + def test_delete_server(self): + context = self.get_admin_context() + service = self.get_central_service() + + # Create a server + server = self.create_server() + + # Delete the server + service.delete_server(context, server['id']) + + # Fetch the server again, ensuring an exception is raised + with self.assertRaises(exceptions.ServerNotFound): + service.get_server(context, server['id']) + + # Domain Tests + def test_create_domain(self): + context = self.get_admin_context() + service = self.get_central_service() + + values = dict( + name='example.com', + email='info@example.com' + ) + + # Create a domain + domain = service.create_domain(context, values=values) + + # Ensure all values have been set correctly + self.assertIsNotNone(domain['id']) + self.assertEqual(domain['name'], values['name']) + self.assertEqual(domain['email'], values['email']) + + def test_get_domains(self): + context = self.get_admin_context() + service = self.get_central_service() + + # Ensure we have no domains to start with. + domains = service.get_domains(context) + self.assertEqual(len(domains), 0) + + # Create a single domain (using default values) + self.create_domain() + + # Ensure we can retrieve the newly created domain + domains = service.get_domains(context) + self.assertEqual(len(domains), 1) + self.assertEqual(domains[0]['name'], 'example.com') + + # Create a second domain + self.create_domain(name='example.net') + + # Ensure we can retrieve both domain + domains = service.get_domains(context) + self.assertEqual(len(domains), 2) + self.assertEqual(domains[0]['name'], 'example.com') + self.assertEqual(domains[1]['name'], 'example.net') + + def test_get_domain(self): + context = self.get_admin_context() + service = self.get_central_service() + + # Create a domain + domain_name = '%d.example.com' % random.randint(10, 1000) + expected_domain = self.create_domain(name=domain_name) + + # Retrieve it, and ensure it's the same + domain = service.get_domain(context, expected_domain['id']) + self.assertEqual(domain['id'], expected_domain['id']) + self.assertEqual(domain['name'], expected_domain['name']) + self.assertEqual(domain['email'], expected_domain['email']) + + def test_update_domain(self): + context = self.get_admin_context() + service = self.get_central_service() + + # Create a domain + expected_domain = self.create_domain() + + # Update the domain + values = dict(email='new@example.com') + service.update_domain(context, expected_domain['id'], values=values) + + # Fetch the domain again + domain = service.get_domain(context, expected_domain['id']) + + # Ensure the domain was updated correctly + self.assertEqual(domain['email'], 'new@example.com') + + def test_delete_domain(self): + context = self.get_admin_context() + service = self.get_central_service() + + # Create a domain + domain = self.create_domain() + + # Delete the domain + service.delete_domain(context, domain['id']) + + # Fetch the domain again, ensuring an exception is raised + with self.assertRaises(exceptions.DomainNotFound): + service.get_domain(context, domain['id']) + + # Record Tests + def test_create_record(self): + context = self.get_admin_context() + service = self.get_central_service() + domain = self.create_domain() + + values = dict( + name='www.example.com', + type='A', + data='127.0.0.1' + ) + + # Create a record + record = service.create_record(context, domain['id'], values=values) + + # Ensure all values have been set correctly + self.assertIsNotNone(record['id']) + self.assertIsNotNone(record['ttl']) + self.assertEqual(record['name'], values['name']) + self.assertEqual(record['type'], values['type']) + self.assertEqual(record['data'], values['data']) + + def test_get_records(self): + context = self.get_admin_context() + service = self.get_central_service() + domain = self.create_domain() + + # Ensure we have no records to start with. + records = service.get_records(context, domain['id']) + self.assertEqual(len(records), 0) + + # Create a single record (using default values) + self.create_record(domain['id']) + + # Ensure we can retrieve the newly created record + records = service.get_records(context, domain['id']) + self.assertEqual(len(records), 1) + self.assertEqual(records[0]['name'], 'www.example.com') + + # Create a second record + self.create_record(domain['id'], name='mail.example.com') + + # Ensure we can retrieve both records + records = service.get_records(context, domain['id']) + self.assertEqual(len(records), 2) + self.assertEqual(records[0]['name'], 'www.example.com') + self.assertEqual(records[1]['name'], 'mail.example.com') + + def test_get_record(self): + context = self.get_admin_context() + service = self.get_central_service() + domain = self.create_domain() + + # Create a record + record_name = '%d.example.com' % random.randint(10, 1000) + expected_record = self.create_record(domain['id'], name=record_name) + + # Retrieve it, and ensure it's the same + record = service.get_record(context, domain['id'], + expected_record['id']) + self.assertEqual(record['id'], expected_record['id']) + self.assertEqual(record['name'], expected_record['name']) + + def test_update_record(self): + context = self.get_admin_context() + service = self.get_central_service() + domain = self.create_domain() + + # Create a record + expected_record = self.create_record(domain['id']) + + # Update the server + values = dict(data='127.0.0.2') + service.update_record(context, domain['id'], expected_record['id'], + values=values) + + # Fetch the record again + record = service.get_record(context, domain['id'], + expected_record['id']) + + # Ensure the record was updated correctly + self.assertEqual(record['data'], '127.0.0.2') + + def test_delete_record(self): + context = self.get_admin_context() + service = self.get_central_service() + domain = self.create_domain() + + # Create a record + record = self.create_record(domain['id']) + + # Delete the record + service.delete_record(context, domain['id'], record['id']) + + # Fetch the record again, ensuring an exception is raised + with self.assertRaises(exceptions.RecordNotFound): + service.get_record(context, domain['id'], record['id']) diff --git a/moniker/utils.py b/moniker/utils.py index 9b3d86963..122bd9459 100644 --- a/moniker/utils.py +++ b/moniker/utils.py @@ -13,10 +13,10 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -import functools +import os from moniker.openstack.common import cfg -from moniker.openstack.common import rpc from moniker.openstack.common.notifier import api as notifier_api +from moniker import exceptions def notify(context, service, event_type, payload): @@ -26,10 +26,28 @@ def notify(context, service, event_type, payload): notifier_api.notify(context, publisher_id, event_type, priority, payload) -def fanout_cast(context, topic, method, **kwargs): - msg = { - 'method': method, - 'args': kwargs - } +def find_config(config_path): + """ Find a configuration file using the given hint. - rpc.fanout_cast(context, topic, msg) + Code nabbed from cinder. + + :param config_path: Full or relative path to the config. + :returns: Full path of the config, if it exists. + :raises: `moniker.exceptions.ConfigNotFound` + + """ + possible_locations = [ + config_path, + os.path.join("etc", "moniker", config_path), + os.path.join("etc", config_path), + os.path.join(cfg.CONF.state_path, "etc", "moniker", config_path), + os.path.join(cfg.CONF.state_path, "etc", config_path), + os.path.join(cfg.CONF.state_path, config_path), + "/etc/moniker/%s" % config_path, + ] + + for path in possible_locations: + if os.path.exists(path): + return os.path.abspath(path) + + raise exceptions.ConfigNotFound(os.path.abspath(config_path)) diff --git a/moniker/api/debug.py b/moniker/wsgi.py similarity index 60% rename from moniker/api/debug.py rename to moniker/wsgi.py index d7752144b..94856d49d 100644 --- a/moniker/api/debug.py +++ b/moniker/wsgi.py @@ -13,20 +13,15 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -import flask -from moniker.openstack.common import cfg -from moniker.openstack.common import log as logging - -LOG = logging.getLogger(__name__) - -blueprint = flask.Blueprint('debug', __name__) +from moniker.openstack.common import wsgi -@blueprint.route('/config', methods=['GET']) -def list_config(): - return flask.jsonify(cfg.CONF) +class Middleware(wsgi.Middleware): + @classmethod + def factory(cls, global_config, **local_conf): + """ Used for paste app factories in paste.deploy config files """ + def _factory(app): + return cls(app, **local_conf) -@blueprint.route('/context', methods=['GET']) -def list_config(): - return flask.jsonify(flask.request.context.to_dict()) + return _factory diff --git a/openstack-common.conf b/openstack-common.conf index f01945db4..3728177f0 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -1,3 +1,3 @@ [DEFAULT] -modules=cfg,iniparser,rpc,importutils,excutils,local,jsonutils,gettextutils,timeutils,notifier,context,log,service,eventlet_backdoor,network_utils,threadgroup,loopingcall,utils,exception,setup +modules=cfg,iniparser,rpc,importutils,excutils,local,jsonutils,gettextutils,timeutils,notifier,context,log,service,eventlet_backdoor,network_utils,threadgroup,loopingcall,utils,exception,setup,wsgi base=moniker diff --git a/tools/pip-requires b/tools/pip-requires index 92d217ce1..9a2c92437 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -4,6 +4,10 @@ eventlet sqlalchemy>=0.7 jsonschema>=0.6 ipaddr +setuptools-git>=0.4 # Needed for Keystone Middleware https://launchpad.net/keystone/folsom/2012.2/+download/keystone-2012.2.tar.gz#egg=keystone + +# Optional Stuff that is used by default +kombu diff --git a/tools/test-requires b/tools/test-requires index df1260867..52e3f2586 100644 --- a/tools/test-requires +++ b/tools/test-requires @@ -2,7 +2,3 @@ nose mox coverage pep8>=1.0 -setuptools-git>=0.4 - -# Optional Stuff used by default -kombu