Merge branch 'master' of github.com:managedit/moniker
This commit is contained in:
commit
bd58c9805b
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,9 +7,11 @@ build
|
||||
.tox
|
||||
cover
|
||||
venv
|
||||
.venv
|
||||
*.sublime-workspace
|
||||
*.sqlite
|
||||
var/*
|
||||
etc/*.conf
|
||||
etc/*.ini
|
||||
AUTHORS
|
||||
ChangeLog
|
||||
|
44
README.md
44
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
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
29
etc/moniker-api-paste.ini.sample
Normal file
29
etc/moniker-api-paste.ini.sample
Normal file
@ -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%
|
@ -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
|
||||
|
@ -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'),
|
||||
])
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
51
moniker/api/auth.py
Normal file
51
moniker/api/auth.py
Normal file
@ -0,0 +1,51 @@
|
||||
# Copyright 2012 Managed I.T.
|
||||
#
|
||||
# Author: Kiall Mac Innes <kiall@managedit.ie>
|
||||
#
|
||||
# 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()
|
43
moniker/api/service.py
Normal file
43
moniker/api/service.py
Normal file
@ -0,0 +1,43 @@
|
||||
# Copyright 2012 Managed I.T.
|
||||
#
|
||||
# Author: Kiall Mac Innes <kiall@managedit.ie>
|
||||
#
|
||||
# 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)
|
@ -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
|
||||
|
@ -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/<domain_id>', 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/<domain_id>', 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/<domain_id>', 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)
|
||||
|
@ -44,7 +44,7 @@ def get_records_schema():
|
||||
|
||||
@blueprint.route('/domains/<domain_id>/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/<domain_id>/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/<domain_id>/records/<record_id>', 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/<domain_id>/records/<record_id>', 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/<domain_id>/records/<record_id>',
|
||||
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)
|
||||
|
@ -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/<server_id>', 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/<server_id>', 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/<server_id>', 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)
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
||||
|
@ -52,3 +52,10 @@ def get_engine():
|
||||
engine.connect()
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
def reset_session():
|
||||
global _ENGINE, _SESSION
|
||||
|
||||
_ENGINE = None
|
||||
_SESSION = None
|
||||
|
@ -19,6 +19,10 @@ class Base(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConfigNotFound(Base):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidObject(Base):
|
||||
pass
|
||||
|
||||
|
@ -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.
|
||||
|
728
moniker/openstack/common/wsgi.py
Normal file
728
moniker/openstack/common/wsgi.py
Normal file
@ -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)}
|
@ -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):
|
||||
|
@ -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()
|
||||
|
@ -0,0 +1,23 @@
|
||||
# Copyright 2012 Managed I.T.
|
||||
#
|
||||
# Author: Kiall Mac Innes <kiall@managedit.ie>
|
||||
#
|
||||
# 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()
|
@ -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'])
|
||||
|
@ -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))
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -2,7 +2,3 @@ nose
|
||||
mox
|
||||
coverage
|
||||
pep8>=1.0
|
||||
setuptools-git>=0.4
|
||||
|
||||
# Optional Stuff used by default
|
||||
kombu
|
||||
|
Loading…
Reference in New Issue
Block a user