From fd409626fa0b1bac5cc1c97e59d7f5b13a5a890f Mon Sep 17 00:00:00 2001 From: Yaguang Tang Date: Thu, 1 Aug 2013 18:22:55 +0800 Subject: [PATCH 01/24] Add timeout parameter in requests Fix bug #1207260 Change-Id: I0f57a9b27c2da2521adb6aebfe3fa072c6b56808 --- cinderclient/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cinderclient/client.py b/cinderclient/client.py index 857f80a..958a9eb 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -79,6 +79,7 @@ class HTTPClient(object): self.auth_token = None self.proxy_token = proxy_token self.proxy_tenant_id = proxy_tenant_id + self.timeout = timeout if insecure: self.verify_cert = False @@ -133,6 +134,8 @@ class HTTPClient(object): kwargs['data'] = json.dumps(kwargs['body']) del kwargs['body'] + if self.timeout: + kwargs.setdefault('timeout', self.timeout) self.http_log_req((url, method,), kwargs) resp = requests.request( method, From c95e59f51041dc01812d34a6b6e0765dc8c4d034 Mon Sep 17 00:00:00 2001 From: Christian Berendt Date: Tue, 6 Aug 2013 11:59:34 +0200 Subject: [PATCH 02/24] convert third-party exception to ConnectionError fixes bug #1207635 Change-Id: I37da522e812286e72706409b8a6d4652515f720f --- cinderclient/client.py | 3 ++- cinderclient/exceptions.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cinderclient/client.py b/cinderclient/client.py index 857f80a..792ffd5 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -192,7 +192,8 @@ class HTTPClient(object): except requests.exceptions.ConnectionError as e: # Catch a connection refused from requests.request self._logger.debug("Connection refused: %s" % e) - raise + msg = 'Unable to establish connection: %s' % e + raise exceptions.ConnectionError(msg) self._logger.debug( "Failed attempt(%s of %s), retrying in %s seconds" % (attempts, self.retries, backoff)) diff --git a/cinderclient/exceptions.py b/cinderclient/exceptions.py index 0c52c9c..9d3e0bc 100644 --- a/cinderclient/exceptions.py +++ b/cinderclient/exceptions.py @@ -39,6 +39,11 @@ class EndpointNotFound(Exception): pass +class ConnectionError(Exception): + """Could not open a connection to the API service.""" + pass + + class AmbiguousEndpoints(Exception): """Found more than one matching endpoint in Service Catalog.""" def __init__(self, endpoints=None): From fdf65aee922eea5a8bb047b55f5d5ade7cbe759e Mon Sep 17 00:00:00 2001 From: Chuck Short Date: Sat, 3 Aug 2013 01:54:45 +0000 Subject: [PATCH 03/24] python3: Fix tox requirements Update tox.ini to no to install distribute. Also bump testrepository to a newerver version since it fixes some python3 compat issues. Change-Id: I735dc28cdb94e3376b391d11220c1ba90ddb8b2e Signed-off-by: Chuck Short --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index ca047b7..65bcb21 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,5 @@ [tox] +distribute = False envlist = py26,py27,py33,pep8 [testenv] From 2465bb5d7ecbe0b1ca0158fe02b5c3fc0dde508e Mon Sep 17 00:00:00 2001 From: Monty Taylor Date: Wed, 7 Aug 2013 19:03:55 -0300 Subject: [PATCH 04/24] Updated from global requirements Change-Id: I2e2bd3a38458e1307bcc0410da74dc76c0a5987a --- requirements.txt | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 068a2f9..3a14372 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -pbr>=0.5.16,<0.6 +pbr>=0.5.21,<1.0 argparse PrettyTable>=0.6,<0.8 -requests>=1.1,<1.2.3 +requests>=1.1 simplejson>=2.0.9 six diff --git a/setup.py b/setup.py index 15f4e9d..2a0786a 100644 --- a/setup.py +++ b/setup.py @@ -18,5 +18,5 @@ import setuptools setuptools.setup( - setup_requires=['pbr>=0.5.20'], + setup_requires=['pbr>=0.5.21,<1.0'], pbr=True) From 34c7c8c2e2c87a6203ec9f4d8ac67fef816eb868 Mon Sep 17 00:00:00 2001 From: Mike Perez Date: Wed, 7 Aug 2013 10:01:26 -0700 Subject: [PATCH 05/24] Add support for multiple cinder endpoints Before v1 and v2 were set to the same service_type, so you could only have one set at a time. This assumes the catalog is setup for v1 service_type 'volume' and v2 service_type 'volumev2'. For backwards compatibility, we will allow v2 to be setup with service_type 'volume' for existing installations. Change-Id: Ife6d2cdb12d894b84ea3b276767fb93d487355d5 --- cinderclient/service_catalog.py | 21 ++-- cinderclient/shell.py | 6 +- cinderclient/tests/test_service_catalog.py | 137 ++++++++++++++++++++- cinderclient/tests/v1/test_auth.py | 57 ++++----- cinderclient/tests/v2/test_auth.py | 57 ++++----- cinderclient/v2/client.py | 2 +- cinderclient/v2/shell.py | 84 ++++++------- 7 files changed, 254 insertions(+), 110 deletions(-) diff --git a/cinderclient/service_catalog.py b/cinderclient/service_catalog.py index 2be335a..09ef59c 100644 --- a/cinderclient/service_catalog.py +++ b/cinderclient/service_catalog.py @@ -52,15 +52,22 @@ class ServiceCatalog(object): catalog = self.catalog['access']['serviceCatalog'] for service in catalog: - if service.get("type") != service_type: + + # NOTE(thingee): For backwards compatibility, if they have v2 + # enabled and the service_type is set to 'volume', go ahead and + # accept that. + skip_service_type_check = False + if service_type == 'volumev2' and service['type'] == 'volume': + version = service['endpoints'][0]['publicURL'].split('/')[3] + if version == 'v2': + skip_service_type_check = True + + if (not skip_service_type_check + and service.get("type") != service_type): continue - if (service_name and service_type == 'compute' and - service.get('name') != service_name): - continue - - if (volume_service_name and service_type == 'volume' and - service.get('name') != volume_service_name): + if (volume_service_name and service_type in ('volume', 'volumev2') + and service.get('name') != volume_service_name): continue endpoints = service['endpoints'] diff --git a/cinderclient/shell.py b/cinderclient/shell.py index d067996..049324c 100644 --- a/cinderclient/shell.py +++ b/cinderclient/shell.py @@ -41,7 +41,7 @@ from cinderclient.v2 import shell as shell_v2 DEFAULT_OS_VOLUME_API_VERSION = "1" DEFAULT_CINDER_ENDPOINT_TYPE = 'publicURL' -DEFAULT_CINDER_SERVICE_TYPE = 'compute' +DEFAULT_CINDER_SERVICE_TYPE = 'volume' logger = logging.getLogger(__name__) @@ -145,7 +145,7 @@ class OpenStackCinderShell(object): parser.add_argument('--service-type', metavar='', - help='Defaults to compute for most actions') + help='Defaults to volume for most actions') parser.add_argument('--service_type', help=argparse.SUPPRESS) @@ -173,7 +173,7 @@ class OpenStackCinderShell(object): help=argparse.SUPPRESS) parser.add_argument('--os-volume-api-version', - metavar='', + metavar='', default=utils.env('OS_VOLUME_API_VERSION', default=DEFAULT_OS_VOLUME_API_VERSION), help='Accepts 1 or 2,defaults ' diff --git a/cinderclient/tests/test_service_catalog.py b/cinderclient/tests/test_service_catalog.py index 1025bd5..1055962 100644 --- a/cinderclient/tests/test_service_catalog.py +++ b/cinderclient/tests/test_service_catalog.py @@ -72,7 +72,7 @@ SERVICE_CATALOG = { "endpoints_links": [], }, { - "name": "Nova Volumes", + "name": "Cinder Volume Service", "type": "volume", "endpoints": [ { @@ -101,6 +101,128 @@ SERVICE_CATALOG = { }, ], }, + { + "name": "Cinder Volume Service V2", + "type": "volumev2", + "endpoints": [ + { + "tenantId": "1", + "publicURL": "https://volume1.host/v2/1234", + "internalURL": "https://volume1.host/v2/1234", + "region": "South", + "versionId": "2.0", + "versionInfo": "uri", + "versionList": "uri" + }, + { + "tenantId": "2", + "publicURL": "https://volume1.host/v2/3456", + "internalURL": "https://volume1.host/v2/3456", + "region": "South", + "versionId": "1.1", + "versionInfo": "https://volume1.host/v2/", + "versionList": "https://volume1.host/" + }, + ], + "endpoints_links": [ + { + "rel": "next", + "href": "https://identity1.host/v2.0/endpoints" + }, + ], + }, + ], + "serviceCatalog_links": [ + { + "rel": "next", + "href": "https://identity.host/v2.0/endpoints?session=2hfh8Ar", + }, + ], + }, +} + +SERVICE_COMPATIBILITY_CATALOG = { + "access": { + "token": { + "id": "ab48a9efdfedb23ty3494", + "expires": "2010-11-01T03:32:15-05:00", + "tenant": { + "id": "345", + "name": "My Project" + } + }, + "user": { + "id": "123", + "name": "jqsmith", + "roles": [ + { + "id": "234", + "name": "compute:admin", + }, + { + "id": "235", + "name": "object-store:admin", + "tenantId": "1", + } + ], + "roles_links": [], + }, + "serviceCatalog": [ + { + "name": "Cloud Servers", + "type": "compute", + "endpoints": [ + { + "tenantId": "1", + "publicURL": "https://compute1.host/v1/1234", + "internalURL": "https://compute1.host/v1/1234", + "region": "North", + "versionId": "1.0", + "versionInfo": "https://compute1.host/v1/", + "versionList": "https://compute1.host/" + }, + { + "tenantId": "2", + "publicURL": "https://compute1.host/v1/3456", + "internalURL": "https://compute1.host/v1/3456", + "region": "North", + "versionId": "1.1", + "versionInfo": "https://compute1.host/v1/", + "versionList": "https://compute1.host/" + }, + ], + "endpoints_links": [], + }, + { + "name": "Cinder Volume Service V2", + "type": "volume", + "endpoints": [ + { + "tenantId": "1", + "publicURL": "https://volume1.host/v2/1234", + "internalURL": "https://volume1.host/v2/1234", + "region": "South", + "versionId": "2.0", + "versionInfo": "uri", + "versionList": "uri" + }, + { + "tenantId": "2", + "publicURL": "https://volume1.host/v2/3456", + "internalURL": "https://volume1.host/v2/3456", + "region": "South", + "versionId": "1.1", + "versionInfo": "https://volume1.host/v2/", + "versionList": "https://volume1.host/" + }, + ], + "endpoints_links": [ + { + "rel": "next", + "href": "https://identity1.host/v2.0/endpoints" + }, + ], + }, ], "serviceCatalog_links": [ { @@ -136,5 +258,18 @@ class ServiceCatalogTest(utils.TestCase): self.assertEquals(sc.url_for('tenantId', '2', service_type='volume'), "https://volume1.host/v1/3456") + self.assertEquals(sc.url_for('tenantId', '2', service_type='volumev2'), + "https://volume1.host/v2/3456") + self.assertEquals(sc.url_for('tenantId', '2', service_type='volumev2'), + "https://volume1.host/v2/3456") + self.assertRaises(exceptions.EndpointNotFound, sc.url_for, "region", "North", service_type='volume') + + def test_compatibility_service_type(self): + sc = service_catalog.ServiceCatalog(SERVICE_COMPATIBILITY_CATALOG) + + self.assertEquals(sc.url_for('tenantId', '1', service_type='volume'), + "https://volume1.host/v2/1234") + self.assertEquals(sc.url_for('tenantId', '2', service_type='volume'), + "https://volume1.host/v2/3456") diff --git a/cinderclient/tests/v1/test_auth.py b/cinderclient/tests/v1/test_auth.py index b681dc7..fa9a126 100644 --- a/cinderclient/tests/v1/test_auth.py +++ b/cinderclient/tests/v1/test_auth.py @@ -24,7 +24,7 @@ from cinderclient.tests import utils class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_success(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v1", service_type='volume') resp = { "access": { "token": { @@ -33,13 +33,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volume", "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v1", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "adminURL": "http://localhost:8776/v1", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, @@ -89,8 +89,9 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): test_auth_call() def test_authenticate_tenant_id(self): - cs = client.Client("username", "password", auth_url="auth_url/v2.0", - tenant_id='tenant_id', service_type='compute') + cs = client.Client("username", "password", + auth_url="http://localhost:8776/v1", + tenant_id='tenant_id', service_type='volume') resp = { "access": { "token": { @@ -105,13 +106,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volume", "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v1", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "adminURL": "http://localhost:8776/v1", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, @@ -164,7 +165,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_failure(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0") + "http://localhost:8776/v1") resp = {"unauthorized": {"message": "Unauthorized", "code": "401"}} auth_response = utils.TestResponse({ "status_code": 401, @@ -181,7 +182,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_auth_redirect(self): cs = client.Client("username", "password", "project_id", - "auth_url/v1", service_type='compute') + "http://localhost:8776/v1", service_type='volume') dict_correct_response = { "access": { "token": { @@ -190,13 +191,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volume", "endpoints": [ { - "adminURL": "http://localhost:8774/v1", + "adminURL": "http://localhost:8776/v1", "region": "RegionOne", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1/", }, ], }, @@ -265,7 +266,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_ambiguous_endpoints(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v1", service_type='volume') resp = { "access": { "token": { @@ -274,25 +275,25 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "adminURL": "http://localhost:8774/v1", - "type": "compute", - "name": "Compute CLoud", + "adminURL": "http://localhost:8776/v1", + "type": "volume", + "name": "Cinder Volume Service", "endpoints": [ { "region": "RegionOne", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, { - "adminURL": "http://localhost:8774/v1", - "type": "compute", - "name": "Hyper-compute Cloud", + "adminURL": "http://localhost:8776/v1", + "type": "volume", + "name": "Cinder Volume Cloud Service", "endpoints": [ { - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, diff --git a/cinderclient/tests/v2/test_auth.py b/cinderclient/tests/v2/test_auth.py index 89dd18f..2ae3eed 100644 --- a/cinderclient/tests/v2/test_auth.py +++ b/cinderclient/tests/v2/test_auth.py @@ -27,7 +27,7 @@ from cinderclient.tests import utils class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_success(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v2", service_type='volumev2') resp = { "access": { "token": { @@ -36,13 +36,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volumev2", "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v2", - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "adminURL": "http://localhost:8776/v2", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", }, ], }, @@ -92,8 +92,9 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): test_auth_call() def test_authenticate_tenant_id(self): - cs = client.Client("username", "password", auth_url="auth_url/v2.0", - tenant_id='tenant_id', service_type='compute') + cs = client.Client("username", "password", + auth_url="http://localhost:8776/v2", + tenant_id='tenant_id', service_type='volumev2') resp = { "access": { "token": { @@ -108,13 +109,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": 'volumev2', "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v2", - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "adminURL": "http://localhost:8776/v2", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", }, ], }, @@ -167,7 +168,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_failure(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0") + "http://localhost:8776/v2") resp = {"unauthorized": {"message": "Unauthorized", "code": "401"}} auth_response = utils.TestResponse({ "status_code": 401, @@ -184,7 +185,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_auth_redirect(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2", service_type='compute') + "http://localhost:8776/v2", service_type='volumev2') dict_correct_response = { "access": { "token": { @@ -193,13 +194,13 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "type": "compute", + "type": "volumev2", "endpoints": [ { - "adminURL": "http://localhost:8774/v2", + "adminURL": "http://localhost:8776/v2", "region": "RegionOne", - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2/", }, ], }, @@ -268,7 +269,7 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_ambiguous_endpoints(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v2", service_type='volumev2') resp = { "access": { "token": { @@ -277,25 +278,25 @@ class AuthenticateAgainstKeystoneTests(utils.TestCase): }, "serviceCatalog": [ { - "adminURL": "http://localhost:8774/v2", - "type": "compute", - "name": "Compute CLoud", + "adminURL": "http://localhost:8776/v1", + "type": "volumev2", + "name": "Cinder Volume Service", "endpoints": [ { "region": "RegionOne", - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "internalURL": "http://localhost:8776/v1", + "publicURL": "http://localhost:8776/v1", }, ], }, { - "adminURL": "http://localhost:8774/v2", - "type": "compute", - "name": "Hyper-compute Cloud", + "adminURL": "http://localhost:8776/v2", + "type": "volumev2", + "name": "Cinder Volume V2", "endpoints": [ { - "internalURL": "http://localhost:8774/v2", - "publicURL": "http://localhost:8774/v2/", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", }, ], }, diff --git a/cinderclient/v2/client.py b/cinderclient/v2/client.py index fea400f..31781a8 100644 --- a/cinderclient/v2/client.py +++ b/cinderclient/v2/client.py @@ -44,7 +44,7 @@ class Client(object): insecure=False, timeout=None, tenant_id=None, proxy_tenant_id=None, proxy_token=None, region_name=None, endpoint_type='publicURL', extensions=None, - service_type='volume', service_name=None, + service_type='volumev2', service_name=None, volume_service_name=None, retries=None, http_log_debug=False, cacert=None): diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index 53f4c04..a1c1f22 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -146,7 +146,7 @@ def _extract_metadata(args): metavar='', default=None, help='Filter results by status') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_list(cs, args): """List all the volumes.""" # NOTE(thingee): Backwards-compatibility with v1 args @@ -174,7 +174,7 @@ def do_list(cs, args): @utils.arg('volume', metavar='', help='ID of the volume.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_show(cs, args): """Show details about a volume.""" info = dict() @@ -247,7 +247,7 @@ def do_show(cs, args): action='append', default=[], help='Scheduler hint like in nova') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_create(cs, args): """Add a new volume.""" # NOTE(thingee): Backwards-compatibility with v1 args @@ -298,7 +298,7 @@ def do_create(cs, args): @utils.arg('volume', metavar='', help='ID of the volume to delete.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_delete(cs, args): """Remove a volume.""" volume = _find_volume(cs, args.volume) @@ -308,7 +308,7 @@ def do_delete(cs, args): @utils.arg('volume', metavar='', help='ID of the volume to delete.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_force_delete(cs, args): """Attempt forced removal of a volume, regardless of its state.""" volume = _find_volume(cs, args.volume) @@ -320,7 +320,7 @@ def do_force_delete(cs, args): help=('Indicate which state to assign the volume. Options include ' 'available, error, creating, deleting, error_deleting. If no ' 'state is provided, available will be used.')) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_reset_state(cs, args): """Explicitly update the state of a volume.""" volume = _find_volume(cs, args.volume) @@ -341,7 +341,7 @@ def do_reset_state(cs, args): help=argparse.SUPPRESS) @utils.arg('--display_description', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_rename(cs, args): """Rename a volume.""" kwargs = {} @@ -369,7 +369,7 @@ def do_rename(cs, args): action='append', default=[], help='Metadata to set/unset (only key is necessary on unset)') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_metadata(cs, args): """Set or Delete metadata on a volume.""" volume = _find_volume(cs, args.volume) @@ -412,7 +412,7 @@ def do_metadata(cs, args): help='Filter results by volume-id') @utils.arg('--volume_id', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_snapshot_list(cs, args): """List all the snapshots.""" all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) @@ -436,7 +436,7 @@ def do_snapshot_list(cs, args): @utils.arg('snapshot', metavar='', help='ID of the snapshot.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_snapshot_show(cs, args): """Show details about a snapshot.""" snapshot = _find_volume_snapshot(cs, args.snapshot) @@ -468,7 +468,7 @@ def do_snapshot_show(cs, args): help=argparse.SUPPRESS) @utils.arg('--display_description', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_snapshot_create(cs, args): """Add a new snapshot.""" if args.display_name is not None: @@ -487,7 +487,7 @@ def do_snapshot_create(cs, args): @utils.arg('snapshot-id', metavar='', help='ID of the snapshot to delete.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_snapshot_delete(cs, args): """Remove a snapshot.""" snapshot = _find_volume_snapshot(cs, args.snapshot_id) @@ -504,7 +504,7 @@ def do_snapshot_delete(cs, args): help=argparse.SUPPRESS) @utils.arg('--display_description', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_snapshot_rename(cs, args): """Rename a snapshot.""" kwargs = {} @@ -528,7 +528,7 @@ def do_snapshot_rename(cs, args): 'Options include available, error, creating, ' 'deleting, error_deleting. If no state is provided, ' 'available will be used.')) -@utils.service_type('snapshot') +@utils.service_type('volumev2') def do_snapshot_reset_state(cs, args): """Explicitly update the state of a snapshot.""" snapshot = _find_volume_snapshot(cs, args.snapshot) @@ -544,14 +544,14 @@ def _print_type_and_extra_specs_list(vtypes): utils.print_list(vtypes, ['ID', 'Name', 'extra_specs'], formatters) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_type_list(cs, args): """Print a list of available 'volume types'.""" vtypes = cs.volume_types.list() _print_volume_type_list(vtypes) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_extra_specs_list(cs, args): """Print a list of current 'volume types and extra specs' (Admin Only).""" vtypes = cs.volume_types.list() @@ -561,7 +561,7 @@ def do_extra_specs_list(cs, args): @utils.arg('name', metavar='', help="Name of the new volume type") -@utils.service_type('volume') +@utils.service_type('volumev2') def do_type_create(cs, args): """Create a new volume type.""" vtype = cs.volume_types.create(args.name) @@ -571,7 +571,7 @@ def do_type_create(cs, args): @utils.arg('id', metavar='', help="Unique ID of the volume type to delete") -@utils.service_type('volume') +@utils.service_type('volumev2') def do_type_delete(cs, args): """Delete a specific volume type.""" cs.volume_types.delete(args.id) @@ -590,7 +590,7 @@ def do_type_delete(cs, args): action='append', default=[], help='Extra_specs to set/unset (only key is necessary on unset)') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_type_key(cs, args): "Set or unset extra_spec for a volume type.""" vtype = _find_volume_type(cs, args.vtype) @@ -648,7 +648,7 @@ def _quota_update(manager, identifier, args): @utils.arg('tenant', metavar='', help='UUID of tenant to list the quotas for.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_quota_show(cs, args): """List the quotas for a tenant.""" @@ -658,7 +658,7 @@ def do_quota_show(cs, args): @utils.arg('tenant', metavar='', help='UUID of tenant to list the default quotas for.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_quota_defaults(cs, args): """List the default quotas for a tenant.""" @@ -684,7 +684,7 @@ def do_quota_defaults(cs, args): metavar='', default=None, help='Volume type (Optional, Default=None)') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_quota_update(cs, args): """Update the quotas for a tenant.""" @@ -694,7 +694,7 @@ def do_quota_update(cs, args): @utils.arg('class_name', metavar='', help='Name of quota class to list the quotas for.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_quota_class_show(cs, args): """List the quotas for a quota class.""" @@ -720,14 +720,14 @@ def do_quota_class_show(cs, args): metavar='', default=None, help='Volume type (Optional, Default=None)') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_quota_class_update(cs, args): """Update the quotas for a quota class.""" _quota_update(cs.quota_classes, args.class_name, args) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_absolute_limits(cs, args): """Print a list of absolute limits for a user""" limits = cs.limits.get().absolute @@ -735,7 +735,7 @@ def do_absolute_limits(cs, args): utils.print_list(limits, columns) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_rate_limits(cs, args): """Print a list of rate limits for a user""" limits = cs.limits.get().rate @@ -783,7 +783,7 @@ def _find_volume_type(cs, vtype): help='Name for created image') @utils.arg('--image_name', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_upload_to_image(cs, args): """Upload volume to image service as image.""" volume = _find_volume(cs, args.volume_id) @@ -809,7 +809,7 @@ def do_upload_to_image(cs, args): metavar='', default=None, help='Options backup description (Default=None)') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_backup_create(cs, args): """Creates a backup.""" if args.display_name is not None: @@ -825,7 +825,7 @@ def do_backup_create(cs, args): @utils.arg('backup', metavar='', help='ID of the backup.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_backup_show(cs, args): """Show details about a backup.""" backup = _find_backup(cs, args.backup) @@ -836,7 +836,7 @@ def do_backup_show(cs, args): utils.print_dict(info) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_backup_list(cs, args): """List all the backups.""" backups = cs.backups.list() @@ -847,7 +847,7 @@ def do_backup_list(cs, args): @utils.arg('backup', metavar='', help='ID of the backup to delete.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_backup_delete(cs, args): """Remove a backup.""" backup = _find_backup(cs, args.backup) @@ -859,7 +859,7 @@ def do_backup_delete(cs, args): @utils.arg('--volume-id', metavar='', help='Optional ID of the volume to restore to.', default=None) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_backup_restore(cs, args): """Restore a backup.""" cs.restores.restore(args.backup, @@ -874,7 +874,7 @@ def do_backup_restore(cs, args): help='Optional transfer name. (Default=None)') @utils.arg('--display-name', help=argparse.SUPPRESS) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_transfer_create(cs, args): """Creates a volume transfer.""" if args.display_name is not None: @@ -891,7 +891,7 @@ def do_transfer_create(cs, args): @utils.arg('transfer', metavar='', help='ID of the transfer to delete.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_transfer_delete(cs, args): """Undo a transfer.""" transfer = _find_transfer(cs, args.transfer) @@ -902,7 +902,7 @@ def do_transfer_delete(cs, args): help='ID of the transfer to accept.') @utils.arg('auth_key', metavar='', help='Auth key of the transfer to accept.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_transfer_accept(cs, args): """Accepts a volume transfer.""" transfer = cs.transfers.accept(args.transfer, args.auth_key) @@ -913,7 +913,7 @@ def do_transfer_accept(cs, args): utils.print_dict(info) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_transfer_list(cs, args): """List all the transfers.""" transfers = cs.transfers.list() @@ -923,7 +923,7 @@ def do_transfer_list(cs, args): @utils.arg('transfer', metavar='', help='ID of the transfer to accept.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_transfer_show(cs, args): """Show details about a transfer.""" transfer = _find_transfer(cs, args.transfer) @@ -939,7 +939,7 @@ def do_transfer_show(cs, args): metavar='', type=int, help='New size of volume in GB') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_extend(cs, args): """Attempt to extend the size of an existing volume.""" volume = _find_volume(cs, args.volume) @@ -950,7 +950,7 @@ def do_extend(cs, args): help='Name of host.') @utils.arg('--binary', metavar='', default=None, help='Service binary.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_service_list(cs, args): """List all the services. Filter by host & service binary.""" result = cs.services.list(host=args.host, binary=args.binary) @@ -960,7 +960,7 @@ def do_service_list(cs, args): @utils.arg('host', metavar='', help='Name of host.') @utils.arg('binary', metavar='', help='Service binary.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_service_enable(cs, args): """Enable the service.""" cs.services.enable(args.host, args.binary) @@ -968,7 +968,7 @@ def do_service_enable(cs, args): @utils.arg('host', metavar='', help='Name of host.') @utils.arg('binary', metavar='', help='Service binary.') -@utils.service_type('volume') +@utils.service_type('volumev2') def do_service_disable(cs, args): """Disable the service.""" cs.services.disable(args.host, args.binary) @@ -1016,7 +1016,7 @@ def _treeizeAvailabilityZone(zone): return result -@utils.service_type('volume') +@utils.service_type('volumev2') def do_availability_zone_list(cs, _args): """List all the availability zones.""" try: From e19c1ccb3a0a3d179b86e75b2a58e22867d0e922 Mon Sep 17 00:00:00 2001 From: Chuck Short Date: Fri, 9 Aug 2013 15:20:47 +0000 Subject: [PATCH 06/24] Add missing babel dependency Add missing babel dependency. Change-Id: I9d9c6fe84ada1fa6c5cfb10052f76689a2c84206 Signed-off-by: Chuck Short --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 068a2f9..9ee909d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ argparse PrettyTable>=0.6,<0.8 requests>=1.1,<1.2.3 simplejson>=2.0.9 +Babel>=0.9.6 six From 219798536d47ce23c73a30894b3d3eb69574f7ad Mon Sep 17 00:00:00 2001 From: Seif Lotfy Date: Sat, 10 Aug 2013 22:00:01 +0000 Subject: [PATCH 07/24] Add print for "backup-create" command For convenience reasons backup-create should print out metadata Fixes bug: 1210874 Change-Id: I327aaadb3b82c2073cec5807aa429c4ffac6ee0f --- cinderclient/v1/shell.py | 16 ++++++++++++---- cinderclient/v2/shell.py | 16 ++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 6c5d266..111a177 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -731,10 +731,18 @@ def do_upload_to_image(cs, args): @utils.service_type('volume') def do_backup_create(cs, args): """Creates a backup.""" - cs.backups.create(args.volume, - args.container, - args.display_name, - args.display_description) + backup = cs.backups.create(args.volume, + args.container, + args.display_name, + args.display_description) + + info = {"volume_id": args.volume} + info.update(backup._info) + + if 'links' in info: + info.pop('links') + + utils.print_dict(info) @utils.arg('backup', metavar='', help='ID of the backup.') diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index a1c1f22..8006b84 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -818,10 +818,18 @@ def do_backup_create(cs, args): if args.display_description is not None: args.description = args.display_description - cs.backups.create(args.volume, - args.container, - args.name, - args.description) + backup = cs.backups.create(args.volume, + args.container, + args.name, + args.description) + + info = {"volume_id": args.volume} + info.update(backup._info) + + if 'links' in info: + info.pop('links') + + utils.print_dict(info) @utils.arg('backup', metavar='', help='ID of the backup.') From b7297f3052f74f8379b854f78da49b8c05fd393c Mon Sep 17 00:00:00 2001 From: Seif Lotfy Date: Sat, 10 Aug 2013 22:31:36 +0000 Subject: [PATCH 08/24] Add commandline option --metadata for cinder list Fixes bug: 1203471 Change-Id: I8d0bd839ea467f8995e1588ec51a5590c8b80d69 --- cinderclient/v1/shell.py | 8 ++++++++ cinderclient/v2/shell.py | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 6c5d266..0a999c7 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -154,6 +154,13 @@ def _extract_metadata(args): metavar='', default=None, help='Filter results by status') +@utils.arg( + '--metadata', + type=str, + nargs='*', + metavar='', + help='Filter results by metadata', + default=None) @utils.service_type('volume') def do_list(cs, args): """List all the volumes.""" @@ -162,6 +169,7 @@ def do_list(cs, args): 'all_tenants': all_tenants, 'display_name': args.display_name, 'status': args.status, + 'metadata': _extract_metadata(args) if args.metadata else None, } volumes = cs.volumes.list(search_opts=search_opts) _translate_volume_keys(volumes) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index a1c1f22..b15a363 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -146,6 +146,12 @@ def _extract_metadata(args): metavar='', default=None, help='Filter results by status') +@utils.arg('--metadata', + type=str, + nargs='*', + metavar='', + help='Filter results by metadata', + default=None) @utils.service_type('volumev2') def do_list(cs, args): """List all the volumes.""" @@ -158,6 +164,7 @@ def do_list(cs, args): 'all_tenants': all_tenants, 'name': args.name, 'status': args.status, + 'metadata': _extract_metadata(args) if args.metadata else None, } volumes = cs.volumes.list(search_opts=search_opts) _translate_volume_keys(volumes) From eb413f5373af70cbd41a0f9bf0800cf47d609dcc Mon Sep 17 00:00:00 2001 From: Peter Hamilton Date: Mon, 12 Aug 2013 09:05:44 -0400 Subject: [PATCH 09/24] Fixing malformed assert message formatting This modification addresses Bug #1210296, specifically addressing malformed assert message formatting in assert_called_anytime. When verifying that an API method was called during test execution, the assert statement used will throw a TypeError when formatting the assert message, instead of the expected AssertionError. This occurs because a nested tuple in the format list is not properly expanded. The error itself likely exists because assert_called_anytime is not currently used in the python-cinderclient testing framework. The fix involves joining the arguments in the format list into a single tuple, allowing proper argument expansion. Fixes: bug 1210296 Change-Id: I6cf9dd55cff318e8a850637c540436c91dac08df --- cinderclient/tests/fakes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinderclient/tests/fakes.py b/cinderclient/tests/fakes.py index 29260e1..41996a9 100644 --- a/cinderclient/tests/fakes.py +++ b/cinderclient/tests/fakes.py @@ -67,7 +67,7 @@ class FakeClient(object): break assert found, 'Expected %s %s; got %s' % ( - expected, self.client.callstack) + expected + (self.client.callstack, )) if body is not None: try: From 41cf3f193be7320f069963679889ef43662c490b Mon Sep 17 00:00:00 2001 From: Peter Hamilton Date: Mon, 12 Aug 2013 08:55:22 -0400 Subject: [PATCH 10/24] Fixing erroneous clearing of test callstack This modification addresses Bug #1210292, specifically addressing the case where the test framework callstack is cleared when no asserts are raised in assert_called_anytime. This test method verifies that a specific API method call was made at any time during a test. If a test makes multiple calls to this method, and the first call yields no assertions, the subsequent calls will fail because the first assert in the method verifies that the callstack is non-empty. The error itself likely exists because assert_called_anytime is not currently used in the python-cinderclient testing framework. The fix involves deleting the last line in the method that clears the callstack. Fixes: bug 1210292 Change-Id: I8b7b740957841a328b2f0ca888190f758cbdd234 --- cinderclient/tests/fakes.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cinderclient/tests/fakes.py b/cinderclient/tests/fakes.py index 29260e1..0216cac 100644 --- a/cinderclient/tests/fakes.py +++ b/cinderclient/tests/fakes.py @@ -78,8 +78,6 @@ class FakeClient(object): print(body) raise - self.client.callstack = [] - def clear_callstack(self): self.client.callstack = [] From 7b08b98a3bec2fbb9e7ee503f560880802fba412 Mon Sep 17 00:00:00 2001 From: Chuck Short Date: Tue, 6 Aug 2013 17:43:46 +0000 Subject: [PATCH 11/24] Sync strutils from oslo Sync strutils from oslo. Also import apiclient, gettextutils, and importutils. Change-Id: I565fd2cf40f2ea21842c6dbd581430b25d99fea6 Signed-off-by: Chuck Short --- .../openstack/common/apiclient/__init__.py | 16 + .../openstack/common/apiclient/auth.py | 227 ++++++++ .../openstack/common/apiclient/base.py | 492 ++++++++++++++++++ .../openstack/common/apiclient/client.py | 360 +++++++++++++ .../openstack/common/apiclient/exceptions.py | 446 ++++++++++++++++ .../openstack/common/apiclient/fake_client.py | 172 ++++++ cinderclient/openstack/common/gettextutils.py | 305 +++++++++++ cinderclient/openstack/common/importutils.py | 68 +++ cinderclient/openstack/common/strutils.py | 153 ++++-- tools/install_venv_common.py | 5 +- 10 files changed, 2208 insertions(+), 36 deletions(-) create mode 100644 cinderclient/openstack/common/apiclient/__init__.py create mode 100644 cinderclient/openstack/common/apiclient/auth.py create mode 100644 cinderclient/openstack/common/apiclient/base.py create mode 100644 cinderclient/openstack/common/apiclient/client.py create mode 100644 cinderclient/openstack/common/apiclient/exceptions.py create mode 100644 cinderclient/openstack/common/apiclient/fake_client.py create mode 100644 cinderclient/openstack/common/gettextutils.py create mode 100644 cinderclient/openstack/common/importutils.py diff --git a/cinderclient/openstack/common/apiclient/__init__.py b/cinderclient/openstack/common/apiclient/__init__.py new file mode 100644 index 0000000..d5d0022 --- /dev/null +++ b/cinderclient/openstack/common/apiclient/__init__.py @@ -0,0 +1,16 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation +# 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. diff --git a/cinderclient/openstack/common/apiclient/auth.py b/cinderclient/openstack/common/apiclient/auth.py new file mode 100644 index 0000000..374d20b --- /dev/null +++ b/cinderclient/openstack/common/apiclient/auth.py @@ -0,0 +1,227 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation +# Copyright 2013 Spanish National Research Council. +# 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. + +# E0202: An attribute inherited from %s hide this method +# pylint: disable=E0202 + +import abc +import argparse +import logging +import os + +from stevedore import extension + +from cinderclient.openstack.common.apiclient import exceptions + + +logger = logging.getLogger(__name__) + + +_discovered_plugins = {} + + +def discover_auth_systems(): + """Discover the available auth-systems. + + This won't take into account the old style auth-systems. + """ + global _discovered_plugins + _discovered_plugins = {} + + def add_plugin(ext): + _discovered_plugins[ext.name] = ext.plugin + + ep_namespace = "cinderclient.openstack.common.apiclient.auth" + mgr = extension.ExtensionManager(ep_namespace) + mgr.map(add_plugin) + + +def load_auth_system_opts(parser): + """Load options needed by the available auth-systems into a parser. + + This function will try to populate the parser with options from the + available plugins. + """ + group = parser.add_argument_group("Common auth options") + BaseAuthPlugin.add_common_opts(group) + for name, auth_plugin in _discovered_plugins.iteritems(): + group = parser.add_argument_group( + "Auth-system '%s' options" % name, + conflict_handler="resolve") + auth_plugin.add_opts(group) + + +def load_plugin(auth_system): + try: + plugin_class = _discovered_plugins[auth_system] + except KeyError: + raise exceptions.AuthSystemNotFound(auth_system) + return plugin_class(auth_system=auth_system) + + +def load_plugin_from_args(args): + """Load requred plugin and populate it with options. + + Try to guess auth system if it is not specified. Systems are tried in + alphabetical order. + + :type args: argparse.Namespace + :raises: AuthorizationFailure + """ + auth_system = args.os_auth_system + if auth_system: + plugin = load_plugin(auth_system) + plugin.parse_opts(args) + plugin.sufficient_options() + return plugin + + for plugin_auth_system in sorted(_discovered_plugins.iterkeys()): + plugin_class = _discovered_plugins[plugin_auth_system] + plugin = plugin_class() + plugin.parse_opts(args) + try: + plugin.sufficient_options() + except exceptions.AuthPluginOptionsMissing: + continue + return plugin + raise exceptions.AuthPluginOptionsMissing(["auth_system"]) + + +class BaseAuthPlugin(object): + """Base class for authentication plugins. + + An authentication plugin needs to override at least the authenticate + method to be a valid plugin. + """ + + __metaclass__ = abc.ABCMeta + + auth_system = None + opt_names = [] + common_opt_names = [ + "auth_system", + "username", + "password", + "tenant_name", + "token", + "auth_url", + ] + + def __init__(self, auth_system=None, **kwargs): + self.auth_system = auth_system or self.auth_system + self.opts = dict((name, kwargs.get(name)) + for name in self.opt_names) + + @staticmethod + def _parser_add_opt(parser, opt): + """Add an option to parser in two variants. + + :param opt: option name (with underscores) + """ + dashed_opt = opt.replace("_", "-") + env_var = "OS_%s" % opt.upper() + arg_default = os.environ.get(env_var, "") + arg_help = "Defaults to env[%s]." % env_var + parser.add_argument( + "--os-%s" % dashed_opt, + metavar="<%s>" % dashed_opt, + default=arg_default, + help=arg_help) + parser.add_argument( + "--os_%s" % opt, + metavar="<%s>" % dashed_opt, + help=argparse.SUPPRESS) + + @classmethod + def add_opts(cls, parser): + """Populate the parser with the options for this plugin. + """ + for opt in cls.opt_names: + # use `BaseAuthPlugin.common_opt_names` since it is never + # changed in child classes + if opt not in BaseAuthPlugin.common_opt_names: + cls._parser_add_opt(parser, opt) + + @classmethod + def add_common_opts(cls, parser): + """Add options that are common for several plugins. + """ + for opt in cls.common_opt_names: + cls._parser_add_opt(parser, opt) + + @staticmethod + def get_opt(opt_name, args): + """Return option name and value. + + :param opt_name: name of the option, e.g., "username" + :param args: parsed arguments + """ + return (opt_name, getattr(args, "os_%s" % opt_name, None)) + + def parse_opts(self, args): + """Parse the actual auth-system options if any. + + This method is expected to populate the attribute `self.opts` with a + dict containing the options and values needed to make authentication. + """ + self.opts.update(dict(self.get_opt(opt_name, args) + for opt_name in self.opt_names)) + + def authenticate(self, http_client): + """Authenticate using plugin defined method. + + The method usually analyses `self.opts` and performs + a request to authentication server. + + :param http_client: client object that needs authentication + :type http_client: HTTPClient + :raises: AuthorizationFailure + """ + self.sufficient_options() + self._do_authenticate(http_client) + + @abc.abstractmethod + def _do_authenticate(self, http_client): + """Protected method for authentication. + """ + + def sufficient_options(self): + """Check if all required options are present. + + :raises: AuthPluginOptionsMissing + """ + missing = [opt + for opt in self.opt_names + if not self.opts.get(opt)] + if missing: + raise exceptions.AuthPluginOptionsMissing(missing) + + @abc.abstractmethod + def token_and_endpoint(self, endpoint_type, service_type): + """Return token and endpoint. + + :param service_type: Service type of the endpoint + :type service_type: string + :param endpoint_type: Type of endpoint. + Possible values: public or publicURL, + internal or internalURL, + admin or adminURL + :type endpoint_type: string + :returns: tuple of token and endpoint strings + :raises: EndpointException + """ diff --git a/cinderclient/openstack/common/apiclient/base.py b/cinderclient/openstack/common/apiclient/base.py new file mode 100644 index 0000000..1b3e790 --- /dev/null +++ b/cinderclient/openstack/common/apiclient/base.py @@ -0,0 +1,492 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC +# Copyright 2012 Grid Dynamics +# Copyright 2013 OpenStack Foundation +# 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. + +""" +Base utilities to build API operation managers and objects on top of. +""" + +# E1102: %s is not callable +# pylint: disable=E1102 + +import abc +import urllib + +from cinderclient.openstack.common.apiclient import exceptions +from cinderclient.openstack.common import strutils + + +def getid(obj): + """Return id if argument is a Resource. + + Abstracts the common pattern of allowing both an object or an object's ID + (UUID) as a parameter when dealing with relationships. + """ + try: + if obj.uuid: + return obj.uuid + except AttributeError: + pass + try: + return obj.id + except AttributeError: + return obj + + +# TODO(aababilov): call run_hooks() in HookableMixin's child classes +class HookableMixin(object): + """Mixin so classes can register and run hooks.""" + _hooks_map = {} + + @classmethod + def add_hook(cls, hook_type, hook_func): + """Add a new hook of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param hook_func: hook function + """ + if hook_type not in cls._hooks_map: + cls._hooks_map[hook_type] = [] + + cls._hooks_map[hook_type].append(hook_func) + + @classmethod + def run_hooks(cls, hook_type, *args, **kwargs): + """Run all hooks of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param **args: args to be passed to every hook function + :param **kwargs: kwargs to be passed to every hook function + """ + hook_funcs = cls._hooks_map.get(hook_type) or [] + for hook_func in hook_funcs: + hook_func(*args, **kwargs) + + +class BaseManager(HookableMixin): + """Basic manager type providing common operations. + + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, client): + """Initializes BaseManager with `client`. + + :param client: instance of BaseClient descendant for HTTP requests + """ + super(BaseManager, self).__init__() + self.client = client + + def _list(self, url, response_key, obj_class=None, json=None): + """List the collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + :param obj_class: class for constructing the returned objects + (self.resource_class will be used by default) + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + """ + if json: + body = self.client.post(url, json=json).json() + else: + body = self.client.get(url).json() + + if obj_class is None: + obj_class = self.resource_class + + data = body[response_key] + # NOTE(ja): keystone returns values as list as {'values': [ ... ]} + # unlike other services which just return the list... + try: + data = data['values'] + except (KeyError, TypeError): + pass + + return [obj_class(self, res, loaded=True) for res in data if res] + + def _get(self, url, response_key): + """Get an object from collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'server' + """ + body = self.client.get(url).json() + return self.resource_class(self, body[response_key], loaded=True) + + def _head(self, url): + """Retrieve request headers for an object. + + :param url: a partial URL, e.g., '/servers' + """ + resp = self.client.head(url) + return resp.status_code == 204 + + def _post(self, url, json, response_key, return_raw=False): + """Create an object. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + :param return_raw: flag to force returning raw JSON instead of + Python object of self.resource_class + """ + body = self.client.post(url, json=json).json() + if return_raw: + return body[response_key] + return self.resource_class(self, body[response_key]) + + def _put(self, url, json=None, response_key=None): + """Update an object with PUT method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + """ + resp = self.client.put(url, json=json) + # PUT requests may not return a body + if resp.content: + body = resp.json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _patch(self, url, json=None, response_key=None): + """Update an object with PATCH method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + """ + body = self.client.patch(url, json=json).json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _delete(self, url): + """Delete an object. + + :param url: a partial URL, e.g., '/servers/my-server' + """ + return self.client.delete(url) + + +class ManagerWithFind(BaseManager): + """Manager with additional `find()`/`findall()` methods.""" + + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def list(self): + pass + + def find(self, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + matches = self.findall(**kwargs) + num_matches = len(matches) + if num_matches == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(msg) + elif num_matches > 1: + raise exceptions.NoUniqueMatch() + else: + return matches[0] + + def findall(self, **kwargs): + """Find all items with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + found = [] + searches = kwargs.items() + + for obj in self.list(): + try: + if all(getattr(obj, attr) == value + for (attr, value) in searches): + found.append(obj) + except AttributeError: + continue + + return found + + +class CrudManager(BaseManager): + """Base manager class for manipulating entities. + + Children of this class are expected to define a `collection_key` and `key`. + + - `collection_key`: Usually a plural noun by convention (e.g. `entities`); + used to refer collections in both URL's (e.g. `/v3/entities`) and JSON + objects containing a list of member resources (e.g. `{'entities': [{}, + {}, {}]}`). + - `key`: Usually a singular noun by convention (e.g. `entity`); used to + refer to an individual member of the collection. + + """ + collection_key = None + key = None + + def build_url(self, base_url=None, **kwargs): + """Builds a resource URL for the given kwargs. + + Given an example collection where `collection_key = 'entities'` and + `key = 'entity'`, the following URL's could be generated. + + By default, the URL will represent a collection of entities, e.g.:: + + /entities + + If kwargs contains an `entity_id`, then the URL will represent a + specific member, e.g.:: + + /entities/{entity_id} + + :param base_url: if provided, the generated URL will be appended to it + """ + url = base_url if base_url is not None else '' + + url += '/%s' % self.collection_key + + # do we have a specific entity? + entity_id = kwargs.get('%s_id' % self.key) + if entity_id is not None: + url += '/%s' % entity_id + + return url + + def _filter_kwargs(self, kwargs): + """Drop null values and handle ids.""" + for key, ref in kwargs.copy().iteritems(): + if ref is None: + kwargs.pop(key) + else: + if isinstance(ref, Resource): + kwargs.pop(key) + kwargs['%s_id' % key] = getid(ref) + return kwargs + + def create(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._post( + self.build_url(**kwargs), + {self.key: kwargs}, + self.key) + + def get(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._get( + self.build_url(**kwargs), + self.key) + + def head(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._head(self.build_url(**kwargs)) + + def list(self, base_url=None, **kwargs): + """List the collection. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % urllib.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + + def put(self, base_url=None, **kwargs): + """Update an element. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._put(self.build_url(base_url=base_url, **kwargs)) + + def update(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + params = kwargs.copy() + params.pop('%s_id' % self.key) + + return self._patch( + self.build_url(**kwargs), + {self.key: params}, + self.key) + + def delete(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + + return self._delete( + self.build_url(**kwargs)) + + def find(self, base_url=None, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + rl = self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % urllib.urlencode(kwargs) if kwargs else '', + }, + self.collection_key) + num = len(rl) + + if num == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(404, msg) + elif num > 1: + raise exceptions.NoUniqueMatch + else: + return rl[0] + + +class Extension(HookableMixin): + """Extension descriptor.""" + + SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') + manager_class = None + + def __init__(self, name, module): + super(Extension, self).__init__() + self.name = name + self.module = module + self._parse_extension_module() + + def _parse_extension_module(self): + self.manager_class = None + for attr_name, attr_value in self.module.__dict__.items(): + if attr_name in self.SUPPORTED_HOOKS: + self.add_hook(attr_name, attr_value) + else: + try: + if issubclass(attr_value, BaseManager): + self.manager_class = attr_value + except TypeError: + pass + + def __repr__(self): + return "" % self.name + + +class Resource(object): + """Base class for OpenStack resources (tenant, user, etc.). + + This is pretty much just a bag for attributes. + """ + + HUMAN_ID = False + NAME_ATTR = 'name' + + def __init__(self, manager, info, loaded=False): + """Populate and bind to a manager. + + :param manager: BaseManager object + :param info: dictionary representing resource attributes + :param loaded: prevent lazy-loading if set to True + """ + self.manager = manager + self._info = info + self._add_details(info) + self._loaded = loaded + + def __repr__(self): + reprkeys = sorted(k + for k in self.__dict__.keys() + if k[0] != '_' and k != 'manager') + info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) + return "<%s %s>" % (self.__class__.__name__, info) + + @property + def human_id(self): + """Human-readable ID which can be used for bash completion. + """ + if self.NAME_ATTR in self.__dict__ and self.HUMAN_ID: + return strutils.to_slug(getattr(self, self.NAME_ATTR)) + return None + + def _add_details(self, info): + for (k, v) in info.iteritems(): + try: + setattr(self, k, v) + self._info[k] = v + except AttributeError: + # In this case we already defined the attribute on the class + pass + + def __getattr__(self, k): + if k not in self.__dict__: + #NOTE(bcwaldon): disallow lazy-loading if already loaded once + if not self.is_loaded(): + self.get() + return self.__getattr__(k) + + raise AttributeError(k) + else: + return self.__dict__[k] + + def get(self): + # set_loaded() first ... so if we have to bail, we know we tried. + self.set_loaded(True) + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.id) + if new: + self._add_details(new._info) + + def __eq__(self, other): + if not isinstance(other, Resource): + return NotImplemented + # two resources of different types are not equal + if not isinstance(other, self.__class__): + return False + if hasattr(self, 'id') and hasattr(other, 'id'): + return self.id == other.id + return self._info == other._info + + def is_loaded(self): + return self._loaded + + def set_loaded(self, val): + self._loaded = val diff --git a/cinderclient/openstack/common/apiclient/client.py b/cinderclient/openstack/common/apiclient/client.py new file mode 100644 index 0000000..85837da --- /dev/null +++ b/cinderclient/openstack/common/apiclient/client.py @@ -0,0 +1,360 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack LLC +# Copyright 2011 Piston Cloud Computing, Inc. +# Copyright 2013 Alessio Ababilov +# Copyright 2013 Grid Dynamics +# Copyright 2013 OpenStack Foundation +# 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. + +""" +OpenStack Client interface. Handles the REST calls and responses. +""" + +# E0202: An attribute inherited from %s hide this method +# pylint: disable=E0202 + +import logging +import time + +try: + import simplejson as json +except ImportError: + import json + +import requests + +from cinderclient.openstack.common.apiclient import exceptions +from cinderclient.openstack.common import importutils + + +_logger = logging.getLogger(__name__) + + +class HTTPClient(object): + """This client handles sending HTTP requests to OpenStack servers. + + Features: + - share authentication information between several clients to different + services (e.g., for compute and image clients); + - reissue authentication request for expired tokens; + - encode/decode JSON bodies; + - raise exeptions on HTTP errors; + - pluggable authentication; + - store authentication information in a keyring; + - store time spent for requests; + - register clients for particular services, so one can use + `http_client.identity` or `http_client.compute`; + - log requests and responses in a format that is easy to copy-and-paste + into terminal and send the same request with curl. + """ + + user_agent = "cinderclient.openstack.common.apiclient" + + def __init__(self, + auth_plugin, + region_name=None, + endpoint_type="publicURL", + original_ip=None, + verify=True, + cert=None, + timeout=None, + timings=False, + keyring_saver=None, + debug=False, + user_agent=None, + http=None): + self.auth_plugin = auth_plugin + + self.endpoint_type = endpoint_type + self.region_name = region_name + + self.original_ip = original_ip + self.timeout = timeout + self.verify = verify + self.cert = cert + + self.keyring_saver = keyring_saver + self.debug = debug + self.user_agent = user_agent or self.user_agent + + self.times = [] # [("item", starttime, endtime), ...] + self.timings = timings + + # requests within the same session can reuse TCP connections from pool + self.http = http or requests.Session() + + self.cached_token = None + + def _http_log_req(self, method, url, kwargs): + if not self.debug: + return + + string_parts = [ + "curl -i", + "-X '%s'" % method, + "'%s'" % url, + ] + + for element in kwargs['headers']: + header = "-H '%s: %s'" % (element, kwargs['headers'][element]) + string_parts.append(header) + + _logger.debug("REQ: %s" % " ".join(string_parts)) + if 'data' in kwargs: + _logger.debug("REQ BODY: %s\n" % (kwargs['data'])) + + def _http_log_resp(self, resp): + if not self.debug: + return + _logger.debug( + "RESP: [%s] %s\n", + resp.status_code, + resp.headers) + if resp._content_consumed: + _logger.debug( + "RESP BODY: %s\n", + resp.text) + + def serialize(self, kwargs): + if kwargs.get('json') is not None: + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['data'] = json.dumps(kwargs['json']) + try: + del kwargs['json'] + except KeyError: + pass + + def get_timings(self): + return self.times + + def reset_timings(self): + self.times = [] + + def request(self, method, url, **kwargs): + """Send an http request with the specified characteristics. + + Wrapper around `requests.Session.request` to handle tasks such as + setting headers, JSON encoding/decoding, and error handling. + + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to +' requests.Session.request (such as `headers`) or `json` + that will be encoded as JSON and used as `data` argument + """ + kwargs.setdefault("headers", kwargs.get("headers", {})) + kwargs["headers"]["User-Agent"] = self.user_agent + if self.original_ip: + kwargs["headers"]["Forwarded"] = "for=%s;by=%s" % ( + self.original_ip, self.user_agent) + if self.timeout is not None: + kwargs.setdefault("timeout", self.timeout) + kwargs.setdefault("verify", self.verify) + if self.cert is not None: + kwargs.setdefault("cert", self.cert) + self.serialize(kwargs) + + self._http_log_req(method, url, kwargs) + if self.timings: + start_time = time.time() + resp = self.http.request(method, url, **kwargs) + if self.timings: + self.times.append(("%s %s" % (method, url), + start_time, time.time())) + self._http_log_resp(resp) + + if resp.status_code >= 400: + _logger.debug( + "Request returned failure status: %s", + resp.status_code) + raise exceptions.from_response(resp, method, url) + + return resp + + @staticmethod + def concat_url(endpoint, url): + """Concatenate endpoint and final URL. + + E.g., "http://keystone/v2.0/" and "/tokens" are concatenated to + "http://keystone/v2.0/tokens". + + :param endpoint: the base URL + :param url: the final URL + """ + return "%s/%s" % (endpoint.rstrip("/"), url.strip("/")) + + def client_request(self, client, method, url, **kwargs): + """Send an http request using `client`'s endpoint and specified `url`. + + If request was rejected as unauthorized (possibly because the token is + expired), issue one authorization attempt and send the request once + again. + + :param client: instance of BaseClient descendant + :param method: method of HTTP request + :param url: URL of HTTP request + :param kwargs: any other parameter that can be passed to +' `HTTPClient.request` + """ + + filter_args = { + "endpoint_type": client.endpoint_type or self.endpoint_type, + "service_type": client.service_type, + } + token, endpoint = (self.cached_token, client.cached_endpoint) + just_authenticated = False + if not (token and endpoint): + try: + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + except exceptions.EndpointException: + pass + if not (token and endpoint): + self.authenticate() + just_authenticated = True + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + if not (token and endpoint): + raise exceptions.AuthorizationFailure( + "Cannot find endpoint or token for request") + + old_token_endpoint = (token, endpoint) + kwargs.setdefault("headers", {})["X-Auth-Token"] = token + self.cached_token = token + client.cached_endpoint = endpoint + # Perform the request once. If we get Unauthorized, then it + # might be because the auth token expired, so try to + # re-authenticate and try again. If it still fails, bail. + try: + return self.request( + method, self.concat_url(endpoint, url), **kwargs) + except exceptions.Unauthorized as unauth_ex: + if just_authenticated: + raise + self.cached_token = None + client.cached_endpoint = None + self.authenticate() + try: + token, endpoint = self.auth_plugin.token_and_endpoint( + **filter_args) + except exceptions.EndpointException: + raise unauth_ex + if (not (token and endpoint) or + old_token_endpoint == (token, endpoint)): + raise unauth_ex + self.cached_token = token + client.cached_endpoint = endpoint + kwargs["headers"]["X-Auth-Token"] = token + return self.request( + method, self.concat_url(endpoint, url), **kwargs) + + def add_client(self, base_client_instance): + """Add a new instance of :class:`BaseClient` descendant. + + `self` will store a reference to `base_client_instance`. + + Example: + + >>> def test_clients(): + ... from keystoneclient.auth import keystone + ... from openstack.common.apiclient import client + ... auth = keystone.KeystoneAuthPlugin( + ... username="user", password="pass", tenant_name="tenant", + ... auth_url="http://auth:5000/v2.0") + ... openstack_client = client.HTTPClient(auth) + ... # create nova client + ... from novaclient.v1_1 import client + ... client.Client(openstack_client) + ... # create keystone client + ... from keystoneclient.v2_0 import client + ... client.Client(openstack_client) + ... # use them + ... openstack_client.identity.tenants.list() + ... openstack_client.compute.servers.list() + """ + service_type = base_client_instance.service_type + if service_type and not hasattr(self, service_type): + setattr(self, service_type, base_client_instance) + + def authenticate(self): + self.auth_plugin.authenticate(self) + # Store the authentication results in the keyring for later requests + if self.keyring_saver: + self.keyring_saver.save(self) + + +class BaseClient(object): + """Top-level object to access the OpenStack API. + + This client uses :class:`HTTPClient` to send requests. :class:`HTTPClient` + will handle a bunch of issues such as authentication. + """ + + service_type = None + endpoint_type = None # "publicURL" will be used + cached_endpoint = None + + def __init__(self, http_client, extensions=None): + self.http_client = http_client + http_client.add_client(self) + + # Add in any extensions... + if extensions: + for extension in extensions: + if extension.manager_class: + setattr(self, extension.name, + extension.manager_class(self)) + + def client_request(self, method, url, **kwargs): + return self.http_client.client_request( + self, method, url, **kwargs) + + def head(self, url, **kwargs): + return self.client_request("HEAD", url, **kwargs) + + def get(self, url, **kwargs): + return self.client_request("GET", url, **kwargs) + + def post(self, url, **kwargs): + return self.client_request("POST", url, **kwargs) + + def put(self, url, **kwargs): + return self.client_request("PUT", url, **kwargs) + + def delete(self, url, **kwargs): + return self.client_request("DELETE", url, **kwargs) + + def patch(self, url, **kwargs): + return self.client_request("PATCH", url, **kwargs) + + @staticmethod + def get_class(api_name, version, version_map): + """Returns the client class for the requested API version + + :param api_name: the name of the API, e.g. 'compute', 'image', etc + :param version: the requested API version + :param version_map: a dict of client classes keyed by version + :rtype: a client class for the requested API version + """ + try: + client_path = version_map[str(version)] + except (KeyError, ValueError): + msg = "Invalid %s client version '%s'. must be one of: %s" % ( + (api_name, version, ', '.join(version_map.keys()))) + raise exceptions.UnsupportedVersion(msg) + + return importutils.import_class(client_path) diff --git a/cinderclient/openstack/common/apiclient/exceptions.py b/cinderclient/openstack/common/apiclient/exceptions.py new file mode 100644 index 0000000..b03def7 --- /dev/null +++ b/cinderclient/openstack/common/apiclient/exceptions.py @@ -0,0 +1,446 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 Nebula, Inc. +# Copyright 2013 Alessio Ababilov +# Copyright 2013 OpenStack Foundation +# 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. + +""" +Exception definitions. +""" + +import sys + + +class ClientException(Exception): + """The base exception class for all exceptions this library raises. + """ + pass + + +class MissingArgs(ClientException): + """Supplied arguments are not sufficient for calling a function.""" + def __init__(self, missing): + self.missing = missing + msg = "Missing argument(s): %s" % ", ".join(missing) + super(MissingArgs, self).__init__(msg) + + +class ValidationError(ClientException): + """Error in validation on API client side.""" + pass + + +class UnsupportedVersion(ClientException): + """User is trying to use an unsupported version of the API.""" + pass + + +class CommandError(ClientException): + """Error in CLI tool.""" + pass + + +class AuthorizationFailure(ClientException): + """Cannot authorize API client.""" + pass + + +class AuthPluginOptionsMissing(AuthorizationFailure): + """Auth plugin misses some options.""" + def __init__(self, opt_names): + super(AuthPluginOptionsMissing, self).__init__( + "Authentication failed. Missing options: %s" % + ", ".join(opt_names)) + self.opt_names = opt_names + + +class AuthSystemNotFound(AuthorizationFailure): + """User has specified a AuthSystem that is not installed.""" + def __init__(self, auth_system): + super(AuthSystemNotFound, self).__init__( + "AuthSystemNotFound: %s" % repr(auth_system)) + self.auth_system = auth_system + + +class NoUniqueMatch(ClientException): + """Multiple entities found instead of one.""" + pass + + +class EndpointException(ClientException): + """Something is rotten in Service Catalog.""" + pass + + +class EndpointNotFound(EndpointException): + """Could not find requested endpoint in Service Catalog.""" + pass + + +class AmbiguousEndpoints(EndpointException): + """Found more than one matching endpoint in Service Catalog.""" + def __init__(self, endpoints=None): + super(AmbiguousEndpoints, self).__init__( + "AmbiguousEndpoints: %s" % repr(endpoints)) + self.endpoints = endpoints + + +class HttpError(ClientException): + """The base exception class for all HTTP exceptions. + """ + http_status = 0 + message = "HTTP Error" + + def __init__(self, message=None, details=None, + response=None, request_id=None, + url=None, method=None, http_status=None): + self.http_status = http_status or self.http_status + self.message = message or self.message + self.details = details + self.request_id = request_id + self.response = response + self.url = url + self.method = method + formatted_string = "%s (HTTP %s)" % (self.message, self.http_status) + if request_id: + formatted_string += " (Request-ID: %s)" % request_id + super(HttpError, self).__init__(formatted_string) + + +class HTTPClientError(HttpError): + """Client-side HTTP error. + + Exception for cases in which the client seems to have erred. + """ + message = "HTTP Client Error" + + +class HttpServerError(HttpError): + """Server-side HTTP error. + + Exception for cases in which the server is aware that it has + erred or is incapable of performing the request. + """ + message = "HTTP Server Error" + + +class BadRequest(HTTPClientError): + """HTTP 400 - Bad Request. + + The request cannot be fulfilled due to bad syntax. + """ + http_status = 400 + message = "Bad Request" + + +class Unauthorized(HTTPClientError): + """HTTP 401 - Unauthorized. + + Similar to 403 Forbidden, but specifically for use when authentication + is required and has failed or has not yet been provided. + """ + http_status = 401 + message = "Unauthorized" + + +class PaymentRequired(HTTPClientError): + """HTTP 402 - Payment Required. + + Reserved for future use. + """ + http_status = 402 + message = "Payment Required" + + +class Forbidden(HTTPClientError): + """HTTP 403 - Forbidden. + + The request was a valid request, but the server is refusing to respond + to it. + """ + http_status = 403 + message = "Forbidden" + + +class NotFound(HTTPClientError): + """HTTP 404 - Not Found. + + The requested resource could not be found but may be available again + in the future. + """ + http_status = 404 + message = "Not Found" + + +class MethodNotAllowed(HTTPClientError): + """HTTP 405 - Method Not Allowed. + + A request was made of a resource using a request method not supported + by that resource. + """ + http_status = 405 + message = "Method Not Allowed" + + +class NotAcceptable(HTTPClientError): + """HTTP 406 - Not Acceptable. + + The requested resource is only capable of generating content not + acceptable according to the Accept headers sent in the request. + """ + http_status = 406 + message = "Not Acceptable" + + +class ProxyAuthenticationRequired(HTTPClientError): + """HTTP 407 - Proxy Authentication Required. + + The client must first authenticate itself with the proxy. + """ + http_status = 407 + message = "Proxy Authentication Required" + + +class RequestTimeout(HTTPClientError): + """HTTP 408 - Request Timeout. + + The server timed out waiting for the request. + """ + http_status = 408 + message = "Request Timeout" + + +class Conflict(HTTPClientError): + """HTTP 409 - Conflict. + + Indicates that the request could not be processed because of conflict + in the request, such as an edit conflict. + """ + http_status = 409 + message = "Conflict" + + +class Gone(HTTPClientError): + """HTTP 410 - Gone. + + Indicates that the resource requested is no longer available and will + not be available again. + """ + http_status = 410 + message = "Gone" + + +class LengthRequired(HTTPClientError): + """HTTP 411 - Length Required. + + The request did not specify the length of its content, which is + required by the requested resource. + """ + http_status = 411 + message = "Length Required" + + +class PreconditionFailed(HTTPClientError): + """HTTP 412 - Precondition Failed. + + The server does not meet one of the preconditions that the requester + put on the request. + """ + http_status = 412 + message = "Precondition Failed" + + +class RequestEntityTooLarge(HTTPClientError): + """HTTP 413 - Request Entity Too Large. + + The request is larger than the server is willing or able to process. + """ + http_status = 413 + message = "Request Entity Too Large" + + def __init__(self, *args, **kwargs): + try: + self.retry_after = int(kwargs.pop('retry_after')) + except (KeyError, ValueError): + self.retry_after = 0 + + super(RequestEntityTooLarge, self).__init__(*args, **kwargs) + + +class RequestUriTooLong(HTTPClientError): + """HTTP 414 - Request-URI Too Long. + + The URI provided was too long for the server to process. + """ + http_status = 414 + message = "Request-URI Too Long" + + +class UnsupportedMediaType(HTTPClientError): + """HTTP 415 - Unsupported Media Type. + + The request entity has a media type which the server or resource does + not support. + """ + http_status = 415 + message = "Unsupported Media Type" + + +class RequestedRangeNotSatisfiable(HTTPClientError): + """HTTP 416 - Requested Range Not Satisfiable. + + The client has asked for a portion of the file, but the server cannot + supply that portion. + """ + http_status = 416 + message = "Requested Range Not Satisfiable" + + +class ExpectationFailed(HTTPClientError): + """HTTP 417 - Expectation Failed. + + The server cannot meet the requirements of the Expect request-header field. + """ + http_status = 417 + message = "Expectation Failed" + + +class UnprocessableEntity(HTTPClientError): + """HTTP 422 - Unprocessable Entity. + + The request was well-formed but was unable to be followed due to semantic + errors. + """ + http_status = 422 + message = "Unprocessable Entity" + + +class InternalServerError(HttpServerError): + """HTTP 500 - Internal Server Error. + + A generic error message, given when no more specific message is suitable. + """ + http_status = 500 + message = "Internal Server Error" + + +# NotImplemented is a python keyword. +class HttpNotImplemented(HttpServerError): + """HTTP 501 - Not Implemented. + + The server either does not recognize the request method, or it lacks + the ability to fulfill the request. + """ + http_status = 501 + message = "Not Implemented" + + +class BadGateway(HttpServerError): + """HTTP 502 - Bad Gateway. + + The server was acting as a gateway or proxy and received an invalid + response from the upstream server. + """ + http_status = 502 + message = "Bad Gateway" + + +class ServiceUnavailable(HttpServerError): + """HTTP 503 - Service Unavailable. + + The server is currently unavailable. + """ + http_status = 503 + message = "Service Unavailable" + + +class GatewayTimeout(HttpServerError): + """HTTP 504 - Gateway Timeout. + + The server was acting as a gateway or proxy and did not receive a timely + response from the upstream server. + """ + http_status = 504 + message = "Gateway Timeout" + + +class HttpVersionNotSupported(HttpServerError): + """HTTP 505 - HttpVersion Not Supported. + + The server does not support the HTTP protocol version used in the request. + """ + http_status = 505 + message = "HTTP Version Not Supported" + + +# In Python 2.4 Exception is old-style and thus doesn't have a __subclasses__() +# so we can do this: +# _code_map = dict((c.http_status, c) +# for c in HttpError.__subclasses__()) +_code_map = {} +for obj in sys.modules[__name__].__dict__.values(): + if isinstance(obj, type): + try: + http_status = obj.http_status + except AttributeError: + pass + else: + if http_status: + _code_map[http_status] = obj + + +def from_response(response, method, url): + """Returns an instance of :class:`HttpError` or subclass based on response. + + :param response: instance of `requests.Response` class + :param method: HTTP method used for request + :param url: URL used for request + """ + kwargs = { + "http_status": response.status_code, + "response": response, + "method": method, + "url": url, + "request_id": response.headers.get("x-compute-request-id"), + } + if "retry-after" in response.headers: + kwargs["retry_after"] = response.headers["retry-after"] + + content_type = response.headers.get("Content-Type", "") + if content_type.startswith("application/json"): + try: + body = response.json() + except ValueError: + pass + else: + if hasattr(body, "keys"): + error = body[body.keys()[0]] + kwargs["message"] = error.get("message", None) + kwargs["details"] = error.get("details", None) + elif content_type.startswith("text/"): + kwargs["details"] = response.text + + try: + cls = _code_map[response.status_code] + except KeyError: + if 500 <= response.status_code < 600: + cls = HttpServerError + elif 400 <= response.status_code < 500: + cls = HTTPClientError + else: + cls = HttpError + return cls(**kwargs) diff --git a/cinderclient/openstack/common/apiclient/fake_client.py b/cinderclient/openstack/common/apiclient/fake_client.py new file mode 100644 index 0000000..914cebd --- /dev/null +++ b/cinderclient/openstack/common/apiclient/fake_client.py @@ -0,0 +1,172 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 OpenStack Foundation +# 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. + +""" +A fake server that "responds" to API methods with pre-canned responses. + +All of these responses come from the spec, so if for some reason the spec's +wrong the tests might raise AssertionError. I've indicated in comments the +places where actual behavior differs from the spec. +""" + +# W0102: Dangerous default value %s as argument +# pylint: disable=W0102 + +import json +import urlparse + +import requests + +from cinderclient.openstack.common.apiclient import client + + +def assert_has_keys(dct, required=[], optional=[]): + for k in required: + try: + assert k in dct + except AssertionError: + extra_keys = set(dct.keys()).difference(set(required + optional)) + raise AssertionError("found unexpected keys: %s" % + list(extra_keys)) + + +class TestResponse(requests.Response): + """Wrap requests.Response and provide a convenient initialization. + """ + + def __init__(self, data): + super(TestResponse, self).__init__() + self._content_consumed = True + if isinstance(data, dict): + self.status_code = data.get('status_code', 200) + # Fake the text attribute to streamline Response creation + text = data.get('text', "") + if isinstance(text, (dict, list)): + self._content = json.dumps(text) + default_headers = { + "Content-Type": "application/json", + } + else: + self._content = text + default_headers = {} + self.headers = data.get('headers') or default_headers + else: + self.status_code = data + + def __eq__(self, other): + return (self.status_code == other.status_code and + self.headers == other.headers and + self._content == other._content) + + +class FakeHTTPClient(client.HTTPClient): + + def __init__(self, *args, **kwargs): + self.callstack = [] + self.fixtures = kwargs.pop("fixtures", None) or {} + if not args and not "auth_plugin" in kwargs: + args = (None, ) + super(FakeHTTPClient, self).__init__(*args, **kwargs) + + def assert_called(self, method, url, body=None, pos=-1): + """Assert than an API method was just called. + """ + expected = (method, url) + called = self.callstack[pos][0:2] + assert self.callstack, \ + "Expected %s %s but no calls were made." % expected + + assert expected == called, 'Expected %s %s; got %s %s' % \ + (expected + called) + + if body is not None: + if self.callstack[pos][3] != body: + raise AssertionError('%r != %r' % + (self.callstack[pos][3], body)) + + def assert_called_anytime(self, method, url, body=None): + """Assert than an API method was called anytime in the test. + """ + expected = (method, url) + + assert self.callstack, \ + "Expected %s %s but no calls were made." % expected + + found = False + entry = None + for entry in self.callstack: + if expected == entry[0:2]: + found = True + break + + assert found, 'Expected %s %s; got %s' % \ + (method, url, self.callstack) + if body is not None: + assert entry[3] == body, "%s != %s" % (entry[3], body) + + self.callstack = [] + + def clear_callstack(self): + self.callstack = [] + + def authenticate(self): + pass + + def client_request(self, client, method, url, **kwargs): + # Check that certain things are called correctly + if method in ["GET", "DELETE"]: + assert "json" not in kwargs + + # Note the call + self.callstack.append( + (method, + url, + kwargs.get("headers") or {}, + kwargs.get("json") or kwargs.get("data"))) + try: + fixture = self.fixtures[url][method] + except KeyError: + pass + else: + return TestResponse({"headers": fixture[0], + "text": fixture[1]}) + + # Call the method + args = urlparse.parse_qsl(urlparse.urlparse(url)[4]) + kwargs.update(args) + munged_url = url.rsplit('?', 1)[0] + munged_url = munged_url.strip('/').replace('/', '_').replace('.', '_') + munged_url = munged_url.replace('-', '_') + + callback = "%s_%s" % (method.lower(), munged_url) + + if not hasattr(self, callback): + raise AssertionError('Called unknown API method: %s %s, ' + 'expected fakes method name: %s' % + (method, url, callback)) + + resp = getattr(self, callback)(**kwargs) + if len(resp) == 3: + status, headers, body = resp + else: + status, body = resp + headers = {} + return TestResponse({ + "status_code": status, + "text": body, + "headers": headers, + }) diff --git a/cinderclient/openstack/common/gettextutils.py b/cinderclient/openstack/common/gettextutils.py new file mode 100644 index 0000000..e887869 --- /dev/null +++ b/cinderclient/openstack/common/gettextutils.py @@ -0,0 +1,305 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 Red Hat, Inc. +# Copyright 2013 IBM Corp. +# 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. + +""" +gettext for openstack-common modules. + +Usual usage in an openstack.common module: + + from cinderclient.openstack.common.gettextutils import _ +""" + +import copy +import gettext +import logging.handlers +import os +import re +import UserString + +from babel import localedata +import six + +_localedir = os.environ.get('cinderclient'.upper() + '_LOCALEDIR') +_t = gettext.translation('cinderclient', localedir=_localedir, fallback=True) + +_AVAILABLE_LANGUAGES = [] + + +def _(msg): + return _t.ugettext(msg) + + +def install(domain, lazy=False): + """Install a _() function using the given translation domain. + + Given a translation domain, install a _() function using gettext's + install() function. + + The main difference from gettext.install() is that we allow + overriding the default localedir (e.g. /usr/share/locale) using + a translation-domain-specific environment variable (e.g. + NOVA_LOCALEDIR). + + :param domain: the translation domain + :param lazy: indicates whether or not to install the lazy _() function. + The lazy _() introduces a way to do deferred translation + of messages by installing a _ that builds Message objects, + instead of strings, which can then be lazily translated into + any available locale. + """ + if lazy: + # NOTE(mrodden): Lazy gettext functionality. + # + # The following introduces a deferred way to do translations on + # messages in OpenStack. We override the standard _() function + # and % (format string) operation to build Message objects that can + # later be translated when we have more information. + # + # Also included below is an example LocaleHandler that translates + # Messages to an associated locale, effectively allowing many logs, + # each with their own locale. + + def _lazy_gettext(msg): + """Create and return a Message object. + + Lazy gettext function for a given domain, it is a factory method + for a project/module to get a lazy gettext function for its own + translation domain (i.e. nova, glance, cinder, etc.) + + Message encapsulates a string so that we can translate + it later when needed. + """ + return Message(msg, domain) + + import __builtin__ + __builtin__.__dict__['_'] = _lazy_gettext + else: + localedir = '%s_LOCALEDIR' % domain.upper() + gettext.install(domain, + localedir=os.environ.get(localedir), + unicode=True) + + +class Message(UserString.UserString, object): + """Class used to encapsulate translatable messages.""" + def __init__(self, msg, domain): + # _msg is the gettext msgid and should never change + self._msg = msg + self._left_extra_msg = '' + self._right_extra_msg = '' + self.params = None + self.locale = None + self.domain = domain + + @property + def data(self): + # NOTE(mrodden): this should always resolve to a unicode string + # that best represents the state of the message currently + + localedir = os.environ.get(self.domain.upper() + '_LOCALEDIR') + if self.locale: + lang = gettext.translation(self.domain, + localedir=localedir, + languages=[self.locale], + fallback=True) + else: + # use system locale for translations + lang = gettext.translation(self.domain, + localedir=localedir, + fallback=True) + + full_msg = (self._left_extra_msg + + lang.ugettext(self._msg) + + self._right_extra_msg) + + if self.params is not None: + full_msg = full_msg % self.params + + return six.text_type(full_msg) + + def _save_dictionary_parameter(self, dict_param): + full_msg = self.data + # look for %(blah) fields in string; + # ignore %% and deal with the + # case where % is first character on the line + keys = re.findall('(?:[^%]|^)?%\((\w*)\)[a-z]', full_msg) + + # if we don't find any %(blah) blocks but have a %s + if not keys and re.findall('(?:[^%]|^)%[a-z]', full_msg): + # apparently the full dictionary is the parameter + params = copy.deepcopy(dict_param) + else: + params = {} + for key in keys: + try: + params[key] = copy.deepcopy(dict_param[key]) + except TypeError: + # cast uncopyable thing to unicode string + params[key] = unicode(dict_param[key]) + + return params + + def _save_parameters(self, other): + # we check for None later to see if + # we actually have parameters to inject, + # so encapsulate if our parameter is actually None + if other is None: + self.params = (other, ) + elif isinstance(other, dict): + self.params = self._save_dictionary_parameter(other) + else: + # fallback to casting to unicode, + # this will handle the problematic python code-like + # objects that cannot be deep-copied + try: + self.params = copy.deepcopy(other) + except TypeError: + self.params = unicode(other) + + return self + + # overrides to be more string-like + def __unicode__(self): + return self.data + + def __str__(self): + return self.data.encode('utf-8') + + def __getstate__(self): + to_copy = ['_msg', '_right_extra_msg', '_left_extra_msg', + 'domain', 'params', 'locale'] + new_dict = self.__dict__.fromkeys(to_copy) + for attr in to_copy: + new_dict[attr] = copy.deepcopy(self.__dict__[attr]) + + return new_dict + + def __setstate__(self, state): + for (k, v) in state.items(): + setattr(self, k, v) + + # operator overloads + def __add__(self, other): + copied = copy.deepcopy(self) + copied._right_extra_msg += other.__str__() + return copied + + def __radd__(self, other): + copied = copy.deepcopy(self) + copied._left_extra_msg += other.__str__() + return copied + + def __mod__(self, other): + # do a format string to catch and raise + # any possible KeyErrors from missing parameters + self.data % other + copied = copy.deepcopy(self) + return copied._save_parameters(other) + + def __mul__(self, other): + return self.data * other + + def __rmul__(self, other): + return other * self.data + + def __getitem__(self, key): + return self.data[key] + + def __getslice__(self, start, end): + return self.data.__getslice__(start, end) + + def __getattribute__(self, name): + # NOTE(mrodden): handle lossy operations that we can't deal with yet + # These override the UserString implementation, since UserString + # uses our __class__ attribute to try and build a new message + # after running the inner data string through the operation. + # At that point, we have lost the gettext message id and can just + # safely resolve to a string instead. + ops = ['capitalize', 'center', 'decode', 'encode', + 'expandtabs', 'ljust', 'lstrip', 'replace', 'rjust', 'rstrip', + 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill'] + if name in ops: + return getattr(self.data, name) + else: + return UserString.UserString.__getattribute__(self, name) + + +def get_available_languages(domain): + """Lists the available languages for the given translation domain. + + :param domain: the domain to get languages for + """ + if _AVAILABLE_LANGUAGES: + return _AVAILABLE_LANGUAGES + + localedir = '%s_LOCALEDIR' % domain.upper() + find = lambda x: gettext.find(domain, + localedir=os.environ.get(localedir), + languages=[x]) + + # NOTE(mrodden): en_US should always be available (and first in case + # order matters) since our in-line message strings are en_US + _AVAILABLE_LANGUAGES.append('en_US') + # NOTE(luisg): Babel <1.0 used a function called list(), which was + # renamed to locale_identifiers() in >=1.0, the requirements master list + # requires >=0.9.6, uncapped, so defensively work with both. We can remove + # this check when the master list updates to >=1.0, and all projects udpate + list_identifiers = (getattr(localedata, 'list', None) or + getattr(localedata, 'locale_identifiers')) + locale_identifiers = list_identifiers() + for i in locale_identifiers: + if find(i) is not None: + _AVAILABLE_LANGUAGES.append(i) + return _AVAILABLE_LANGUAGES + + +def get_localized_message(message, user_locale): + """Gets a localized version of the given message in the given locale.""" + if (isinstance(message, Message)): + if user_locale: + message.locale = user_locale + return unicode(message) + else: + return message + + +class LocaleHandler(logging.Handler): + """Handler that can have a locale associated to translate Messages. + + A quick example of how to utilize the Message class above. + LocaleHandler takes a locale and a target logging.Handler object + to forward LogRecord objects to after translating the internal Message. + """ + + def __init__(self, locale, target): + """Initialize a LocaleHandler + + :param locale: locale to use for translating messages + :param target: logging.Handler object to forward + LogRecord objects to after translation + """ + logging.Handler.__init__(self) + self.locale = locale + self.target = target + + def emit(self, record): + if isinstance(record.msg, Message): + # set the locale and resolve to a string + record.msg.locale = self.locale + + self.target.emit(record) diff --git a/cinderclient/openstack/common/importutils.py b/cinderclient/openstack/common/importutils.py new file mode 100644 index 0000000..7a303f9 --- /dev/null +++ b/cinderclient/openstack/common/importutils.py @@ -0,0 +1,68 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack Foundation. +# 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 related utilities and helper functions. +""" + +import sys +import traceback + + +def import_class(import_str): + """Returns a class from a string including module and class.""" + mod_str, _sep, class_str = import_str.rpartition('.') + try: + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + except (ValueError, AttributeError): + raise ImportError('Class %s cannot be found (%s)' % + (class_str, + traceback.format_exception(*sys.exc_info()))) + + +def import_object(import_str, *args, **kwargs): + """Import a class and return an instance of it.""" + return import_class(import_str)(*args, **kwargs) + + +def import_object_ns(name_space, import_str, *args, **kwargs): + """Tries to import object from default namespace. + + Imports a class and return an instance of it, first by trying + to find the class in a default namespace, then failing back to + a full path if not found in the default namespace. + """ + import_value = "%s.%s" % (name_space, import_str) + try: + return import_class(import_value)(*args, **kwargs) + except ImportError: + return import_class(import_str)(*args, **kwargs) + + +def import_module(import_str): + """Import a module.""" + __import__(import_str) + return sys.modules[import_str] + + +def try_import(import_str, default=None): + """Try to import a module and if it fails return default.""" + try: + return import_module(import_str) + except ImportError: + return default diff --git a/cinderclient/openstack/common/strutils.py b/cinderclient/openstack/common/strutils.py index 7813b64..7c9fcec 100644 --- a/cinderclient/openstack/common/strutils.py +++ b/cinderclient/openstack/common/strutils.py @@ -1,6 +1,6 @@ # vim: tabstop=4 shiftwidth=4 softtabstop=4 -# Copyright 2011 OpenStack LLC. +# Copyright 2011 OpenStack Foundation. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -19,15 +19,35 @@ System-level utilities and helper functions. """ -import logging +import re import sys +import unicodedata -LOG = logging.getLogger(__name__) +import six + +from cinderclient.openstack.common.gettextutils import _ # noqa + + +# Used for looking up extensions of text +# to their 'multiplied' byte amount +BYTE_MULTIPLIERS = { + '': 1, + 't': 1024 ** 4, + 'g': 1024 ** 3, + 'm': 1024 ** 2, + 'k': 1024, +} +BYTE_REGEX = re.compile(r'(^-?\d+)(\D*)') + +TRUE_STRINGS = ('1', 't', 'true', 'on', 'y', 'yes') +FALSE_STRINGS = ('0', 'f', 'false', 'off', 'n', 'no') + +SLUGIFY_STRIP_RE = re.compile(r"[^\w\s-]") +SLUGIFY_HYPHENATE_RE = re.compile(r"[-\s]+") def int_from_bool_as_string(subject): - """ - Interpret a string as a boolean and return either 1 or 0. + """Interpret a string as a boolean and return either 1 or 0. Any string value in: @@ -40,42 +60,53 @@ def int_from_bool_as_string(subject): return bool_from_string(subject) and 1 or 0 -def bool_from_string(subject): +def bool_from_string(subject, strict=False): + """Interpret a string as a boolean. + + A case-insensitive match is performed such that strings matching 't', + 'true', 'on', 'y', 'yes', or '1' are considered True and, when + `strict=False`, anything else is considered False. + + Useful for JSON-decoded stuff and config file parsing. + + If `strict=True`, unrecognized values, including None, will raise a + ValueError which is useful when parsing values passed in from an API call. + Strings yielding False are 'f', 'false', 'off', 'n', 'no', or '0'. """ - Interpret a string as a boolean. + if not isinstance(subject, six.string_types): + subject = str(subject) - Any string value in: + lowered = subject.strip().lower() - ('True', 'true', 'On', 'on', 'Yes', 'yes', '1') - - is interpreted as a boolean True. - - Useful for JSON-decoded stuff and config file parsing - """ - if isinstance(subject, bool): - return subject - if isinstance(subject, basestring): - if subject.strip().lower() in ('true', 'on', 'yes', '1'): - return True - return False + if lowered in TRUE_STRINGS: + return True + elif lowered in FALSE_STRINGS: + return False + elif strict: + acceptable = ', '.join( + "'%s'" % s for s in sorted(TRUE_STRINGS + FALSE_STRINGS)) + msg = _("Unrecognized value '%(val)s', acceptable values are:" + " %(acceptable)s") % {'val': subject, + 'acceptable': acceptable} + raise ValueError(msg) + else: + return False def safe_decode(text, incoming=None, errors='strict'): - """ - Decodes incoming str using `incoming` if they're - not already unicode. + """Decodes incoming str using `incoming` if they're not already unicode. :param incoming: Text's current encoding :param errors: Errors handling policy. See here for valid values http://docs.python.org/2/library/codecs.html :returns: text or a unicode `incoming` encoded representation of it. - :raises TypeError: If text is not an isntance of basestring + :raises TypeError: If text is not an isntance of str """ - if not isinstance(text, basestring): + if not isinstance(text, six.string_types): raise TypeError("%s can't be decoded" % type(text)) - if isinstance(text, unicode): + if isinstance(text, six.text_type): return text if not incoming: @@ -102,11 +133,10 @@ def safe_decode(text, incoming=None, errors='strict'): def safe_encode(text, incoming=None, encoding='utf-8', errors='strict'): - """ - Encodes incoming str/unicode using `encoding`. If - incoming is not specified, text is expected to - be encoded with current python's default encoding. - (`sys.getdefaultencoding`) + """Encodes incoming str/unicode using `encoding`. + + If incoming is not specified, text is expected to be encoded with + current python's default encoding. (`sys.getdefaultencoding`) :param incoming: Text's current encoding :param encoding: Expected encoding for text (Default UTF-8) @@ -114,16 +144,16 @@ def safe_encode(text, incoming=None, values http://docs.python.org/2/library/codecs.html :returns: text or a bytestring `encoding` encoded representation of it. - :raises TypeError: If text is not an isntance of basestring + :raises TypeError: If text is not an isntance of str """ - if not isinstance(text, basestring): + if not isinstance(text, six.string_types): raise TypeError("%s can't be encoded" % type(text)) if not incoming: incoming = (sys.stdin.encoding or sys.getdefaultencoding()) - if isinstance(text, unicode): + if isinstance(text, six.text_type): return text.encode(encoding, errors) elif text and encoding != incoming: # Decode text before encoding it with `encoding` @@ -131,3 +161,58 @@ def safe_encode(text, incoming=None, return text.encode(encoding, errors) return text + + +def to_bytes(text, default=0): + """Converts a string into an integer of bytes. + + Looks at the last characters of the text to determine + what conversion is needed to turn the input text into a byte number. + Supports "B, K(B), M(B), G(B), and T(B)". (case insensitive) + + :param text: String input for bytes size conversion. + :param default: Default return value when text is blank. + + """ + match = BYTE_REGEX.search(text) + if match: + magnitude = int(match.group(1)) + mult_key_org = match.group(2) + if not mult_key_org: + return magnitude + elif text: + msg = _('Invalid string format: %s') % text + raise TypeError(msg) + else: + return default + mult_key = mult_key_org.lower().replace('b', '', 1) + multiplier = BYTE_MULTIPLIERS.get(mult_key) + if multiplier is None: + msg = _('Unknown byte multiplier: %s') % mult_key_org + raise TypeError(msg) + return magnitude * multiplier + + +def to_slug(value, incoming=None, errors="strict"): + """Normalize string. + + Convert to lowercase, remove non-word characters, and convert spaces + to hyphens. + + Inspired by Django's `slugify` filter. + + :param value: Text to slugify + :param incoming: Text's current encoding + :param errors: Errors handling policy. See here for valid + values http://docs.python.org/2/library/codecs.html + :returns: slugified unicode representation of `value` + :raises TypeError: If text is not an instance of str + """ + value = safe_decode(value, incoming, errors) + # NOTE(aababilov): no need to use safe_(encode|decode) here: + # encodings are always "ascii", error handling is always "ignore" + # and types are always known (first: unicode; second: str) + value = unicodedata.normalize("NFKD", value).encode( + "ascii", "ignore").decode("ascii") + value = SLUGIFY_STRIP_RE.sub("", value).strip().lower() + return SLUGIFY_HYPHENATE_RE.sub("-", value) diff --git a/tools/install_venv_common.py b/tools/install_venv_common.py index f428c1e..6ce5d00 100644 --- a/tools/install_venv_common.py +++ b/tools/install_venv_common.py @@ -114,9 +114,10 @@ class InstallVenv(object): print('Installing dependencies with pip (this can take a while)...') # First things first, make sure our venv has the latest pip and - # setuptools. - self.pip_install('pip>=1.3') + # setuptools and pbr + self.pip_install('pip>=1.4') self.pip_install('setuptools') + self.pip_install('pbr') self.pip_install('-r', self.requirements) self.pip_install('-r', self.test_requirements) From 109415c26dca7e1f2fb302c25f0788037cc14023 Mon Sep 17 00:00:00 2001 From: Peter Hamilton Date: Wed, 21 Aug 2013 14:20:41 -0400 Subject: [PATCH 12/24] Add volume encryption metadata to cinderclient This modification adds support for volume type encryption to python-cinderclient, supporting two Cinder API extensions. The first support set provides accessors to python-cinderclient for retrieving volume encryption metadata from Cinder. These changes provide other services (e.g., Nova) access to encryption metadata (e.g., encryption key UUIDs). See the volume encryption key API extension in Cinder for more information. The second support set creates a new python-cinderclient resource manager, along with matching shell commands, that provides creation and accessor operations for encryption type information. These operations allow users and services to define encryption information (e.g., cipher, key size, encryption provider) for a pre-existing volume type. See the volume type encryption API extension in Cinder for more information. blueprint encrypt-cinder-volumes Change-Id: Id4b2425d699678eb1997863362ddb9bf5ba6f033 --- cinderclient/tests/v1/fakes.py | 26 +++++ cinderclient/tests/v1/test_shell.py | 57 +++++++++++ .../tests/v1/test_volume_encryption_types.py | 95 ++++++++++++++++++ .../tests/v1/test_volume_transfers.py | 2 +- cinderclient/tests/v1/test_volumes.py | 4 + cinderclient/tests/v2/fakes.py | 26 +++++ cinderclient/tests/v2/test_shell.py | 57 +++++++++++ .../tests/v2/test_volume_encryption_types.py | 95 ++++++++++++++++++ .../tests/v2/test_volume_transfers.py | 2 +- cinderclient/tests/v2/test_volumes.py | 4 + cinderclient/v1/client.py | 3 + cinderclient/v1/shell.py | 85 +++++++++++++++- cinderclient/v1/volume_encryption_types.py | 96 +++++++++++++++++++ cinderclient/v1/volume_types.py | 2 +- cinderclient/v1/volumes.py | 11 ++- cinderclient/v2/client.py | 3 + cinderclient/v2/shell.py | 83 ++++++++++++++++ cinderclient/v2/volume_encryption_types.py | 96 +++++++++++++++++++ cinderclient/v2/volumes.py | 11 ++- 19 files changed, 752 insertions(+), 6 deletions(-) create mode 100644 cinderclient/tests/v1/test_volume_encryption_types.py create mode 100644 cinderclient/tests/v2/test_volume_encryption_types.py create mode 100644 cinderclient/v1/volume_encryption_types.py create mode 100644 cinderclient/v2/volume_encryption_types.py diff --git a/cinderclient/tests/v1/fakes.py b/cinderclient/tests/v1/fakes.py index c83f5ad..88e58ea 100644 --- a/cinderclient/tests/v1/fakes.py +++ b/cinderclient/tests/v1/fakes.py @@ -276,6 +276,10 @@ class FakeHTTPClient(base_client.HTTPClient): r = {'volume': self.get_volumes_detail()[2]['volumes'][0]} return (200, {}, r) + def get_volumes_1234_encryption(self, **kw): + r = {'encryption_key_id': 'id'} + return (200, {}, r) + def post_volumes_1234_action(self, body, **kw): _body = None resp = 202 @@ -383,6 +387,11 @@ class FakeHTTPClient(base_client.HTTPClient): 'name': 'test-type-1', 'extra_specs': {}}}) + def get_types_2(self, **kw): + return (200, {}, {'volume_type': {'id': 2, + 'name': 'test-type-2', + 'extra_specs': {}}}) + def post_types(self, body, **kw): return (202, {}, {'volume_type': {'id': 3, 'name': 'test-type-3', @@ -398,6 +407,23 @@ class FakeHTTPClient(base_client.HTTPClient): def delete_types_1(self, **kw): return (202, {}, None) + # + # VolumeEncryptionTypes + # + def get_types_1_encryption(self, **kw): + return (200, {}, {'id': 1, 'volume_type_id': 1, 'provider': 'test', + 'cipher': 'test', 'key_size': 1, + 'control_location': 'front'}) + + def get_types_2_encryption(self, **kw): + return (200, {}, {}) + + def post_types_2_encryption(self, body, **kw): + return (200, {}, {'encryption': {}}) + + def put_types_1_encryption_1(self, body, **kw): + return (200, {}, {}) + # # Set/Unset metadata # diff --git a/cinderclient/tests/v1/test_shell.py b/cinderclient/tests/v1/test_shell.py index 71d7896..014df94 100644 --- a/cinderclient/tests/v1/test_shell.py +++ b/cinderclient/tests/v1/test_shell.py @@ -203,3 +203,60 @@ class ShellTest(utils.TestCase): self.run_command('snapshot-reset-state --state error 1234') expected = {'os-reset_status': {'status': 'error'}} self.assert_called('POST', '/snapshots/1234/action', body=expected) + + def test_encryption_type_list(self): + """ + Test encryption-type-list shell command. + + Verify a series of GET requests are made: + - one to get the volume type list information + - one per volume type to retrieve the encryption type information + """ + self.run_command('encryption-type-list') + self.assert_called_anytime('GET', '/types') + self.assert_called_anytime('GET', '/types/1/encryption') + self.assert_called_anytime('GET', '/types/2/encryption') + + def test_encryption_type_show(self): + """ + Test encryption-type-show shell command. + + Verify two GET requests are made per command invocation: + - one to get the volume type information + - one to get the encryption type information + """ + self.run_command('encryption-type-show 1') + self.assert_called('GET', '/types/1/encryption') + self.assert_called_anytime('GET', '/types/1') + + def test_encryption_type_create(self): + """ + Test encryption-type-create shell command. + + Verify GET and POST requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one POST request to create the new encryption type + """ + expected = {'encryption': {'cipher': None, 'key_size': None, + 'provider': 'TestProvider', + 'control_location': None}} + self.run_command('encryption-type-create 2 TestProvider') + self.assert_called('POST', '/types/2/encryption', body=expected) + self.assert_called_anytime('GET', '/types/2') + + def test_encryption_type_update(self): + """ + Test encryption-type-update shell command. + + Verify two GETs/one PUT requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one GET request to retrieve the relevant encryption type information + - one PUT request to update the encryption type information + """ + self.skipTest("Not implemented") + + def test_encryption_type_delete(self): + """ + Test encryption-type-delete shell command. + """ + self.skipTest("Not implemented") diff --git a/cinderclient/tests/v1/test_volume_encryption_types.py b/cinderclient/tests/v1/test_volume_encryption_types.py new file mode 100644 index 0000000..d9af7d8 --- /dev/null +++ b/cinderclient/tests/v1/test_volume_encryption_types.py @@ -0,0 +1,95 @@ +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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. + +from cinderclient.v1.volume_encryption_types import VolumeEncryptionType +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + +cs = fakes.FakeClient() + + +class VolumeEncryptionTypesTest(utils.TestCase): + """ + Test suite for the Volume Encryption Types Resource and Manager. + """ + + def test_list(self): + """ + Unit test for VolumeEncryptionTypesManager.list + + Verify that a series of GET requests are made: + - one GET request for the list of volume types + - one GET request per volume type for encryption type information + + Verify that all returned information is :class: VolumeEncryptionType + """ + encryption_types = cs.volume_encryption_types.list() + cs.assert_called_anytime('GET', '/types') + cs.assert_called_anytime('GET', '/types/2/encryption') + cs.assert_called_anytime('GET', '/types/1/encryption') + for encryption_type in encryption_types: + self.assertIsInstance(encryption_type, VolumeEncryptionType) + + def test_get(self): + """ + Unit test for VolumeEncryptionTypesManager.get + + Verify that one GET request is made for the volume type encryption + type information. Verify that returned information is :class: + VolumeEncryptionType + """ + encryption_type = cs.volume_encryption_types.get(1) + cs.assert_called('GET', '/types/1/encryption') + self.assertIsInstance(encryption_type, VolumeEncryptionType) + + def test_get_no_encryption(self): + """ + Unit test for VolumeEncryptionTypesManager.get + + Verify that a request on a volume type with no associated encryption + type information returns a VolumeEncryptionType with no attributes. + """ + encryption_type = cs.volume_encryption_types.get(2) + self.assertIsInstance(encryption_type, VolumeEncryptionType) + self.assertFalse(hasattr(encryption_type, 'id'), + 'encryption type has an id') + + def test_create(self): + """ + Unit test for VolumeEncryptionTypesManager.create + + Verify that one POST request is made for the encryption type creation. + Verify that encryption type creation returns a VolumeEncryptionType. + """ + result = cs.volume_encryption_types.create(2, {'encryption': + {'provider': 'Test', + 'key_size': None, + 'cipher': None, + 'control_location': + None}}) + cs.assert_called('POST', '/types/2/encryption') + self.assertIsInstance(result, VolumeEncryptionType) + + def test_update(self): + """ + Unit test for VolumeEncryptionTypesManager.update + """ + self.skipTest("Not implemented") + + def test_delete(self): + """ + Unit test for VolumeEncryptionTypesManager.delete + """ + self.skipTest("Not implemented") diff --git a/cinderclient/tests/v1/test_volume_transfers.py b/cinderclient/tests/v1/test_volume_transfers.py index 40fb09b..47656d7 100644 --- a/cinderclient/tests/v1/test_volume_transfers.py +++ b/cinderclient/tests/v1/test_volume_transfers.py @@ -20,7 +20,7 @@ from cinderclient.tests.v1 import fakes cs = fakes.FakeClient() -class VolumeTRansfersTest(utils.TestCase): +class VolumeTransfersTest(utils.TestCase): def test_create(self): cs.transfers.create('1234') diff --git a/cinderclient/tests/v1/test_volumes.py b/cinderclient/tests/v1/test_volumes.py index 0da88e2..2da7509 100644 --- a/cinderclient/tests/v1/test_volumes.py +++ b/cinderclient/tests/v1/test_volumes.py @@ -87,3 +87,7 @@ class VolumesTest(utils.TestCase): v = cs.volumes.get('1234') cs.volumes.extend(v, 2) cs.assert_called('POST', '/volumes/1234/action') + + def test_get_encryption_metadata(self): + cs.volumes.get_encryption_metadata('1234') + cs.assert_called('GET', '/volumes/1234/encryption') diff --git a/cinderclient/tests/v2/fakes.py b/cinderclient/tests/v2/fakes.py index 8f70e09..f9a20a8 100644 --- a/cinderclient/tests/v2/fakes.py +++ b/cinderclient/tests/v2/fakes.py @@ -283,6 +283,10 @@ class FakeHTTPClient(base_client.HTTPClient): r = {'volume': self.get_volumes_detail()[2]['volumes'][0]} return (200, {}, r) + def get_volumes_1234_encryption(self, **kw): + r = {'encryption_key_id': 'id'} + return (200, {}, r) + def post_volumes_1234_action(self, body, **kw): _body = None resp = 202 @@ -390,6 +394,11 @@ class FakeHTTPClient(base_client.HTTPClient): 'name': 'test-type-1', 'extra_specs': {}}}) + def get_types_2(self, **kw): + return (200, {}, {'volume_type': {'id': 2, + 'name': 'test-type-2', + 'extra_specs': {}}}) + def post_types(self, body, **kw): return (202, {}, {'volume_type': {'id': 3, 'name': 'test-type-3', @@ -405,6 +414,23 @@ class FakeHTTPClient(base_client.HTTPClient): def delete_types_1(self, **kw): return (202, {}, None) + # + # VolumeEncryptionTypes + # + def get_types_1_encryption(self, **kw): + return (200, {}, {'id': 1, 'volume_type_id': 1, 'provider': 'test', + 'cipher': 'test', 'key_size': 1, + 'control_location': 'front'}) + + def get_types_2_encryption(self, **kw): + return (200, {}, {}) + + def post_types_2_encryption(self, body, **kw): + return (200, {}, {'encryption': {}}) + + def put_types_1_encryption_1(self, body, **kw): + return (200, {}, {}) + # # Set/Unset metadata # diff --git a/cinderclient/tests/v2/test_shell.py b/cinderclient/tests/v2/test_shell.py index 2405192..6f9a53f 100644 --- a/cinderclient/tests/v2/test_shell.py +++ b/cinderclient/tests/v2/test_shell.py @@ -181,3 +181,60 @@ class ShellTest(utils.TestCase): self.run_command('snapshot-reset-state --state error 1234') expected = {'os-reset_status': {'status': 'error'}} self.assert_called('POST', '/snapshots/1234/action', body=expected) + + def test_encryption_type_list(self): + """ + Test encryption-type-list shell command. + + Verify a series of GET requests are made: + - one to get the volume type list information + - one per volume type to retrieve the encryption type information + """ + self.run_command('encryption-type-list') + self.assert_called_anytime('GET', '/types') + self.assert_called_anytime('GET', '/types/1/encryption') + self.assert_called_anytime('GET', '/types/2/encryption') + + def test_encryption_type_show(self): + """ + Test encryption-type-show shell command. + + Verify two GET requests are made per command invocation: + - one to get the volume type information + - one to get the encryption type information + """ + self.run_command('encryption-type-show 1') + self.assert_called('GET', '/types/1/encryption') + self.assert_called_anytime('GET', '/types/1') + + def test_encryption_type_create(self): + """ + Test encryption-type-create shell command. + + Verify GET and POST requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one POST request to create the new encryption type + """ + expected = {'encryption': {'cipher': None, 'key_size': None, + 'provider': 'TestProvider', + 'control_location': None}} + self.run_command('encryption-type-create 2 TestProvider') + self.assert_called('POST', '/types/2/encryption', body=expected) + self.assert_called_anytime('GET', '/types/2') + + def test_encryption_type_update(self): + """ + Test encryption-type-update shell command. + + Verify two GETs/one PUT requests are made per command invocation: + - one GET request to retrieve the relevant volume type information + - one GET request to retrieve the relevant encryption type information + - one PUT request to update the encryption type information + """ + self.skipTest("Not implemented") + + def test_encryption_type_delete(self): + """ + Test encryption-type-delete shell command. + """ + self.skipTest("Not implemented") diff --git a/cinderclient/tests/v2/test_volume_encryption_types.py b/cinderclient/tests/v2/test_volume_encryption_types.py new file mode 100644 index 0000000..96a0c02 --- /dev/null +++ b/cinderclient/tests/v2/test_volume_encryption_types.py @@ -0,0 +1,95 @@ +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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. + +from cinderclient.v2.volume_encryption_types import VolumeEncryptionType +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes + +cs = fakes.FakeClient() + + +class VolumeEncryptionTypesTest(utils.TestCase): + """ + Test suite for the Volume Encryption Types Resource and Manager. + """ + + def test_list(self): + """ + Unit test for VolumeEncryptionTypesManager.list + + Verify that a series of GET requests are made: + - one GET request for the list of volume types + - one GET request per volume type for encryption type information + + Verify that all returned information is :class: VolumeEncryptionType + """ + encryption_types = cs.volume_encryption_types.list() + cs.assert_called_anytime('GET', '/types') + cs.assert_called_anytime('GET', '/types/2/encryption') + cs.assert_called_anytime('GET', '/types/1/encryption') + for encryption_type in encryption_types: + self.assertIsInstance(encryption_type, VolumeEncryptionType) + + def test_get(self): + """ + Unit test for VolumeEncryptionTypesManager.get + + Verify that one GET request is made for the volume type encryption + type information. Verify that returned information is :class: + VolumeEncryptionType + """ + encryption_type = cs.volume_encryption_types.get(1) + cs.assert_called('GET', '/types/1/encryption') + self.assertIsInstance(encryption_type, VolumeEncryptionType) + + def test_get_no_encryption(self): + """ + Unit test for VolumeEncryptionTypesManager.get + + Verify that a request on a volume type with no associated encryption + type information returns a VolumeEncryptionType with no attributes. + """ + encryption_type = cs.volume_encryption_types.get(2) + self.assertIsInstance(encryption_type, VolumeEncryptionType) + self.assertFalse(hasattr(encryption_type, 'id'), + 'encryption type has an id') + + def test_create(self): + """ + Unit test for VolumeEncryptionTypesManager.create + + Verify that one POST request is made for the encryption type creation. + Verify that encryption type creation returns a VolumeEncryptionType. + """ + result = cs.volume_encryption_types.create(2, {'encryption': + {'provider': 'Test', + 'key_size': None, + 'cipher': None, + 'control_location': + None}}) + cs.assert_called('POST', '/types/2/encryption') + self.assertIsInstance(result, VolumeEncryptionType) + + def test_update(self): + """ + Unit test for VolumeEncryptionTypesManager.update + """ + self.skipTest("Not implemented") + + def test_delete(self): + """ + Unit test for VolumeEncryptionTypesManager.delete + """ + self.skipTest("Not implemented") diff --git a/cinderclient/tests/v2/test_volume_transfers.py b/cinderclient/tests/v2/test_volume_transfers.py index 40fb09b..47656d7 100644 --- a/cinderclient/tests/v2/test_volume_transfers.py +++ b/cinderclient/tests/v2/test_volume_transfers.py @@ -20,7 +20,7 @@ from cinderclient.tests.v1 import fakes cs = fakes.FakeClient() -class VolumeTRansfersTest(utils.TestCase): +class VolumeTransfersTest(utils.TestCase): def test_create(self): cs.transfers.create('1234') diff --git a/cinderclient/tests/v2/test_volumes.py b/cinderclient/tests/v2/test_volumes.py index 8a2560d..594bba4 100644 --- a/cinderclient/tests/v2/test_volumes.py +++ b/cinderclient/tests/v2/test_volumes.py @@ -90,3 +90,7 @@ class VolumesTest(utils.TestCase): v = cs.volumes.get('1234') cs.volumes.extend(v, 2) cs.assert_called('POST', '/volumes/1234/action') + + def test_get_encryption_metadata(self): + cs.volumes.get_encryption_metadata('1234') + cs.assert_called('GET', '/volumes/1234/encryption') diff --git a/cinderclient/v1/client.py b/cinderclient/v1/client.py index 1272c4e..60376ab 100644 --- a/cinderclient/v1/client.py +++ b/cinderclient/v1/client.py @@ -22,6 +22,7 @@ from cinderclient.v1 import services from cinderclient.v1 import volumes from cinderclient.v1 import volume_snapshots from cinderclient.v1 import volume_types +from cinderclient.v1 import volume_encryption_types from cinderclient.v1 import volume_backups from cinderclient.v1 import volume_backups_restore from cinderclient.v1 import volume_transfers @@ -59,6 +60,8 @@ class Client(object): self.volumes = volumes.VolumeManager(self) self.volume_snapshots = volume_snapshots.SnapshotManager(self) self.volume_types = volume_types.VolumeTypeManager(self) + self.volume_encryption_types = \ + volume_encryption_types.VolumeEncryptionTypeManager(self) self.quota_classes = quota_classes.QuotaClassSetManager(self) self.quotas = quotas.QuotaSetManager(self) self.backups = volume_backups.VolumeBackupManager(self) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 6c5d266..88e0ec2 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -525,7 +525,7 @@ def do_type_delete(cs, args): help='Extra_specs to set/unset (only key is necessary on unset)') @utils.service_type('volume') def do_type_key(cs, args): - "Set or unset extra_spec for a volume type.""" + """Set or unset extra_spec for a volume type.""" vtype = _find_volume_type(cs, args.vtype) if args.metadata is not None: @@ -947,3 +947,86 @@ def do_availability_zone_list(cs, _args): result += _treeizeAvailabilityZone(zone) _translate_availability_zone_keys(result) utils.print_list(result, ['Name', 'Status']) + + +def _print_volume_encryption_type_list(encryption_types): + """ + Display a tabularized list of volume encryption types. + + :param encryption_types: a list of :class: VolumeEncryptionType instances + """ + utils.print_list(encryption_types, ['Volume Type ID', 'Provider', + 'Cipher', 'Key Size', + 'Control Location']) + + +@utils.service_type('volume') +def do_encryption_type_list(cs, args): + """List encryption type information for all volume types (Admin Only).""" + result = cs.volume_encryption_types.list() + utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher', + 'Key Size', 'Control Location']) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.service_type('volume') +def do_encryption_type_show(cs, args): + """Show the encryption type information for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + + result = cs.volume_encryption_types.get(volume_type) + + # Display result or an empty table if no result + if hasattr(result, 'volume_type_id'): + _print_volume_encryption_type_list([result]) + else: + _print_volume_encryption_type_list([]) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.arg('provider', + metavar='', + type=str, + help="Class providing encryption support (e.g. LuksEncryptor)") +@utils.arg('--cipher', + metavar='', + type=str, + required=False, + default=None, + help="Encryption algorithm/mode to use (e.g., aes-xts-plain64) " + "(Optional, Default=None)") +@utils.arg('--key_size', + metavar='', + type=int, + required=False, + default=None, + help="Size of the encryption key, in bits (e.g., 128, 256) " + "(Optional, Default=None)") +@utils.arg('--control_location', + metavar='', + choices=['front-end', 'back-end'], + type=str, + required=False, + default=None, + help="Notional service where encryption is performed (e.g., " + "front-end=Nova). Values: 'front-end', 'back-end' " + "(Optional, Default=None)") +@utils.service_type('volume') +def do_encryption_type_create(cs, args): + """Create a new encryption type for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + + body = {} + body['provider'] = args.provider + body['cipher'] = args.cipher + body['key_size'] = args.key_size + body['control_location'] = args.control_location + + result = cs.volume_encryption_types.create(volume_type, body) + _print_volume_encryption_type_list([result]) diff --git a/cinderclient/v1/volume_encryption_types.py b/cinderclient/v1/volume_encryption_types.py new file mode 100644 index 0000000..b97c6f0 --- /dev/null +++ b/cinderclient/v1/volume_encryption_types.py @@ -0,0 +1,96 @@ +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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. + + +""" +Volume Encryption Type interface +""" + +from cinderclient import base + + +class VolumeEncryptionType(base.Resource): + """ + A Volume Encryption Type is a collection of settings used to conduct + encryption for a specific volume type. + """ + def __repr__(self): + return "" % self.name + + +class VolumeEncryptionTypeManager(base.ManagerWithFind): + """ + Manage :class: `VolumeEncryptionType` resources. + """ + resource_class = VolumeEncryptionType + + def list(self): + """ + List all volume encryption types. + + :param volume_types: a list of volume types + :return: a list of :class: VolumeEncryptionType instances + """ + # Since the encryption type is a volume type extension, we cannot get + # all encryption types without going through all volume types. + volume_types = self.api.volume_types.list() + encryption_types = [] + for volume_type in volume_types: + encryption_type = self._get("/types/%s/encryption" + % base.getid(volume_type)) + if hasattr(encryption_type, 'volume_type_id'): + encryption_types.append(encryption_type) + return encryption_types + + def get(self, volume_type): + """ + Get the volume encryption type for the specified volume type. + + :param volume_type: the volume type to query + :return: an instance of :class: VolumeEncryptionType + """ + return self._get("/types/%s/encryption" % base.getid(volume_type)) + + def create(self, volume_type, specs): + """ + Create a new encryption type for the specified volume type. + + :param volume_type: the volume type on which to add an encryption type + :param specs: the encryption type specifications to add + :return: an instance of :class: VolumeEncryptionType + """ + body = {'encryption': specs} + return self._create("/types/%s/encryption" % base.getid(volume_type), + body, "encryption") + + def update(self, volume_type, specs): + """ + Update the encryption type information for the specified volume type. + + :param volume_type: the volume type whose encryption type information + must be updated + :param specs: the encryption type specifications to update + :return: an instance of :class: VolumeEncryptionType + """ + raise NotImplementedError() + + def delete(self, volume_type): + """ + Delete the encryption type information for the specified volume type. + + :param volume_type: the volume type whose encryption type information + must be deleted + """ + raise NotImplementedError() diff --git a/cinderclient/v1/volume_types.py b/cinderclient/v1/volume_types.py index 93eabd3..12c4612 100644 --- a/cinderclient/v1/volume_types.py +++ b/cinderclient/v1/volume_types.py @@ -55,7 +55,7 @@ class VolumeType(base.Resource): def unset_keys(self, keys): """ - Unset extra specs on a volue type. + Unset extra specs on a volume type. :param type_id: The :class:`VolumeType` to unset extra spec on :param keys: A list of keys to be unset diff --git a/cinderclient/v1/volumes.py b/cinderclient/v1/volumes.py index 9c870cb..6d63e72 100644 --- a/cinderclient/v1/volumes.py +++ b/cinderclient/v1/volumes.py @@ -134,13 +134,13 @@ class VolumeManager(base.ManagerWithFind): :param display_name: Name of the volume :param display_description: Description of the volume :param volume_type: Type of volume - :rtype: :class:`Volume` :param user_id: User id derived from context :param project_id: Project id derived from context :param availability_zone: Availability Zone to use :param metadata: Optional metadata to set on volume creation :param imageRef: reference to an image stored in glance :param source_volid: ID of source volume to clone from + :rtype: :class:`Volume` """ if metadata is None: @@ -352,3 +352,12 @@ class VolumeManager(base.ManagerWithFind): return self._action('os-extend', base.getid(volume), {'new_size': new_size}) + + def get_encryption_metadata(self, volume_id): + """ + Retrieve the encryption metadata from the desired volume. + + :param volume_id: the id of the volume to query + :return: a dictionary of volume encryption metadata + """ + return self._get("/volumes/%s/encryption" % volume_id)._info diff --git a/cinderclient/v2/client.py b/cinderclient/v2/client.py index 31781a8..2f73ed6 100644 --- a/cinderclient/v2/client.py +++ b/cinderclient/v2/client.py @@ -22,6 +22,7 @@ from cinderclient.v2 import services from cinderclient.v2 import volumes from cinderclient.v2 import volume_snapshots from cinderclient.v2 import volume_types +from cinderclient.v2 import volume_encryption_types from cinderclient.v2 import volume_backups from cinderclient.v2 import volume_backups_restore from cinderclient.v1 import volume_transfers @@ -57,6 +58,8 @@ class Client(object): self.volumes = volumes.VolumeManager(self) self.volume_snapshots = volume_snapshots.SnapshotManager(self) self.volume_types = volume_types.VolumeTypeManager(self) + self.volume_encryption_types = \ + volume_encryption_types.VolumeEncryptionTypeManager(self) self.quota_classes = quota_classes.QuotaClassSetManager(self) self.quotas = quotas.QuotaSetManager(self) self.backups = volume_backups.VolumeBackupManager(self) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index a1c1f22..8a1900c 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -1032,3 +1032,86 @@ def do_availability_zone_list(cs, _args): result += _treeizeAvailabilityZone(zone) _translate_availability_zone_keys(result) utils.print_list(result, ['Name', 'Status']) + + +def _print_volume_encryption_type_list(encryption_types): + """ + Display a tabularized list of volume encryption types. + + :param encryption_types: a list of :class: VolumeEncryptionType instances + """ + utils.print_list(encryption_types, ['Volume Type ID', 'Provider', + 'Cipher', 'Key Size', + 'Control Location']) + + +@utils.service_type('volumev2') +def do_encryption_type_list(cs, args): + """List encryption type information for all volume types (Admin Only).""" + result = cs.volume_encryption_types.list() + utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher', + 'Key Size', 'Control Location']) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.service_type('volumev2') +def do_encryption_type_show(cs, args): + """Show the encryption type information for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + + result = cs.volume_encryption_types.get(volume_type) + + # Display result or an empty table if no result + if hasattr(result, 'volume_type_id'): + _print_volume_encryption_type_list([result]) + else: + _print_volume_encryption_type_list([]) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.arg('provider', + metavar='', + type=str, + help="Class providing encryption support (e.g. LuksEncryptor)") +@utils.arg('--cipher', + metavar='', + type=str, + required=False, + default=None, + help="Encryption algorithm/mode to use (e.g., aes-xts-plain64) " + "(Optional, Default=None)") +@utils.arg('--key_size', + metavar='', + type=int, + required=False, + default=None, + help="Size of the encryption key, in bits (e.g., 128, 256) " + "(Optional, Default=None)") +@utils.arg('--control_location', + metavar='', + choices=['front-end', 'back-end'], + type=str, + required=False, + default=None, + help="Notional service where encryption is performed (e.g., " + "front-end=Nova). Values: 'front-end', 'back-end' " + "(Optional, Default=None)") +@utils.service_type('volumev2') +def do_encryption_type_create(cs, args): + """Create a new encryption type for a volume type (Admin Only).""" + volume_type = _find_volume_type(cs, args.volume_type) + + body = {} + body['provider'] = args.provider + body['cipher'] = args.cipher + body['key_size'] = args.key_size + body['control_location'] = args.control_location + + result = cs.volume_encryption_types.create(volume_type, body) + _print_volume_encryption_type_list([result]) diff --git a/cinderclient/v2/volume_encryption_types.py b/cinderclient/v2/volume_encryption_types.py new file mode 100644 index 0000000..b97c6f0 --- /dev/null +++ b/cinderclient/v2/volume_encryption_types.py @@ -0,0 +1,96 @@ +# Copyright (c) 2013 The Johns Hopkins University/Applied Physics Laboratory +# 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. + + +""" +Volume Encryption Type interface +""" + +from cinderclient import base + + +class VolumeEncryptionType(base.Resource): + """ + A Volume Encryption Type is a collection of settings used to conduct + encryption for a specific volume type. + """ + def __repr__(self): + return "" % self.name + + +class VolumeEncryptionTypeManager(base.ManagerWithFind): + """ + Manage :class: `VolumeEncryptionType` resources. + """ + resource_class = VolumeEncryptionType + + def list(self): + """ + List all volume encryption types. + + :param volume_types: a list of volume types + :return: a list of :class: VolumeEncryptionType instances + """ + # Since the encryption type is a volume type extension, we cannot get + # all encryption types without going through all volume types. + volume_types = self.api.volume_types.list() + encryption_types = [] + for volume_type in volume_types: + encryption_type = self._get("/types/%s/encryption" + % base.getid(volume_type)) + if hasattr(encryption_type, 'volume_type_id'): + encryption_types.append(encryption_type) + return encryption_types + + def get(self, volume_type): + """ + Get the volume encryption type for the specified volume type. + + :param volume_type: the volume type to query + :return: an instance of :class: VolumeEncryptionType + """ + return self._get("/types/%s/encryption" % base.getid(volume_type)) + + def create(self, volume_type, specs): + """ + Create a new encryption type for the specified volume type. + + :param volume_type: the volume type on which to add an encryption type + :param specs: the encryption type specifications to add + :return: an instance of :class: VolumeEncryptionType + """ + body = {'encryption': specs} + return self._create("/types/%s/encryption" % base.getid(volume_type), + body, "encryption") + + def update(self, volume_type, specs): + """ + Update the encryption type information for the specified volume type. + + :param volume_type: the volume type whose encryption type information + must be updated + :param specs: the encryption type specifications to update + :return: an instance of :class: VolumeEncryptionType + """ + raise NotImplementedError() + + def delete(self, volume_type): + """ + Delete the encryption type information for the specified volume type. + + :param volume_type: the volume type whose encryption type information + must be deleted + """ + raise NotImplementedError() diff --git a/cinderclient/v2/volumes.py b/cinderclient/v2/volumes.py index 14535af..be4a9e6 100644 --- a/cinderclient/v2/volumes.py +++ b/cinderclient/v2/volumes.py @@ -129,7 +129,6 @@ class VolumeManager(base.ManagerWithFind): :param name: Name of the volume :param description: Description of the volume :param volume_type: Type of volume - :rtype: :class:`Volume` :param user_id: User id derived from context :param project_id: Project id derived from context :param availability_zone: Availability Zone to use @@ -138,6 +137,7 @@ class VolumeManager(base.ManagerWithFind): :param source_volid: ID of source volume to clone from :param scheduler_hints: (optional extension) arbitrary key-value pairs specified by the client to help boot an instance + :rtype: :class:`Volume` """ if metadata is None: @@ -334,3 +334,12 @@ class VolumeManager(base.ManagerWithFind): return self._action('os-extend', base.getid(volume), {'new_size': new_size}) + + def get_encryption_metadata(self, volume_id): + """ + Retrieve the encryption metadata from the desired volume. + + :param volume_id: the id of the volume to query + :return: a dictionary of volume encryption metadata + """ + return self._get("/volumes/%s/encryption" % volume_id)._info From 38e1d061e8cbec45a7cb9e7c8acd1ad254b0acfb Mon Sep 17 00:00:00 2001 From: Eric Harney Date: Tue, 23 Jul 2013 12:51:49 -0400 Subject: [PATCH 13/24] Add update_snapshot_metadata action This allows Nova to update the state and progress of a snapshot that it is manipulating. Also fix formatting bug in AssertionError in post_snapshots_1234_action. Implements blueprint qemu-assisted-snapshots Change-Id: Ia108e14870410b783c5d074db89acb94e83fce99 --- cinderclient/tests/v1/fakes.py | 4 ++- .../tests/v1/test_snapshot_actions.py | 35 +++++++++++++++++++ cinderclient/tests/v2/fakes.py | 4 ++- .../tests/v2/test_snapshot_actions.py | 35 +++++++++++++++++++ cinderclient/v1/volume_snapshots.py | 4 +++ cinderclient/v2/volume_snapshots.py | 4 +++ 6 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 cinderclient/tests/v1/test_snapshot_actions.py create mode 100644 cinderclient/tests/v2/test_snapshot_actions.py diff --git a/cinderclient/tests/v1/fakes.py b/cinderclient/tests/v1/fakes.py index c83f5ad..2c4eaa4 100644 --- a/cinderclient/tests/v1/fakes.py +++ b/cinderclient/tests/v1/fakes.py @@ -244,8 +244,10 @@ class FakeHTTPClient(base_client.HTTPClient): action = body.keys()[0] if action == 'os-reset_status': assert 'status' in body['os-reset_status'] + elif action == 'os-update_snapshot_status': + assert 'status' in body['os-update_snapshot_status'] else: - raise AssertionError('Unexpected action: %s" % action') + raise AssertionError("Unexpected action: %s" % action) return (resp, {}, _body) # diff --git a/cinderclient/tests/v1/test_snapshot_actions.py b/cinderclient/tests/v1/test_snapshot_actions.py new file mode 100644 index 0000000..70b14e1 --- /dev/null +++ b/cinderclient/tests/v1/test_snapshot_actions.py @@ -0,0 +1,35 @@ +# Copyright 2013 Red Hat, 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. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class SnapshotActionsTest(utils.TestCase): + def test_update_snapshot_status(self): + s = cs.volume_snapshots.get('1234') + cs.volume_snapshots.update_snapshot_status(s, + {'status': 'available'}) + cs.assert_called('POST', '/snapshots/1234/action') + + def test_update_snapshot_status_with_progress(self): + s = cs.volume_snapshots.get('1234') + cs.volume_snapshots.update_snapshot_status(s, + {'status': 'available', + 'progress': '73%'}) + cs.assert_called('POST', '/snapshots/1234/action') diff --git a/cinderclient/tests/v2/fakes.py b/cinderclient/tests/v2/fakes.py index 8f70e09..82fb6e2 100644 --- a/cinderclient/tests/v2/fakes.py +++ b/cinderclient/tests/v2/fakes.py @@ -251,8 +251,10 @@ class FakeHTTPClient(base_client.HTTPClient): action = body.keys()[0] if action == 'os-reset_status': assert 'status' in body['os-reset_status'] + elif action == 'os-update_snapshot_status': + assert 'status' in body['os-update_snapshot_status'] else: - raise AssertionError('Unexpected action: %s" % action') + raise AssertionError('Unexpected action: %s' % action) return (resp, {}, _body) # diff --git a/cinderclient/tests/v2/test_snapshot_actions.py b/cinderclient/tests/v2/test_snapshot_actions.py new file mode 100644 index 0000000..f70cc8f --- /dev/null +++ b/cinderclient/tests/v2/test_snapshot_actions.py @@ -0,0 +1,35 @@ +# Copyright 2013 Red Hat, 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. + +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes + + +cs = fakes.FakeClient() + + +class SnapshotActionsTest(utils.TestCase): + def test_update_snapshot_status(self): + s = cs.volume_snapshots.get('1234') + cs.volume_snapshots.update_snapshot_status(s, + {'status': 'available'}) + cs.assert_called('POST', '/snapshots/1234/action') + + def test_update_snapshot_status_with_progress(self): + s = cs.volume_snapshots.get('1234') + cs.volume_snapshots.update_snapshot_status(s, + {'status': 'available', + 'progress': '73%'}) + cs.assert_called('POST', '/snapshots/1234/action') diff --git a/cinderclient/v1/volume_snapshots.py b/cinderclient/v1/volume_snapshots.py index 63e92c8..0aa6495 100644 --- a/cinderclient/v1/volume_snapshots.py +++ b/cinderclient/v1/volume_snapshots.py @@ -148,3 +148,7 @@ class SnapshotManager(base.ManagerWithFind): self.run_hooks('modify_body_for_action', body, **kwargs) url = '/snapshots/%s/action' % base.getid(snapshot) return self.api.client.post(url, body=body) + + def update_snapshot_status(self, snapshot, update_dict): + return self._action('os-update_snapshot_status', + base.getid(snapshot), update_dict) diff --git a/cinderclient/v2/volume_snapshots.py b/cinderclient/v2/volume_snapshots.py index ef529eb..7aa9097 100644 --- a/cinderclient/v2/volume_snapshots.py +++ b/cinderclient/v2/volume_snapshots.py @@ -133,3 +133,7 @@ class SnapshotManager(base.ManagerWithFind): self.run_hooks('modify_body_for_action', body, **kwargs) url = '/snapshots/%s/action' % base.getid(snapshot) return self.api.client.post(url, body=body) + + def update_snapshot_status(self, snapshot, update_dict): + return self._action('os-update_snapshot_status', + base.getid(snapshot), update_dict) From b757c348b758fb5517cc0a08675b6c3ae8a7538e Mon Sep 17 00:00:00 2001 From: Kui Shi Date: Tue, 3 Sep 2013 04:39:24 +0800 Subject: [PATCH 14/24] Don't need to init testr explicitly In run_tests.sh, function init_testr will initialize testr if the directory .testrepository is not existed. Actually, testr will do the check before run the test: In Python package testrepository, setuptools_command.py:Testr.run 68 def run(self): 69 """Set up testr repo, then run testr""" 70 if not os.path.isdir(".testrepository"): 71 self._run_testr("init") So, init_testr can be removed safely. Fixes Bug #1220147 Change-Id: Ide99a836cd601453624c7a562b7256c86bd46811 --- run_tests.sh | 6 ------ 1 file changed, 6 deletions(-) diff --git a/run_tests.sh b/run_tests.sh index 3299a30..017f7e2 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -102,11 +102,6 @@ if [ $no_site_packages -eq 1 ]; then installvenvopts="--no-site-packages" fi -function init_testr { - if [ ! -d .testrepository ]; then - ${wrapper} testr init - fi -} function run_tests { # Cleanup *pyc @@ -223,7 +218,6 @@ if [ $recreate_db -eq 1 ]; then rm -f tests.sqlite fi -init_testr run_tests # NOTE(sirp): we only want to run pep8 when we're running the full-test suite, From 2ac8f3a337130a7d130fb49bfacbf1842d3e5e47 Mon Sep 17 00:00:00 2001 From: Ken'ichi Ohmichi Date: Fri, 6 Sep 2013 13:17:36 +0900 Subject: [PATCH 15/24] Fix help messages for name arguments Users can specify the name of an instance as the argument, which are passed through find_resource(), instead of the id. This patch changes some help messages for explaining this behavior. Related-Bug: #1220590 Change-Id: I9af1259af4319b82b94d7b28536def3107ec5dd5 --- cinderclient/v1/shell.py | 59 ++++++++++++++++++++++------------------ cinderclient/v2/shell.py | 55 +++++++++++++++++++------------------ 2 files changed, 62 insertions(+), 52 deletions(-) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 91c2046..a4d4cb1 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -61,22 +61,22 @@ def _poll_for_status(poll_fn, obj_id, action, final_ok_states, def _find_volume(cs, volume): - """Get a volume by ID.""" + """Get a volume by name or ID.""" return utils.find_resource(cs.volumes, volume) def _find_volume_snapshot(cs, snapshot): - """Get a volume snapshot by ID.""" + """Get a volume snapshot by name or ID.""" return utils.find_resource(cs.volume_snapshots, snapshot) def _find_backup(cs, backup): - """Get a backup by ID.""" + """Get a backup by name or ID.""" return utils.find_resource(cs.backups, backup) def _find_transfer(cs, transfer): - """Get a transfer by ID.""" + """Get a transfer by name or ID.""" return utils.find_resource(cs.transfers, transfer) @@ -182,7 +182,7 @@ def do_list(cs, args): 'Size', 'Volume Type', 'Bootable', 'Attached to']) -@utils.arg('volume', metavar='', help='ID of the volume.') +@utils.arg('volume', metavar='', help='Name or ID of the volume.') @utils.service_type('volume') def do_show(cs, args): """Show details about a volume.""" @@ -276,7 +276,8 @@ def do_create(cs, args): _print_volume(volume) -@utils.arg('volume', metavar='', help='ID of the volume to delete.') +@utils.arg('volume', metavar='', + help='Name or ID of the volume to delete.') @utils.service_type('volume') def do_delete(cs, args): """Remove a volume.""" @@ -284,7 +285,8 @@ def do_delete(cs, args): volume.delete() -@utils.arg('volume', metavar='', help='ID of the volume to delete.') +@utils.arg('volume', metavar='', + help='Name or ID of the volume to delete.') @utils.service_type('volume') def do_force_delete(cs, args): """Attempt forced removal of a volume, regardless of its state.""" @@ -292,7 +294,8 @@ def do_force_delete(cs, args): volume.force_delete() -@utils.arg('volume', metavar='', help='ID of the volume to modify.') +@utils.arg('volume', metavar='', + help='Name or ID of the volume to modify.') @utils.arg('--state', metavar='', default='available', help=('Indicate which state to assign the volume. Options include ' 'available, error, creating, deleting, error_deleting. If no ' @@ -304,7 +307,8 @@ def do_reset_state(cs, args): volume.reset_state(args.state) -@utils.arg('volume', metavar='', help='ID of the volume to rename.') +@utils.arg('volume', metavar='', + help='Name or ID of the volume to rename.') @utils.arg('display_name', nargs='?', metavar='', help='New display-name for the volume.') @utils.arg('--display-description', metavar='', @@ -323,7 +327,7 @@ def do_rename(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume to update metadata on.') + help='Name or ID of the volume to update metadata on.') @utils.arg('action', metavar='', choices=['set', 'unset'], @@ -392,7 +396,8 @@ def do_snapshot_list(cs, args): ['ID', 'Volume ID', 'Status', 'Display Name', 'Size']) -@utils.arg('snapshot', metavar='', help='ID of the snapshot.') +@utils.arg('snapshot', metavar='', + help='Name or ID of the snapshot.') @utils.service_type('volume') def do_snapshot_show(cs, args): """Show details about a snapshot.""" @@ -435,17 +440,18 @@ def do_snapshot_create(cs, args): _print_volume_snapshot(snapshot) -@utils.arg('snapshot_id', - metavar='', - help='ID of the snapshot to delete.') +@utils.arg('snapshot', + metavar='', + help='Name or ID of the snapshot to delete.') @utils.service_type('volume') def do_snapshot_delete(cs, args): """Remove a snapshot.""" - snapshot = _find_volume_snapshot(cs, args.snapshot_id) + snapshot = _find_volume_snapshot(cs, args.snapshot) snapshot.delete() -@utils.arg('snapshot', metavar='', help='ID of the snapshot.') +@utils.arg('snapshot', metavar='', + help='Name or ID of the snapshot.') @utils.arg('display_name', nargs='?', metavar='', help='New display-name for the snapshot.') @utils.arg('--display-description', metavar='', @@ -463,7 +469,7 @@ def do_snapshot_rename(cs, args): @utils.arg('snapshot', metavar='', - help='ID of the snapshot to modify.') + help='Name or ID of the snapshot to modify.') @utils.arg('--state', metavar='', default='available', help=('Indicate which state to assign the snapshot. ' @@ -693,9 +699,9 @@ def _find_volume_type(cs, vtype): return utils.find_resource(cs.volume_types, vtype) -@utils.arg('volume_id', - metavar='', - help='ID of the volume to upload to an image') +@utils.arg('volume', + metavar='', + help='Name or ID of the volume to upload to an image') @utils.arg('--force', metavar='', help='Optional flag to indicate whether ' @@ -718,7 +724,7 @@ def _find_volume_type(cs, vtype): @utils.service_type('volume') def do_upload_to_image(cs, args): """Upload volume to image service as image.""" - volume = _find_volume(cs, args.volume_id) + volume = _find_volume(cs, args.volume) _print_volume_image(volume.upload_to_image(args.force, args.image_name, args.container_format, @@ -753,7 +759,7 @@ def do_backup_create(cs, args): utils.print_dict(info) -@utils.arg('backup', metavar='', help='ID of the backup.') +@utils.arg('backup', metavar='', help='Name or ID of the backup.') @utils.service_type('volume') def do_backup_show(cs, args): """Show details about a backup.""" @@ -777,7 +783,7 @@ def do_backup_list(cs, args): @utils.arg('backup', metavar='', - help='ID of the backup to delete.') + help='Name or ID of the backup to delete.') @utils.service_type('volume') def do_backup_delete(cs, args): """Remove a backup.""" @@ -817,7 +823,7 @@ def do_transfer_create(cs, args): @utils.arg('transfer', metavar='', - help='ID of the transfer to delete.') + help='Name or ID of the transfer to delete.') @utils.service_type('volume') def do_transfer_delete(cs, args): """Undo a transfer.""" @@ -851,7 +857,7 @@ def do_transfer_list(cs, args): @utils.arg('transfer', metavar='', - help='ID of the transfer to accept.') + help='Name or ID of the transfer to accept.') @utils.service_type('volume') def do_transfer_show(cs, args): """Show details about a transfer.""" @@ -865,7 +871,8 @@ def do_transfer_show(cs, args): utils.print_dict(info) -@utils.arg('volume', metavar='', help='ID of the volume to extend.') +@utils.arg('volume', metavar='', + help='Name or ID of the volume to extend.') @utils.arg('new_size', metavar='', type=int, diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index 6e1adbc..ff17a5e 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -59,22 +59,22 @@ def _poll_for_status(poll_fn, obj_id, action, final_ok_states, def _find_volume(cs, volume): - """Get a volume by ID.""" + """Get a volume by name or ID.""" return utils.find_resource(cs.volumes, volume) def _find_volume_snapshot(cs, snapshot): - """Get a volume snapshot by ID.""" + """Get a volume snapshot by name or ID.""" return utils.find_resource(cs.volume_snapshots, snapshot) def _find_backup(cs, backup): - """Get a backup by ID.""" + """Get a backup by name or ID.""" return utils.find_resource(cs.backups, backup) def _find_transfer(cs, transfer): - """Get a transfer by ID.""" + """Get a transfer by name or ID.""" return utils.find_resource(cs.transfers, transfer) @@ -180,7 +180,7 @@ def do_list(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume.') + help='Name or ID of the volume.') @utils.service_type('volumev2') def do_show(cs, args): """Show details about a volume.""" @@ -304,7 +304,7 @@ def do_create(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume to delete.') + help='Name or ID of the volume to delete.') @utils.service_type('volumev2') def do_delete(cs, args): """Remove a volume.""" @@ -314,7 +314,7 @@ def do_delete(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume to delete.') + help='Name or ID of the volume to delete.') @utils.service_type('volumev2') def do_force_delete(cs, args): """Attempt forced removal of a volume, regardless of its state.""" @@ -322,7 +322,8 @@ def do_force_delete(cs, args): volume.force_delete() -@utils.arg('volume', metavar='', help='ID of the volume to modify.') +@utils.arg('volume', metavar='', + help='Name or ID of the volume to modify.') @utils.arg('--state', metavar='', default='available', help=('Indicate which state to assign the volume. Options include ' 'available, error, creating, deleting, error_deleting. If no ' @@ -336,7 +337,7 @@ def do_reset_state(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume to rename.') + help='Name or ID of the volume to rename.') @utils.arg('name', nargs='?', metavar='', @@ -365,7 +366,7 @@ def do_rename(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume to update metadata on.') + help='Name or ID of the volume to update metadata on.') @utils.arg('action', metavar='', choices=['set', 'unset'], @@ -442,7 +443,7 @@ def do_snapshot_list(cs, args): @utils.arg('snapshot', metavar='', - help='ID of the snapshot.') + help='Name or ID of the snapshot.') @utils.service_type('volumev2') def do_snapshot_show(cs, args): """Show details about a snapshot.""" @@ -491,17 +492,18 @@ def do_snapshot_create(cs, args): _print_volume_snapshot(snapshot) -@utils.arg('snapshot-id', - metavar='', - help='ID of the snapshot to delete.') +@utils.arg('snapshot', + metavar='', + help='Name or ID of the snapshot to delete.') @utils.service_type('volumev2') def do_snapshot_delete(cs, args): """Remove a snapshot.""" - snapshot = _find_volume_snapshot(cs, args.snapshot_id) + snapshot = _find_volume_snapshot(cs, args.snapshot) snapshot.delete() -@utils.arg('snapshot', metavar='', help='ID of the snapshot.') +@utils.arg('snapshot', metavar='', + help='Name or ID of the snapshot.') @utils.arg('name', nargs='?', metavar='', help='New name for the snapshot.') @utils.arg('--description', metavar='', @@ -528,7 +530,7 @@ def do_snapshot_rename(cs, args): @utils.arg('snapshot', metavar='', - help='ID of the snapshot to modify.') + help='Name or ID of the snapshot to modify.') @utils.arg('--state', metavar='', default='available', help=('Indicate which state to assign the snapshot. ' @@ -762,9 +764,9 @@ def _find_volume_type(cs, vtype): return utils.find_resource(cs.volume_types, vtype) -@utils.arg('volume-id', - metavar='', - help='ID of the volume to snapshot') +@utils.arg('volume', + metavar='', + help='Name or ID of the volume to snapshot') @utils.arg('--force', metavar='', help='Optional flag to indicate whether ' @@ -793,7 +795,7 @@ def _find_volume_type(cs, vtype): @utils.service_type('volumev2') def do_upload_to_image(cs, args): """Upload volume to image service as image.""" - volume = _find_volume(cs, args.volume_id) + volume = _find_volume(cs, args.volume) _print_volume_image(volume.upload_to_image(args.force, args.image_name, args.container_format, @@ -839,7 +841,7 @@ def do_backup_create(cs, args): utils.print_dict(info) -@utils.arg('backup', metavar='', help='ID of the backup.') +@utils.arg('backup', metavar='', help='Name or ID of the backup.') @utils.service_type('volumev2') def do_backup_show(cs, args): """Show details about a backup.""" @@ -861,7 +863,7 @@ def do_backup_list(cs, args): @utils.arg('backup', metavar='', - help='ID of the backup to delete.') + help='Name or ID of the backup to delete.') @utils.service_type('volumev2') def do_backup_delete(cs, args): """Remove a backup.""" @@ -905,7 +907,7 @@ def do_transfer_create(cs, args): @utils.arg('transfer', metavar='', - help='ID of the transfer to delete.') + help='Name or ID of the transfer to delete.') @utils.service_type('volumev2') def do_transfer_delete(cs, args): """Undo a transfer.""" @@ -937,7 +939,7 @@ def do_transfer_list(cs, args): @utils.arg('transfer', metavar='', - help='ID of the transfer to accept.') + help='Name or ID of the transfer to accept.') @utils.service_type('volumev2') def do_transfer_show(cs, args): """Show details about a transfer.""" @@ -949,7 +951,8 @@ def do_transfer_show(cs, args): utils.print_dict(info) -@utils.arg('volume', metavar='', help='ID of the volume to extend.') +@utils.arg('volume', metavar='', + help='Name or ID of the volume to extend.') @utils.arg('new-size', metavar='', type=int, From d7796ef737b0b9c70fabf1416b42ad42c2d27b4a Mon Sep 17 00:00:00 2001 From: Avishay Traeger Date: Thu, 18 Jul 2013 16:17:21 +0300 Subject: [PATCH 16/24] Implement ability to migrate volume Implements ability to call migrate_volume and migrate_volume_completion APIs. The former includes shell code while the latter is called by Nova and should not be invoked via shell. Change-Id: I6e81d7a6321f367a356f0a0dee385221363a4227 --- cinderclient/tests/v1/fakes.py | 3 +++ cinderclient/tests/v1/test_volumes.py | 5 ++++ cinderclient/tests/v2/fakes.py | 3 +++ cinderclient/tests/v2/test_volumes.py | 5 ++++ cinderclient/v1/shell.py | 14 +++++++++++ cinderclient/v1/volumes.py | 34 +++++++++++++++++++++++++++ cinderclient/v2/shell.py | 14 +++++++++++ cinderclient/v2/volumes.py | 34 +++++++++++++++++++++++++++ 8 files changed, 112 insertions(+) diff --git a/cinderclient/tests/v1/fakes.py b/cinderclient/tests/v1/fakes.py index 69f3d1f..40ddd4c 100644 --- a/cinderclient/tests/v1/fakes.py +++ b/cinderclient/tests/v1/fakes.py @@ -308,6 +308,9 @@ class FakeHTTPClient(base_client.HTTPClient): assert 'status' in body[action] elif action == 'os-extend': assert body[action].keys() == ['new_size'] + elif action == 'os-migrate_volume': + assert 'host' in body[action] + assert 'force_host_copy' in body[action] else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, _body) diff --git a/cinderclient/tests/v1/test_volumes.py b/cinderclient/tests/v1/test_volumes.py index 2da7509..bb73438 100644 --- a/cinderclient/tests/v1/test_volumes.py +++ b/cinderclient/tests/v1/test_volumes.py @@ -91,3 +91,8 @@ class VolumesTest(utils.TestCase): def test_get_encryption_metadata(self): cs.volumes.get_encryption_metadata('1234') cs.assert_called('GET', '/volumes/1234/encryption') + + def test_migrate(self): + v = cs.volumes.get('1234') + cs.volumes.migrate_volume(v, 'dest', False) + cs.assert_called('POST', '/volumes/1234/action') diff --git a/cinderclient/tests/v2/fakes.py b/cinderclient/tests/v2/fakes.py index 745fd39..474d10c 100644 --- a/cinderclient/tests/v2/fakes.py +++ b/cinderclient/tests/v2/fakes.py @@ -315,6 +315,9 @@ class FakeHTTPClient(base_client.HTTPClient): assert 'status' in body[action] elif action == 'os-extend': assert body[action].keys() == ['new_size'] + elif action == 'os-migrate_volume': + assert 'host' in body[action] + assert 'force_host_copy' in body[action] else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, _body) diff --git a/cinderclient/tests/v2/test_volumes.py b/cinderclient/tests/v2/test_volumes.py index 594bba4..73ee4a8 100644 --- a/cinderclient/tests/v2/test_volumes.py +++ b/cinderclient/tests/v2/test_volumes.py @@ -94,3 +94,8 @@ class VolumesTest(utils.TestCase): def test_get_encryption_metadata(self): cs.volumes.get_encryption_metadata('1234') cs.assert_called('GET', '/volumes/1234/encryption') + + def test_migrate(self): + v = cs.volumes.get('1234') + cs.volumes.migrate_volume(v, 'dest', False) + cs.assert_called('POST', '/volumes/1234/action') diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 91c2046..12331ef 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -1046,3 +1046,17 @@ def do_encryption_type_create(cs, args): result = cs.volume_encryption_types.create(volume_type, body) _print_volume_encryption_type_list([result]) + + +@utils.arg('volume', metavar='', help='ID of the volume to migrate') +@utils.arg('host', metavar='', help='Destination host') +@utils.arg('--force-host-copy', metavar='', + help='Optional flag to force the use of the generic ' + 'host-based migration mechanism, bypassing driver ' + 'optimizations (Default=False).', + default=False) +@utils.service_type('volume') +def do_migrate(cs, args): + """Migrate the volume to the new host.""" + volume = _find_volume(cs, args.volume) + volume.migrate_volume(args.host, args.force_host_copy) diff --git a/cinderclient/v1/volumes.py b/cinderclient/v1/volumes.py index 6d63e72..6804d59 100644 --- a/cinderclient/v1/volumes.py +++ b/cinderclient/v1/volumes.py @@ -114,6 +114,15 @@ class Volume(base.Resource): self.manager.extend(self, volume, new_size) + def migrate_volume(self, host, force_host_copy): + """Migrate the volume to a new host.""" + self.manager.migrate_volume(self, host, force_host_copy) + +# def migrate_volume_completion(self, old_volume, new_volume, error): +# """Complete the migration of the volume.""" +# self.manager.migrate_volume_completion(self, old_volume, +# new_volume, error) + class VolumeManager(base.ManagerWithFind): """ @@ -361,3 +370,28 @@ class VolumeManager(base.ManagerWithFind): :return: a dictionary of volume encryption metadata """ return self._get("/volumes/%s/encryption" % volume_id)._info + + def migrate_volume(self, volume, host, force_host_copy): + """Migrate volume to new host. + + :param volume: The :class:`Volume` to migrate + :param host: The destination host + :param force_host_copy: Skip driver optimizations + """ + + return self._action('os-migrate_volume', + volume, + {'host': host, 'force_host_copy': force_host_copy}) + + def migrate_volume_completion(self, old_volume, new_volume, error): + """Complete the migration from the old volume to the temp new one. + + :param old_volume: The original :class:`Volume` in the migration + :param new_volume: The new temporary :class:`Volume` in the migration + :param error: Inform of an error to cause migration cleanup + """ + + new_volume_id = base.getid(new_volume) + return self._action('os-migrate_volume_completion', + old_volume, + {'new_volume': new_volume_id, 'error': error})[1] diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index 6e1adbc..9515bca 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -800,6 +800,20 @@ def do_upload_to_image(cs, args): args.disk_format)) +@utils.arg('volume', metavar='', help='ID of the volume to migrate') +@utils.arg('host', metavar='', help='Destination host') +@utils.arg('--force-host-copy', metavar='', + help='Optional flag to force the use of the generic ' + 'host-based migration mechanism, bypassing driver ' + 'optimizations (Default=False).', + default=False) +@utils.service_type('volume') +def do_migrate(cs, args): + """Migrate the volume to the new host.""" + volume = _find_volume(cs, args.volume) + volume.migrate_volume(args.host, args.force_host_copy) + + @utils.arg('volume', metavar='', help='ID of the volume to backup.') @utils.arg('--container', metavar='', diff --git a/cinderclient/v2/volumes.py b/cinderclient/v2/volumes.py index be4a9e6..2ec885d 100644 --- a/cinderclient/v2/volumes.py +++ b/cinderclient/v2/volumes.py @@ -112,6 +112,15 @@ class Volume(base.Resource): self.manager.extend(self, volume, new_size) + def migrate_volume(self, host, force_host_copy): + """Migrate the volume to a new host.""" + self.manager.migrate_volume(self, host, force_host_copy) + +# def migrate_volume_completion(self, old_volume, new_volume, error): +# """Complete the migration of the volume.""" +# self.manager.migrate_volume_completion(self, old_volume, +# new_volume, error) + class VolumeManager(base.ManagerWithFind): """Manage :class:`Volume` resources.""" @@ -343,3 +352,28 @@ class VolumeManager(base.ManagerWithFind): :return: a dictionary of volume encryption metadata """ return self._get("/volumes/%s/encryption" % volume_id)._info + + def migrate_volume(self, volume, host, force_host_copy): + """Migrate volume to new host. + + :param volume: The :class:`Volume` to migrate + :param host: The destination host + :param force_host_copy: Skip driver optimizations + """ + + return self._action('os-migrate_volume', + volume, + {'host': host, 'force_host_copy': force_host_copy}) + + def migrate_volume_completion(self, old_volume, new_volume, error): + """Complete the migration from the old volume to the temp new one. + + :param old_volume: The original :class:`Volume` in the migration + :param new_volume: The new temporary :class:`Volume` in the migration + :param error: Inform of an error to cause migration cleanup + """ + + new_volume_id = base.getid(new_volume) + return self._action('os-migrate_volume_completion', + old_volume, + {'new_volume': new_volume_id, 'error': error})[1] From 405702c8c0f3befab31bdfe92fd10f408f8b5a0c Mon Sep 17 00:00:00 2001 From: Ken'ichi Ohmichi Date: Wed, 18 Sep 2013 16:44:51 +0900 Subject: [PATCH 17/24] Add volume name arguments This patch adds volume name arguments to the following subcommands: * snapshot-create * backup-create * backup-restore * transfer-create Fixes bug #1220590 Change-Id: Ib0ff6e62d45abb14fa8c7313511ef6f72befe0d5 --- cinderclient/utils.py | 5 ++++ cinderclient/v1/shell.py | 53 ++++++++++++++++++++-------------------- cinderclient/v2/shell.py | 53 ++++++++++++++++++++-------------------- 3 files changed, 59 insertions(+), 52 deletions(-) diff --git a/cinderclient/utils.py b/cinderclient/utils.py index 93be477..c7e4ebc 100644 --- a/cinderclient/utils.py +++ b/cinderclient/utils.py @@ -230,6 +230,11 @@ def find_resource(manager, name_or_id): raise exceptions.CommandError(msg) +def find_volume(cs, volume): + """Get a volume by name or ID.""" + return find_resource(cs.volumes, volume) + + def _format_servers_list_networks(server): output = [] for (network, addresses) in list(server.networks.items()): diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index a4d4cb1..db6b23b 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -60,11 +60,6 @@ def _poll_for_status(poll_fn, obj_id, action, final_ok_states, time.sleep(poll_period) -def _find_volume(cs, volume): - """Get a volume by name or ID.""" - return utils.find_resource(cs.volumes, volume) - - def _find_volume_snapshot(cs, snapshot): """Get a volume snapshot by name or ID.""" return utils.find_resource(cs.volume_snapshots, snapshot) @@ -186,7 +181,7 @@ def do_list(cs, args): @utils.service_type('volume') def do_show(cs, args): """Show details about a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) _print_volume(volume) @@ -281,7 +276,7 @@ def do_create(cs, args): @utils.service_type('volume') def do_delete(cs, args): """Remove a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) volume.delete() @@ -290,7 +285,7 @@ def do_delete(cs, args): @utils.service_type('volume') def do_force_delete(cs, args): """Attempt forced removal of a volume, regardless of its state.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) volume.force_delete() @@ -303,7 +298,7 @@ def do_force_delete(cs, args): @utils.service_type('volume') def do_reset_state(cs, args): """Explicitly update the state of a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) volume.reset_state(args.state) @@ -322,7 +317,7 @@ def do_rename(cs, args): kwargs['display_name'] = args.display_name if args.display_description is not None: kwargs['display_description'] = args.display_description - _find_volume(cs, args.volume).update(**kwargs) + utils.find_volume(cs, args.volume).update(**kwargs) @utils.arg('volume', @@ -340,7 +335,7 @@ def do_rename(cs, args): @utils.service_type('volume') def do_metadata(cs, args): """Set or Delete metadata on a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) metadata = _extract_metadata(args) if args.action == 'set': @@ -405,9 +400,9 @@ def do_snapshot_show(cs, args): _print_volume_snapshot(snapshot) -@utils.arg('volume_id', - metavar='', - help='ID of the volume to snapshot') +@utils.arg('volume', + metavar='', + help='Name or ID of the volume to snapshot') @utils.arg('--force', metavar='', help='Optional flag to indicate whether ' @@ -433,7 +428,8 @@ def do_snapshot_show(cs, args): @utils.service_type('volume') def do_snapshot_create(cs, args): """Add a new snapshot.""" - snapshot = cs.volume_snapshots.create(args.volume_id, + volume = utils.find_volume(cs, args.volume) + snapshot = cs.volume_snapshots.create(volume.id, args.force, args.display_name, args.display_description) @@ -724,7 +720,7 @@ def _find_volume_type(cs, vtype): @utils.service_type('volume') def do_upload_to_image(cs, args): """Upload volume to image service as image.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) _print_volume_image(volume.upload_to_image(args.force, args.image_name, args.container_format, @@ -732,7 +728,7 @@ def do_upload_to_image(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume to backup.') + help='Name or ID of the volume to backup.') @utils.arg('--container', metavar='', help='Optional Backup container name. (Default=None)', default=None) @@ -745,12 +741,13 @@ def do_upload_to_image(cs, args): @utils.service_type('volume') def do_backup_create(cs, args): """Creates a backup.""" - backup = cs.backups.create(args.volume, + volume = utils.find_volume(cs, args.volume) + backup = cs.backups.create(volume.id, args.container, args.display_name, args.display_description) - info = {"volume_id": args.volume} + info = {"volume_id": volume.id} info.update(backup._info) if 'links' in info: @@ -793,25 +790,29 @@ def do_backup_delete(cs, args): @utils.arg('backup', metavar='', help='ID of the backup to restore.') -@utils.arg('--volume-id', metavar='', - help='Optional ID of the volume to restore to.', +@utils.arg('--volume-id', metavar='', + help='Optional ID(or name) of the volume to restore to.', default=None) @utils.service_type('volume') def do_backup_restore(cs, args): """Restore a backup.""" - cs.restores.restore(args.backup, - args.volume_id) + if args.volume: + volume_id = utils.find_volume(cs, args.volume).id + else: + volume_id = None + cs.restores.restore(args.backup, volume_id) @utils.arg('volume', metavar='', - help='ID of the volume to transfer.') + help='Name or ID of the volume to transfer.') @utils.arg('--display-name', metavar='', help='Optional transfer name. (Default=None)', default=None) @utils.service_type('volume') def do_transfer_create(cs, args): """Creates a volume transfer.""" - transfer = cs.transfers.create(args.volume, + volume = utils.find_volume(cs, args.volume) + transfer = cs.transfers.create(volume.id, args.display_name) info = dict() info.update(transfer._info) @@ -880,7 +881,7 @@ def do_transfer_show(cs, args): @utils.service_type('volume') def do_extend(cs, args): """Attempt to extend the size of an existing volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) cs.volumes.extend(volume, args.new_size) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index ff17a5e..7366459 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -58,11 +58,6 @@ def _poll_for_status(poll_fn, obj_id, action, final_ok_states, time.sleep(poll_period) -def _find_volume(cs, volume): - """Get a volume by name or ID.""" - return utils.find_resource(cs.volumes, volume) - - def _find_volume_snapshot(cs, snapshot): """Get a volume snapshot by name or ID.""" return utils.find_resource(cs.volume_snapshots, snapshot) @@ -185,7 +180,7 @@ def do_list(cs, args): def do_show(cs, args): """Show details about a volume.""" info = dict() - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) info.update(volume._info) info.pop('links', None) @@ -308,7 +303,7 @@ def do_create(cs, args): @utils.service_type('volumev2') def do_delete(cs, args): """Remove a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) volume.delete() @@ -318,7 +313,7 @@ def do_delete(cs, args): @utils.service_type('volumev2') def do_force_delete(cs, args): """Attempt forced removal of a volume, regardless of its state.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) volume.force_delete() @@ -331,7 +326,7 @@ def do_force_delete(cs, args): @utils.service_type('volumev2') def do_reset_state(cs, args): """Explicitly update the state of a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) volume.reset_state(args.state) @@ -361,7 +356,7 @@ def do_rename(cs, args): elif args.description is not None: kwargs['description'] = args.description - _find_volume(cs, args.volume).update(**kwargs) + utils.find_volume(cs, args.volume).update(**kwargs) @utils.arg('volume', @@ -380,7 +375,7 @@ def do_rename(cs, args): @utils.service_type('volumev2') def do_metadata(cs, args): """Set or Delete metadata on a volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) metadata = _extract_metadata(args) if args.action == 'set': @@ -451,9 +446,9 @@ def do_snapshot_show(cs, args): _print_volume_snapshot(snapshot) -@utils.arg('volume-id', - metavar='', - help='ID of the volume to snapshot') +@utils.arg('volume', + metavar='', + help='Name or ID of the volume to snapshot') @utils.arg('--force', metavar='', help='Optional flag to indicate whether ' @@ -485,7 +480,8 @@ def do_snapshot_create(cs, args): if args.display_description is not None: args.description = args.display_description - snapshot = cs.volume_snapshots.create(args.volume_id, + volume = utils.find_volume(cs, args.volume) + snapshot = cs.volume_snapshots.create(volume.id, args.force, args.name, args.description) @@ -795,7 +791,7 @@ def _find_volume_type(cs, vtype): @utils.service_type('volumev2') def do_upload_to_image(cs, args): """Upload volume to image service as image.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) _print_volume_image(volume.upload_to_image(args.force, args.image_name, args.container_format, @@ -803,7 +799,7 @@ def do_upload_to_image(cs, args): @utils.arg('volume', metavar='', - help='ID of the volume to backup.') + help='Name or ID of the volume to backup.') @utils.arg('--container', metavar='', help='Optional backup container name. (Default=None)', default=None) @@ -827,12 +823,13 @@ def do_backup_create(cs, args): if args.display_description is not None: args.description = args.display_description - backup = cs.backups.create(args.volume, + volume = utils.find_volume(cs, args.volume) + backup = cs.backups.create(volume.id, args.container, args.name, args.description) - info = {"volume_id": args.volume} + info = {"volume_id": volume.id} info.update(backup._info) if 'links' in info: @@ -873,18 +870,21 @@ def do_backup_delete(cs, args): @utils.arg('backup', metavar='', help='ID of the backup to restore.') -@utils.arg('--volume-id', metavar='', - help='Optional ID of the volume to restore to.', +@utils.arg('--volume-id', metavar='', + help='Optional ID(or name) of the volume to restore to.', default=None) @utils.service_type('volumev2') def do_backup_restore(cs, args): """Restore a backup.""" - cs.restores.restore(args.backup, - args.volume_id) + if args.volume: + volume_id = utils.find_volume(cs, args.volume).id + else: + volume_id = None + cs.restores.restore(args.backup, volume_id) @utils.arg('volume', metavar='', - help='ID of the volume to transfer.') + help='Name or ID of the volume to transfer.') @utils.arg('--name', metavar='', default=None, @@ -897,7 +897,8 @@ def do_transfer_create(cs, args): if args.display_name is not None: args.name = args.display_name - transfer = cs.transfers.create(args.volume, + volume = utils.find_volume(cs, args.volume) + transfer = cs.transfers.create(volume.id, args.name) info = dict() info.update(transfer._info) @@ -960,7 +961,7 @@ def do_transfer_show(cs, args): @utils.service_type('volumev2') def do_extend(cs, args): """Attempt to extend the size of an existing volume.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) cs.volumes.extend(volume, args.new_size) From 327b397f6340c20d5f9e1fe3e80224aa0f5ae7fa Mon Sep 17 00:00:00 2001 From: Avishay Traeger Date: Wed, 25 Sep 2013 10:30:17 +0300 Subject: [PATCH 18/24] Use v2 endpoint with v2 shell for migration Change the volume migration shell to use the v2 endpoint for v2. Change-Id: I0a902aa5e3f86c0d9562eb75677b11a364f9d371 Closes-Bug: #1230124 --- cinderclient/v2/shell.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index a1f9ad3..337519a 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -809,7 +809,7 @@ def do_upload_to_image(cs, args): 'host-based migration mechanism, bypassing driver ' 'optimizations (Default=False).', default=False) -@utils.service_type('volume') +@utils.service_type('volumev2') def do_migrate(cs, args): """Migrate the volume to the new host.""" volume = _find_volume(cs, args.volume) From 876bff6398527a0686a1bc31f8ec7d45e3624a9a Mon Sep 17 00:00:00 2001 From: Eric Harney Date: Thu, 8 Aug 2013 12:02:24 -0400 Subject: [PATCH 19/24] Error if arguments are not supplied for rename commands This changes behavior from: $ cinder rename volume1 $ to $ cinder rename volume1 ERROR: Must supply either display-name or display-description. for both 'rename' and 'snapshot-rename'. Change-Id: I675a3b1428a7fe10653394c80e4a5a473e14c740 --- cinderclient/tests/v1/test_shell.py | 16 ++++++++-------- cinderclient/tests/v2/test_shell.py | 16 ++++++++-------- cinderclient/v1/shell.py | 10 ++++++++++ cinderclient/v2/shell.py | 8 ++++++++ 4 files changed, 34 insertions(+), 16 deletions(-) diff --git a/cinderclient/tests/v1/test_shell.py b/cinderclient/tests/v1/test_shell.py index 014df94..13c2a19 100644 --- a/cinderclient/tests/v1/test_shell.py +++ b/cinderclient/tests/v1/test_shell.py @@ -127,7 +127,7 @@ class ShellTest(utils.TestCase): 'status=available&volume_id=1234') def test_rename(self): - # basic rename with positional agruments + # basic rename with positional arguments self.run_command('rename 1234 new-name') expected = {'volume': {'display_name': 'new-name'}} self.assert_called('PUT', '/volumes/1234', body=expected) @@ -143,12 +143,12 @@ class ShellTest(utils.TestCase): 'display_description': 'new-description', }} self.assert_called('PUT', '/volumes/1234', body=expected) - # noop, the only all will be the lookup - self.run_command('rename 1234') - self.assert_called('GET', '/volumes/1234') + + # Call rename with no arguments + self.assertRaises(SystemExit, self.run_command, 'rename') def test_rename_snapshot(self): - # basic rename with positional agruments + # basic rename with positional arguments self.run_command('snapshot-rename 1234 new-name') expected = {'snapshot': {'display_name': 'new-name'}} self.assert_called('PUT', '/snapshots/1234', body=expected) @@ -165,9 +165,9 @@ class ShellTest(utils.TestCase): 'display_description': 'new-description', }} self.assert_called('PUT', '/snapshots/1234', body=expected) - # noop, the only all will be the lookup - self.run_command('snapshot-rename 1234') - self.assert_called('GET', '/snapshots/1234') + + # Call snapshot-rename with no arguments + self.assertRaises(SystemExit, self.run_command, 'snapshot-rename') def test_set_metadata_set(self): self.run_command('metadata 1234 set key1=val1 key2=val2') diff --git a/cinderclient/tests/v2/test_shell.py b/cinderclient/tests/v2/test_shell.py index 6f9a53f..4b7178a 100644 --- a/cinderclient/tests/v2/test_shell.py +++ b/cinderclient/tests/v2/test_shell.py @@ -105,7 +105,7 @@ class ShellTest(utils.TestCase): 'status=available&volume_id=1234') def test_rename(self): - # basic rename with positional agruments + # basic rename with positional arguments self.run_command('rename 1234 new-name') expected = {'volume': {'name': 'new-name'}} self.assert_called('PUT', '/volumes/1234', body=expected) @@ -121,12 +121,12 @@ class ShellTest(utils.TestCase): 'description': 'new-description', }} self.assert_called('PUT', '/volumes/1234', body=expected) - # noop, the only all will be the lookup - self.run_command('rename 1234') - self.assert_called('GET', '/volumes/1234') + + # Call rename with no arguments + self.assertRaises(SystemExit, self.run_command, 'rename') def test_rename_snapshot(self): - # basic rename with positional agruments + # basic rename with positional arguments self.run_command('snapshot-rename 1234 new-name') expected = {'snapshot': {'name': 'new-name'}} self.assert_called('PUT', '/snapshots/1234', body=expected) @@ -143,9 +143,9 @@ class ShellTest(utils.TestCase): 'description': 'new-description', }} self.assert_called('PUT', '/snapshots/1234', body=expected) - # noop, the only all will be the lookup - self.run_command('snapshot-rename 1234') - self.assert_called('GET', '/snapshots/1234') + + # Call snapshot-rename with no arguments + self.assertRaises(SystemExit, self.run_command, 'snapshot-rename') def test_set_metadata_set(self): self.run_command('metadata 1234 set key1=val1 key2=val2') diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 34b5353..b0a223e 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -317,6 +317,11 @@ def do_rename(cs, args): kwargs['display_name'] = args.display_name if args.display_description is not None: kwargs['display_description'] = args.display_description + + if not any(kwargs): + msg = 'Must supply either display-name or display-description.' + raise exceptions.ClientException(code=1, message=msg) + utils.find_volume(cs, args.volume).update(**kwargs) @@ -461,6 +466,11 @@ def do_snapshot_rename(cs, args): kwargs['display_name'] = args.display_name if args.display_description is not None: kwargs['display_description'] = args.display_description + + if not any(kwargs): + msg = 'Must supply either display-name or display-description.' + raise exceptions.ClientException(code=1, message=msg) + _find_volume_snapshot(cs, args.snapshot).update(**kwargs) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index f1e45c4..bd4d54b 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -356,6 +356,10 @@ def do_rename(cs, args): elif args.description is not None: kwargs['description'] = args.description + if not any(kwargs): + msg = 'Must supply either name or description.' + raise exceptions.ClientException(code=1, message=msg) + utils.find_volume(cs, args.volume).update(**kwargs) @@ -522,6 +526,10 @@ def do_snapshot_rename(cs, args): elif args.display_description is not None: kwargs['description'] = args.display_description + if not any(kwargs): + msg = 'Must supply either name or description.' + raise exceptions.ClientException(code=1, message=msg) + _find_volume_snapshot(cs, args.snapshot).update(**kwargs) From 5ad95e9fd236a1f27cbcf1105494d6680a7d8ffe Mon Sep 17 00:00:00 2001 From: ZhiQiang Fan Date: Fri, 20 Sep 2013 03:31:42 +0800 Subject: [PATCH 20/24] Replace OpenStack LLC with OpenStack Foundation NOTE: * openstack/common/* should be synced from oslo, so i leave them untouched. * add (c) symbol for related lines, leave others untouched. Change-Id: I46a87c7f248d3468b1fdf5661411962faf2fb875 Fixes-Bug: #1214176 --- cinderclient/__init__.py | 2 +- cinderclient/base.py | 2 +- cinderclient/client.py | 2 +- cinderclient/extension.py | 2 +- cinderclient/service_catalog.py | 2 +- cinderclient/shell.py | 2 +- cinderclient/tests/v1/fakes.py | 2 +- cinderclient/tests/v1/test_quota_classes.py | 2 +- cinderclient/tests/v1/test_quotas.py | 2 +- cinderclient/tests/v1/test_services.py | 2 +- cinderclient/tests/v1/test_shell.py | 2 +- cinderclient/tests/v2/__init__.py | 2 +- cinderclient/tests/v2/contrib/test_list_extensions.py | 2 +- cinderclient/tests/v2/fakes.py | 2 +- cinderclient/tests/v2/test_auth.py | 2 +- cinderclient/tests/v2/test_quota_classes.py | 2 +- cinderclient/tests/v2/test_quotas.py | 2 +- cinderclient/tests/v2/test_services.py | 2 +- cinderclient/tests/v2/test_shell.py | 2 +- cinderclient/tests/v2/test_types.py | 2 +- cinderclient/tests/v2/test_volumes.py | 2 +- cinderclient/utils.py | 2 +- cinderclient/v1/__init__.py | 2 +- cinderclient/v1/client.py | 2 +- cinderclient/v1/contrib/__init__.py | 2 +- cinderclient/v1/contrib/list_extensions.py | 2 +- cinderclient/v1/quota_classes.py | 2 +- cinderclient/v1/quotas.py | 2 +- cinderclient/v1/services.py | 2 +- cinderclient/v1/shell.py | 2 +- cinderclient/v2/__init__.py | 2 +- cinderclient/v2/client.py | 2 +- cinderclient/v2/contrib/__init__.py | 2 +- cinderclient/v2/contrib/list_extensions.py | 2 +- cinderclient/v2/quota_classes.py | 2 +- cinderclient/v2/quotas.py | 2 +- cinderclient/v2/services.py | 2 +- cinderclient/v2/shell.py | 2 +- cinderclient/v2/volume_snapshots.py | 2 +- cinderclient/v2/volume_types.py | 2 +- cinderclient/v2/volumes.py | 2 +- 41 files changed, 41 insertions(+), 41 deletions(-) diff --git a/cinderclient/__init__.py b/cinderclient/__init__.py index 5d43513..bfaa627 100644 --- a/cinderclient/__init__.py +++ b/cinderclient/__init__.py @@ -1,6 +1,6 @@ # vim: tabstop=4 shiftwidth=4 softtabstop=4 -# Copyright 2012 OpenStack LLC +# Copyright (c) 2012 OpenStack Foundation # # 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 diff --git a/cinderclient/base.py b/cinderclient/base.py index 4e29078..e1f44eb 100644 --- a/cinderclient/base.py +++ b/cinderclient/base.py @@ -1,6 +1,6 @@ # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/client.py b/cinderclient/client.py index 0bb7b49..4846e4a 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # Copyright 2010 Jacob Kaplan-Moss # Copyright 2011 Piston Cloud Computing, Inc. # All Rights Reserved. diff --git a/cinderclient/extension.py b/cinderclient/extension.py index 07d8450..84c67e9 100644 --- a/cinderclient/extension.py +++ b/cinderclient/extension.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/service_catalog.py b/cinderclient/service_catalog.py index 09ef59c..b43eaed 100644 --- a/cinderclient/service_catalog.py +++ b/cinderclient/service_catalog.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # Copyright 2011, Piston Cloud Computing, Inc. # # All Rights Reserved. diff --git a/cinderclient/shell.py b/cinderclient/shell.py index 049324c..c9c1529 100644 --- a/cinderclient/shell.py +++ b/cinderclient/shell.py @@ -1,5 +1,5 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v1/fakes.py b/cinderclient/tests/v1/fakes.py index 40ddd4c..2c052c6 100644 --- a/cinderclient/tests/v1/fakes.py +++ b/cinderclient/tests/v1/fakes.py @@ -1,5 +1,5 @@ # Copyright (c) 2011 X.commerce, a business unit of eBay Inc. -# Copyright 2011 OpenStack, LLC +# Copyright (c) 2011 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/cinderclient/tests/v1/test_quota_classes.py b/cinderclient/tests/v1/test_quota_classes.py index 83e297f..0cb3122 100644 --- a/cinderclient/tests/v1/test_quota_classes.py +++ b/cinderclient/tests/v1/test_quota_classes.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v1/test_quotas.py b/cinderclient/tests/v1/test_quotas.py index 7ebb061..faff9f6 100644 --- a/cinderclient/tests/v1/test_quotas.py +++ b/cinderclient/tests/v1/test_quotas.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v1/test_services.py b/cinderclient/tests/v1/test_services.py index 2320a26..7a1ec85 100644 --- a/cinderclient/tests/v1/test_services.py +++ b/cinderclient/tests/v1/test_services.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v1/test_shell.py b/cinderclient/tests/v1/test_shell.py index 014df94..ec44427 100644 --- a/cinderclient/tests/v1/test_shell.py +++ b/cinderclient/tests/v1/test_shell.py @@ -1,6 +1,6 @@ # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v2/__init__.py b/cinderclient/tests/v2/__init__.py index 0cd9c14..f2c41f4 100644 --- a/cinderclient/tests/v2/__init__.py +++ b/cinderclient/tests/v2/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # diff --git a/cinderclient/tests/v2/contrib/test_list_extensions.py b/cinderclient/tests/v2/contrib/test_list_extensions.py index ff59cd2..66126be 100644 --- a/cinderclient/tests/v2/contrib/test_list_extensions.py +++ b/cinderclient/tests/v2/contrib/test_list_extensions.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # diff --git a/cinderclient/tests/v2/fakes.py b/cinderclient/tests/v2/fakes.py index 474d10c..88a94bb 100644 --- a/cinderclient/tests/v2/fakes.py +++ b/cinderclient/tests/v2/fakes.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack, LLC +# Copyright (c) 2013 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/cinderclient/tests/v2/test_auth.py b/cinderclient/tests/v2/test_auth.py index 2ae3eed..9704840 100644 --- a/cinderclient/tests/v2/test_auth.py +++ b/cinderclient/tests/v2/test_auth.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # diff --git a/cinderclient/tests/v2/test_quota_classes.py b/cinderclient/tests/v2/test_quota_classes.py index 83cc710..0fee1e8 100644 --- a/cinderclient/tests/v2/test_quota_classes.py +++ b/cinderclient/tests/v2/test_quota_classes.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v2/test_quotas.py b/cinderclient/tests/v2/test_quotas.py index 37ceeed..eb4531a 100644 --- a/cinderclient/tests/v2/test_quotas.py +++ b/cinderclient/tests/v2/test_quotas.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v2/test_services.py b/cinderclient/tests/v2/test_services.py index e4bce29..5ee3ea1 100644 --- a/cinderclient/tests/v2/test_services.py +++ b/cinderclient/tests/v2/test_services.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v2/test_shell.py b/cinderclient/tests/v2/test_shell.py index 6f9a53f..93940a7 100644 --- a/cinderclient/tests/v2/test_shell.py +++ b/cinderclient/tests/v2/test_shell.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/tests/v2/test_types.py b/cinderclient/tests/v2/test_types.py index de8c743..70fbaeb 100644 --- a/cinderclient/tests/v2/test_types.py +++ b/cinderclient/tests/v2/test_types.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # diff --git a/cinderclient/tests/v2/test_volumes.py b/cinderclient/tests/v2/test_volumes.py index 73ee4a8..7b85369 100644 --- a/cinderclient/tests/v2/test_volumes.py +++ b/cinderclient/tests/v2/test_volumes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # diff --git a/cinderclient/utils.py b/cinderclient/utils.py index c7e4ebc..0fdcce9 100644 --- a/cinderclient/utils.py +++ b/cinderclient/utils.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v1/__init__.py b/cinderclient/v1/__init__.py index fbb7b00..3637ffd 100644 --- a/cinderclient/v1/__init__.py +++ b/cinderclient/v1/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2012 OpenStack, LLC. +# Copyright (c) 2012 OpenStack Foundation # # All Rights Reserved. # diff --git a/cinderclient/v1/client.py b/cinderclient/v1/client.py index 60376ab..4cbe6d0 100644 --- a/cinderclient/v1/client.py +++ b/cinderclient/v1/client.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v1/contrib/__init__.py b/cinderclient/v1/contrib/__init__.py index dc6c3a3..788cea1 100644 --- a/cinderclient/v1/contrib/__init__.py +++ b/cinderclient/v1/contrib/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v1/contrib/list_extensions.py b/cinderclient/v1/contrib/list_extensions.py index 91fa040..5aab82f 100644 --- a/cinderclient/v1/contrib/list_extensions.py +++ b/cinderclient/v1/contrib/list_extensions.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v1/quota_classes.py b/cinderclient/v1/quota_classes.py index c6a85f4..1f880fb 100644 --- a/cinderclient/v1/quota_classes.py +++ b/cinderclient/v1/quota_classes.py @@ -1,4 +1,4 @@ -# Copyright 2012 OpenStack LLC. +# Copyright (c) 2012 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v1/quotas.py b/cinderclient/v1/quotas.py index bf37462..a0028a9 100644 --- a/cinderclient/v1/quotas.py +++ b/cinderclient/v1/quotas.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v1/services.py b/cinderclient/v1/services.py index b2427dd..6afd5c5 100644 --- a/cinderclient/v1/services.py +++ b/cinderclient/v1/services.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 34b5353..fbd76bd 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -1,6 +1,6 @@ # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/__init__.py b/cinderclient/v2/__init__.py index d09fab5..75afdec 100644 --- a/cinderclient/v2/__init__.py +++ b/cinderclient/v2/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # diff --git a/cinderclient/v2/client.py b/cinderclient/v2/client.py index 2f73ed6..9b8cad9 100644 --- a/cinderclient/v2/client.py +++ b/cinderclient/v2/client.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/contrib/__init__.py b/cinderclient/v2/contrib/__init__.py index 0cd9c14..f2c41f4 100644 --- a/cinderclient/v2/contrib/__init__.py +++ b/cinderclient/v2/contrib/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # diff --git a/cinderclient/v2/contrib/list_extensions.py b/cinderclient/v2/contrib/list_extensions.py index 9031a51..eab9435 100644 --- a/cinderclient/v2/contrib/list_extensions.py +++ b/cinderclient/v2/contrib/list_extensions.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/quota_classes.py b/cinderclient/v2/quota_classes.py index 2d46a6d..4e44914 100644 --- a/cinderclient/v2/quota_classes.py +++ b/cinderclient/v2/quota_classes.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/quotas.py b/cinderclient/v2/quotas.py index 5b19b07..0972210 100644 --- a/cinderclient/v2/quotas.py +++ b/cinderclient/v2/quotas.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/services.py b/cinderclient/v2/services.py index b2427dd..6afd5c5 100644 --- a/cinderclient/v2/services.py +++ b/cinderclient/v2/services.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index f1e45c4..509b5a4 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/volume_snapshots.py b/cinderclient/v2/volume_snapshots.py index 7aa9097..4e16ba8 100644 --- a/cinderclient/v2/volume_snapshots.py +++ b/cinderclient/v2/volume_snapshots.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may diff --git a/cinderclient/v2/volume_types.py b/cinderclient/v2/volume_types.py index 9d4c2ff..bc382bd 100644 --- a/cinderclient/v2/volume_types.py +++ b/cinderclient/v2/volume_types.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/cinderclient/v2/volumes.py b/cinderclient/v2/volumes.py index 2ec885d..e948cff 100644 --- a/cinderclient/v2/volumes.py +++ b/cinderclient/v2/volumes.py @@ -1,4 +1,4 @@ -# Copyright 2013 OpenStack LLC. +# Copyright (c) 2013 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may From 7b93e66bf0b652b7bc51e329663a31adbd54f7b0 Mon Sep 17 00:00:00 2001 From: Avishay Traeger Date: Sun, 29 Sep 2013 12:16:19 +0300 Subject: [PATCH 21/24] Fix find volume for migrate command Recently _find_volume was removed, but not fixed for migrate. Change-Id: I72b1b169bc67f89de10b7e729fc461b9114d3789 Closes-Bug: #1231117 --- cinderclient/tests/v1/test_shell.py | 6 ++++++ cinderclient/tests/v2/test_shell.py | 6 ++++++ cinderclient/v1/shell.py | 4 +++- cinderclient/v2/shell.py | 3 ++- 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/cinderclient/tests/v1/test_shell.py b/cinderclient/tests/v1/test_shell.py index 014df94..82cdfae 100644 --- a/cinderclient/tests/v1/test_shell.py +++ b/cinderclient/tests/v1/test_shell.py @@ -260,3 +260,9 @@ class ShellTest(utils.TestCase): Test encryption-type-delete shell command. """ self.skipTest("Not implemented") + + def test_migrate_volume(self): + self.run_command('migrate 1234 fakehost --force-host-copy=True') + expected = {'os-migrate_volume': {'force_host_copy': 'True', + 'host': 'fakehost'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) diff --git a/cinderclient/tests/v2/test_shell.py b/cinderclient/tests/v2/test_shell.py index 6f9a53f..bb50206 100644 --- a/cinderclient/tests/v2/test_shell.py +++ b/cinderclient/tests/v2/test_shell.py @@ -238,3 +238,9 @@ class ShellTest(utils.TestCase): Test encryption-type-delete shell command. """ self.skipTest("Not implemented") + + def test_migrate_volume(self): + self.run_command('migrate 1234 fakehost --force-host-copy=True') + expected = {'os-migrate_volume': {'force_host_copy': 'True', + 'host': 'fakehost'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index 34b5353..0cb3a71 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -1059,6 +1059,7 @@ def do_encryption_type_create(cs, args): @utils.arg('volume', metavar='', help='ID of the volume to migrate') @utils.arg('host', metavar='', help='Destination host') @utils.arg('--force-host-copy', metavar='', + choices=['True', 'False'], required=False, help='Optional flag to force the use of the generic ' 'host-based migration mechanism, bypassing driver ' 'optimizations (Default=False).', @@ -1066,5 +1067,6 @@ def do_encryption_type_create(cs, args): @utils.service_type('volume') def do_migrate(cs, args): """Migrate the volume to the new host.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) + volume.migrate_volume(args.host, args.force_host_copy) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index f1e45c4..bef4964 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -801,6 +801,7 @@ def do_upload_to_image(cs, args): @utils.arg('volume', metavar='', help='ID of the volume to migrate') @utils.arg('host', metavar='', help='Destination host') @utils.arg('--force-host-copy', metavar='', + choices=['True', 'False'], required=False, help='Optional flag to force the use of the generic ' 'host-based migration mechanism, bypassing driver ' 'optimizations (Default=False).', @@ -808,7 +809,7 @@ def do_upload_to_image(cs, args): @utils.service_type('volume') def do_migrate(cs, args): """Migrate the volume to the new host.""" - volume = _find_volume(cs, args.volume) + volume = utils.find_volume(cs, args.volume) volume.migrate_volume(args.host, args.force_host_copy) From 87628cc4852ba49e4dd300091b4e5494d4507714 Mon Sep 17 00:00:00 2001 From: Zhiteng Huang Date: Thu, 5 Sep 2013 12:02:59 +0800 Subject: [PATCH 22/24] Implement qos support This patch enables Cinder v1/v2 QoS API support, adding following subcommands to cinder client: * create QoS Specs cinder qos-create [ ...] * delete QoS Specs cinder qos-delete [--force ] 'force' is a flag indicates whether to delete a 'in-use' qos specs, which is still associated with other entities (e.g. volume types). * update QoS Specs - add new key/value pairs or update existing key/value cinder qos-key set key=value [key=value ...] - delete key/value pairs cinder qos-key unset key [key ...] * associate QoS Specs with specified volume type cinder qos-associate * disassociate QoS Specs from specified volume type cinder qos-disassociate * disassociate QoS Specs from all associated volume types cinder qos-disassociate-all * query entities that are associated with specified QoS Specs cinder qos-get-associations * list all QoS Specs cinder qos-list DocImpact Change-Id: Ie1ddd29fede8660b475bac14c4fc35496d5fe0bc --- cinderclient/tests/v1/fakes.py | 90 ++++++++++++++++++ cinderclient/tests/v1/test_qos.py | 79 ++++++++++++++++ cinderclient/tests/v2/fakes.py | 91 ++++++++++++++++++ cinderclient/tests/v2/test_qos.py | 79 ++++++++++++++++ cinderclient/v1/client.py | 2 + cinderclient/v1/qos_specs.py | 149 ++++++++++++++++++++++++++++++ cinderclient/v1/shell.py | 128 +++++++++++++++++++++++++ cinderclient/v2/client.py | 2 + cinderclient/v2/qos_specs.py | 149 ++++++++++++++++++++++++++++++ cinderclient/v2/shell.py | 134 ++++++++++++++++++++++++++- 10 files changed, 899 insertions(+), 4 deletions(-) create mode 100644 cinderclient/tests/v1/test_qos.py create mode 100644 cinderclient/tests/v2/test_qos.py create mode 100644 cinderclient/v1/qos_specs.py create mode 100644 cinderclient/v2/qos_specs.py diff --git a/cinderclient/tests/v1/fakes.py b/cinderclient/tests/v1/fakes.py index 2c052c6..87c9b39 100644 --- a/cinderclient/tests/v1/fakes.py +++ b/cinderclient/tests/v1/fakes.py @@ -116,6 +116,34 @@ def _stub_restore(): return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} +def _stub_qos_full(id, base_uri, tenant_id, name=None, specs=None): + if not name: + name = 'fake-name' + if not specs: + specs = {} + + return { + 'qos_specs': { + 'id': id, + 'name': name, + 'consumer': 'back-end', + 'specs': specs, + }, + 'links': { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + } + + +def _stub_qos_associates(id, name): + return { + 'assoications_type': 'volume_type', + 'name': name, + 'id': id, + } + + def _stub_transfer_full(id, base_uri, tenant_id): return { 'id': id, @@ -505,6 +533,68 @@ class FakeHTTPClient(base_client.HTTPClient): return (200, {}, {'restore': _stub_restore()}) + # + # QoSSpecs + # + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id1 = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + return (200, {}, + _stub_qos_full(qos_id1, base_uri, tenant_id)) + + def get_qos_specs(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id1 = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + qos_id2 = '0FD8DD14-A396-4E55-9573-1FE59042E95B' + return (200, {}, + {'qos_specs': [ + _stub_qos_full(qos_id1, base_uri, tenant_id, 'name-1'), + _stub_qos_full(qos_id2, base_uri, tenant_id)]}) + + def post_qos_specs(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + qos_name = 'qos-name' + return (202, {}, + _stub_qos_full(qos_id, base_uri, tenant_id, qos_name)) + + def put_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + return (202, {}, None) + + def put_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_delete_keys( + self, **kw): + return (202, {}, None) + + def delete_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_associations( + self, **kw): + type_id1 = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + type_id2 = '4230B13A-AB37-4E84-B777-EFBA6FCEE4FF' + type_name1 = 'type1' + type_name2 = 'type2' + return (202, {}, + {'qos_associations': [ + _stub_qos_associates(type_id1, type_name1), + _stub_qos_associates(type_id2, type_name2)]}) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_associate( + self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate( + self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate_all( + self, **kw): + return (202, {}, None) + # # VolumeTransfers # diff --git a/cinderclient/tests/v1/test_qos.py b/cinderclient/tests/v1/test_qos.py new file mode 100644 index 0000000..e127a47 --- /dev/null +++ b/cinderclient/tests/v1/test_qos.py @@ -0,0 +1,79 @@ +# Copyright (C) 2013 eBay 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. + +from cinderclient.tests import utils +from cinderclient.tests.v1 import fakes + + +cs = fakes.FakeClient() + + +class QoSSpecsTest(utils.TestCase): + + def test_create(self): + specs = dict(k1='v1', k2='v2') + cs.qos_specs.create('qos-name', specs) + cs.assert_called('POST', '/qos-specs') + + def test_get(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.get(qos_id) + cs.assert_called('GET', '/qos-specs/%s' % qos_id) + + def test_list(self): + cs.qos_specs.list() + cs.assert_called('GET', '/qos-specs') + + def test_delete(self): + cs.qos_specs.delete('1B6B6A04-A927-4AEB-810B-B7BAAD49F57C') + cs.assert_called('DELETE', + '/qos-specs/1B6B6A04-A927-4AEB-810B-B7BAAD49F57C?' + 'force=False') + + def test_set_keys(self): + body = {'qos_specs': dict(k1='v1')} + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.set_keys(qos_id, body) + cs.assert_called('PUT', '/qos-specs/%s' % qos_id) + + def test_unset_keys(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + body = {'keys': ['k1']} + cs.qos_specs.unset_keys(qos_id, body) + cs.assert_called('PUT', '/qos-specs/%s/delete_keys' % qos_id) + + def test_get_associations(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.get_associations(qos_id) + cs.assert_called('GET', '/qos-specs/%s/associations' % qos_id) + + def test_associate(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + type_id = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + cs.qos_specs.associate(qos_id, type_id) + cs.assert_called('GET', '/qos-specs/%s/associate?vol_type_id=%s' + % (qos_id, type_id)) + + def test_disassociate(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + type_id = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + cs.qos_specs.disassociate(qos_id, type_id) + cs.assert_called('GET', '/qos-specs/%s/disassociate?vol_type_id=%s' + % (qos_id, type_id)) + + def test_disassociate_all(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.disassociate_all(qos_id) + cs.assert_called('GET', '/qos-specs/%s/disassociate_all' % qos_id) diff --git a/cinderclient/tests/v2/fakes.py b/cinderclient/tests/v2/fakes.py index 88a94bb..d057456 100644 --- a/cinderclient/tests/v2/fakes.py +++ b/cinderclient/tests/v2/fakes.py @@ -119,6 +119,34 @@ def _stub_backup(id, base_uri, tenant_id): } +def _stub_qos_full(id, base_uri, tenant_id, name=None, specs=None): + if not name: + name = 'fake-name' + if not specs: + specs = {} + + return { + 'qos_specs': { + 'id': id, + 'name': name, + 'consumer': 'back-end', + 'specs': specs, + }, + 'links': { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + } + + +def _stub_qos_associates(id, name): + return { + 'assoications_type': 'volume_type', + 'name': name, + 'id': id, + } + + def _stub_restore(): return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} @@ -512,6 +540,69 @@ class FakeHTTPClient(base_client.HTTPClient): return (200, {}, {'restore': _stub_restore()}) + # + # QoSSpecs + # + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id1 = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + return (200, {}, + _stub_qos_full(qos_id1, base_uri, tenant_id)) + + def get_qos_specs(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id1 = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + qos_id2 = '0FD8DD14-A396-4E55-9573-1FE59042E95B' + return (200, {}, + {'qos_specs': [ + _stub_qos_full(qos_id1, base_uri, tenant_id, 'name-1'), + _stub_qos_full(qos_id2, base_uri, tenant_id)]}) + + def post_qos_specs(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + qos_name = 'qos-name' + return (202, {}, + _stub_qos_full(qos_id, base_uri, tenant_id, qos_name)) + + def put_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + return (202, {}, None) + + def put_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_delete_keys( + self, **kw): + return (202, {}, None) + + def delete_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C(self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_associations( + self, **kw): + type_id1 = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + type_id2 = '4230B13A-AB37-4E84-B777-EFBA6FCEE4FF' + type_name1 = 'type1' + type_name2 = 'type2' + return (202, {}, + {'qos_associations': [ + _stub_qos_associates(type_id1, type_name1), + _stub_qos_associates(type_id2, type_name2)]}) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_associate( + self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate( + self, **kw): + return (202, {}, None) + + def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate_all( + self, **kw): + return (202, {}, None) + + # # # VolumeTransfers # diff --git a/cinderclient/tests/v2/test_qos.py b/cinderclient/tests/v2/test_qos.py new file mode 100644 index 0000000..3f3e6cf --- /dev/null +++ b/cinderclient/tests/v2/test_qos.py @@ -0,0 +1,79 @@ +# Copyright (C) 2013 eBay 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. + +from cinderclient.tests import utils +from cinderclient.tests.v2 import fakes + + +cs = fakes.FakeClient() + + +class QoSSpecsTest(utils.TestCase): + + def test_create(self): + specs = dict(k1='v1', k2='v2') + cs.qos_specs.create('qos-name', specs) + cs.assert_called('POST', '/qos-specs') + + def test_get(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.get(qos_id) + cs.assert_called('GET', '/qos-specs/%s' % qos_id) + + def test_list(self): + cs.qos_specs.list() + cs.assert_called('GET', '/qos-specs') + + def test_delete(self): + cs.qos_specs.delete('1B6B6A04-A927-4AEB-810B-B7BAAD49F57C') + cs.assert_called('DELETE', + '/qos-specs/1B6B6A04-A927-4AEB-810B-B7BAAD49F57C?' + 'force=False') + + def test_set_keys(self): + body = {'qos_specs': dict(k1='v1')} + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.set_keys(qos_id, body) + cs.assert_called('PUT', '/qos-specs/%s' % qos_id) + + def test_unset_keys(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + body = {'keys': ['k1']} + cs.qos_specs.unset_keys(qos_id, body) + cs.assert_called('PUT', '/qos-specs/%s/delete_keys' % qos_id) + + def test_get_associations(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.get_associations(qos_id) + cs.assert_called('GET', '/qos-specs/%s/associations' % qos_id) + + def test_associate(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + type_id = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + cs.qos_specs.associate(qos_id, type_id) + cs.assert_called('GET', '/qos-specs/%s/associate?vol_type_id=%s' + % (qos_id, type_id)) + + def test_disassociate(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + type_id = '4230B13A-7A37-4E84-B777-EFBA6FCEE4FF' + cs.qos_specs.disassociate(qos_id, type_id) + cs.assert_called('GET', '/qos-specs/%s/disassociate?vol_type_id=%s' + % (qos_id, type_id)) + + def test_disassociate_all(self): + qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' + cs.qos_specs.disassociate_all(qos_id) + cs.assert_called('GET', '/qos-specs/%s/disassociate_all' % qos_id) diff --git a/cinderclient/v1/client.py b/cinderclient/v1/client.py index 4cbe6d0..82d1ee0 100644 --- a/cinderclient/v1/client.py +++ b/cinderclient/v1/client.py @@ -16,6 +16,7 @@ from cinderclient import client from cinderclient.v1 import availability_zones from cinderclient.v1 import limits +from cinderclient.v1 import qos_specs from cinderclient.v1 import quota_classes from cinderclient.v1 import quotas from cinderclient.v1 import services @@ -62,6 +63,7 @@ class Client(object): self.volume_types = volume_types.VolumeTypeManager(self) self.volume_encryption_types = \ volume_encryption_types.VolumeEncryptionTypeManager(self) + self.qos_specs = qos_specs.QoSSpecsManager(self) self.quota_classes = quota_classes.QuotaClassSetManager(self) self.quotas = quotas.QuotaSetManager(self) self.backups = volume_backups.VolumeBackupManager(self) diff --git a/cinderclient/v1/qos_specs.py b/cinderclient/v1/qos_specs.py new file mode 100644 index 0000000..b4e4272 --- /dev/null +++ b/cinderclient/v1/qos_specs.py @@ -0,0 +1,149 @@ +# Copyright (c) 2013 eBay Inc. +# Copyright (c) OpenStack LLC. +# +# 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. + + +""" +QoS Specs interface. +""" + +from cinderclient import base + + +class QoSSpecs(base.Resource): + """QoS specs entity represents quality-of-service parameters/requirements. + + A QoS specs is a set of parameters or requirements for quality-of-service + purpose, which can be associated with volume types (for now). In future, + QoS specs may be extended to be associated other entities, such as single + volume. + """ + def __repr__(self): + return "" % self.name + + def delete(self): + return self.manager.delete(self) + + +class QoSSpecsManager(base.ManagerWithFind): + """ + Manage :class:`QoSSpecs` resources. + """ + resource_class = QoSSpecs + + def list(self): + """Get a list of all qos specs. + + :rtype: list of :class:`QoSSpecs`. + """ + return self._list("/qos-specs", "qos_specs") + + def get(self, qos_specs): + """Get a specific qos specs. + + :param qos_specs: The ID of the :class:`QoSSpecs` to get. + :rtype: :class:`QoSSpecs` + """ + return self._get("/qos-specs/%s" % base.getid(qos_specs), "qos_specs") + + def delete(self, qos_specs, force=False): + """Delete a specific qos specs. + + :param qos_specs: The ID of the :class:`QoSSpecs` to be removed. + :param force: Flag that indicates whether to delete target qos specs + if it was in-use. + """ + self._delete("/qos-specs/%s?force=%s" % + (base.getid(qos_specs), force)) + + def create(self, name, specs): + """Create a qos specs. + + :param name: Descriptive name of the qos specs, must be unique + :param specs: A dict of key/value pairs to be set + :rtype: :class:`QoSSpecs` + """ + + body = { + "qos_specs": { + "name": name, + } + } + + body["qos_specs"].update(specs) + return self._create("/qos-specs", body, "qos_specs") + + def set_keys(self, qos_specs, specs): + """Update a qos specs with new specifications. + + :param qos_specs: The ID of qos specs + :param specs: A dict of key/value pairs to be set + :rtype: :class:`QoSSpecs` + """ + + body = { + "qos_specs": {} + } + + body["qos_specs"].update(specs) + return self._update("/qos-specs/%s" % qos_specs, body) + + def unset_keys(self, qos_specs, specs): + """Update a qos specs with new specifications. + + :param qos_specs: The ID of qos specs + :param specs: A list of key to be unset + :rtype: :class:`QoSSpecs` + """ + + body = {'keys': specs} + + return self._update("/qos-specs/%s/delete_keys" % qos_specs, + body) + + def get_associations(self, qos_specs): + """Get associated entities of a qos specs. + + :param qos_specs: The id of the :class: `QoSSpecs` + :return: a list of entities that associated with specific qos specs. + """ + return self._list("/qos-specs/%s/associations" % base.getid(qos_specs), + "qos_associations") + + def associate(self, qos_specs, vol_type_id): + """Associate a volume type with specific qos specs. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + self.api.client.get("/qos-specs/%s/associate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + + def disassociate(self, qos_specs, vol_type_id): + """Disassociate qos specs from volume type. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + self.api.client.get("/qos-specs/%s/disassociate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + + def disassociate_all(self, qos_specs): + """Disassociate all entities from specific qos specs. + + :param qos_specs: The qos specs to be associated with + """ + self.api.client.get("/qos-specs/%s/disassociate_all" % + base.getid(qos_specs)) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py index fbd76bd..afb5f85 100644 --- a/cinderclient/v1/shell.py +++ b/cinderclient/v1/shell.py @@ -24,6 +24,7 @@ import sys import time from cinderclient import exceptions +from cinderclient.openstack.common import strutils from cinderclient import utils from cinderclient.v1 import availability_zones @@ -75,6 +76,11 @@ def _find_transfer(cs, transfer): return utils.find_resource(cs.transfers, transfer) +def _find_qos_specs(cs, qos_specs): + """Get a qos specs by ID.""" + return utils.find_resource(cs.qos_specs, qos_specs) + + def _print_volume(volume): utils.print_dict(volume._info) @@ -1068,3 +1074,125 @@ def do_migrate(cs, args): """Migrate the volume to the new host.""" volume = _find_volume(cs, args.volume) volume.migrate_volume(args.host, args.force_host_copy) + + +def _print_qos_specs(qos_specs): + utils.print_dict(qos_specs._info) + + +def _print_qos_specs_list(q_specs): + utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def _print_qos_specs_and_associations_list(q_specs): + utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def _print_associations_list(associations): + utils.print_list(associations, ['Association_Type', 'Name', 'ID']) + + +@utils.arg('name', + metavar='', + help="Name of the new QoS specs") +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Specifications for QoS') +@utils.service_type('volume') +def do_qos_create(cs, args): + """Create a new qos specs.""" + keypair = None + if args.metadata is not None: + keypair = _extract_metadata(args) + qos_specs = cs.qos_specs.create(args.name, keypair) + _print_qos_specs(qos_specs) + + +@utils.service_type('volume') +def do_qos_list(cs, args): + """Get full list of qos specs.""" + qos_specs = cs.qos_specs.list() + _print_qos_specs_list(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs to show.') +@utils.service_type('volume') +def do_qos_show(cs, args): + """Get a specific qos specs.""" + qos_specs = _find_qos_specs(cs, args.qos_specs) + _print_qos_specs(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs to delete.') +@utils.arg('--force', + metavar='', + default=False, + help='Optional flag that indicates whether to delete ' + 'specified qos specs even if it is in-use.') +@utils.service_type('volume') +def do_qos_delete(cs, args): + """Delete a specific qos specs.""" + force = strutils.bool_from_string(args.force) + qos_specs = _find_qos_specs(cs, args.qos_specs) + cs.qos_specs.delete(qos_specs, force) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type to be associated with.') +@utils.service_type('volume') +def do_qos_associate(cs, args): + """Associate qos specs with specific volume type.""" + cs.qos_specs.associate(args.qos_specs, args.vol_type_id) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type to be associated with.') +@utils.service_type('volume') +def do_qos_disassociate(cs, args): + """Disassociate qos specs from specific volume type.""" + cs.qos_specs.disassociate(args.qos_specs, args.vol_type_id) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs to be operate on.') +@utils.service_type('volume') +def do_qos_disassociate_all(cs, args): + """Disassociate qos specs from all of its associations.""" + cs.qos_specs.disassociate_all(args.qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos specs') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help="Actions: 'set' or 'unset'") +@utils.arg('metadata', metavar='key=value', + nargs='+', + default=[], + help='QoS specs to set/unset (only key is necessary on unset)') +def do_qos_key(cs, args): + """Set or unset specifications for a qos spec.""" + keypair = _extract_metadata(args) + + if args.action == 'set': + cs.qos_specs.set_keys(args.qos_specs, keypair) + elif args.action == 'unset': + cs.qos_specs.unset_keys(args.qos_specs, list(keypair.keys())) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs.') +@utils.service_type('volume') +def do_qos_get_association(cs, args): + """Get all associations of specific qos specs.""" + associations = cs.qos_specs.get_associations(args.qos_specs) + _print_associations_list(associations) diff --git a/cinderclient/v2/client.py b/cinderclient/v2/client.py index 9b8cad9..7b91c23 100644 --- a/cinderclient/v2/client.py +++ b/cinderclient/v2/client.py @@ -16,6 +16,7 @@ from cinderclient import client from cinderclient.v1 import availability_zones from cinderclient.v2 import limits +from cinderclient.v2 import qos_specs from cinderclient.v2 import quota_classes from cinderclient.v2 import quotas from cinderclient.v2 import services @@ -60,6 +61,7 @@ class Client(object): self.volume_types = volume_types.VolumeTypeManager(self) self.volume_encryption_types = \ volume_encryption_types.VolumeEncryptionTypeManager(self) + self.qos_specs = qos_specs.QoSSpecsManager(self) self.quota_classes = quota_classes.QuotaClassSetManager(self) self.quotas = quotas.QuotaSetManager(self) self.backups = volume_backups.VolumeBackupManager(self) diff --git a/cinderclient/v2/qos_specs.py b/cinderclient/v2/qos_specs.py new file mode 100644 index 0000000..b4e4272 --- /dev/null +++ b/cinderclient/v2/qos_specs.py @@ -0,0 +1,149 @@ +# Copyright (c) 2013 eBay Inc. +# Copyright (c) OpenStack LLC. +# +# 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. + + +""" +QoS Specs interface. +""" + +from cinderclient import base + + +class QoSSpecs(base.Resource): + """QoS specs entity represents quality-of-service parameters/requirements. + + A QoS specs is a set of parameters or requirements for quality-of-service + purpose, which can be associated with volume types (for now). In future, + QoS specs may be extended to be associated other entities, such as single + volume. + """ + def __repr__(self): + return "" % self.name + + def delete(self): + return self.manager.delete(self) + + +class QoSSpecsManager(base.ManagerWithFind): + """ + Manage :class:`QoSSpecs` resources. + """ + resource_class = QoSSpecs + + def list(self): + """Get a list of all qos specs. + + :rtype: list of :class:`QoSSpecs`. + """ + return self._list("/qos-specs", "qos_specs") + + def get(self, qos_specs): + """Get a specific qos specs. + + :param qos_specs: The ID of the :class:`QoSSpecs` to get. + :rtype: :class:`QoSSpecs` + """ + return self._get("/qos-specs/%s" % base.getid(qos_specs), "qos_specs") + + def delete(self, qos_specs, force=False): + """Delete a specific qos specs. + + :param qos_specs: The ID of the :class:`QoSSpecs` to be removed. + :param force: Flag that indicates whether to delete target qos specs + if it was in-use. + """ + self._delete("/qos-specs/%s?force=%s" % + (base.getid(qos_specs), force)) + + def create(self, name, specs): + """Create a qos specs. + + :param name: Descriptive name of the qos specs, must be unique + :param specs: A dict of key/value pairs to be set + :rtype: :class:`QoSSpecs` + """ + + body = { + "qos_specs": { + "name": name, + } + } + + body["qos_specs"].update(specs) + return self._create("/qos-specs", body, "qos_specs") + + def set_keys(self, qos_specs, specs): + """Update a qos specs with new specifications. + + :param qos_specs: The ID of qos specs + :param specs: A dict of key/value pairs to be set + :rtype: :class:`QoSSpecs` + """ + + body = { + "qos_specs": {} + } + + body["qos_specs"].update(specs) + return self._update("/qos-specs/%s" % qos_specs, body) + + def unset_keys(self, qos_specs, specs): + """Update a qos specs with new specifications. + + :param qos_specs: The ID of qos specs + :param specs: A list of key to be unset + :rtype: :class:`QoSSpecs` + """ + + body = {'keys': specs} + + return self._update("/qos-specs/%s/delete_keys" % qos_specs, + body) + + def get_associations(self, qos_specs): + """Get associated entities of a qos specs. + + :param qos_specs: The id of the :class: `QoSSpecs` + :return: a list of entities that associated with specific qos specs. + """ + return self._list("/qos-specs/%s/associations" % base.getid(qos_specs), + "qos_associations") + + def associate(self, qos_specs, vol_type_id): + """Associate a volume type with specific qos specs. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + self.api.client.get("/qos-specs/%s/associate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + + def disassociate(self, qos_specs, vol_type_id): + """Disassociate qos specs from volume type. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + self.api.client.get("/qos-specs/%s/disassociate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + + def disassociate_all(self, qos_specs): + """Disassociate all entities from specific qos specs. + + :param qos_specs: The qos specs to be associated with + """ + self.api.client.get("/qos-specs/%s/disassociate_all" % + base.getid(qos_specs)) diff --git a/cinderclient/v2/shell.py b/cinderclient/v2/shell.py index 509b5a4..c1eb0c4 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v2/shell.py @@ -25,6 +25,7 @@ import six from cinderclient import exceptions from cinderclient import utils +from cinderclient.openstack.common import strutils from cinderclient.v2 import availability_zones @@ -73,6 +74,11 @@ def _find_transfer(cs, transfer): return utils.find_resource(cs.transfers, transfer) +def _find_qos_specs(cs, qos_specs): + """Get a qos specs by ID.""" + return utils.find_resource(cs.qos_specs, qos_specs) + + def _print_volume_snapshot(snapshot): utils.print_dict(snapshot._info) @@ -106,7 +112,7 @@ def _translate_availability_zone_keys(collection): def _extract_metadata(args): metadata = {} - for metadatum in args.metadata[0]: + for metadatum in args.metadata: # unset doesn't require a val, so we have the if/else if '=' in metadatum: (key, value) = metadatum.split('=', 1) @@ -369,7 +375,6 @@ def do_rename(cs, args): @utils.arg('metadata', metavar='', nargs='+', - action='append', default=[], help='Metadata to set/unset (only key is necessary on unset)') @utils.service_type('volumev2') @@ -592,12 +597,11 @@ def do_type_delete(cs, args): @utils.arg('metadata', metavar='', nargs='+', - action='append', default=[], help='Extra_specs to set/unset (only key is necessary on unset)') @utils.service_type('volumev2') def do_type_key(cs, args): - "Set or unset extra_spec for a volume type.""" + """Set or unset extra_spec for a volume type.""" vtype = _find_volume_type(cs, args.vtype) keypair = _extract_metadata(args) @@ -1148,3 +1152,125 @@ def do_encryption_type_create(cs, args): result = cs.volume_encryption_types.create(volume_type, body) _print_volume_encryption_type_list([result]) + + +def _print_qos_specs(qos_specs): + utils.print_dict(qos_specs._info) + + +def _print_qos_specs_list(q_specs): + utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def _print_qos_specs_and_associations_list(q_specs): + utils.print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def _print_associations_list(associations): + utils.print_list(associations, ['Association_Type', 'Name', 'ID']) + + +@utils.arg('name', + metavar='', + help="Name of the new QoS specs") +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Specifications for QoS') +@utils.service_type('volumev2') +def do_qos_create(cs, args): + """Create a new qos specs.""" + keypair = None + if args.metadata is not None: + keypair = _extract_metadata(args) + qos_specs = cs.qos_specs.create(args.name, keypair) + _print_qos_specs(qos_specs) + + +@utils.service_type('volumev2') +def do_qos_list(cs, args): + """Get full list of qos specs.""" + qos_specs = cs.qos_specs.list() + _print_qos_specs_list(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs to show.') +@utils.service_type('volumev2') +def do_qos_show(cs, args): + """Get a specific qos specs.""" + qos_specs = _find_qos_specs(cs, args.qos_specs) + _print_qos_specs(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs to delete.') +@utils.arg('--force', + metavar='', + default=False, + help='Optional flag that indicates whether to delete ' + 'specified qos specs even if it is in-use.') +@utils.service_type('volumev2') +def do_qos_delete(cs, args): + """Delete a specific qos specs.""" + force = strutils.bool_from_string(args.force) + qos_specs = _find_qos_specs(cs, args.qos_specs) + cs.qos_specs.delete(qos_specs, force) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type to be associated with.') +@utils.service_type('volumev2') +def do_qos_associate(cs, args): + """Associate qos specs with specific volume type.""" + cs.qos_specs.associate(args.qos_specs, args.vol_type_id) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type to be associated with.') +@utils.service_type('volumev2') +def do_qos_disassociate(cs, args): + """Disassociate qos specs from specific volume type.""" + cs.qos_specs.disassociate(args.qos_specs, args.vol_type_id) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos_specs to be operate on.') +@utils.service_type('volumev2') +def do_qos_disassociate_all(cs, args): + """Disassociate qos specs from all of its associations.""" + cs.qos_specs.disassociate_all(args.qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of qos specs') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help="Actions: 'set' or 'unset'") +@utils.arg('metadata', metavar='key=value', + nargs='+', + default=[], + help='QoS specs to set/unset (only key is necessary on unset)') +def do_qos_key(cs, args): + """Set or unset specifications for a qos spec.""" + keypair = _extract_metadata(args) + + if args.action == 'set': + cs.qos_specs.set_keys(args.qos_specs, keypair) + elif args.action == 'unset': + cs.qos_specs.unset_keys(args.qos_specs, list(keypair.keys())) + + +@utils.arg('qos_specs', metavar='', + help='ID of the qos_specs.') +@utils.service_type('volumev2') +def do_qos_get_association(cs, args): + """Get all associations of specific qos specs.""" + associations = cs.qos_specs.get_associations(args.qos_specs) + _print_associations_list(associations) From 945b211cd0bab1636168d2227da67eb37c8505af Mon Sep 17 00:00:00 2001 From: John Griffith Date: Wed, 2 Oct 2013 14:50:50 -0600 Subject: [PATCH 23/24] Synch up with OSLO-Incubator Wanted to get updates before next push to pypi. The main thing driving this is we're now calling in some methods from strutils and gettextutils that don't have the py33 updates. Change-Id: I358f08f5c5c0a9ee6729947a8f01b1e96de0a729 --- .../openstack/common/apiclient/base.py | 2 +- .../openstack/common/apiclient/client.py | 2 +- cinderclient/openstack/common/gettextutils.py | 106 ++++++++++++++---- tools/install_venv_common.py | 20 ++-- 4 files changed, 95 insertions(+), 35 deletions(-) diff --git a/cinderclient/openstack/common/apiclient/base.py b/cinderclient/openstack/common/apiclient/base.py index 1b3e790..caef843 100644 --- a/cinderclient/openstack/common/apiclient/base.py +++ b/cinderclient/openstack/common/apiclient/base.py @@ -1,7 +1,7 @@ # vim: tabstop=4 shiftwidth=4 softtabstop=4 # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC +# Copyright 2011 OpenStack Foundation # Copyright 2012 Grid Dynamics # Copyright 2013 OpenStack Foundation # All Rights Reserved. diff --git a/cinderclient/openstack/common/apiclient/client.py b/cinderclient/openstack/common/apiclient/client.py index 85837da..77d4579 100644 --- a/cinderclient/openstack/common/apiclient/client.py +++ b/cinderclient/openstack/common/apiclient/client.py @@ -1,7 +1,7 @@ # vim: tabstop=4 shiftwidth=4 softtabstop=4 # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC +# Copyright 2011 OpenStack Foundation # Copyright 2011 Piston Cloud Computing, Inc. # Copyright 2013 Alessio Ababilov # Copyright 2013 Grid Dynamics diff --git a/cinderclient/openstack/common/gettextutils.py b/cinderclient/openstack/common/gettextutils.py index e887869..ce57f89 100644 --- a/cinderclient/openstack/common/gettextutils.py +++ b/cinderclient/openstack/common/gettextutils.py @@ -26,10 +26,13 @@ Usual usage in an openstack.common module: import copy import gettext -import logging.handlers +import logging import os import re -import UserString +try: + import UserString as _userString +except ImportError: + import collections as _userString from babel import localedata import six @@ -37,11 +40,29 @@ import six _localedir = os.environ.get('cinderclient'.upper() + '_LOCALEDIR') _t = gettext.translation('cinderclient', localedir=_localedir, fallback=True) -_AVAILABLE_LANGUAGES = [] +_AVAILABLE_LANGUAGES = {} +USE_LAZY = False + + +def enable_lazy(): + """Convenience function for configuring _() to use lazy gettext + + Call this at the start of execution to enable the gettextutils._ + function to use lazy gettext functionality. This is useful if + your project is importing _ directly instead of using the + gettextutils.install() way of importing the _ function. + """ + global USE_LAZY + USE_LAZY = True def _(msg): - return _t.ugettext(msg) + if USE_LAZY: + return Message(msg, 'cinderclient') + else: + if six.PY3: + return _t.gettext(msg) + return _t.ugettext(msg) def install(domain, lazy=False): @@ -86,24 +107,28 @@ def install(domain, lazy=False): """ return Message(msg, domain) - import __builtin__ - __builtin__.__dict__['_'] = _lazy_gettext + from six import moves + moves.builtins.__dict__['_'] = _lazy_gettext else: localedir = '%s_LOCALEDIR' % domain.upper() - gettext.install(domain, - localedir=os.environ.get(localedir), - unicode=True) + if six.PY3: + gettext.install(domain, + localedir=os.environ.get(localedir)) + else: + gettext.install(domain, + localedir=os.environ.get(localedir), + unicode=True) -class Message(UserString.UserString, object): +class Message(_userString.UserString, object): """Class used to encapsulate translatable messages.""" def __init__(self, msg, domain): # _msg is the gettext msgid and should never change self._msg = msg self._left_extra_msg = '' self._right_extra_msg = '' + self._locale = None self.params = None - self.locale = None self.domain = domain @property @@ -123,8 +148,13 @@ class Message(UserString.UserString, object): localedir=localedir, fallback=True) + if six.PY3: + ugettext = lang.gettext + else: + ugettext = lang.ugettext + full_msg = (self._left_extra_msg + - lang.ugettext(self._msg) + + ugettext(self._msg) + self._right_extra_msg) if self.params is not None: @@ -132,6 +162,33 @@ class Message(UserString.UserString, object): return six.text_type(full_msg) + @property + def locale(self): + return self._locale + + @locale.setter + def locale(self, value): + self._locale = value + if not self.params: + return + + # This Message object may have been constructed with one or more + # Message objects as substitution parameters, given as a single + # Message, or a tuple or Map containing some, so when setting the + # locale for this Message we need to set it for those Messages too. + if isinstance(self.params, Message): + self.params.locale = value + return + if isinstance(self.params, tuple): + for param in self.params: + if isinstance(param, Message): + param.locale = value + return + if isinstance(self.params, dict): + for param in self.params.values(): + if isinstance(param, Message): + param.locale = value + def _save_dictionary_parameter(self, dict_param): full_msg = self.data # look for %(blah) fields in string; @@ -150,7 +207,7 @@ class Message(UserString.UserString, object): params[key] = copy.deepcopy(dict_param[key]) except TypeError: # cast uncopyable thing to unicode string - params[key] = unicode(dict_param[key]) + params[key] = six.text_type(dict_param[key]) return params @@ -169,7 +226,7 @@ class Message(UserString.UserString, object): try: self.params = copy.deepcopy(other) except TypeError: - self.params = unicode(other) + self.params = six.text_type(other) return self @@ -178,11 +235,13 @@ class Message(UserString.UserString, object): return self.data def __str__(self): + if six.PY3: + return self.__unicode__() return self.data.encode('utf-8') def __getstate__(self): to_copy = ['_msg', '_right_extra_msg', '_left_extra_msg', - 'domain', 'params', 'locale'] + 'domain', 'params', '_locale'] new_dict = self.__dict__.fromkeys(to_copy) for attr in to_copy: new_dict[attr] = copy.deepcopy(self.__dict__[attr]) @@ -236,7 +295,7 @@ class Message(UserString.UserString, object): if name in ops: return getattr(self.data, name) else: - return UserString.UserString.__getattribute__(self, name) + return _userString.UserString.__getattribute__(self, name) def get_available_languages(domain): @@ -244,8 +303,8 @@ def get_available_languages(domain): :param domain: the domain to get languages for """ - if _AVAILABLE_LANGUAGES: - return _AVAILABLE_LANGUAGES + if domain in _AVAILABLE_LANGUAGES: + return copy.copy(_AVAILABLE_LANGUAGES[domain]) localedir = '%s_LOCALEDIR' % domain.upper() find = lambda x: gettext.find(domain, @@ -254,7 +313,7 @@ def get_available_languages(domain): # NOTE(mrodden): en_US should always be available (and first in case # order matters) since our in-line message strings are en_US - _AVAILABLE_LANGUAGES.append('en_US') + language_list = ['en_US'] # NOTE(luisg): Babel <1.0 used a function called list(), which was # renamed to locale_identifiers() in >=1.0, the requirements master list # requires >=0.9.6, uncapped, so defensively work with both. We can remove @@ -264,16 +323,17 @@ def get_available_languages(domain): locale_identifiers = list_identifiers() for i in locale_identifiers: if find(i) is not None: - _AVAILABLE_LANGUAGES.append(i) - return _AVAILABLE_LANGUAGES + language_list.append(i) + _AVAILABLE_LANGUAGES[domain] = language_list + return copy.copy(language_list) def get_localized_message(message, user_locale): """Gets a localized version of the given message in the given locale.""" - if (isinstance(message, Message)): + if isinstance(message, Message): if user_locale: message.locale = user_locale - return unicode(message) + return six.text_type(message) else: return message diff --git a/tools/install_venv_common.py b/tools/install_venv_common.py index 6ce5d00..92d66ae 100644 --- a/tools/install_venv_common.py +++ b/tools/install_venv_common.py @@ -119,8 +119,7 @@ class InstallVenv(object): self.pip_install('setuptools') self.pip_install('pbr') - self.pip_install('-r', self.requirements) - self.pip_install('-r', self.test_requirements) + self.pip_install('-r', self.requirements, '-r', self.test_requirements) def post_process(self): self.get_distro().post_process() @@ -202,12 +201,13 @@ class Fedora(Distro): RHEL: https://bugzilla.redhat.com/958868 """ - # Install "patch" program if it's not there - if not self.check_pkg('patch'): - self.die("Please install 'patch'.") + if os.path.exists('contrib/redhat-eventlet.patch'): + # Install "patch" program if it's not there + if not self.check_pkg('patch'): + self.die("Please install 'patch'.") - # Apply the eventlet patch - self.apply_patch(os.path.join(self.venv, 'lib', self.py_version, - 'site-packages', - 'eventlet/green/subprocess.py'), - 'contrib/redhat-eventlet.patch') + # Apply the eventlet patch + self.apply_patch(os.path.join(self.venv, 'lib', self.py_version, + 'site-packages', + 'eventlet/green/subprocess.py'), + 'contrib/redhat-eventlet.patch') From 4a507601d7dded2efb1bd2e885155ba38db9538c Mon Sep 17 00:00:00 2001 From: John Griffith Date: Thu, 3 Oct 2013 17:22:34 -0600 Subject: [PATCH 24/24] Update docs/index.rst with release info for 1.0.6 Add features and bugs list for 1.0.6 pypi release. Change-Id: I27134a04670bcfd1bca201ce1e0bba201e8ff98c --- doc/source/index.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/doc/source/index.rst b/doc/source/index.rst index 3a658b1..e72aed1 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -29,6 +29,31 @@ See also :doc:`/man/cinder`. Release Notes ============= +1.0.6 +----- +* Add support for multiple endpoints +* Add response info for backup command +* Add metadata option to cinder list command +* Add timeout parameter for requests +* Add update action for snapshot metadata +* Add encryption metadata support +* Add volume migrate support + +.. _1221104: http://bugs.launchpad.net/python-cinderclient/+bug/1221104 +.. _1220590: http://bugs.launchpad.net/python-cinderclient/+bug/1220590 +.. _1220147: http://bugs.launchpad.net/python-cinderclient/+bug/1220147 +.. _1214176: http://bugs.launchpad.net/python-cinderclient/+bug/1214176 +.. _1210874: http://bugs.launchpad.net/python-cinderclient/+bug/1210874 +.. _1210296: http://bugs.launchpad.net/python-cinderclient/+bug/1210296 +.. _1210292: http://bugs.launchpad.net/python-cinderclient/+bug/1210292 +.. _1207635: http://bugs.launchpad.net/python-cinderclient/+bug/1207635 +.. _1207609: http://bugs.launchpad.net/python-cinderclient/+bug/1207609 +.. _1207260: http://bugs.launchpad.net/python-cinderclient/+bug/1207260 +.. _1206968: http://bugs.launchpad.net/python-cinderclient/+bug/1206968 +.. _1203471: http://bugs.launchpad.net/python-cinderclient/+bug/1203471 +.. _1200214: http://bugs.launchpad.net/python-cinderclient/+bug/1200214 +.. _1195014: http://bugs.launchpad.net/python-cinderclient/+bug/1195014 + 1.0.5 ----- * Add CLI man page