Merge "add encryption to secret datasource config fields"
This commit is contained in:
commit
3bab6bf44f
|
@ -77,6 +77,8 @@ core_opts = [
|
||||||
'engines.'),
|
'engines.'),
|
||||||
cfg.StrOpt('policy_library_path', default='/etc/congress/library',
|
cfg.StrOpt('policy_library_path', default='/etc/congress/library',
|
||||||
help=_('The directory containing library policy files.')),
|
help=_('The directory containing library policy files.')),
|
||||||
|
cfg.StrOpt('encryption_key_path', default='/etc/congress/keys',
|
||||||
|
help=_('The directory containing encryption keys.')),
|
||||||
cfg.BoolOpt('distributed_architecture',
|
cfg.BoolOpt('distributed_architecture',
|
||||||
deprecated_for_removal=True,
|
deprecated_for_removal=True,
|
||||||
deprecated_reason='distributed architecture is now the only '
|
deprecated_reason='distributed architecture is now the only '
|
||||||
|
|
|
@ -25,6 +25,7 @@ from sqlalchemy.orm import exc as db_exc
|
||||||
from congress.db import api as db
|
from congress.db import api as db
|
||||||
from congress.db import db_ds_table_data as table_data
|
from congress.db import db_ds_table_data as table_data
|
||||||
from congress.db import model_base
|
from congress.db import model_base
|
||||||
|
from congress import encryption
|
||||||
|
|
||||||
|
|
||||||
class Datasource(model_base.BASE, model_base.HasId):
|
class Datasource(model_base.BASE, model_base.HasId):
|
||||||
|
@ -46,8 +47,39 @@ class Datasource(model_base.BASE, model_base.HasId):
|
||||||
self.enabled = enabled
|
self.enabled = enabled
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt_secret_config_fields(ds_db_obj, secret_config_fields):
|
||||||
|
'''encrypt secret config fields'''
|
||||||
|
config = json.loads(ds_db_obj.config)
|
||||||
|
if config is None: # nothing to encrypt
|
||||||
|
return ds_db_obj # return original obj
|
||||||
|
if '__encrypted_fields' in config:
|
||||||
|
raise Exception('Attempting to encrypt already encrypted datasource '
|
||||||
|
'DB object. This should not occer.')
|
||||||
|
for field in secret_config_fields:
|
||||||
|
config[field] = encryption.encrypt(config[field])
|
||||||
|
config['__encrypted_fields'] = secret_config_fields
|
||||||
|
ds_db_obj.config = json.dumps(config)
|
||||||
|
return ds_db_obj
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_secret_config_fields(ds_db_obj):
|
||||||
|
'''de-encrypt previously encrypted secret config fields'''
|
||||||
|
config = json.loads(ds_db_obj.config)
|
||||||
|
if config is None:
|
||||||
|
return ds_db_obj # return original object
|
||||||
|
if '__encrypted_fields' not in config: # not previously encrypted
|
||||||
|
return ds_db_obj # return original object
|
||||||
|
else:
|
||||||
|
for field in config['__encrypted_fields']:
|
||||||
|
config[field] = encryption.decrypt(config[field])
|
||||||
|
del config['__encrypted_fields']
|
||||||
|
ds_db_obj.config = json.dumps(config)
|
||||||
|
return ds_db_obj
|
||||||
|
|
||||||
|
|
||||||
def add_datasource(id_, name, driver, config, description,
|
def add_datasource(id_, name, driver, config, description,
|
||||||
enabled, session=None):
|
enabled, session=None, secret_config_fields=None):
|
||||||
|
secret_config_fields = secret_config_fields or []
|
||||||
session = session or db.get_session()
|
session = session or db.get_session()
|
||||||
with session.begin(subtransactions=True):
|
with session.begin(subtransactions=True):
|
||||||
datasource = Datasource(
|
datasource = Datasource(
|
||||||
|
@ -57,6 +89,7 @@ def add_datasource(id_, name, driver, config, description,
|
||||||
config=config,
|
config=config,
|
||||||
description=description,
|
description=description,
|
||||||
enabled=enabled)
|
enabled=enabled)
|
||||||
|
_encrypt_secret_config_fields(datasource, secret_config_fields)
|
||||||
session.add(datasource)
|
session.add(datasource)
|
||||||
return datasource
|
return datasource
|
||||||
|
|
||||||
|
@ -93,9 +126,9 @@ def get_datasource(name_or_id, session=None):
|
||||||
def get_datasource_by_id(id_, session=None):
|
def get_datasource_by_id(id_, session=None):
|
||||||
session = session or db.get_session()
|
session = session or db.get_session()
|
||||||
try:
|
try:
|
||||||
return (session.query(Datasource).
|
return _decrypt_secret_config_fields(session.query(Datasource).
|
||||||
filter(Datasource.id == id_).
|
filter(Datasource.id == id_).
|
||||||
one())
|
one())
|
||||||
except db_exc.NoResultFound:
|
except db_exc.NoResultFound:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -103,14 +136,14 @@ def get_datasource_by_id(id_, session=None):
|
||||||
def get_datasource_by_name(name, session=None):
|
def get_datasource_by_name(name, session=None):
|
||||||
session = session or db.get_session()
|
session = session or db.get_session()
|
||||||
try:
|
try:
|
||||||
return (session.query(Datasource).
|
return _decrypt_secret_config_fields(session.query(Datasource).
|
||||||
filter(Datasource.name == name).
|
filter(Datasource.name == name).
|
||||||
one())
|
one())
|
||||||
except db_exc.NoResultFound:
|
except db_exc.NoResultFound:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_datasources(session=None, deleted=False):
|
def get_datasources(session=None, deleted=False):
|
||||||
session = session or db.get_session()
|
session = session or db.get_session()
|
||||||
return (session.query(Datasource).
|
return [_decrypt_secret_config_fields(ds_obj)
|
||||||
all())
|
for ds_obj in session.query(Datasource).all()]
|
||||||
|
|
|
@ -59,6 +59,7 @@ class DSManagerService(data_service.DataService):
|
||||||
if update_db:
|
if update_db:
|
||||||
LOG.debug("updating db")
|
LOG.debug("updating db")
|
||||||
try:
|
try:
|
||||||
|
driver_info = self.node.get_driver_info(req['driver'])
|
||||||
# Note(thread-safety): blocking call
|
# Note(thread-safety): blocking call
|
||||||
datasource = datasources_db.add_datasource(
|
datasource = datasources_db.add_datasource(
|
||||||
id_=req['id'],
|
id_=req['id'],
|
||||||
|
@ -66,7 +67,8 @@ class DSManagerService(data_service.DataService):
|
||||||
driver=req['driver'],
|
driver=req['driver'],
|
||||||
config=req['config'],
|
config=req['config'],
|
||||||
description=req['description'],
|
description=req['description'],
|
||||||
enabled=req['enabled'])
|
enabled=req['enabled'],
|
||||||
|
secret_config_fields=driver_info.get('secret', []))
|
||||||
except db_exc.DBDuplicateEntry:
|
except db_exc.DBDuplicateEntry:
|
||||||
raise exception.DatasourceNameInUse(value=req['name'])
|
raise exception.DatasourceNameInUse(value=req['name'])
|
||||||
except db_exc.DBError:
|
except db_exc.DBError:
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
# Copyright (c) 2017 VMware, Inc. 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.
|
||||||
|
|
||||||
|
"""Encryption module for handling passwords in Congress."""
|
||||||
|
from __future__ import print_function
|
||||||
|
from __future__ import division
|
||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography import fernet
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from oslo_config import cfg
|
||||||
|
from oslo_log import log as logging
|
||||||
|
|
||||||
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
__key = None
|
||||||
|
__fernet = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_key_file_path():
|
||||||
|
return os.path.join(cfg.CONF.encryption_key_path, 'aes_key')
|
||||||
|
|
||||||
|
|
||||||
|
def key_file_exists():
|
||||||
|
return os.path.isfile(_get_key_file_path())
|
||||||
|
|
||||||
|
|
||||||
|
def read_key_from_file():
|
||||||
|
with io.open(_get_key_file_path(), 'r', encoding='ascii') as key_file:
|
||||||
|
key = str(key_file.read()).encode('ascii')
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def create_new_key_file():
|
||||||
|
dir_path = os.path.dirname(_get_key_file_path())
|
||||||
|
if not os.path.isdir(dir_path):
|
||||||
|
os.makedirs(dir_path, mode=0o700) # important: restrictive permissions
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
# first create file with restrictive permissions, then write key
|
||||||
|
# two separate file opens because each version supports
|
||||||
|
# permissions and encoding respectively, but neither supports both.
|
||||||
|
with os.fdopen(os.open(_get_key_file_path(), os.O_CREAT | os.O_WRONLY,
|
||||||
|
0o600), 'w'):
|
||||||
|
pass
|
||||||
|
with io.open(_get_key_file_path(), 'w', encoding='ascii') as key_file:
|
||||||
|
key_file.write(key.decode('ascii'))
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_key():
|
||||||
|
'''initialize key.'''
|
||||||
|
global __key
|
||||||
|
global __fernet
|
||||||
|
if key_file_exists():
|
||||||
|
__key = read_key_from_file()
|
||||||
|
else:
|
||||||
|
__key = create_new_key_file()
|
||||||
|
|
||||||
|
__fernet = Fernet(__key)
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_if_needed():
|
||||||
|
'''initialize key if not already initialized.'''
|
||||||
|
global __fernet
|
||||||
|
if not __fernet:
|
||||||
|
initialize_key()
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(string):
|
||||||
|
initialize_if_needed()
|
||||||
|
return __fernet.encrypt(string.encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidToken(fernet.InvalidToken):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(string):
|
||||||
|
initialize_if_needed()
|
||||||
|
try:
|
||||||
|
return __fernet.decrypt(string.encode('utf-8')).decode('utf-8')
|
||||||
|
except fernet.InvalidToken as exc:
|
||||||
|
raise InvalidToken(exc)
|
|
@ -38,7 +38,7 @@ from congress.db import api as db_api
|
||||||
# This appears in main() too. Removing either instance breaks something.
|
# This appears in main() too. Removing either instance breaks something.
|
||||||
config.init(sys.argv[1:])
|
config.init(sys.argv[1:])
|
||||||
from congress.common import eventlet_server
|
from congress.common import eventlet_server
|
||||||
|
from congress import encryption
|
||||||
from congress import harness
|
from congress import harness
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
@ -145,6 +145,7 @@ def main():
|
||||||
sys.exit("ERROR: Unable to find configuration file via default "
|
sys.exit("ERROR: Unable to find configuration file via default "
|
||||||
"search paths ~/.congress/, ~/, /etc/congress/, /etc/) and "
|
"search paths ~/.congress/, ~/, /etc/congress/, /etc/) and "
|
||||||
"the '--config-file' option!")
|
"the '--config-file' option!")
|
||||||
|
encryption.initialize_key()
|
||||||
if cfg.CONF.replicated_policy_engine and not (
|
if cfg.CONF.replicated_policy_engine and not (
|
||||||
db_api.is_mysql() or db_api.is_postgres()):
|
db_api.is_mysql() or db_api.is_postgres()):
|
||||||
if db_api.is_sqlite():
|
if db_api.is_sqlite():
|
||||||
|
|
|
@ -16,6 +16,8 @@ from __future__ import print_function
|
||||||
from __future__ import division
|
from __future__ import division
|
||||||
from __future__ import absolute_import
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
from oslo_utils import uuidutils
|
from oslo_utils import uuidutils
|
||||||
|
|
||||||
from congress.db import datasources
|
from congress.db import datasources
|
||||||
|
@ -31,14 +33,15 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
id_=id_,
|
id_=id_,
|
||||||
name="hiya",
|
name="hiya",
|
||||||
driver="foo",
|
driver="foo",
|
||||||
config='{user: foo}',
|
config={'user': 'foo'},
|
||||||
description="hello",
|
description="hello",
|
||||||
enabled=True)
|
enabled=True)
|
||||||
self.assertEqual(id_, source.id)
|
self.assertEqual(id_, source.id)
|
||||||
self.assertEqual("hiya", source.name)
|
self.assertEqual("hiya", source.name)
|
||||||
self.assertEqual("foo", source.driver)
|
self.assertEqual("foo", source.driver)
|
||||||
self.assertEqual("hello", source.description)
|
self.assertEqual("hello", source.description)
|
||||||
self.assertEqual('"{user: foo}"', source.config)
|
self.assertEqual({'user': 'foo', '__encrypted_fields': []},
|
||||||
|
json.loads(source.config))
|
||||||
self.assertTrue(source.enabled)
|
self.assertTrue(source.enabled)
|
||||||
|
|
||||||
def test_delete_datasource(self):
|
def test_delete_datasource(self):
|
||||||
|
@ -47,7 +50,7 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
id_=id_,
|
id_=id_,
|
||||||
name="hiya",
|
name="hiya",
|
||||||
driver="foo",
|
driver="foo",
|
||||||
config='{user: foo}',
|
config={'user': 'foo'},
|
||||||
description="hello",
|
description="hello",
|
||||||
enabled=True)
|
enabled=True)
|
||||||
self.assertTrue(datasources.delete_datasource(id_))
|
self.assertTrue(datasources.delete_datasource(id_))
|
||||||
|
@ -62,7 +65,7 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
id_=id_,
|
id_=id_,
|
||||||
name="hiya",
|
name="hiya",
|
||||||
driver="foo",
|
driver="foo",
|
||||||
config='{user: foo}',
|
config={'user': 'foo'},
|
||||||
description="hello",
|
description="hello",
|
||||||
enabled=True)
|
enabled=True)
|
||||||
db_ds_table_data.store_ds_table_data(
|
db_ds_table_data.store_ds_table_data(
|
||||||
|
@ -79,7 +82,7 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
id_=id_,
|
id_=id_,
|
||||||
name="hiya",
|
name="hiya",
|
||||||
driver="foo",
|
driver="foo",
|
||||||
config='{user: foo}',
|
config={'user': 'foo'},
|
||||||
description="hello",
|
description="hello",
|
||||||
enabled=True)
|
enabled=True)
|
||||||
source = datasources.get_datasource_by_name('hiya')
|
source = datasources.get_datasource_by_name('hiya')
|
||||||
|
@ -87,7 +90,7 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
self.assertEqual("hiya", source.name)
|
self.assertEqual("hiya", source.name)
|
||||||
self.assertEqual("foo", source.driver)
|
self.assertEqual("foo", source.driver)
|
||||||
self.assertEqual("hello", source.description)
|
self.assertEqual("hello", source.description)
|
||||||
self.assertEqual('"{user: foo}"', source.config)
|
self.assertEqual({'user': 'foo'}, json.loads(source.config))
|
||||||
self.assertTrue(source.enabled)
|
self.assertTrue(source.enabled)
|
||||||
|
|
||||||
def test_get_datasource_by_id(self):
|
def test_get_datasource_by_id(self):
|
||||||
|
@ -96,7 +99,7 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
id_=id_,
|
id_=id_,
|
||||||
name="hiya",
|
name="hiya",
|
||||||
driver="foo",
|
driver="foo",
|
||||||
config='{user: foo}',
|
config={'user': 'foo'},
|
||||||
description="hello",
|
description="hello",
|
||||||
enabled=True)
|
enabled=True)
|
||||||
source = datasources.get_datasource(id_)
|
source = datasources.get_datasource(id_)
|
||||||
|
@ -104,7 +107,7 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
self.assertEqual("hiya", source.name)
|
self.assertEqual("hiya", source.name)
|
||||||
self.assertEqual("foo", source.driver)
|
self.assertEqual("foo", source.driver)
|
||||||
self.assertEqual("hello", source.description)
|
self.assertEqual("hello", source.description)
|
||||||
self.assertEqual('"{user: foo}"', source.config)
|
self.assertEqual({'user': 'foo'}, json.loads(source.config))
|
||||||
self.assertTrue(source.enabled)
|
self.assertTrue(source.enabled)
|
||||||
|
|
||||||
def test_get_datasource(self):
|
def test_get_datasource(self):
|
||||||
|
@ -113,7 +116,7 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
id_=id_,
|
id_=id_,
|
||||||
name="hiya",
|
name="hiya",
|
||||||
driver="foo",
|
driver="foo",
|
||||||
config='{user: foo}',
|
config={'user': 'foo'},
|
||||||
description="hello",
|
description="hello",
|
||||||
enabled=True)
|
enabled=True)
|
||||||
sources = datasources.get_datasources()
|
sources = datasources.get_datasources()
|
||||||
|
@ -121,5 +124,23 @@ class TestDbDatasource(base.SqlTestCase):
|
||||||
self.assertEqual("hiya", sources[0].name)
|
self.assertEqual("hiya", sources[0].name)
|
||||||
self.assertEqual("foo", sources[0].driver)
|
self.assertEqual("foo", sources[0].driver)
|
||||||
self.assertEqual("hello", sources[0].description)
|
self.assertEqual("hello", sources[0].description)
|
||||||
self.assertEqual('"{user: foo}"', sources[0].config)
|
self.assertEqual({'user': 'foo'}, json.loads(sources[0].config))
|
||||||
|
self.assertTrue(sources[0].enabled)
|
||||||
|
|
||||||
|
def test_get_datasource_with_encryption(self):
|
||||||
|
id_ = uuidutils.generate_uuid()
|
||||||
|
datasources.add_datasource(
|
||||||
|
id_=id_,
|
||||||
|
name="hiya",
|
||||||
|
driver="foo",
|
||||||
|
config={'user': 'foo'},
|
||||||
|
description="hello",
|
||||||
|
enabled=True,
|
||||||
|
secret_config_fields=['user'])
|
||||||
|
sources = datasources.get_datasources()
|
||||||
|
self.assertEqual(id_, sources[0].id)
|
||||||
|
self.assertEqual("hiya", sources[0].name)
|
||||||
|
self.assertEqual("foo", sources[0].driver)
|
||||||
|
self.assertEqual("hello", sources[0].description)
|
||||||
|
self.assertEqual({'user': 'foo'}, json.loads(sources[0].config))
|
||||||
self.assertTrue(sources[0].enabled)
|
self.assertTrue(sources[0].enabled)
|
||||||
|
|
|
@ -317,6 +317,8 @@ class TestDseNode(base.SqlTestCase):
|
||||||
node = services['node']
|
node = services['node']
|
||||||
ds_manager = services['ds_manager']
|
ds_manager = services['ds_manager']
|
||||||
ds = self._get_datasource_request()
|
ds = self._get_datasource_request()
|
||||||
|
mock_driver_info.return_value = {'secret': [],
|
||||||
|
'module': mock.MagicMock()}
|
||||||
ds_manager.add_datasource(ds)
|
ds_manager.add_datasource(ds)
|
||||||
mock_driver_info.side_effect = [exception.DriverNotFound]
|
mock_driver_info.side_effect = [exception.DriverNotFound]
|
||||||
node.delete_missing_driver_datasources()
|
node.delete_missing_driver_datasources()
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
[DEFAULT]
|
||||||
|
encryption_key_path = 'congress/tests/etc/keys'
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
connection = 'sqlite://'
|
connection = 'sqlite://'
|
||||||
# connection = mysql+pymysql://root:password@127.0.0.1/congress?charset=utf8
|
# connection = mysql+pymysql://root:password@127.0.0.1/congress?charset=utf8
|
|
@ -4,6 +4,7 @@ auth_strategy = noauth
|
||||||
datasource_sync_period = 5
|
datasource_sync_period = 5
|
||||||
debug = True
|
debug = True
|
||||||
replicated_policy_engine = True
|
replicated_policy_engine = True
|
||||||
|
encryption_key_path = 'congress/tests/etc/keys'
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# connection = mysql+pymysql://root:password@127.0.0.1/congress?charset=utf8
|
# connection = mysql+pymysql://root:password@127.0.0.1/congress?charset=utf8
|
||||||
|
|
|
@ -4,6 +4,7 @@ auth_strategy = noauth
|
||||||
datasource_sync_period = 5
|
datasource_sync_period = 5
|
||||||
debug = True
|
debug = True
|
||||||
replicated_policy_engine = True
|
replicated_policy_engine = True
|
||||||
|
encryption_key_path = 'congress/tests/etc/keys'
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# connection = mysql+pymysql://root:password@127.0.0.1/congress?charset=utf8
|
# connection = mysql+pymysql://root:password@127.0.0.1/congress?charset=utf8
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
---
|
||||||
|
prelude: >
|
||||||
|
upgrade:
|
||||||
|
- A new config option `encryption_key_path` has been added to the DEFAULT
|
||||||
|
section to specify the path to the directory containing encryption keys for
|
||||||
|
encrypting secret fields in datasource config. The default value
|
||||||
|
(/etc/congress/keys) works for most deployments. A new key will be
|
||||||
|
automatically generated and placed in the directory specified by the
|
||||||
|
config option.
|
||||||
|
|
||||||
|
security:
|
||||||
|
- Secret fields in datasource configuration are now encrypted using Fernet
|
||||||
|
(AES-128 CBC; HMAC-SHA256).
|
||||||
|
Existing datasources are unaffected. To encrypt the secret
|
||||||
|
fields of existing datasources, simply delete and re-add after Congress
|
||||||
|
upgrade.
|
|
@ -23,6 +23,7 @@ python-cinderclient>=3.0.0 # Apache-2.0
|
||||||
python-swiftclient>=3.2.0 # Apache-2.0
|
python-swiftclient>=3.2.0 # Apache-2.0
|
||||||
python-ironicclient>=1.14.0 # Apache-2.0
|
python-ironicclient>=1.14.0 # Apache-2.0
|
||||||
alembic>=0.8.10 # MIT
|
alembic>=0.8.10 # MIT
|
||||||
|
cryptography>=1.6,!=2.0 # BSD/Apache-2.0
|
||||||
python-dateutil>=2.4.2 # BSD
|
python-dateutil>=2.4.2 # BSD
|
||||||
python-glanceclient>=2.7.0 # Apache-2.0
|
python-glanceclient>=2.7.0 # Apache-2.0
|
||||||
Routes>=2.3.1 # MIT
|
Routes>=2.3.1 # MIT
|
||||||
|
|
Loading…
Reference in New Issue