Migrate to keystoneauth1
For authentication, the community recommends the use of keystoneauth1 instead of python-keystoneclient. Therefore, Blazar should follow this trend and migrate to keystoneauth1. This patch enables blazarclient to use keystoneauth1 for authentications and REST API requests and also enables use of project_id, project_name, project_domain_id, project_domain_name, user_domain_id and user_domain_name for authentication. Change-Id: I08c8b753972c27b4e6bbe07a8aa51e0e72fbc56d Closes-Bug: #1661215
This commit is contained in:
parent
443adb3659
commit
a3448e5510
|
@ -1,136 +0,0 @@
|
|||
# Copyright (c) 2013 Mirantis Inc.
|
||||
#
|
||||
# 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 json
|
||||
|
||||
import requests
|
||||
|
||||
from blazarclient import exception
|
||||
from blazarclient.i18n import _
|
||||
|
||||
|
||||
class BaseClientManager(object):
|
||||
"""Base manager to interact with a particular type of API.
|
||||
|
||||
There are environments, nodes and jobs types of API requests.
|
||||
Manager provides CRUD operations for them.
|
||||
"""
|
||||
def __init__(self, blazar_url, auth_token):
|
||||
self.blazar_url = blazar_url
|
||||
self.auth_token = auth_token
|
||||
|
||||
USER_AGENT = 'python-blazarclient'
|
||||
|
||||
def _get(self, url, response_key):
|
||||
"""Sends get request to Blazar.
|
||||
|
||||
:param url: URL to the wanted Blazar resource.
|
||||
:type url: str
|
||||
|
||||
:param response_key: Type of resource (environment, node, job).
|
||||
:type response_key: str
|
||||
|
||||
:returns: Resource entity (entities) that was (were) asked.
|
||||
:rtype: dict | list
|
||||
"""
|
||||
resp, body = self.request(url, 'GET')
|
||||
return body[response_key]
|
||||
|
||||
def _create(self, url, body, response_key):
|
||||
"""Sends create request to Blazar.
|
||||
|
||||
:param url: URL to the wanted Blazar resource.
|
||||
:type url: str
|
||||
|
||||
:param body: Values resource to be created from.
|
||||
:type body: dict
|
||||
|
||||
:param response_key: Type of resource (environment, node, job).
|
||||
:type response_key: str
|
||||
|
||||
:returns: Resource entity that was created.
|
||||
:rtype: dict
|
||||
"""
|
||||
resp, body = self.request(url, 'POST', body=body)
|
||||
return body[response_key]
|
||||
|
||||
def _delete(self, url):
|
||||
"""Sends delete request to Blazar.
|
||||
|
||||
:param url: URL to the wanted Blazar resource.
|
||||
:type url: str
|
||||
"""
|
||||
resp, body = self.request(url, 'DELETE')
|
||||
|
||||
def _update(self, url, body, response_key=None):
|
||||
"""Sends update request to Blazar.
|
||||
|
||||
:param url: URL to the wanted Blazar resource.
|
||||
:type url: str
|
||||
|
||||
:param body: Values resource to be updated from.
|
||||
:type body: dict
|
||||
|
||||
:param response_key: Type of resource (environment, node, job).
|
||||
:type response_key: str
|
||||
|
||||
:returns: Resource entity that was updated.
|
||||
:rtype: dict
|
||||
"""
|
||||
resp, body = self.request(url, 'PUT', body=body)
|
||||
return body[response_key]
|
||||
|
||||
def request(self, url, method, **kwargs):
|
||||
"""Base request method.
|
||||
|
||||
Adds specific headers and URL prefix to the request.
|
||||
|
||||
:param url: Resource URL.
|
||||
:type url: str
|
||||
|
||||
:param method: Method to be called (GET, POST, PUT, DELETE).
|
||||
:type method: str
|
||||
|
||||
:returns: Response and body.
|
||||
:rtype: tuple
|
||||
"""
|
||||
kwargs.setdefault('headers', kwargs.get('headers', {}))
|
||||
kwargs['headers']['User-Agent'] = self.USER_AGENT
|
||||
kwargs['headers']['Accept'] = 'application/json'
|
||||
kwargs['headers']['x-auth-token'] = self.auth_token
|
||||
|
||||
if 'body' in kwargs:
|
||||
kwargs['headers']['Content-Type'] = 'application/json'
|
||||
kwargs['data'] = json.dumps(kwargs['body'])
|
||||
del kwargs['body']
|
||||
|
||||
resp = requests.request(method, self.blazar_url + url, **kwargs)
|
||||
|
||||
try:
|
||||
body = json.loads(resp.text)
|
||||
except ValueError:
|
||||
body = None
|
||||
|
||||
if resp.status_code >= 400:
|
||||
if body is not None:
|
||||
error_message = body.get('error_message', body)
|
||||
else:
|
||||
error_message = resp.text
|
||||
|
||||
body = _("ERROR: {0}").format(error_message)
|
||||
raise exception.BlazarClientException(body, code=resp.status_code)
|
||||
|
||||
return resp, body
|
|
@ -25,8 +25,8 @@ import sys
|
|||
|
||||
from cliff import app
|
||||
from cliff import commandmanager
|
||||
from keystoneclient import client as keystone_client
|
||||
from keystoneclient import exceptions as keystone_exceptions
|
||||
from keystoneauth1 import identity
|
||||
from keystoneauth1 import session
|
||||
from oslo_utils import encodeutils
|
||||
import six
|
||||
|
||||
|
@ -195,6 +195,36 @@ class BlazarShell(app.App):
|
|||
parser.add_argument(
|
||||
'--os_auth_url',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--os-project-name', metavar='<auth-project-name>',
|
||||
default=env('OS_PROJECT_NAME'),
|
||||
help='Authentication project name (Env: OS_PROJECT_NAME)')
|
||||
parser.add_argument(
|
||||
'--os_project_name',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--os-project-id', metavar='<auth-project-id>',
|
||||
default=env('OS_PROJECT_ID'),
|
||||
help='Authentication project ID (Env: OS_PROJECT_ID)')
|
||||
parser.add_argument(
|
||||
'--os_project_id',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--os-project-domain-name', metavar='<auth-project-domain-name>',
|
||||
default=env('OS_PROJECT_DOMAIN_NAME'),
|
||||
help='Authentication project domain name '
|
||||
'(Env: OS_PROJECT_DOMAIN_NAME)')
|
||||
parser.add_argument(
|
||||
'--os_project_domain_name',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--os-project-domain-id', metavar='<auth-project-domain-id>',
|
||||
default=env('OS_PROJECT_DOMAIN_ID'),
|
||||
help='Authentication project domain ID '
|
||||
'(Env: OS_PROJECT_DOMAIN_ID)')
|
||||
parser.add_argument(
|
||||
'--os_project_domain_id',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--os-tenant-name', metavar='<auth-tenant-name>',
|
||||
default=env('OS_TENANT_NAME'),
|
||||
|
@ -213,6 +243,20 @@ class BlazarShell(app.App):
|
|||
parser.add_argument(
|
||||
'--os_username',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--os-user-domain-name', metavar='<auth-user-domain-name>',
|
||||
default=env('OS_USER_DOMAIN_NAME'),
|
||||
help='Authentication user domain name (Env: OS_USER_DOMAIN_NAME)')
|
||||
parser.add_argument(
|
||||
'--os_user_domain_name',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--os-user-domain-id', metavar='<auth-user-domain-id>',
|
||||
default=env('OS_USER_DOMAIN_ID'),
|
||||
help='Authentication user domain ID (Env: OS_USER_DOMAIN_ID)')
|
||||
parser.add_argument(
|
||||
'--os_user_domain_id',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--os-password', metavar='<auth-password>',
|
||||
default=utils.env('OS_PASSWORD'),
|
||||
|
@ -234,6 +278,10 @@ class BlazarShell(app.App):
|
|||
parser.add_argument(
|
||||
'--os_token',
|
||||
help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
'--service-type', metavar='<service-type>',
|
||||
default=env('BLAZAR_SERVICE_TYPE', default='reservation'),
|
||||
help='Defaults to env[BLAZAR_SERVICE_TYPE] or reservation.')
|
||||
parser.add_argument(
|
||||
'--endpoint-type', metavar='<endpoint-type>',
|
||||
default=env('OS_ENDPOINT_TYPE', default='publicURL'),
|
||||
|
@ -373,61 +421,45 @@ class BlazarShell(app.App):
|
|||
return result
|
||||
|
||||
def authenticate_user(self):
|
||||
"""Make sure the user has provided all of the authentication
|
||||
info we need.
|
||||
"""
|
||||
if not self.options.os_token:
|
||||
if not self.options.os_username:
|
||||
raise exception.CommandError(
|
||||
"You must provide a username via"
|
||||
" either --os-username or env[OS_USERNAME]")
|
||||
"""Authenticate user and set client by using passed params."""
|
||||
|
||||
if not self.options.os_password:
|
||||
raise exception.CommandError(
|
||||
"You must provide a password via"
|
||||
" either --os-password or env[OS_PASSWORD]")
|
||||
if self.options.os_token:
|
||||
auth = identity.Token(
|
||||
auth_url=self.options.os_auth_url,
|
||||
token=self.options.os_token,
|
||||
tenant_id=self.options.os_tenant_id,
|
||||
tenant_name=self.options.os_tenant_name,
|
||||
project_id=self.options.os_project_id,
|
||||
project_name=self.options.os_project_name,
|
||||
project_domain_id=self.options.os_project_domain_id,
|
||||
project_domain_name=self.options.os_project_domain_name
|
||||
)
|
||||
else:
|
||||
auth = identity.Password(
|
||||
auth_url=self.options.os_auth_url,
|
||||
username=self.options.os_username,
|
||||
tenant_id=self.options.os_tenant_id,
|
||||
tenant_name=self.options.os_tenant_name,
|
||||
password=self.options.os_password,
|
||||
project_id=self.options.os_project_id,
|
||||
project_name=self.options.os_project_name,
|
||||
project_domain_id=self.options.os_project_domain_id,
|
||||
project_domain_name=self.options.os_project_domain_name,
|
||||
user_domain_id=self.options.os_user_domain_id,
|
||||
user_domain_name=self.options.os_user_domain_name
|
||||
)
|
||||
|
||||
if (not self.options.os_tenant_name and
|
||||
not self.options.os_tenant_id):
|
||||
raise exception.CommandError(
|
||||
"You must provide a tenant_name or tenant_id via"
|
||||
" --os-tenant-name, env[OS_TENANT_NAME]"
|
||||
" --os-tenant-id, or via env[OS_TENANT_ID]")
|
||||
|
||||
if not self.options.os_auth_url:
|
||||
raise exception.CommandError(
|
||||
"You must provide an auth url via"
|
||||
" either --os-auth-url or via env[OS_AUTH_URL]")
|
||||
|
||||
keystone = keystone_client.Client(
|
||||
token=self.options.os_token,
|
||||
auth_url=self.options.os_auth_url,
|
||||
tenant_id=self.options.os_tenant_id,
|
||||
tenant_name=self.options.os_tenant_name,
|
||||
password=self.options.os_password,
|
||||
region_name=self.options.os_region_name,
|
||||
username=self.options.os_username,
|
||||
insecure=self.options.insecure,
|
||||
cert=self.options.os_cacert
|
||||
sess = session.Session(
|
||||
auth=auth,
|
||||
verify=(self.options.os_cacert or not self.options.insecure)
|
||||
)
|
||||
|
||||
auth = keystone.authenticate()
|
||||
|
||||
if auth:
|
||||
try:
|
||||
blazar_url = keystone.service_catalog.url_for(
|
||||
service_type='reservation'
|
||||
)
|
||||
except keystone_exceptions.EndpointNotFound:
|
||||
raise exception.NoBlazarEndpoint()
|
||||
else:
|
||||
raise exception.NotAuthorized("User %s is not authorized." %
|
||||
self.options.os_username)
|
||||
|
||||
client = blazar_client.Client(self.options.os_reservation_api_version,
|
||||
blazar_url=blazar_url,
|
||||
auth_token=keystone.auth_token)
|
||||
self.client = client
|
||||
self.client = blazar_client.Client(
|
||||
self.options.os_reservation_api_version,
|
||||
session=sess,
|
||||
service_type=self.options.service_type,
|
||||
interface=self.options.endpoint_type
|
||||
)
|
||||
return
|
||||
|
||||
def initialize_app(self, argv):
|
||||
|
|
|
@ -1,102 +0,0 @@
|
|||
# Copyright (c) 2014 Mirantis Inc.
|
||||
#
|
||||
# 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 requests
|
||||
|
||||
from blazarclient import base
|
||||
from blazarclient import exception
|
||||
from blazarclient import tests
|
||||
|
||||
|
||||
class BaseClientManagerTestCase(tests.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(BaseClientManagerTestCase, self).setUp()
|
||||
|
||||
self.url = "www.fake.com/reservation"
|
||||
self.token = "aaa-bbb-ccc"
|
||||
self.fake_key = "fake_key"
|
||||
self.response = "RESPONSE"
|
||||
self.exception = exception
|
||||
|
||||
self.manager = base.BaseClientManager(self.url, self.token)
|
||||
self.request = self.patch(requests, "request")
|
||||
|
||||
def test_get(self):
|
||||
self.patch(
|
||||
self.manager, "request").return_value = (
|
||||
self.response, {"fake_key": "FAKE"})
|
||||
self.assertEqual(self.manager._get(self.url, self.fake_key), "FAKE")
|
||||
|
||||
def test_create(self):
|
||||
self.patch(
|
||||
self.manager, "request").return_value = (
|
||||
self.response, {"fake_key": "FAKE"})
|
||||
self.assertEqual(self.manager._create(self.url, {}, self.fake_key),
|
||||
"FAKE")
|
||||
|
||||
def test_delete(self):
|
||||
request = self.patch(self.manager, "request")
|
||||
request.return_value = (self.response, {"fake_key": "FAKE"})
|
||||
self.manager._delete(self.url)
|
||||
request.assert_called_once_with(self.url, "DELETE")
|
||||
|
||||
def test_update(self):
|
||||
self.patch(
|
||||
self.manager, "request").return_value = (
|
||||
self.response, {"fake_key": "FAKE"})
|
||||
self.assertEqual(self.manager._update(self.url, {}, self.fake_key),
|
||||
"FAKE")
|
||||
|
||||
def test_request_ok_with_body(self):
|
||||
self.request.return_value.status_code = 200
|
||||
self.request.return_value.text = '{"key": "value"}'
|
||||
|
||||
kwargs = {"body": {"key": "value"}}
|
||||
|
||||
self.assertEqual((
|
||||
self.request(), {"key": "value"}),
|
||||
self.manager.request(self.url, "POST", **kwargs))
|
||||
|
||||
def test_request_ok_without_body(self):
|
||||
self.request.return_value.status_code = 200
|
||||
self.request.return_value.text = "key"
|
||||
|
||||
kwargs = {"body": "key"}
|
||||
|
||||
self.assertEqual((
|
||||
self.request(), None),
|
||||
self.manager.request(self.url, "POST", **kwargs))
|
||||
|
||||
def test_request_fail_with_body(self):
|
||||
self.request.return_value.status_code = 400
|
||||
self.request.return_value.text = '{"key": "value"}'
|
||||
|
||||
kwargs = {"body": {"key": "value"}}
|
||||
|
||||
self.assertRaises(exception.BlazarClientException,
|
||||
self.manager.request,
|
||||
self.url, "POST", **kwargs)
|
||||
|
||||
def test_request_fail_without_body(self):
|
||||
self.request.return_value.status_code = 400
|
||||
self.request.return_value.text = "REAL_ERROR"
|
||||
|
||||
kwargs = {"body": "key"}
|
||||
|
||||
self.assertRaises(exception.BlazarClientException,
|
||||
self.manager.request,
|
||||
self.url, "POST", **kwargs)
|
|
@ -29,8 +29,11 @@ from blazarclient import shell
|
|||
from blazarclient import tests
|
||||
|
||||
FAKE_ENV = {'OS_USERNAME': 'username',
|
||||
'OS_USER_DOMAIN_ID': 'user_domain_id',
|
||||
'OS_PASSWORD': 'password',
|
||||
'OS_TENANT_NAME': 'tenant_name',
|
||||
'OS_PROJECT_NAME': 'project_name',
|
||||
'OS_PROJECT_DOMAIN_ID': 'project_domain_id',
|
||||
'OS_AUTH_URL': 'http://no.where'}
|
||||
|
||||
|
||||
|
|
|
@ -31,11 +31,15 @@ class Client(object):
|
|||
...
|
||||
"""
|
||||
|
||||
def __init__(self, blazar_url, auth_token):
|
||||
self.blazar_url = blazar_url
|
||||
self.auth_token = auth_token
|
||||
def __init__(self, session, *args, **kwargs):
|
||||
self.session = session
|
||||
self.version = '1'
|
||||
|
||||
self.lease = leases.LeaseClientManager(self.blazar_url,
|
||||
self.auth_token)
|
||||
self.host = hosts.ComputeHostClientManager(self.blazar_url,
|
||||
self.auth_token)
|
||||
self.lease = leases.LeaseClientManager(session=self.session,
|
||||
version=self.version,
|
||||
*args,
|
||||
**kwargs)
|
||||
self.host = hosts.ComputeHostClientManager(session=self.session,
|
||||
version=self.version,
|
||||
*args,
|
||||
**kwargs)
|
||||
|
|
|
@ -13,38 +13,46 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from blazarclient import base
|
||||
from keystoneauth1 import adapter
|
||||
|
||||
from blazarclient.i18n import _
|
||||
|
||||
|
||||
class ComputeHostClientManager(base.BaseClientManager):
|
||||
class ComputeHostClientManager(adapter.LegacyJsonAdapter):
|
||||
"""Manager for the ComputeHost connected requests."""
|
||||
|
||||
client_name = 'python-blazarclient'
|
||||
|
||||
def create(self, name, **kwargs):
|
||||
"""Creates host from values passed."""
|
||||
values = {'name': name}
|
||||
values.update(**kwargs)
|
||||
|
||||
return self._create('/os-hosts', values, response_key='host')
|
||||
resp, body = self.post('/os-hosts', body=values)
|
||||
return body['host']
|
||||
|
||||
def get(self, host_id):
|
||||
"""Describes host specifications such as name and details."""
|
||||
return self._get('/os-hosts/%s' % host_id, 'host')
|
||||
resp, body = super(ComputeHostClientManager,
|
||||
self).get('/os-hosts/%s' % host_id)
|
||||
return body['host']
|
||||
|
||||
def update(self, host_id, values):
|
||||
"""Update attributes of the host."""
|
||||
if not values:
|
||||
return _('No values to update passed.')
|
||||
return self._update('/os-hosts/%s' % host_id, values,
|
||||
response_key='host')
|
||||
resp, body = self.put('/os-hosts/%s' % host_id, body=values)
|
||||
return body['host']
|
||||
|
||||
def delete(self, host_id):
|
||||
"""Deletes host with specified ID."""
|
||||
self._delete('/os-hosts/%s' % host_id)
|
||||
resp, body = super(ComputeHostClientManager,
|
||||
self).delete('/os-hosts/%s' % host_id)
|
||||
|
||||
def list(self, sort_by=None):
|
||||
"""List all hosts."""
|
||||
hosts = self._get('/os-hosts', 'hosts')
|
||||
resp, body = super(ComputeHostClientManager,
|
||||
self).get('/os-hosts')
|
||||
hosts = body['hosts']
|
||||
if sort_by:
|
||||
hosts = sorted(hosts, key=lambda l: l[sort_by])
|
||||
return hosts
|
||||
|
|
|
@ -13,29 +13,34 @@
|
|||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from keystoneauth1 import adapter
|
||||
from oslo_utils import timeutils
|
||||
|
||||
from blazarclient import base
|
||||
from blazarclient.i18n import _
|
||||
from blazarclient import utils
|
||||
|
||||
|
||||
class LeaseClientManager(base.BaseClientManager):
|
||||
class LeaseClientManager(adapter.LegacyJsonAdapter):
|
||||
"""Manager for the lease connected requests."""
|
||||
|
||||
client_name = 'python-blazarclient'
|
||||
|
||||
def create(self, name, start, end, reservations, events, before_end=None):
|
||||
"""Creates lease from values passed."""
|
||||
values = {'name': name, 'start_date': start, 'end_date': end,
|
||||
'reservations': reservations, 'events': events,
|
||||
'before_end_date': before_end}
|
||||
|
||||
return self._create('/leases', values, 'lease')
|
||||
resp, body = self.post('/leases', body=values)
|
||||
return body['lease']
|
||||
|
||||
def get(self, lease_id):
|
||||
"""Describes lease specifications such as name, status and locked
|
||||
condition.
|
||||
"""
|
||||
return self._get('/leases/%s' % lease_id, 'lease')
|
||||
resp, body = super(LeaseClientManager,
|
||||
self).get('/leases/%s' % lease_id)
|
||||
return body['lease']
|
||||
|
||||
def update(self, lease_id, name=None, prolong_for=None, reduce_by=None,
|
||||
end_date=None, advance_by=None, defer_by=None, start_date=None,
|
||||
|
@ -76,16 +81,18 @@ class LeaseClientManager(base.BaseClientManager):
|
|||
|
||||
if not values:
|
||||
return _('No values to update passed.')
|
||||
return self._update('/leases/%s' % lease_id, values,
|
||||
response_key='lease')
|
||||
resp, body = self.put('/leases/%s' % lease_id, body=values)
|
||||
return body['lease']
|
||||
|
||||
def delete(self, lease_id):
|
||||
"""Deletes lease with specified ID."""
|
||||
self._delete('/leases/%s' % lease_id)
|
||||
resp, body = super(LeaseClientManager,
|
||||
self).delete('/leases/%s' % lease_id)
|
||||
|
||||
def list(self, sort_by=None):
|
||||
"""List all leases."""
|
||||
leases = self._get('/leases', 'leases')
|
||||
resp, body = super(LeaseClientManager, self).get('/leases')
|
||||
leases = body['leases']
|
||||
if sort_by:
|
||||
leases = sorted(leases, key=lambda l: l[sort_by])
|
||||
return leases
|
||||
|
|
|
@ -4,10 +4,9 @@
|
|||
pbr!=2.1.0,>=2.0.0 # Apache-2.0
|
||||
cliff>=2.8.0 # Apache-2.0
|
||||
PrettyTable<0.8,>=0.7.1 # BSD
|
||||
python-keystoneclient>=3.8.0 # Apache-2.0
|
||||
requests>=2.14.2 # Apache-2.0
|
||||
six>=1.9.0 # MIT
|
||||
Babel!=2.4.0,>=2.3.4 # BSD
|
||||
oslo.i18n>=3.15.3 # Apache-2.0
|
||||
oslo.log>=3.30.0 # Apache-2.0
|
||||
oslo.utils>=3.28.0 # Apache-2.0
|
||||
keystoneauth1>=3.2.0 # Apache-2.0
|
||||
|
|
Loading…
Reference in New Issue