diff --git a/cmd/README.rst b/cmd/README.rst new file mode 100755 index 0000000..80451f0 --- /dev/null +++ b/cmd/README.rst @@ -0,0 +1,17 @@ +=============================== +cmd +=============================== + +Scripts to start the API, JobDaemon and JobWorker service + +api.py: + start API service + python api.py --config-file=../etc/api.conf + +jobdaemon.py: + start JobDaemon service + python jobdaemon.py --config-file=../etc/jobdaemon.conf + +jobworker.py: + start JobWorker service + python jobworker.py --config-file=../etc/jobworker.conf diff --git a/cmd/api.py b/cmd/api.py index 3d21ed2..8174744 100755 --- a/cmd/api.py +++ b/cmd/api.py @@ -21,11 +21,10 @@ import sys from oslo_config import cfg from oslo_log import log as logging +from oslo_service import wsgi import logging as std_logging -from werkzeug import serving - from kingbird.api import apicfg from kingbird.api import app @@ -52,13 +51,15 @@ def main(): LOG.info(_LI("Server on http://%(host)s:%(port)s with %(workers)s"), {'host': host, 'port': port, 'workers': workers}) - serving.run_simple(host, port, - application, - processes=workers) + service = wsgi.Server(CONF, "Kingbird", application, host, port) + + app.serve(service, CONF, workers) LOG.info(_LI("Configuration:")) CONF.log_opt_values(LOG, std_logging.INFO) + app.wait() + if __name__ == '__main__': main() diff --git a/devstack/README.rst b/devstack/README.rst new file mode 100755 index 0000000..757f7c5 --- /dev/null +++ b/devstack/README.rst @@ -0,0 +1,18 @@ +=============================== +devstack +=============================== + +Scripts to integrate the API, JobDaemon and JobWorker service to OpenStack +devstack development environment + +local.conf.sample: + sample configruation to integrate kingbird into devstack + cuntomize the configuration file to tell devstack which OpenStack services + will be launched + +plugin.sh: plugin to the devstack + devstack will automaticly search the devstack folder, and load, exeucte + the plugin.sh in different environment establishment phase + +settins: configuration for kingbird in the devstack + devstack will automaticly load the settings to be used in the plugin.sh diff --git a/etc/README.rst b/etc/README.rst new file mode 100755 index 0000000..08f6c03 --- /dev/null +++ b/etc/README.rst @@ -0,0 +1,14 @@ +=============================== +etc +=============================== + +configuration sample for the API, JobDaemon and JobWorker service + +api.conf: + configuration sample for API service + +jobdaemon.conf: + configuration sample for JobDaemon service + +jobworker.conf: + configuration sample for JobWorker service diff --git a/kingbird/api/README.rst b/kingbird/api/README.rst new file mode 100755 index 0000000..4af5f87 --- /dev/null +++ b/kingbird/api/README.rst @@ -0,0 +1,29 @@ +=============================== +api +=============================== + +Kingbird API is Web Server Gateway Interface (WSGI) applications to receive +and process API calls, including keystonemiddleware to do the authentication, +parameter check and validation, convert API calls to job rpc message, and +then send the job to Kingbird Job Daemon through the queue. If the job will +be processed by Kingbird Job Daemon in synchronous way, the Kingbird API will +wait for the response from the Kingbird Job Daemon. Otherwise, the Kingbird +API will send response to the API caller first, and then send the job to +Kingbird Job Daemon in asynchronous way. One of the Kingbird Job Daemons +will be the owner of the job. + +Multiple Kingbird API could run in parallel, and also can work in multi-worker +mode. + +Multiple Kingbird API will be designed and run in stateless mode, persistent +data will be accessed (read and write) from the Kingbird Database through the +DAL module. + +Setup and encapsulate the API WSGI app + +app.py: + Setup and encapsulate the API WSGI app, including integrate the + keystonemiddleware app + +apicfg.py: + API configuration loading and init diff --git a/kingbird/api/apicfg.py b/kingbird/api/apicfg.py index d1d0458..bc97e0e 100755 --- a/kingbird/api/apicfg.py +++ b/kingbird/api/apicfg.py @@ -17,6 +17,7 @@ Routines for configuring kingbird, largely copy from Neutron """ +import os import sys @@ -40,8 +41,9 @@ common_opts = [ help=_("The port to bind to")), cfg.IntOpt('api_workers', default=2, help=_("number of api workers")), - cfg.StrOpt('api_paste_config', default="api-paste.ini", - help=_("The API paste config file to use")), + cfg.StrOpt('state_path', + default=os.path.join(os.path.dirname(__file__), '../'), + help='Top-level directory for maintaining kingbird state'), cfg.StrOpt('api_extensions_path', default="", help=_("The path for API extensions")), cfg.StrOpt('auth_strategy', default='keystone', diff --git a/kingbird/api/app.py b/kingbird/api/app.py index 95f4ab5..d403b56 100755 --- a/kingbird/api/app.py +++ b/kingbird/api/app.py @@ -13,12 +13,15 @@ # License for the specific language governing permissions and limitations # under the License. +import pecan + from keystonemiddleware import auth_token from oslo_config import cfg from oslo_middleware import request_id -import pecan +from oslo_service import service from kingbird.common import exceptions as k_exc +from kingbird.common.i18n import _ def setup_app(*args, **kwargs): @@ -64,3 +67,18 @@ def _wrap_app(app): opt_name='auth_strategy', opt_value=cfg.CONF.auth_strategy) return app + + +_launcher = None + + +def serve(api_service, conf, workers=1): + global _launcher + if _launcher: + raise RuntimeError(_('serve() can only be called once')) + + _launcher = service.launch(conf, api_service, workers=workers) + + +def wait(): + _launcher.wait() diff --git a/kingbird/api/controllers/README.rst b/kingbird/api/controllers/README.rst new file mode 100755 index 0000000..c952cae --- /dev/null +++ b/kingbird/api/controllers/README.rst @@ -0,0 +1,14 @@ +=============================== +controllers +=============================== + +API request processing + +root.py: + API root request + +helloworld.py: + sample for adding a new resource/controller for http request processing + +restcomm.py: + common functionality used in API \ No newline at end of file diff --git a/kingbird/api/controllers/helloworld.py b/kingbird/api/controllers/helloworld.py index fabc4a3..27105ad 100755 --- a/kingbird/api/controllers/helloworld.py +++ b/kingbird/api/controllers/helloworld.py @@ -13,14 +13,15 @@ # License for the specific language governing permissions and limitations # under the License. -from kingbird.jobdaemon import jdrpcapi - import pecan from pecan import expose from pecan import request from pecan import rest + import restcomm +from kingbird.jobdaemon import jdrpcapi + class HelloWorldController(rest.RestController): @@ -62,9 +63,15 @@ class HelloWorldController(rest.RestController): # jdmanager, jwmanager instead context = restcomm.extract_context_from_environ() - payload = '## delete call ##, request.body is null' + payload = '## delete cast ##, request.body is null' payload = payload + request.body self.jd_api.say_hello_world_cast(context, payload) + return self._delete_response(context) + + def _delete_response(self, context): + + context = context + return {'cast example': 'check the log produced by jobdaemon ' + 'and jobworker, no value returned here'} diff --git a/kingbird/api/controllers/root.py b/kingbird/api/controllers/root.py index 80c0840..e74ae4f 100755 --- a/kingbird/api/controllers/root.py +++ b/kingbird/api/controllers/root.py @@ -13,9 +13,11 @@ # License for the specific language governing permissions and limitations # under the License. -import helloworld + import pecan +from kingbird.api.controllers import helloworld + class RootController(object): @@ -24,7 +26,7 @@ class RootController(object): if version == 'v1.0': return V1Controller(), remainder - @pecan.expose('json') + @pecan.expose(generic=True, template='json') def index(self): return { "versions": [ @@ -42,6 +44,14 @@ class RootController(object): ] } + @index.when(method='POST') + @index.when(method='PUT') + @index.when(method='DELETE') + @index.when(method='HEAD') + @index.when(method='PATCH') + def not_supported(self): + pecan.abort(405) + class V1Controller(object): @@ -54,7 +64,7 @@ class V1Controller(object): for name, ctrl in self.sub_controllers.items(): setattr(self, name, ctrl) - @pecan.expose('json') + @pecan.expose(generic=True, template='json') def index(self): return { "version": "1.0", @@ -67,3 +77,11 @@ class V1Controller(object): for name in sorted(self.sub_controllers) ] } + + @index.when(method='POST') + @index.when(method='PUT') + @index.when(method='DELETE') + @index.when(method='HEAD') + @index.when(method='PATCH') + def not_supported(self): + pecan.abort(405) diff --git a/kingbird/common/context.py b/kingbird/common/context.py index 0692a0b..050bc9d 100755 --- a/kingbird/common/context.py +++ b/kingbird/common/context.py @@ -47,24 +47,9 @@ class ContextBase(oslo_ctx.RequestContext): 'user_name': self.user_name, 'tenant_name': self.tenant_name, 'auth_url': self.auth_url, - 'auth_token': self.auth_token, - 'auth_token_info': self.auth_token_info, - 'user': self.user, - 'user_domain': self.user_domain, - 'user_domain_name': self.user_domain_name, - 'project': self.project, - 'project_name': self.project_name, - 'project_domain': self.project_domain, - 'project_domain_name': self.project_domain_name, - 'domain': self.domain, - 'domain_name': self.domain_name, - 'trusts': self.trusts, - 'region_name': self.region_name, - 'roles': self.roles, - 'show_deleted': self.show_deleted, - 'is_admin': self.is_admin, - 'request_id': self.request_id, 'password': self.password, + 'default_name': self.default_name, + 'region_name': self.region_name, }) return ctx_dict diff --git a/kingbird/jobdaemon/README.rst b/kingbird/jobdaemon/README.rst new file mode 100755 index 0000000..f6d2eec --- /dev/null +++ b/kingbird/jobdaemon/README.rst @@ -0,0 +1,46 @@ +=============================== +jobdaemon +=============================== + +Kingbird Job Daemon has responsibility for: + Divid job from Kingbird API to smaller jobs, each smaller job will only + be involved with one specific region, and one smaller job will be + dispatched to one Kingbird Job Worker. Multiple smaller jobs may be + dispatched to the same Kingbird Job Worker, it’s up to the load balancing + policy and how many Kingbird Job Workers are running. + + Some job from Kingbird API could not be divided, schedule and re-schedule + such kind of (periodically running, like quota enforcement, regular + event statistic collection task) job to a specific Kingbird Job Worker. + If some Kingbird Job Worker failed, re-balance the job to other Kingbird + Job Workers. + + Monitoring the job/smaller jobs status, and return the result to Kingbird + API if needed. + + Generate task to purge time-out jobs from Kingbird Database + + Multiple Job Daemon could run in parallel, and also can work in + multi-worker mode. But for one job from Kingbird API, only one Kingbird + Job Daemon will be the owner. One Kingbird Job Daemon could be the owner + of multiple jobs from multiple Kingbird APIs + + Multiple Kingbird Daemon will be designed and run in stateless mode, + persistent data will be accessed (read and write) from the Kingbird + Database through the DAL module. + +jdrpcapi.py: + the client side RPC api for JobDaemon. Often the API service will + call the api provided in this file, and the RPC client will send the + request to message-bus, and then the JobDaemon can pickup the RPC message + from the message bus + +jdservice.py: + run JobDaemon in multi-worker mode, and establish RPC server + +jdmanager.py: + all rpc messages received by the jdservice RPC server will be processed + in the jdmanager's regarding function. + +jdcfg.py: + configuration and initialization for JobDaemon \ No newline at end of file diff --git a/kingbird/jobworker/README.rst b/kingbird/jobworker/README.rst new file mode 100755 index 0000000..44432e6 --- /dev/null +++ b/kingbird/jobworker/README.rst @@ -0,0 +1,38 @@ +=============================== +jobworker +=============================== + +Kingbird Job Worker has responsibility for: + Concurrently process the divided smaller jobs from Kingbird Job Daemon. + Each smaller job will be a job to a specific OpenStack instance, i.e., + one OpenStack region. + + Periodically running background job which was assigned by the Kingbird + Job Daemon, Kingbird Job Worker will generate a new one-time job (for + example, for quota enforcement, generate a collecting resource usage job), + and send it to the Kingbird Job Daemon for further processing in each + cycle. Multiple Job Worker could run in parallel, and also can work in + multi-worker mode. But for one smaller job from Kingbird Job Daemon, + only one Kingbird Job Worker will be the owner. One Kingbird Job Worker + could be the owner of multiple smaller jobs from multiple Kingbird + JobDaemons. + + Multiple Kingbird Job Workers will be designed and run in stateless mode, + persistent data will be accessed (read and write) from the Kingbird + Database through the DAL module. + +jwrpcapi.py: + the client side RPC api for JobWoker. Often the JobDaemon service will + call the api provided in this file, and the RPC client will send the + request to message-bus, and then the JobWorker can pickup the RPC message + from the message bus + +jwservice.py: + run JobWorker in multi-worker mode, and establish RPC server + +jwmanager.py: + all rpc messages received by the jwservice RPC server will be processed + in the jwmanager's regarding function. + +jwcfg.py: + configuration and initialization for JobWorker \ No newline at end of file diff --git a/kingbird/tests/functional/__init__.py b/kingbird/tests/functional/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/kingbird/tests/functional/api/__init__.py b/kingbird/tests/functional/api/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/kingbird/tests/functional/api/testroot.py b/kingbird/tests/functional/api/testroot.py new file mode 100755 index 0000000..4b87f07 --- /dev/null +++ b/kingbird/tests/functional/api/testroot.py @@ -0,0 +1,312 @@ +# Copyright (c) 2015 Huawei Technologies Co., Ltd. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock +from mock import patch + +import pecan +from pecan.configuration import set_config +from pecan.testing import load_test_app + +from oslo_config import cfg +from oslo_config import fixture as fixture_config +from oslo_serialization import jsonutils +from oslo_utils import uuidutils + +from kingbird.api import apicfg +from kingbird.api.controllers import helloworld +from kingbird.common import rpc +from kingbird.jobdaemon import jdrpcapi +from kingbird.tests import base + + +OPT_GROUP_NAME = 'keystone_authtoken' +cfg.CONF.import_group(OPT_GROUP_NAME, "keystonemiddleware.auth_token") + + +def fake_say_hello_world_call(self, ctxt, payload): + info_text = "say_hello_world_call, payload: %s" % payload + return {'jobdaemon': info_text} + + +def fake_say_hello_world_cast(self, ctxt, payload): + info_text = "say_hello_world_cast, payload: %s" % payload + return {'jobdaemon': info_text} + + +def fake_delete_response(self, context): + resp = jsonutils.dumps(context.to_dict()) + return resp + + +class KBFunctionalTest(base.KingbirdTestCase): + + def setUp(self): + super(KBFunctionalTest, self).setUp() + + self.addCleanup(set_config, {}, overwrite=True) + + apicfg.test_init() + + self.CONF = self.useFixture(fixture_config.Config()).conf + + # self.setup_messaging(self.CONF) + self.CONF.set_override('auth_strategy', 'noauth') + + self.app = self._make_app() + + def _make_app(self, enable_acl=False): + self.config = { + 'app': { + 'root': 'kingbird.api.controllers.root.RootController', + 'modules': ['kingbird.api'], + 'enable_acl': enable_acl, + 'errors': { + 400: '/error', + '__force_dict__': True + } + }, + } + + return load_test_app(self.config) + + def tearDown(self): + super(KBFunctionalTest, self).tearDown() + pecan.set_config({}, overwrite=True) + + +class TestRootController(KBFunctionalTest): + """Test version listing on root URI.""" + + def test_get(self): + response = self.app.get('/') + self.assertEqual(response.status_int, 200) + json_body = jsonutils.loads(response.body) + versions = json_body.get('versions') + self.assertEqual(1, len(versions)) + + def _test_method_returns_405(self, method): + api_method = getattr(self.app, method) + response = api_method('/', expect_errors=True) + self.assertEqual(response.status_int, 405) + + def test_post(self): + self._test_method_returns_405('post') + + def test_put(self): + self._test_method_returns_405('put') + + def test_patch(self): + self._test_method_returns_405('patch') + + def test_delete(self): + self._test_method_returns_405('delete') + + def test_head(self): + self._test_method_returns_405('head') + + +class TestV1Controller(KBFunctionalTest): + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_get(self): + response = self.app.get('/v1.0') + self.assertEqual(response.status_int, 200) + json_body = jsonutils.loads(response.body) + version = json_body.get('version') + self.assertEqual('1.0', version) + + links = json_body.get('links') + v1_link = links[0] + helloworld_link = links[1] + self.assertEqual('self', v1_link['rel']) + self.assertEqual('helloworld', helloworld_link['rel']) + + def _test_method_returns_405(self, method): + api_method = getattr(self.app, method) + response = api_method('/v1.0', expect_errors=True) + self.assertEqual(response.status_int, 405) + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_post(self): + self._test_method_returns_405('post') + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_put(self): + self._test_method_returns_405('put') + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_patch(self): + self._test_method_returns_405('patch') + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_delete(self): + self._test_method_returns_405('delete') + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_head(self): + self._test_method_returns_405('head') + + +class TestHelloworld(KBFunctionalTest): + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_get(self): + response = self.app.get('/v1.0/helloworld') + self.assertEqual(response.status_int, 200) + self.assertIn('hello world message for', response) + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_post(self): + response = self.app.post_json('/v1.0/helloworld', + headers={'X-Tenant-Id': 'tenid'}) + self.assertEqual(response.status_int, 200) + self.assertIn('## post call ##', response) + self.assertIn('jobdaemon', response) + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_put(self): + response = self.app.put_json('/v1.0/helloworld', + headers={'X-Tenant-Id': 'tenid'}) + self.assertEqual(response.status_int, 200) + self.assertIn('## put call ##', response) + self.assertIn('jobdaemon', response) + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_delete(self): + response = self.app.delete('/v1.0/helloworld', + headers={'X-Tenant-Id': 'tenid'}) + self.assertEqual(response.status_int, 200) + self.assertIn('cast example', response) + self.assertIn('check the log produced by jobdaemon', response) + + +class TestHelloworldContext(KBFunctionalTest): + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + @patch.object(helloworld.HelloWorldController, '_delete_response', + new=fake_delete_response) + def test_context_set_in_request(self): + response = self.app.delete('/v1.0/helloworld', + headers={'X_Auth_Token': 'a-token', + 'X_TENANT_ID': 't-id', + 'X_USER_ID': 'u-id', + 'X_USER_NAME': 'u-name', + 'X_PROJECT_NAME': 't-name', + 'X_DOMAIN_ID': 'domainx', + 'X_USER_DOMAIN_ID': 'd-u', + 'X_PROJECT_DOMAIN_ID': 'p_d', + }) + json_body = jsonutils.loads(response.body) + self.assertIn('a-token', json_body) + self.assertIn('t-id', json_body) + self.assertIn('u-id', json_body) + self.assertIn('u-name', json_body) + self.assertIn('t-name', json_body) + self.assertIn('domainx', json_body) + self.assertIn('d-u', json_body) + self.assertIn('p_d', json_body) + + +class TestErrors(KBFunctionalTest): + + def test_404(self): + response = self.app.get('/assert_called_once', expect_errors=True) + self.assertEqual(response.status_int, 404) + + @patch.object(rpc, 'get_client', new=mock.Mock()) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_call', + new=fake_say_hello_world_call) + @patch.object(jdrpcapi.JobDaemonAPI, 'say_hello_world_cast', + new=fake_say_hello_world_cast) + def test_bad_method(self): + response = self.app.patch('/v1.0/helloworld/123.json', + expect_errors=True) + self.assertEqual(response.status_int, 405) + + +class TestRequestID(KBFunctionalTest): + + def test_request_id(self): + response = self.app.get('/') + self.assertIn('x-openstack-request-id', response.headers) + self.assertTrue( + response.headers['x-openstack-request-id'].startswith('req-')) + id_part = response.headers['x-openstack-request-id'].split('req-')[1] + self.assertTrue(uuidutils.is_uuid_like(id_part)) + + +class TestKeystoneAuth(KBFunctionalTest): + + def setUp(self): + super(KBFunctionalTest, self).setUp() + + self.addCleanup(set_config, {}, overwrite=True) + + apicfg.test_init() + + self.CONF = self.useFixture(fixture_config.Config()).conf + + cfg.CONF.set_override('auth_strategy', 'keystone') + + self.app = self._make_app() + + def test_auth_enforced(self): + response = self.app.get('/', expect_errors=True) + self.assertEqual(response.status_int, 401) diff --git a/requirements.txt b/requirements.txt index 5b30176..3cd86c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,6 @@ pecan>=1.0.0 greenlet>=0.3.2 httplib2>=0.7.5 requests!=2.8.0,>=2.5.2 -Werkzeug>=0.7 # BSD License Jinja2>=2.8 # BSD License (3 clause) keystonemiddleware!=2.4.0,>=2.0.0 netaddr!=0.7.16,>=0.7.12