diff --git a/.coveragerc b/.coveragerc index ef2ba9a3b..d669ccfbb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,7 @@ [run] branch = True source = cinderclient -omit = cinderclient/openstack/*,cinderclient/tests/* +omit = cinderclient/tests/* [report] -ignore-errors = True +ignore_errors = True diff --git a/.gitignore b/.gitignore index 7aa14ea92..9d12a57b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,22 @@ /.* !.gitignore !.mailmap -!.testr.conf +!.stestr.conf .*.sw? subunit.log *,cover cover +covhtml *.pyc AUTHORS ChangeLog doc/build +releasenotes/build build dist cinderclient/versioninfo python_cinderclient.egg-info + +# pylint files +tools/lintstack.head.py +tools/pylint_exceptions diff --git a/.gitreview b/.gitreview index cb9446e7e..9b9acbf33 100644 --- a/.gitreview +++ b/.gitreview @@ -1,4 +1,4 @@ [gerrit] -host=review.openstack.org +host=review.opendev.org port=29418 project=openstack/python-cinderclient.git diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 000000000..96e0ee9be --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=${OS_TEST_PATH:-./cinderclient/tests/unit} +top_dir=./ diff --git a/.testr.conf b/.testr.conf deleted file mode 100644 index 2f5f5031f..000000000 --- a/.testr.conf +++ /dev/null @@ -1,4 +0,0 @@ -[DEFAULT] -test_command=OS_STDOUT_CAPTURE=${OS_STDOUT_CAPTURE:-1} OS_STDERR_CAPTURE=${OS_STDERR_CAPTURE:-1} ${PYTHON:-python} -m subunit.run discover -t ./ ${OS_TEST_PATH:-./cinderclient/tests/unit} $LISTOPT $IDOPTION -test_id_option=--load-list $IDFILE -test_list_option=--list diff --git a/.zuul.yaml b/.zuul.yaml new file mode 100644 index 000000000..dec90e3da --- /dev/null +++ b/.zuul.yaml @@ -0,0 +1,59 @@ +- job: + name: python-cinderclient-functional-base + abstract: true + parent: devstack-tox-functional + timeout: 4500 + required-projects: + - openstack/cinder + - openstack/python-cinderclient + vars: + openrc_enable_export: true + devstack_localrc: + VOLUME_BACKING_FILE_SIZE: 16G + CINDER_QUOTA_VOLUMES: 25 + CINDER_QUOTA_BACKUPS: 25 + CINDER_QUOTA_SNAPSHOTS: 25 + irrelevant-files: + - ^.*\.rst$ + - ^doc/.*$ + - ^releasenotes/.*$ + - ^cinderclient/tests/unit/.*$ + +- job: + name: python-cinderclient-functional-py310 + parent: python-cinderclient-functional-base + # Python 3.10 is the default on Ubuntu 22.04 (Jammy) + nodeset: openstack-single-node-jammy + vars: + python_version: 3.10 + tox_envlist: functional-py310 + +- job: + name: python-cinderclient-functional-py312 + parent: python-cinderclient-functional-base + # Python 3.12 is the default on Ubuntu 24.04 (Noble) + nodeset: openstack-single-node-noble + vars: + python_version: 3.12 + tox_envlist: functional-py312 + +- project: + vars: + ensure_tox_version: '<4' + templates: + - check-requirements + - lib-forward-testing-python3 + - openstack-cover-jobs + - openstack-python3-jobs + - publish-openstack-docs-pti + - release-notes-jobs-python3 + check: + jobs: + - python-cinderclient-functional-py310 + - python-cinderclient-functional-py312 + - openstack-tox-pylint: + voting: false + gate: + jobs: + - python-cinderclient-functional-py310 + - python-cinderclient-functional-py312 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 76d3915d7..000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,12 +0,0 @@ -If you would like to contribute to the development of OpenStack, -you must follow the steps in the "If you're a developer" -section of this page: [http://wiki.openstack.org/HowToContribute](http://wiki.openstack.org/HowToContribute#If_you.27re_a_developer:) - -Once those steps have been completed, changes to OpenStack -should be submitted for review via the Gerrit tool, following -the workflow documented at [http://wiki.openstack.org/GerritWorkflow](http://wiki.openstack.org/GerritWorkflow). - -Pull requests submitted through GitHub will be ignored. - -Bugs should be filed [on Launchpad](https://bugs.launchpad.net/python-cinderclient), -not in GitHub's issue tracker. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst new file mode 100644 index 000000000..5d5f77290 --- /dev/null +++ b/CONTRIBUTING.rst @@ -0,0 +1,19 @@ +The source repository for this project can be found at: + + https://opendev.org/openstack/python-cinderclient + +Pull requests submitted through GitHub are not monitored. + +To start contributing to OpenStack, follow the steps in the contribution guide +to set up and use Gerrit: + + https://docs.openstack.org/contributors/code-and-documentation/quick-start.html + +Bugs should be filed on Launchpad: + + https://bugs.launchpad.net/python-cinderclient + +For more specific information about contributing to this repository, see the +cinderclient contributor guide: + + https://docs.openstack.org/python-cinderclient/latest/contributor/contributing.html diff --git a/HACKING.rst b/HACKING.rst index a48ac28cb..82683e7b0 100644 --- a/HACKING.rst +++ b/HACKING.rst @@ -2,7 +2,7 @@ Cinder Client Style Commandments ================================ - Step 1: Read the OpenStack Style Commandments - http://docs.openstack.org/developer/hacking/ + https://docs.openstack.org/hacking/latest/ - Step 2: Read on Cinder Client Specific Commandments @@ -10,7 +10,8 @@ Cinder Client Specific Commandments General ------- -- Use 'raise' instead of 'raise e' to preserve original traceback or exception being reraised:: +- Use 'raise' instead of 'raise e' to preserve original traceback or exception + being reraised:: except Exception as e: ... @@ -20,58 +21,30 @@ General ... raise # OKAY -Text encoding +Release Notes ------------- -- All text within python code should be of type 'unicode'. - - WRONG: - - >>> s = 'foo' - >>> s - 'foo' - >>> type(s) - - - RIGHT: - - >>> u = u'foo' - >>> u - u'foo' - >>> type(u) - - -- Transitions between internal unicode and external strings should always - be immediately and explicitly encoded or decoded. +- Any patch that makes a change significant to the end consumer or deployer of + an OpenStack environment should include a release note (new features, upgrade + impacts, deprecated functionality, significant bug fixes, etc.) -- All external text that is not explicitly encoded (database storage, - commandline arguments, etc.) should be presumed to be encoded as utf-8. +- Cinder Client uses Reno for release notes management. See the `Reno + Documentation`_ for more details on its usage. - WRONG: - - mystring = infile.readline() - myreturnstring = do_some_magic_with(mystring) - outfile.write(myreturnstring) - - RIGHT: - - mystring = infile.readline() - mytext = s.decode('utf-8') - returntext = do_some_magic_with(mytext) - returnstring = returntext.encode('utf-8') - outfile.write(returnstring) - -Release Notes -------------- -- Each patch should add an entry in the doc/source/index.rst file under - "MASTER". +.. _Reno Documentation: https://docs.openstack.org/reno/latest/ -- On each new release, the entries under "MASTER" will become the release notes - for that release, and "MASTER" will be cleared. +- As a quick example, when adding a new shell command for Awesome Storage + Feature, one could perform the following steps to include a release note for + the new feature:: -- The format should match existing release notes. For example, a feature:: + $ tox -e venv -- reno new add-awesome-command + $ vi releasenotes/notes/add-awesome-command-bb8bb8bb8bb8bb81.yaml - * Add support for function foo + Remove the extra template text from the release note and update the details + so it looks something like:: - Or a bug fix:: + --- + features: + - Added shell command `cinder be-awesome` for Awesome Storage Feature. - .. _1241941: http://bugs.launchpad.net/python-cinderclient/+bug/1241941 +- Include the generated release notes file when submitting your patch for + review. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 5be0f94cc..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,4 +0,0 @@ -include AUTHORS -include ChangeLog -exclude .gitignore -exclude .gitreview diff --git a/README.rst b/README.rst index 87ca887ae..2740e963e 100644 --- a/README.rst +++ b/README.rst @@ -1,30 +1,46 @@ +=========================================== Python bindings to the OpenStack Cinder API =========================================== +.. image:: https://img.shields.io/pypi/v/python-cinderclient.svg + :target: https://pypi.org/project/python-cinderclient/ + :alt: Latest Version + This is a client for the OpenStack Cinder API. There's a Python API (the ``cinderclient`` module), and a command-line script (``cinder``). Each implements 100% of the OpenStack Cinder API. -See the `OpenStack CLI guide`_ for information on how to use the ``cinder`` +See the `OpenStack CLI Reference`_ for information on how to use the ``cinder`` command-line tool. You may also want to look at the `OpenStack API documentation`_. -.. _OpenStack CLI Guide: http://docs.openstack.org/cli/quick-start/content/ -.. _OpenStack API documentation: http://docs.openstack.org/api/ +.. _OpenStack CLI Reference: https://docs.openstack.org/python-openstackclient/latest/cli/ +.. _OpenStack API documentation: https://docs.openstack.org/api-quick-start/ The project is hosted on `Launchpad`_, where bugs can be filed. The code is -hosted on `Github`_. Patches must be submitted using `Gerrit`_, *not* Github -pull requests. +hosted on `OpenStack`_. Patches must be submitted using `Gerrit`_. -.. _Github: https://github.com/openstack/python-cinderclient +.. _OpenStack: https://opendev.org/openstack/python-cinderclient .. _Launchpad: https://launchpad.net/python-cinderclient -.. _Gerrit: http://docs.openstack.org/infra/manual/developers.html#development-workflow +.. _Gerrit: https://docs.openstack.org/infra/manual/developers.html#development-workflow + +* License: Apache License, Version 2.0 +* `PyPi`_ - package installation +* `Online Documentation`_ +* `Blueprints`_ - feature specifications +* `Bugs`_ - issue tracking +* `Source`_ +* `Specs`_ +* `How to Contribute`_ -This code a fork of `Jacobian's python-cloudservers`__ If you need API support -for the Rackspace API solely or the BSD license, you should use that repository. -python-cinderclient is licensed under the Apache License like the rest of OpenStack. +.. _PyPi: https://pypi.org/project/python-cinderclient +.. _Online Documentation: https://docs.openstack.org/python-cinderclient/latest/ +.. _Blueprints: https://blueprints.launchpad.net/python-cinderclient +.. _Bugs: https://bugs.launchpad.net/python-cinderclient +.. _Source: https://opendev.org/openstack/python-cinderclient +.. _How to Contribute: https://docs.openstack.org/infra/manual/developers.html +.. _Specs: https://specs.openstack.org/openstack/cinder-specs/ -__ http://github.com/jacobian/python-cloudservers .. contents:: Contents: :local: @@ -44,16 +60,14 @@ params, but it's easier to just set them as environment variables:: export OS_TENANT_NAME=myproject You will also need to define the authentication url with ``--os-auth-url`` -and the version of the API with ``--os-volume-api-version``. Or set them as -environment variables as well:: - - export OS_AUTH_URL=http://example.com:8774/v1.1/ - export OS_VOLUME_API_VERSION=1 - -If you are using Keystone, you need to set the OS_AUTH_URL to the keystone +and the version of the API with ``--os-volume-api-version``. Or set them as +environment variables as well. Since Block Storage API V2 is officially +deprecated, you are encouraged to set ``OS_VOLUME_API_VERSION=3``. If you +are using Keystone, you need to set the ``OS_AUTH_URL`` to the keystone endpoint:: - export OS_AUTH_URL=http://example.com:5000/v2.0/ + export OS_AUTH_URL=http://controller:5000/v3 + export OS_VOLUME_API_VERSION=3 Since Keystone can return multiple regions in the Service Catalog, you can specify the one you want with ``--os-region-name`` (or @@ -62,79 +76,259 @@ can specify the one you want with ``--os-region-name`` (or You'll find complete documentation on the shell by running ``cinder help``:: - usage: cinder [--debug] [--os-username ] - [--os-password ] - [--os-tenant-name ] [--os-auth-url ] - [--os-region-name ] [--service-type ] - [--service-name ] + usage: cinder [--version] [-d] [--os-auth-system ] + [--service-type ] [--service-name ] [--volume-service-name ] + [--os-endpoint-type ] [--endpoint-type ] - [--os-volume-api-version ] - [--os-cacert ] [--retries ] + [--os-volume-api-version ] + [--retries ] + [--profile HMAC_KEY] [--os-auth-strategy ] + [--os-username ] [--os-password ] + [--os-tenant-name ] + [--os-tenant-id ] [--os-auth-url ] + [--os-user-id ] + [--os-user-domain-id ] + [--os-user-domain-name ] + [--os-project-id ] + [--os-project-name ] + [--os-project-domain-id ] + [--os-project-domain-name ] + [--os-region-name ] [--os-token ] + [--os-url ] [--insecure] [--os-cacert ] + [--os-cert ] [--os-key ] [--timeout ] ... Command-line interface to the OpenStack Cinder API. Positional arguments: - absolute-limits Print a list of absolute limits for a user - create Add a new volume. - credentials Show user credentials returned from auth - delete Remove a volume. - endpoints Discover endpoints that get returned from the - authenticate services - extra-specs-list Print a list of current 'volume types and extra specs' + absolute-limits Lists absolute limits for a user. + api-version Display the server API version information. (Supported + by API versions 3.0 - 3.latest) + availability-zone-list + Lists all availability zones. + backup-create Creates a volume backup. + backup-delete Removes one or more backups. + backup-export Export backup metadata record. + backup-import Import backup metadata record. + backup-list Lists all backups. + backup-reset-state Explicitly updates the backup state. + backup-restore Restores a backup. + backup-show Shows backup details. + cgsnapshot-create Creates a cgsnapshot. + cgsnapshot-delete Removes one or more cgsnapshots. + cgsnapshot-list Lists all cgsnapshots. + cgsnapshot-show Shows cgsnapshot details. + consisgroup-create Creates a consistency group. + consisgroup-create-from-src + Creates a consistency group from a cgsnapshot or a + source CG. + consisgroup-delete Removes one or more consistency groups. + consisgroup-list Lists all consistency groups. + consisgroup-show Shows details of a consistency group. + consisgroup-update Updates a consistency group. + create Creates a volume. + credentials Shows user credentials returned from auth. + delete Removes one or more volumes. + encryption-type-create + Creates encryption type for a volume type. Admin only. + encryption-type-delete + Deletes encryption type for a volume type. Admin only. + encryption-type-list + Shows encryption type details for volume types. Admin + only. + encryption-type-show + Shows encryption type details for a volume type. Admin + only. + encryption-type-update + Update encryption type information for a volume type (Admin Only). - list List all the volumes. - quota-class-show List the quotas for a quota class. - quota-class-update Update the quotas for a quota class. - quota-defaults List the default quotas for a tenant. - quota-show List the quotas for a tenant. - quota-update Update the quotas for a tenant. - rate-limits Print a list of rate limits for a user - rename Rename a volume. - show Show details about a volume. - snapshot-create Add a new snapshot. - snapshot-delete Remove a snapshot. - snapshot-list List all the snapshots. - snapshot-rename Rename a snapshot. - snapshot-show Show details about a snapshot. - type-create Create a new volume type. - type-delete Delete a specific volume type - type-key Set or unset extra_spec for a volume type. - type-list Print a list of available 'volume types'. - bash-completion Prints all of the commands and options to stdout so - that the - help Display help about this program or one of its + endpoints Discovers endpoints registered by authentication + service. + extend Attempts to extend size of an existing volume. + extra-specs-list Lists current volume types and extra specs. + failover-host Failover a replicating cinder-volume host. + force-delete Attempts force-delete of volume, regardless of state. + freeze-host Freeze and disable the specified cinder-volume host. + get-capabilities Show backend volume stats and properties. Admin only. + get-pools Show pool information for backends. Admin only. + image-metadata Sets or deletes volume image metadata. + image-metadata-show + Shows volume image metadata. + list Lists all volumes. + manage Manage an existing volume. + metadata Sets or deletes volume metadata. + metadata-show Shows volume metadata. + metadata-update-all + Updates volume metadata. + migrate Migrates volume to a new host. + qos-associate Associates qos specs with specified volume type. + qos-create Creates a qos specs. + qos-delete Deletes a specified qos specs. + qos-disassociate Disassociates qos specs from specified volume type. + qos-disassociate-all + Disassociates qos specs from all its associations. + qos-get-association + Lists all associations for specified qos specs. + qos-key Sets or unsets specifications for a qos spec. + qos-list Lists qos specs. + qos-show Shows qos specs details. + quota-class-show Lists quotas for a quota class. + quota-class-update Updates quotas for a quota class. + quota-defaults Lists default quotas for a tenant. + quota-delete Delete the quotas for a tenant. + quota-show Lists quotas for a tenant. + quota-update Updates quotas for a tenant. + quota-usage Lists quota usage for a tenant. + rate-limits Lists rate limits for a user. + readonly-mode-update + Updates volume read-only access-mode flag. + rename Renames a volume. + reset-state Explicitly updates the volume state in the Cinder + database. + retype Changes the volume type for a volume. + service-disable Disables the service. + service-enable Enables the service. + service-list Lists all services. Filter by host and service binary. + (Supported by API versions 3.0 - 3.latest) + set-bootable Update bootable status of a volume. + show Shows volume details. + snapshot-create Creates a snapshot. + snapshot-delete Removes one or more snapshots. + snapshot-list Lists all snapshots. + snapshot-manage Manage an existing snapshot. + snapshot-metadata Sets or deletes snapshot metadata. + snapshot-metadata-show + Shows snapshot metadata. + snapshot-metadata-update-all + Updates snapshot metadata. + snapshot-rename Renames a snapshot. + snapshot-reset-state + Explicitly updates the snapshot state. + snapshot-show Shows snapshot details. + snapshot-unmanage Stop managing a snapshot. + thaw-host Thaw and enable the specified cinder-volume host. + transfer-accept Accepts a volume transfer. + transfer-create Creates a volume transfer. + transfer-delete Undoes a transfer. + transfer-list Lists all transfers. + transfer-show Shows transfer details. + type-access-add Adds volume type access for the given project. + type-access-list Print access information about the given volume type. + type-access-remove Removes volume type access for the given project. + type-create Creates a volume type. + type-default List the default volume type. + type-delete Deletes volume type or types. + type-key Sets or unsets extra_spec for a volume type. + type-list Lists available 'volume types'. + type-show Show volume type details. + type-update Updates volume type name, description, and/or + is_public. + unmanage Stop managing a volume. + upload-to-image Uploads volume to Image Service as an image. + version-list List all API versions. (Supported by API versions 3.0 + - 3.latest) + bash-completion Prints arguments for bash_completion. + help Shows help about this program or one of its subcommands. - list-extensions List all the os-api extensions that are available. + list-extensions Optional arguments: - -d, --debug Print debugging output + --version show program's version number and exit + -d, --debug Shows debugging output. + --os-auth-system + Defaults to env[OS_AUTH_SYSTEM]. + --service-type + Service type. For most actions, default is volume. + --service-name + Service name. Default=env[CINDER_SERVICE_NAME]. + --volume-service-name + Volume service name. + Default=env[CINDER_VOLUME_SERVICE_NAME]. + --os-endpoint + Use this API endpoint instead of the Service Catalog. + Default=env[CINDER_ENDPOINT] + --os-endpoint-type + Endpoint type, which is publicURL or internalURL. + Default=env[OS_ENDPOINT_TYPE] or nova + env[CINDER_ENDPOINT_TYPE] or publicURL. + --endpoint-type + DEPRECATED! Use --os-endpoint-type. + --os-volume-api-version + Block Storage API version. Accepts X, X.Y (where X is + major and Y is minor + part).Default=env[OS_VOLUME_API_VERSION]. + --retries Number of retries. + --profile HMAC_KEY HMAC key to use for encrypting context data for + performance profiling of operation. This key needs to + match the one configured on the cinder api server. + Without key the profiling will not be triggered even + if osprofiler is enabled on server side. + Defaults to env[OS_PROFILE]. + --os-auth-strategy + Authentication strategy (Env: OS_AUTH_STRATEGY, + default keystone). For now, any other value will + disable the authentication. --os-username - Defaults to env[OS_USERNAME]. + OpenStack user name. Default=env[OS_USERNAME]. --os-password - Defaults to env[OS_PASSWORD]. + Password for OpenStack user. Default=env[OS_PASSWORD]. --os-tenant-name - Defaults to env[OS_TENANT_NAME]. + Tenant name. Default=env[OS_TENANT_NAME]. + --os-tenant-id + ID for the tenant. Default=env[OS_TENANT_ID]. --os-auth-url - Defaults to env[OS_AUTH_URL]. + URL for the authentication service. + Default=env[OS_AUTH_URL]. + --os-user-id + Authentication user ID (Env: OS_USER_ID). + --os-user-domain-id + OpenStack user domain ID. Defaults to + env[OS_USER_DOMAIN_ID]. + --os-user-domain-name + OpenStack user domain name. Defaults to + env[OS_USER_DOMAIN_NAME]. + --os-project-id + Another way to specify tenant ID. This option is + mutually exclusive with --os-tenant-id. Defaults to + env[OS_PROJECT_ID]. + --os-project-name + Another way to specify tenant name. This option is + mutually exclusive with --os-tenant-name. Defaults to + env[OS_PROJECT_NAME]. + --os-project-domain-id + Defaults to env[OS_PROJECT_DOMAIN_ID]. + --os-project-domain-name + Defaults to env[OS_PROJECT_DOMAIN_NAME]. --os-region-name - Defaults to env[OS_REGION_NAME]. - --service-type - Defaults to compute for most actions - --service-name - Defaults to env[CINDER_SERVICE_NAME] - --volume-service-name - Defaults to env[CINDER_VOLUME_SERVICE_NAME] - --endpoint-type - Defaults to env[CINDER_ENDPOINT_TYPE] or publicURL. - --os-volume-api-version - Accepts 1,defaults to env[OS_VOLUME_API_VERSION]. + Region name. Default=env[OS_REGION_NAME]. + --os-token Defaults to env[OS_TOKEN]. + --os-url Defaults to env[OS_URL]. + + API Connection Options: + Options controlling the HTTP API Connections + + --insecure Explicitly allow client to perform "insecure" TLS + (https) requests. The server's certificate will not be + verified against any certificate authorities. This + option should be used with caution. --os-cacert Specify a CA bundle file to use in verifying a TLS - (https) server certificate. Defaults to env[OS_CACERT] - --retries Number of retries. + (https) server certificate. Defaults to + env[OS_CACERT]. + --os-cert + Defaults to env[OS_CERT]. + --os-key Defaults to env[OS_KEY]. + --timeout Set request timeout (in seconds). + + Run "cinder help SUBCOMMAND" for help on a subcommand. + +If you want to get a particular version API help message, you can add +``--os-volume-api-version `` in help command, like +this:: + + cinder --os-volume-api-version 3.28 help Python API ---------- @@ -143,15 +337,10 @@ There's also a complete Python API, but it has not yet been documented. Quick-start using keystone:: - # use v2.0 auth with http://example.com:5000/v2.0/") - >>> from cinderclient.v1 import client - >>> nt = client.Client(USER, PASS, TENANT, AUTH_URL, service_type="volume") + # use v3 auth with http://controller:5000/v3 + >>> from cinderclient.v3 import client + >>> nt = client.Client(USERNAME, PASSWORD, PROJECT_ID, AUTH_URL) >>> nt.volumes.list() [...] -See release notes and more at ``_. - -* License: Apache License, Version 2.0 -* Documentation: http://docs.openstack.org/developer/python-cinderclient -* Source: http://git.openstack.org/cgit/openstack/python-cinderclient -* Bugs: http://bugs.launchpad.net/python-cinderclient +See release notes and more at ``_. diff --git a/bindep.txt b/bindep.txt new file mode 100644 index 000000000..52426cfd8 --- /dev/null +++ b/bindep.txt @@ -0,0 +1,11 @@ +# This is a cross-platform list tracking distribution packages needed by tests; +# see https://docs.openstack.org/infra/bindep/ for additional information. + +gettext +libffi-dev [platform:dpkg] +libffi-devel [platform:rpm] +libssl-dev [platform:ubuntu-xenial] +locales [platform:debian] +python3-all-dev [platform:ubuntu !platform:ubuntu-precise] +python3-dev [platform:dpkg] +python3-devel [platform:rpm] diff --git a/cinderclient/__init__.py b/cinderclient/__init__.py index 29e35c8e1..dac207385 100644 --- a/cinderclient/__init__.py +++ b/cinderclient/__init__.py @@ -12,9 +12,11 @@ # License for the specific language governing permissions and limitations # under the License. +import pbr.version + + __all__ = ['__version__'] -import pbr.version version_info = pbr.version.VersionInfo('python-cinderclient') # We have a circular import problem when we first run python setup.py sdist diff --git a/cinderclient/_i18n.py b/cinderclient/_i18n.py new file mode 100644 index 000000000..9a38e5568 --- /dev/null +++ b/cinderclient/_i18n.py @@ -0,0 +1,44 @@ +# Copyright 2016 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 +# 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. + +"""oslo.i18n integration module. + +See https://docs.openstack.org/oslo.i18n/latest/user/usage.html . + +""" + +import oslo_i18n + +DOMAIN = "cinderclient" + +_translators = oslo_i18n.TranslatorFactory(domain=DOMAIN) + +# The primary translation function using the well-known name "_" +_ = _translators.primary + +# The contextual translation function using the name "_C" +# requires oslo.i18n >=2.1.0 +_C = _translators.contextual_form + +# The plural translation function using the name "_P" +# requires oslo.i18n >=2.1.0 +_P = _translators.plural_form + + +def get_available_languages(): + return oslo_i18n.get_available_languages(DOMAIN) + + +def enable_lazy(): + return oslo_i18n.enable_lazy() diff --git a/cinderclient/api_versions.py b/cinderclient/api_versions.py new file mode 100644 index 000000000..25818fb56 --- /dev/null +++ b/cinderclient/api_versions.py @@ -0,0 +1,428 @@ +# +# 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 functools +import logging +import re + +from oslo_utils import strutils + +from cinderclient._i18n import _ +from cinderclient import exceptions +from cinderclient import utils + +LOG = logging.getLogger(__name__) + + +# key is unsupported version, value is appropriate supported alternative +REPLACEMENT_VERSIONS = {"1": "3", "2": "3"} +MAX_VERSION = "3.71" +MIN_VERSION = "3.0" + +_SUBSTITUTIONS = {} + +_type_error_msg = "'%(other)s' should be an instance of '%(cls)s'" + + +class APIVersion(object): + """This class represents an API Version with convenience + methods for manipulation and comparison of version + numbers that we need to do to implement microversions. + """ + + def __init__(self, version_str=None): + """Create an API version object.""" + self.ver_major = 0 + self.ver_minor = 0 + + if version_str is not None: + match = re.match(r"^([1-9]\d*)\.([1-9]\d*|0|latest)$", version_str) + if match: + self.ver_major = int(match.group(1)) + if match.group(2) == "latest": + # NOTE(andreykurilin): Infinity allows to easily determine + # latest version and doesn't require any additional checks + # in comparison methods. + self.ver_minor = float("inf") + else: + self.ver_minor = int(match.group(2)) + else: + msg = (_("Invalid format of client version '%s'. " + "Expected format 'X.Y', where X is a major part and Y " + "is a minor part of version.") % version_str) + raise exceptions.UnsupportedVersion(msg) + + def __str__(self): + """Debug/Logging representation of object.""" + if self.is_latest(): + return "Latest API Version Major: %s" % self.ver_major + return ("API Version Major: %s, Minor: %s" + % (self.ver_major, self.ver_minor)) + + def __repr__(self): + if self: + return "" % self.get_string() + return "" + + def __bool__(self): + return self.ver_major != 0 or self.ver_minor != 0 + + __nonzero__ = __bool__ + + def is_latest(self): + return self.ver_minor == float("inf") + + def __lt__(self, other): + if not isinstance(other, APIVersion): + raise TypeError(_type_error_msg % {"other": other, + "cls": self.__class__}) + + return ((self.ver_major, self.ver_minor) < + (other.ver_major, other.ver_minor)) + + def __eq__(self, other): + if not isinstance(other, APIVersion): + raise TypeError(_type_error_msg % {"other": other, + "cls": self.__class__}) + + return ((self.ver_major, self.ver_minor) == + (other.ver_major, other.ver_minor)) + + def __gt__(self, other): + if not isinstance(other, APIVersion): + raise TypeError(_type_error_msg % {"other": other, + "cls": self.__class__}) + + return ((self.ver_major, self.ver_minor) > + (other.ver_major, other.ver_minor)) + + def __le__(self, other): + return self < other or self == other + + def __ne__(self, other): + return not self.__eq__(other) + + def __ge__(self, other): + return self > other or self == other + + def matches(self, min_version, max_version=None): + """Returns whether the version object represents a version + greater than or equal to the minimum version and less than + or equal to the maximum version. + + :param min_version: Minimum acceptable version. + :param max_version: Maximum acceptable version. + :returns: boolean + + If min_version is null then there is no minimum limit. + If max_version is null then there is no maximum limit. + If self is null then raise ValueError + """ + + if not self: + raise ValueError("Null APIVersion doesn't support 'matches'.") + + if isinstance(min_version, str): + min_version = APIVersion(version_str=min_version) + if isinstance(max_version, str): + max_version = APIVersion(version_str=max_version) + + # This will work when they are None and when they are version 0.0 + if not min_version and not max_version: + return True + + if not max_version: + return min_version <= self + if not min_version: + return self <= max_version + return min_version <= self <= max_version + + def get_string(self): + """Converts object to string representation which if used to create + an APIVersion object results in the same version. + """ + if not self: + raise ValueError("Null APIVersion cannot be converted to string.") + elif self.is_latest(): + return "%s.%s" % (self.ver_major, "latest") + return "%s.%s" % (self.ver_major, self.ver_minor) + + def get_major_version(self): + return "%s" % self.ver_major + + +class VersionedMethod(object): + + def __init__(self, name, start_version, end_version, func): + """Versioning information for a single method + + :param name: Name of the method + :param start_version: Minimum acceptable version + :param end_version: Maximum acceptable_version + :param func: Method to call + + Minimum and maximums are inclusive + """ + self.name = name + self.start_version = start_version + self.end_version = end_version + self.func = func + + def __str__(self): + return ("Version Method %s: min: %s, max: %s" + % (self.name, self.start_version, self.end_version)) + + def __repr__(self): + return "" % self.name + + +def get_available_major_versions(): + # NOTE: the discovery code previously here assumed that if a v2 + # module exists, it must contain a client. This will be False + # during the transition period when the v2 client is removed but + # we are still using other classes in that module. Right now there's + # only one client version available, so we simply hard-code it. + return ['3'] + + +def check_major_version(api_version): + """Checks major part of ``APIVersion`` obj is supported. + + :raises cinderclient.exceptions.UnsupportedVersion: if major part is not + supported + """ + available_versions = get_available_major_versions() + if (api_version and str(api_version.ver_major) not in available_versions): + if len(available_versions) == 1: + msg = ("Invalid client version '%(version)s'. " + "Major part should be '%(major)s'") % { + "version": api_version.get_string(), + "major": available_versions[0]} + else: + msg = ("Invalid client version '%(version)s'. " + "Major part must be one of: '%(major)s'") % { + "version": api_version.get_string(), + "major": ", ".join(available_versions)} + raise exceptions.UnsupportedVersion(msg) + + +def get_api_version(version_string): + """Returns checked APIVersion object""" + version_string = str(version_string) + if version_string in REPLACEMENT_VERSIONS: + LOG.warning("Version %(old)s is not supported, use " + "supported version %(now)s instead.", + {"old": version_string, + "now": REPLACEMENT_VERSIONS[version_string]}) + if strutils.is_int_like(version_string): + version_string = "%s.0" % version_string + + api_version = APIVersion(version_string) + check_major_version(api_version) + return api_version + + +def _get_server_version_range(client): + try: + versions = client.services.server_api_version() + except AttributeError: + # Wrong client was used, translate to something helpful. + raise exceptions.UnsupportedVersion( + _('Invalid client version %s to get server version range. Only ' + 'the v3 client is supported for this operation.') % + client.version) + + if not versions: + msg = _("Server does not support microversions. You cannot use this " + "version of the cinderclient with the requested server. " + "Try using a cinderclient version less than 8.0.0.") + raise exceptions.UnsupportedVersion(msg) + + for version in versions: + if '3.' in version.version: + return APIVersion(version.min_version), APIVersion(version.version) + + # if we're still here, there's nothing we understand in the versions + msg = _("You cannot use this version of the cinderclient with the " + "requested server.") + raise exceptions.UnsupportedVersion(msg) + + +def get_highest_version(client): + """Queries the server version info and returns highest supported + microversion + + :param client: client object + :returns: APIVersion + """ + server_start_version, server_end_version = _get_server_version_range( + client) + return server_end_version + + +def discover_version(client, requested_version): + """Checks ``requested_version`` and returns the most recent version + supported by both the API and the client. + + :param client: client object + :param requested_version: requested version represented by APIVersion obj + :returns: APIVersion + """ + + server_start_version, server_end_version = _get_server_version_range( + client) + + _validate_server_version(server_start_version, server_end_version) + + # get the highest version the server can handle relative to the + # requested version + valid_version = _validate_requested_version( + requested_version, + server_start_version, + server_end_version) + + # see if we need to downgrade for the client + client_max = APIVersion(MAX_VERSION) + if client_max < valid_version: + msg = _("Requested version %(requested_version)s is " + "not supported. Downgrading requested version " + "to %(actual_version)s.") + LOG.debug(msg, { + "requested_version": requested_version, + "actual_version": client_max}) + valid_version = client_max + + return valid_version + + +def _validate_requested_version(requested_version, + server_start_version, + server_end_version): + """Validates the requested version. + + Checks 'requested_version' is within the min/max range supported by the + server. If 'requested_version' is not within range then attempts to + downgrade to 'server_end_version'. Otherwise an UnsupportedVersion + exception is thrown. + + :param requested_version: requestedversion represented by APIVersion obj + :param server_start_version: APIVersion object representing server min + :param server_end_version: APIVersion object representing server max + """ + valid_version = requested_version + if not requested_version.matches(server_start_version, server_end_version): + if server_end_version <= requested_version: + if (APIVersion(MIN_VERSION) <= server_end_version and + server_end_version <= APIVersion(MAX_VERSION)): + msg = _("Requested version %(requested_version)s is " + "not supported. Downgrading requested version " + "to %(server_end_version)s.") + LOG.debug(msg, { + "requested_version": requested_version, + "server_end_version": server_end_version}) + valid_version = server_end_version + else: + raise exceptions.UnsupportedVersion( + _("The specified version isn't supported by server. The valid " + "version range is '%(min)s' to '%(max)s'") % { + "min": server_start_version.get_string(), + "max": server_end_version.get_string()}) + + return valid_version + + +def _validate_server_version(server_start_version, server_end_version): + """Validates the server version. + + Checks that the 'server_end_version' is greater than the minimum version + supported by the client. Then checks that the 'server_start_version' is + less than the maximum version supported by the client. + + :param server_start_version: + :param server_end_version: + :return: + """ + if APIVersion(MIN_VERSION) > server_end_version: + raise exceptions.UnsupportedVersion( + _("Server's version is too old. The client's valid version range " + "is '%(client_min)s' to '%(client_max)s'. The server valid " + "version range is '%(server_min)s' to '%(server_max)s'.") % { + 'client_min': MIN_VERSION, + 'client_max': MAX_VERSION, + 'server_min': server_start_version.get_string(), + 'server_max': server_end_version.get_string()}) + elif APIVersion(MAX_VERSION) < server_start_version: + raise exceptions.UnsupportedVersion( + _("Server's version is too new. The client's valid version range " + "is '%(client_min)s' to '%(client_max)s'. The server valid " + "version range is '%(server_min)s' to '%(server_max)s'.") % { + 'client_min': MIN_VERSION, + 'client_max': MAX_VERSION, + 'server_min': server_start_version.get_string(), + 'server_max': server_end_version.get_string()}) + + +def update_headers(headers, api_version): + """Set 'OpenStack-API-Version' header if api_version is not + null + """ + + if api_version and api_version.ver_minor != 0: + headers["OpenStack-API-Version"] = "volume " + api_version.get_string() + + +def add_substitution(versioned_method): + _SUBSTITUTIONS.setdefault(versioned_method.name, []) + _SUBSTITUTIONS[versioned_method.name].append(versioned_method) + + +def get_substitutions(func_name, api_version=None): + substitutions = _SUBSTITUTIONS.get(func_name, []) + if api_version: + return [m for m in substitutions + if api_version.matches(m.start_version, m.end_version)] + return substitutions + + +def wraps(start_version, end_version=None): + start_version = APIVersion(start_version) + if end_version: + end_version = APIVersion(end_version) + else: + end_version = APIVersion("%s.latest" % start_version.ver_major) + + def decor(func): + func.versioned = True + name = utils.get_function_name(func) + versioned_method = VersionedMethod(name, start_version, + end_version, func) + add_substitution(versioned_method) + + @functools.wraps(func) + def substitution(obj, *args, **kwargs): + methods = get_substitutions(name, obj.api_version) + + if not methods: + raise exceptions.VersionNotFoundForAPIMethod( + obj.api_version.get_string(), name) + + method = max(methods, key=lambda f: f.start_version) + + return method.func(obj, *args, **kwargs) + + if hasattr(func, 'arguments'): + for cli_args, cli_kwargs in func.arguments: + utils.add_arg(substitution, *cli_args, **cli_kwargs) + return substitution + + return decor diff --git a/cinderclient/openstack/__init__.py b/cinderclient/apiclient/__init__.py similarity index 100% rename from cinderclient/openstack/__init__.py rename to cinderclient/apiclient/__init__.py diff --git a/cinderclient/openstack/common/apiclient/base.py b/cinderclient/apiclient/base.py similarity index 80% rename from cinderclient/openstack/common/apiclient/base.py rename to cinderclient/apiclient/base.py index bc4a1588b..8caa0bc1b 100644 --- a/cinderclient/openstack/common/apiclient/base.py +++ b/cinderclient/apiclient/base.py @@ -16,20 +16,21 @@ # License for the specific language governing permissions and limitations # under the License. -""" -Base utilities to build API operation managers and objects on top of. -""" +"""Base utilities to build API operation managers and objects on top of.""" # E1102: %s is not callable # pylint: disable=E1102 import abc +import copy -import six -from six.moves.urllib import parse +from oslo_utils import encodeutils +from oslo_utils import strutils +from requests import Response -from cinderclient.openstack.common.apiclient import exceptions -from cinderclient.openstack.common import strutils + +from cinderclient.apiclient import exceptions +from cinderclient import utils def getid(obj): @@ -38,15 +39,10 @@ def getid(obj): 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 + if getattr(obj, 'uuid', None): + return obj.uuid + else: + return getattr(obj, 'id', obj) # TODO(aababilov): call run_hooks() in HookableMixin's child classes @@ -201,8 +197,7 @@ def _delete(self, url): return self.client.delete(url) -@six.add_metaclass(abc.ABCMeta) -class ManagerWithFind(BaseManager): +class ManagerWithFind(BaseManager, metaclass=abc.ABCMeta): """Manager with additional `find()`/`findall()` methods.""" @abc.abstractmethod @@ -291,7 +286,7 @@ def build_url(self, base_url=None, **kwargs): def _filter_kwargs(self, kwargs): """Drop null values and handle ids.""" - for key, ref in six.iteritems(kwargs.copy()): + for key, ref in kwargs.copy().items(): if ref is None: kwargs.pop(key) else: @@ -327,7 +322,7 @@ def list(self, base_url=None, **kwargs): return self._list( '%(base_url)s%(query)s' % { 'base_url': self.build_url(base_url=base_url, **kwargs), - 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '', + 'query': utils.build_query_param(kwargs), }, self.collection_key) @@ -366,7 +361,7 @@ def find(self, base_url=None, **kwargs): rl = self._list( '%(base_url)s%(query)s' % { 'base_url': self.build_url(base_url=base_url, **kwargs), - 'query': '?%s' % parse.urlencode(kwargs) if kwargs else '', + 'query': '?%s' % utils.build_query_param(kwargs), }, self.collection_key) num = len(rl) @@ -408,7 +403,43 @@ def __repr__(self): return "" % self.name -class Resource(object): +class RequestIdMixin(object): + """Wrapper class to expose x-openstack-request-id to the caller.""" + def setup(self): + self.x_openstack_request_ids = [] + + @property + def request_ids(self): + return self.x_openstack_request_ids + + def append_request_ids(self, resp): + """Add request_ids as an attribute to the object + + :param resp: list, Response object or string + """ + if resp is None: + return + + if isinstance(resp, list): + # Add list of request_ids if response is of type list. + for resp_obj in resp: + self._append_request_id(resp_obj) + else: + # Add request_ids if response contains single object. + self._append_request_id(resp) + + def _append_request_id(self, resp): + if isinstance(resp, Response): + # Extract 'x-openstack-request-id' from headers if + # response is a Response object. + request_id = resp.headers.get('x-openstack-request-id') + self.x_openstack_request_ids.append(request_id) + else: + # If resp is of type string (in case of encryption type list) + self.x_openstack_request_ids.append(resp) + + +class Resource(RequestIdMixin): """Base class for OpenStack resources (tenant, user, etc.). This is pretty much just a bag for attributes. @@ -417,22 +448,28 @@ class Resource(object): HUMAN_ID = False NAME_ATTR = 'name' - def __init__(self, manager, info, loaded=False): + def __init__(self, manager, info, loaded=False, resp=None): """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 + :param resp: Response or list of Response objects """ self.manager = manager self._info = info self._add_details(info) self._loaded = loaded + if resp and hasattr(resp, "headers"): + self._checksum = resp.headers.get("Etag") + self.setup() + self.append_request_ids(resp) def __repr__(self): reprkeys = sorted(k for k in self.__dict__.keys() - if k[0] != '_' and k != 'manager') + if k[0] != '_' and + k not in ['manager', 'x_openstack_request_ids']) info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) return "<%s %s>" % (self.__class__.__name__, info) @@ -445,24 +482,32 @@ def human_id(self): return None def _add_details(self, info): - for (k, v) in six.iteritems(info): + for (k, v) in info.items(): try: setattr(self, k, v) - self._info[k] = v except AttributeError: # In this case we already defined the attribute on the class - pass + continue + except UnicodeEncodeError: + setattr(self, encodeutils.safe_encode(k), v) + self._info[k] = v def __getattr__(self, k): - if k not in self.__dict__: - #NOTE(bcwaldon): disallow lazy-loading if already loaded once + if k not in self.__dict__ or k not in self._info: + # 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] + if k in self.__dict__: + return self.__dict__[k] + return self._info[k] + + @property + def api_version(self): + return self.manager.api_version def get(self): # set_loaded() first ... so if we have to bail, we know we tried. @@ -480,12 +525,39 @@ def __eq__(self, other): # 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 __ne__(self, other): + return not self.__eq__(other) + def is_loaded(self): return self._loaded def set_loaded(self, val): self._loaded = val + + def to_dict(self): + return copy.deepcopy(self._info) + + +class ListWithMeta(list, RequestIdMixin): + def __init__(self, values, resp): + super(ListWithMeta, self).__init__(values) + self.setup() + self.append_request_ids(resp) + + +class DictWithMeta(dict, RequestIdMixin): + def __init__(self, values, resp): + super(DictWithMeta, self).__init__(values) + self.setup() + self.append_request_ids(resp) + + +class TupleWithMeta(tuple, RequestIdMixin): + def __new__(cls, values, resp): + return super(TupleWithMeta, cls).__new__(cls, values) + + def __init__(self, values, resp): + self.setup() + self.append_request_ids(resp) diff --git a/cinderclient/openstack/common/apiclient/exceptions.py b/cinderclient/apiclient/exceptions.py similarity index 98% rename from cinderclient/openstack/common/apiclient/exceptions.py rename to cinderclient/apiclient/exceptions.py index 4776d5872..71146c90f 100644 --- a/cinderclient/openstack/common/apiclient/exceptions.py +++ b/cinderclient/apiclient/exceptions.py @@ -23,8 +23,6 @@ import inspect import sys -import six - class ClientException(Exception): """The base exception class for all exceptions this library raises. @@ -396,7 +394,7 @@ class HttpVersionNotSupported(HttpServerError): # _code_map contains all the classes that have http_status attribute. _code_map = dict( (getattr(obj, 'http_status', None), obj) - for name, obj in six.iteritems(vars(sys.modules[__name__])) + for name, obj in vars(sys.modules[__name__]).items() if inspect.isclass(obj) and getattr(obj, 'http_status', False) ) @@ -426,7 +424,7 @@ def from_response(response, method, url): pass else: if hasattr(body, "keys"): - error = body[body.keys()[0]] + error = body[list(body.keys())[0]] kwargs["message"] = error.get("message", None) kwargs["details"] = error.get("details", None) elif content_type.startswith("text/"): diff --git a/cinderclient/auth_plugin.py b/cinderclient/auth_plugin.py deleted file mode 100644 index 2101b93bf..000000000 --- a/cinderclient/auth_plugin.py +++ /dev/null @@ -1,143 +0,0 @@ -# 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. - -import logging -import pkg_resources - -import six - -from cinderclient import exceptions -from cinderclient import utils - - -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. - """ - ep_name = 'openstack.client.auth_plugin' - for ep in pkg_resources.iter_entry_points(ep_name): - try: - auth_plugin = ep.load() - except (ImportError, pkg_resources.UnknownExtra, AttributeError) as e: - logger.debug("ERROR: Cannot load auth plugin %s" % ep.name) - logger.debug(e, exc_info=1) - else: - _discovered_plugins[ep.name] = auth_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. - """ - for name, auth_plugin in six.iteritems(_discovered_plugins): - add_opts_fn = getattr(auth_plugin, "add_opts", None) - if add_opts_fn: - group = parser.add_argument_group("Auth-system '%s' options" % - name) - add_opts_fn(group) - - -def load_plugin(auth_system): - if auth_system in _discovered_plugins: - return _discovered_plugins[auth_system]() - - # NOTE(aloga): If we arrive here, the plugin will be an old-style one, - # so we have to create a fake AuthPlugin for it. - return DeprecatedAuthPlugin(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. - """ - def __init__(self): - self.opts = {} - - def get_auth_url(self): - """Return the auth url for the plugin (if any).""" - return None - - @staticmethod - def add_opts(parser): - """Populate and return the parser with the options for this plugin. - - If the plugin does not need any options, it should return the same - parser untouched. - """ - return parser - - 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. - If the dict is empty, the client should assume that it needs the same - options as the 'keystone' auth system (i.e. os_username and - os_password). - - Returns the self.opts dict. - """ - return self.opts - - def authenticate(self, cls, auth_url): - """Authenticate using plugin defined method.""" - raise exceptions.AuthSystemNotFound(self.auth_system) - - -class DeprecatedAuthPlugin(object): - """Class to mimic the AuthPlugin class for deprecated auth systems. - - Old auth systems only define two entry points: openstack.client.auth_url - and openstack.client.authenticate. This class will load those entry points - into a class similar to a valid AuthPlugin. - """ - def __init__(self, auth_system): - self.auth_system = auth_system - - def authenticate(cls, auth_url): - raise exceptions.AuthSystemNotFound(self.auth_system) - - self.opts = {} - - self.get_auth_url = lambda: None - self.authenticate = authenticate - - self._load_endpoints() - - def _load_endpoints(self): - ep_name = 'openstack.client.auth_url' - fn = utils._load_entry_point(ep_name, name=self.auth_system) - if fn: - self.get_auth_url = fn - - ep_name = 'openstack.client.authenticate' - fn = utils._load_entry_point(ep_name, name=self.auth_system) - if fn: - self.authenticate = fn - - def parse_opts(self, args): - return self.opts diff --git a/cinderclient/base.py b/cinderclient/base.py index f2ed7b85f..a99b29c8e 100644 --- a/cinderclient/base.py +++ b/cinderclient/base.py @@ -23,13 +23,25 @@ import hashlib import os -import six - +from cinderclient.apiclient import base as common_base from cinderclient import exceptions -from cinderclient.openstack.common.apiclient import base as common_base from cinderclient import utils +# Valid sort directions and client sort keys +SORT_DIR_VALUES = ('asc', 'desc') +SORT_KEY_VALUES = ('id', 'status', 'size', 'availability_zone', 'name', + 'bootable', 'created_at', 'reference') +SORT_MANAGEABLE_KEY_VALUES = ('size', 'reference') +# Mapping of client keys to actual sort keys +SORT_KEY_MAPPINGS = {'name': 'display_name'} +# Additional sort keys for resources +SORT_KEY_ADD_VALUES = { + 'backups': ('data_timestamp', ), + 'messages': ('resource_type', 'event_id', 'resource_uuid', + 'message_level', 'guaranteed_until', 'request_id'), +} + Resource = common_base.Resource @@ -38,13 +50,10 @@ def getid(obj): Abstracts the common pattern of allowing both an object or an object's ID as a parameter when dealing with relationships. """ - try: - return obj.id - except AttributeError: - return obj + return getattr(obj, 'id', obj) -class Manager(utils.HookableMixin): +class Manager(common_base.HookableMixin): """ Managers interact with a particular type of API (servers, flavors, images, etc.) and provide CRUD operations for them. @@ -54,6 +63,10 @@ class Manager(utils.HookableMixin): def __init__(self, api): self.api = api + @property + def api_version(self): + return self.api.api_version + def _list(self, url, response_key, obj_class=None, body=None, limit=None, items=None): resp = None @@ -76,17 +89,18 @@ def _list(self, url, response_key, obj_class=None, body=None, except KeyError: pass - with self.completion_cache('human_id', obj_class, mode="w"): - with self.completion_cache('uuid', obj_class, mode="w"): - items_new = [obj_class(self, res, loaded=True) - for res in data if res] + items_new = [obj_class(self, res, loaded=True) + for res in data if res] if limit: limit = int(limit) margin = limit - len(items) if margin <= len(items_new): # If the limit is reached, return the items. items = items + items_new[:margin] - return items + if "count" in body: + return common_base.ListWithMeta(items, resp), body['count'] + else: + return common_base.ListWithMeta(items, resp) else: items = items + items_new else: @@ -96,19 +110,121 @@ def _list(self, url, response_key, obj_class=None, body=None, # than osapi_max_limit, so we have to retrieve multiple times to # get the complete list. next = None - if 'volumes_links' in body: - volumes_links = body['volumes_links'] - if volumes_links: - for volumes_link in volumes_links: - if 'rel' in volumes_link and 'next' == volumes_link['rel']: - next = volumes_link['href'] + link_name = response_key + '_links' + if link_name in body: + links = body[link_name] + if links: + for link in links: + if 'rel' in link and 'next' == link['rel']: + next = link['href'] break if next: # As long as the 'next' link is not empty, keep requesting it # till there is no more items. items = self._list(next, response_key, obj_class, None, limit, items) - return items + # If we use '--with-count' to get the resource count, + # the _list function will return the tuple result with + # (resources, count). + # So here, we must check the items' type then to do return. + if isinstance(items, tuple): + items = items[0] + if "count" in body: + return common_base.ListWithMeta(items, resp), body['count'] + else: + return common_base.ListWithMeta(items, resp) + + def _build_list_url(self, resource_type, detailed=True, search_opts=None, + marker=None, limit=None, sort=None, offset=None): + + if search_opts is None: + search_opts = {} + + query_params = {} + for key, val in search_opts.items(): + if val: + query_params[key] = val + + if marker: + query_params['marker'] = marker + + if limit: + query_params['limit'] = limit + + if sort: + query_params['sort'] = self._format_sort_param(sort, + resource_type) + + if offset: + query_params['offset'] = offset + query_params = query_params + # Transform the dict to a sequence of two-element tuples in fixed + # order, then the encoded string will be consistent in Python 2&3. + + query_string = utils.build_query_param(query_params, sort=True) + + detail = "" + if detailed: + detail = "/detail" + + return ("/%(resource_type)s%(detail)s%(query_string)s" % + {"resource_type": resource_type, "detail": detail, + "query_string": query_string}) + + def _format_sort_param(self, sort, resource_type=None): + """Formats the sort information into the sort query string parameter. + + The input sort information can be any of the following: + - Comma-separated string in the form of + - List of strings in the form of + - List of either string keys, or tuples of (key, dir) + + For example, the following import sort values are valid: + - 'key1:dir1,key2,key3:dir3' + - ['key1:dir1', 'key2', 'key3:dir3'] + - [('key1', 'dir1'), 'key2', ('key3', dir3')] + + :param sort: Input sort information + :returns: Formatted query string parameter or None + :raise ValueError: If an invalid sort direction or invalid sort key is + given + """ + if not sort: + return None + + if isinstance(sort, str): + # Convert the string into a list for consistent validation + sort = [s for s in sort.split(',') if s] + + sort_array = [] + for sort_item in sort: + sort_key, _sep, sort_dir = sort_item.partition(':') + sort_key = sort_key.strip() + sort_key = self._format_sort_key_param(sort_key, resource_type) + if sort_dir: + sort_dir = sort_dir.strip() + if sort_dir not in SORT_DIR_VALUES: + msg = ('sort_dir must be one of the following: %s.' + % ', '.join(SORT_DIR_VALUES)) + raise ValueError(msg) + sort_array.append('%s:%s' % (sort_key, sort_dir)) + else: + sort_array.append(sort_key) + return ','.join(sort_array) + + def _format_sort_key_param(self, sort_key, resource_type=None): + valid_sort_keys = SORT_KEY_VALUES + if resource_type: + add_sort_keys = SORT_KEY_ADD_VALUES.get(resource_type, None) + if add_sort_keys: + valid_sort_keys += add_sort_keys + + if sort_key in valid_sort_keys: + return SORT_KEY_MAPPINGS.get(sort_key, sort_key) + + msg = ('sort_key must be one of the following: %s.' % + ', '.join(valid_sort_keys)) + raise ValueError(msg) @contextlib.contextmanager def completion_cache(self, cache_type, obj_class, mode): @@ -124,19 +240,19 @@ def completion_cache(self, cache_type, obj_class, mode): often enough to keep the cache reasonably up-to-date. """ base_dir = utils.env('CINDERCLIENT_UUID_CACHE_DIR', - default="~/.cinderclient") + default="~/.cache/cinderclient") # NOTE(sirp): Keep separate UUID caches for each username + endpoint # pair username = utils.env('OS_USERNAME', 'CINDER_USERNAME') url = utils.env('OS_URL', 'CINDER_URL') - uniqifier = hashlib.md5(username.encode('utf-8') + - url.encode('utf-8')).hexdigest() + uniqifier = hashlib.sha1(username.encode('utf-8') + # nosec + url.encode('utf-8')).hexdigest() cache_dir = os.path.expanduser(os.path.join(base_dir, uniqifier)) try: - os.makedirs(cache_dir, 0o755) + os.makedirs(cache_dir, 0o750) except OSError: # NOTE(kiall): This is typically either permission denied while # attempting to create the directory, or the directory @@ -162,42 +278,85 @@ def completion_cache(self, cache_type, obj_class, mode): cache = getattr(self, cache_attr, None) if cache: cache.close() - delattr(self, cache_attr) + try: + delattr(self, cache_attr) + except AttributeError: + # NOTE(kiall): If this attr is deleted by another + # operation, don't fail any way. + pass def write_to_completion_cache(self, cache_type, val): cache = getattr(self, "_%s_cache" % cache_type, None) if cache: - cache.write("%s\n" % val) + try: + cache.write("%s\n" % val) + except UnicodeEncodeError: + pass def _get(self, url, response_key=None): resp, body = self.api.client.get(url) if response_key: - return self.resource_class(self, body[response_key], loaded=True) + return self.resource_class(self, body[response_key], loaded=True, + resp=resp) else: - return self.resource_class(self, body, loaded=True) + return self.resource_class(self, body, loaded=True, resp=resp) def _create(self, url, body, response_key, return_raw=False, **kwargs): self.run_hooks('modify_body_for_create', body, **kwargs) resp, body = self.api.client.post(url, body=body) if return_raw: - return body[response_key] + return common_base.DictWithMeta(body[response_key], resp) - with self.completion_cache('human_id', self.resource_class, mode="a"): - with self.completion_cache('uuid', self.resource_class, mode="a"): - return self.resource_class(self, body[response_key]) + return self.resource_class(self, body[response_key], resp=resp) def _delete(self, url): resp, body = self.api.client.delete(url) + return common_base.TupleWithMeta((resp, body), resp) def _update(self, url, body, response_key=None, **kwargs): self.run_hooks('modify_body_for_update', body, **kwargs) - resp, body = self.api.client.put(url, body=body) + resp, body = self.api.client.put(url, body=body, **kwargs) + if response_key: + return self.resource_class(self, body[response_key], loaded=True, + resp=resp) + + # (NOTE)ankit: In case of qos_specs.unset_keys method, None is + # returned back to the caller and in all other cases dict is + # returned but in order to return request_ids to the caller, it's + # not possible to return None so returning DictWithMeta for all cases. + body = body or {} + return common_base.DictWithMeta(body, resp) + + def _get_with_base_url(self, url, response_key=None): + resp, body = self.api.client.get_with_base_url(url) + if response_key: + return [self.resource_class(self, res, loaded=True) + for res in body[response_key] if res] + else: + return self.resource_class(self, body, loaded=True) + + def _get_all_with_base_url(self, url, response_key=None): + resp, body = self.api.client.get_with_base_url(url) + if response_key: + if isinstance(body[response_key], list): + return [self.resource_class(self, res, loaded=True) + for res in body[response_key] if res] + return self.resource_class(self, body[response_key], + loaded=True) + return self.resource_class(self, body, loaded=True) + + def _create_update_with_base_url(self, url, body, response_key=None): + resp, body = self.api.client.create_update_with_base_url( + url, body=body) if response_key: return self.resource_class(self, body[response_key], loaded=True) - return body + return self.resource_class(self, body, loaded=True) + def _delete_with_base_url(self, url, response_key=None): + self.api.client.delete_with_base_url(url) -class ManagerWithFind(six.with_metaclass(abc.ABCMeta, Manager)): + +class ManagerWithFind(Manager, metaclass=abc.ABCMeta): """ Like a `Manager`, but with additional `find()`/`findall()` methods. """ @@ -210,8 +369,8 @@ 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. + This isn't very efficient for search options which require the + Python side filtering(e.g. 'human_id') """ matches = self.findall(**kwargs) num_matches = len(matches) @@ -221,22 +380,43 @@ def find(self, **kwargs): elif num_matches > 1: raise exceptions.NoUniqueMatch else: + matches[0].append_request_ids(matches.request_ids) 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. + This isn't very efficient for search options which require the + Python side filtering(e.g. 'human_id') """ - found = [] - searches = list(kwargs.items()) # Want to search for all tenants here so that when attempting to delete # that a user like admin doesn't get a failure when trying to delete # another tenant's volume by name. - for obj in self.list(search_opts={'all_tenants': 1}): + search_opts = {'all_tenants': 1} + + # Pass 'name' or 'display_name' search_opts to server filtering to + # increase search performance. + if 'name' in kwargs: + search_opts['name'] = kwargs['name'] + elif 'display_name' in kwargs: + search_opts['display_name'] = kwargs['display_name'] + + found = common_base.ListWithMeta([], None) + # list_volume is used for group query, it's not resource's property. + list_volume = kwargs.pop('list_volume', False) + searches = kwargs.items() + if list_volume: + listing = self.list(search_opts=search_opts, + list_volume=list_volume) + else: + listing = self.list(search_opts=search_opts) + found.append_request_ids(listing.request_ids) + # Not all resources attributes support filters on server side + # (e.g. 'human_id' doesn't), so when doing findall some client + # side filtering is still needed. + for obj in listing: try: if all(getattr(obj, attr) == value for (attr, value) in searches): diff --git a/cinderclient/client.py b/cinderclient/client.py index 142bd9587..209d44197 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -14,55 +14,145 @@ # 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. -""" - -from __future__ import print_function +"""OpenStack Client interface. Handles the REST calls and responses.""" +import glob +import hashlib +import importlib.util +import itertools +import json import logging +import os +import pkgutil import re - -from keystoneclient import access -from keystoneclient import adapter -from keystoneclient.auth.identity import base -from keystoneclient import discover +from time import sleep +import urllib +from urllib import parse as urlparse + +from keystoneauth1 import access +from keystoneauth1 import adapter +from keystoneauth1 import discover +from keystoneauth1.identity import base +from oslo_utils import encodeutils +from oslo_utils import importutils +from oslo_utils import strutils import requests +from cinderclient._i18n import _ +from cinderclient import api_versions from cinderclient import exceptions -from cinderclient.openstack.common.gettextutils import _ -from cinderclient.openstack.common import importutils -from cinderclient.openstack.common import strutils - -osprofiler_web = importutils.try_import("osprofiler.web") +import cinderclient.extension -try: - import urlparse -except ImportError: - import urllib.parse as urlparse - -try: - from eventlet import sleep -except ImportError: - from time import sleep try: - import json -except ImportError: - import simplejson as json + osprofiler_web = importutils.try_import("osprofiler.web") +except Exception: + pass -# Python 2.5 compat fix -if not hasattr(urlparse, 'parse_qsl'): - import cgi - urlparse.parse_qsl = cgi.parse_qsl - -_VALID_VERSIONS = ['v1', 'v2'] +_VALID_VERSIONS = ['v3'] +V3_SERVICE_TYPE = 'volumev3' +SERVICE_TYPES = {'3': V3_SERVICE_TYPE} +REQ_ID_HEADER = 'X-OpenStack-Request-ID' # tell keystoneclient that we can ignore the /v1|v2/{project_id} component of # the service catalog when doing discovery lookups -for svc in ('volume', 'volumev2'): - discover.add_catalog_discover_hack(svc, re.compile('/v[12]/\w+/?$'), '/') +for svc in ('volume', 'volumev3'): + discover.add_catalog_discover_hack(svc, re.compile(r'/v[12]/\w+/?$'), '/') + + +def get_server_version(url, insecure=False, cacert=None, cert=None): + """Queries the server via the naked endpoint and gets version info. + + :param url: url of the cinder endpoint + :param insecure: Explicitly allow client to perform "insecure" TLS + (https) requests + :param cacert: Specify a CA bundle file to use in verifying a TLS + (https) server certificate + :param cert: A client certificate to pass to requests. These are of the + same form as requests expects. Either a single filename + containing both the certificate and key or a tuple containing + the path to the certificate then a path to the key. (optional) + :returns: APIVersion object for min and max version supported by + the server + """ + # NOTE: we (the client) don't support v2 anymore, but this function + # is checking the server version + min_version = "2.0" + current_version = "2.0" + + logger = logging.getLogger(__name__) + try: + u = urllib.parse.urlparse(url) + version_url = None + + # NOTE(andreykurilin): endpoint URL has at least 2 formats: + # 1. The classic (legacy) endpoint: + # http://{host}:{optional_port}/v{2 or 3}/{project-id} + # http://{host}:{optional_port}/v{2 or 3} + # 3. Under wsgi: + # http://{host}:{optional_port}/volume/v{2 or 3} + for ver in ['v2', 'v3']: + if u.path.endswith(ver) or "/{0}/".format(ver) in u.path: + path = u.path[:u.path.rfind(ver)] + version_url = '%s://%s%s' % (u.scheme, u.netloc, path) + break + + if not version_url: + # NOTE(andreykurilin): probably, it is one of the next cases: + # * https://volume.example.com/ + # * https://example.com/volume + # leave as is without cropping. + version_url = url + + if insecure: + verify_cert = False + else: + if cacert: + verify_cert = cacert + else: + verify_cert = True + response = requests.get(version_url, verify=verify_cert, cert=cert) + data = json.loads(response.text) + versions = data['versions'] + for version in versions: + if '3.' in version['version']: + min_version = version['min_version'] + current_version = version['version'] + break + else: + # keep looking in case this cloud is running v2 and + # we haven't seen v3 yet + continue + except exceptions.ClientException as e: + # NOTE: logging the warning but returning the lowest server API version + # supported in this OpenStack release is the legacy behavior, so that's + # what we do here + min_version = '3.0' + current_version = '3.0' + logger.warning("Error in server version query:%s\n" + "Returning APIVersion 3.0", str(e.message)) + return (api_versions.APIVersion(min_version), + api_versions.APIVersion(current_version)) + + +def get_highest_client_server_version(url, insecure=False, + cacert=None, cert=None): + """Returns highest supported version by client and server as a string. + + :raises: UnsupportedVersion if the maximum supported by the server + is less than the minimum supported by the client + """ + min_server, max_server = get_server_version(url, insecure, cacert, cert) + max_client = api_versions.APIVersion(api_versions.MAX_VERSION) + min_client = api_versions.APIVersion(api_versions.MIN_VERSION) + if max_server < min_client: + msg = _("The maximum version supported by the server (%(srv)s) does " + "not meet the minimum version supported by this client " + "(%(cli)s)") % {"srv": str(max_server), + "cli": api_versions.MIN_VERSION} + raise exceptions.UnsupportedVersion(msg) + return min(max_server, max_client).get_string() def get_volume_api_from_url(url): @@ -73,43 +163,58 @@ def get_volume_api_from_url(url): if version in components: return version[1:] - msg = "Invalid client version '%s'. must be one of: %s" % ( - (version, ', '.join(_VALID_VERSIONS))) + msg = (_("Invalid url: '%(url)s'. It must include one of: %(version)s.") + % {'url': url, 'version': ', '.join(_VALID_VERSIONS)}) raise exceptions.UnsupportedVersion(msg) class SessionClient(adapter.LegacyJsonAdapter): - def request(self, url, method, **kwargs): - kwargs.setdefault('authenticated', False) + def __init__(self, *args, **kwargs): + apiver = kwargs.pop('api_version', None) or api_versions.APIVersion() + self.http_log_debug = kwargs.pop('http_log_debug', False) + if not isinstance(apiver, api_versions.APIVersion): + apiver = api_versions.APIVersion(str(apiver)) + if apiver.ver_minor != 0: + kwargs['default_microversion'] = apiver.get_string() + self.retries = kwargs.pop('retries', 0) + self._logger = logging.getLogger(__name__) + super(SessionClient, self).__init__(*args, **kwargs) - # NOTE(thingee): v1 and v2 require the project id in the url. Prepend - # it if we're doing discovery. We figure out if we're doing discovery - # if there is no project id already specified in the path. parts is - # a list where index 1 is the version discovered and index 2 might be - # an empty string or a project id. - endpoint = self.get_endpoint() - parts = urlparse.urlsplit(endpoint).path.split('/') - project_id = self.get_project_id() - if (parts[1] in ['v1', 'v2'] and parts[2] == '' - and project_id is not None): - url = '{0}{1}{2}'.format(endpoint, project_id, url) + def request(self, *args, **kwargs): + kwargs.setdefault('authenticated', False) + if self.http_log_debug: + kwargs.setdefault('logger', self._logger) # Note(tpatil): The standard call raises errors from - # keystoneclient, here we need to raise the cinderclient errors. + # keystoneauth, here we need to raise the cinderclient errors. raise_exc = kwargs.pop('raise_exc', True) - resp, body = super(SessionClient, self).request(url, method, + resp, body = super(SessionClient, self).request(*args, raise_exc=False, **kwargs) + if raise_exc and resp.status_code >= 400: raise exceptions.from_response(resp, body) + if not self.global_request_id: + self.global_request_id = resp.headers.get('x-openstack-request-id') + return resp, body def _cs_request(self, url, method, **kwargs): # this function is mostly redundant but makes compatibility easier kwargs.setdefault('authenticated', True) - return self.request(url, method, **kwargs) + attempts = 0 + while True: + attempts += 1 + try: + return self.request(url, method, **kwargs) + except exceptions.OverLimit as overlim: + if attempts > self.retries or overlim.retry_after < 1: + raise + msg = "Retrying after %s seconds." % overlim.retry_after + self._logger.debug(msg) + sleep(overlim.retry_after) def get(self, url, **kwargs): return self._cs_request(url, 'GET', **kwargs) @@ -123,15 +228,26 @@ def put(self, url, **kwargs): def delete(self, url, **kwargs): return self._cs_request(url, 'DELETE', **kwargs) - def get_volume_api_version_from_endpoint(self): + def _get_base_url(self): endpoint = self.get_endpoint() - if not endpoint: - msg = _('The Cinder server does not support %s. Check your ' - 'providers supported versions and try again with ' - 'setting --os-volume-api-version or the environment ' - 'variable OS_VOLUME_API_VERSION.') % self.version - raise exceptions.InvalidAPIVersion(msg) - return get_volume_api_from_url(endpoint) + m = re.search('(.+)/v[1-3].*', endpoint) + if m: + # Get everything up until the version identifier + base_url = '%s/' % m.group(1) + else: + # Fall back to the root of the URL + base_url = '/'.join(endpoint.split('/')[:3]) + '/' + return base_url + + def get_volume_api_version_from_endpoint(self): + try: + version = get_volume_api_from_url(self.get_endpoint()) + except exceptions.UnsupportedVersion as e: + msg = (_("Service catalog returned invalid url.\n" + "%s") % str(e)) + raise exceptions.UnsupportedVersion(msg) + + return version def authenticate(self, auth=None): self.invalidate(auth) @@ -148,9 +264,26 @@ def service_catalog(self): raise AttributeError('There is no service catalog for this type of ' 'auth plugin.') + def _cs_request_base_url(self, url, method, **kwargs): + base_url = self._get_base_url() + return self._cs_request( + base_url + url, + method, + **kwargs) + + def get_with_base_url(self, url, **kwargs): + return self._cs_request_base_url(url, 'GET', **kwargs) + + def create_update_with_base_url(self, url, **kwargs): + return self._cs_request_base_url(url, 'PUT', **kwargs) + + def delete_with_base_url(self, url, **kwargs): + return self._cs_request_base_url(url, 'DELETE', **kwargs) + class HTTPClient(object): + SENSITIVE_HEADERS = ('X-Auth-Token', 'X-Subject-Token',) USER_AGENT = 'python-cinderclient' def __init__(self, user, password, projectid, auth_url=None, @@ -158,13 +291,17 @@ def __init__(self, user, password, projectid, auth_url=None, proxy_tenant_id=None, proxy_token=None, region_name=None, endpoint_type='publicURL', service_type=None, service_name=None, volume_service_name=None, - bypass_url=None, retries=None, - http_log_debug=False, cacert=None, - auth_system='keystone', auth_plugin=None): + os_endpoint=None, retries=None, + http_log_debug=False, cacert=None, cert=None, + auth_system='keystone', auth_plugin=None, api_version=None, + logger=None, user_domain_name='Default', + project_domain_name='Default', global_request_id=None): self.user = user self.password = password self.projectid = projectid self.tenant_id = tenant_id + self.api_version = api_version or api_versions.APIVersion() + self.global_request_id = global_request_id if auth_system and auth_system != 'keystone' and not auth_plugin: raise exceptions.AuthSystemNotFound(auth_system) @@ -175,22 +312,25 @@ def __init__(self, user, password, projectid, auth_url=None, raise exceptions.EndpointNotFound() self.auth_url = auth_url.rstrip('/') if auth_url else None - self.version = 'v1' + self.ks_version = 'v1' self.region_name = region_name self.endpoint_type = endpoint_type self.service_type = service_type self.service_name = service_name self.volume_service_name = volume_service_name - self.bypass_url = bypass_url.rstrip('/') if bypass_url else bypass_url + self.os_endpoint = os_endpoint.rstrip('/') \ + if os_endpoint else os_endpoint self.retries = int(retries or 0) self.http_log_debug = http_log_debug - self.management_url = self.bypass_url or None + self.management_url = self.os_endpoint or None self.auth_token = None self.proxy_token = proxy_token self.proxy_tenant_id = proxy_tenant_id self.timeout = timeout - + self.user_domain_name = user_domain_name + self.project_domain_name = project_domain_name + self.cert = cert if insecure: self.verify_cert = False else: @@ -202,7 +342,17 @@ def __init__(self, user, password, projectid, auth_url=None, self.auth_system = auth_system self.auth_plugin = auth_plugin - self._logger = logging.getLogger(__name__) + self._logger = logger or logging.getLogger(__name__) + + def _safe_header(self, name, value): + if name in HTTPClient.SENSITIVE_HEADERS: + encoded = value.encode('utf-8') + hashed = hashlib.sha1(encoded) + digested = hashed.hexdigest() + return encodeutils.safe_decode(name), "{SHA1}%s" % digested + else: + return (encodeutils.safe_decode(name), + encodeutils.safe_decode(value)) def http_log_req(self, args, kwargs): if not self.http_log_debug: @@ -216,14 +366,12 @@ def http_log_req(self, args, kwargs): string_parts.append(' %s' % element) for element in kwargs['headers']: - header = ' -H "%s: %s"' % (element, kwargs['headers'][element]) + header = ("-H '%s: %s'" % + self._safe_header(element, kwargs['headers'][element])) string_parts.append(header) if 'data' in kwargs: - if "password" in kwargs['data']: - data = strutils.mask_password(kwargs['data']) - else: - data = kwargs['data'] + data = strutils.mask_password(kwargs['data']) string_parts.append(" -d '%s'" % (data)) self._logger.debug("\nREQ: %s\n" % "".join(string_parts)) @@ -234,7 +382,7 @@ def http_log_resp(self, resp): "RESP: [%s] %s\nRESP BODY: %s\n", resp.status_code, resp.headers, - resp.text) + strutils.mask_password(resp.text)) def request(self, url, method, **kwargs): kwargs.setdefault('headers', kwargs.get('headers', {})) @@ -246,8 +394,11 @@ def request(self, url, method, **kwargs): if 'body' in kwargs: kwargs['headers']['Content-Type'] = 'application/json' - kwargs['data'] = json.dumps(kwargs['body']) - del kwargs['body'] + kwargs['data'] = json.dumps(kwargs.pop('body')) + api_versions.update_headers(kwargs["headers"], self.api_version) + + if self.global_request_id: + kwargs['headers'].setdefault(REQ_ID_HEADER, self.global_request_id) if self.timeout: kwargs.setdefault('timeout', self.timeout) @@ -256,17 +407,16 @@ def request(self, url, method, **kwargs): method, url, verify=self.verify_cert, + cert=self.cert, **kwargs) self.http_log_resp(resp) + body = None if resp.text: try: body = json.loads(resp.text) - except ValueError: - pass - body = None - else: - body = None + except ValueError as e: + self._logger.debug("Load http response text error: %s", e) if resp.status_code >= 400: raise exceptions.from_response(resp, body) @@ -285,10 +435,11 @@ def _cs_request(self, url, method, **kwargs): if self.projectid: kwargs['headers']['X-Auth-Project-Id'] = self.projectid try: - resp, body = self.request(self.management_url + url, method, - **kwargs) + if not url.startswith(self.management_url): + url = self.management_url + url + resp, body = self.request(url, method, **kwargs) return resp, body - except exceptions.BadRequest as e: + except exceptions.BadRequest: if attempts > self.retries: raise except exceptions.Unauthorized: @@ -300,6 +451,13 @@ def _cs_request(self, url, method, **kwargs): attempts -= 1 auth_attempts += 1 continue + except exceptions.OverLimit as overlim: + if attempts > self.retries or overlim.retry_after < 1: + raise + msg = "Retrying after %s seconds." % overlim.retry_after + self._logger.debug(msg) + sleep(overlim.retry_after) + continue except exceptions.ClientException as e: if attempts > self.retries: raise @@ -335,18 +493,31 @@ def delete(self, url, **kwargs): return self._cs_request(url, 'DELETE', **kwargs) def get_volume_api_version_from_endpoint(self): - return get_volume_api_from_url(self.management_url) + try: + version = get_volume_api_from_url(self.management_url) + except exceptions.UnsupportedVersion as e: + if self.management_url == self.os_endpoint: + msg = (_("Invalid url was specified in --os-endpoint %s") + % str(e)) + else: + msg = (_("Service catalog returned invalid url.\n" + "%s") % str(e)) + + raise exceptions.UnsupportedVersion(msg) + + return version def _extract_service_catalog(self, url, resp, body, extract_token=True): """See what the auth service told us and process the response. + We may get redirected to another site, fail or actually get back a service catalog with a token and our endpoints. """ - - if resp.status_code == 200: # content must always present + # content must always present + if resp.status_code == 200 or resp.status_code == 201: try: self.auth_url = url - self.auth_ref = access.AccessInfo.factory(resp, body) + self.auth_ref = access.create(resp=resp, body=body) self.service_catalog = self.auth_ref.service_catalog if extract_token: @@ -354,22 +525,26 @@ def _extract_service_catalog(self, url, resp, body, extract_token=True): management_url = self.service_catalog.url_for( region_name=self.region_name, - endpoint_type=self.endpoint_type, - service_type=self.service_type) + interface=self.endpoint_type, + service_type=self.service_type, + service_name=self.service_name) self.management_url = management_url.rstrip('/') return None except exceptions.AmbiguousEndpoints: print("Found more than one valid endpoint. Use a more " "restrictive filter") raise - except KeyError: + except ValueError: + # ValueError is raised when you pass an invalid response to + # access.create. This should never happen in reality if the + # status code is 200. raise exceptions.AuthorizationFailure() except exceptions.EndpointNotFound: print("Could not find any suitable endpoint. Correct region?") raise elif resp.status_code == 305: - return resp['location'] + return resp.headers['location'] else: raise exceptions.from_response(resp, body) @@ -394,6 +569,9 @@ def _fetch_endpoints_from_auth(self, url): return self._extract_service_catalog(url, resp, body, extract_token=False) + def set_management_url(self, url): + self.management_url = url + def authenticate(self): magic_tuple = urlparse.urlsplit(self.auth_url) scheme, netloc, path, query, frag = magic_tuple @@ -403,7 +581,7 @@ def authenticate(self): path_parts = path.split('/') for part in path_parts: if len(part) > 0 and part[0] == 'v': - self.version = part + self.ks_version = part break # TODO(sandy): Assume admin endpoint is 35357 for now. @@ -413,19 +591,17 @@ def authenticate(self): path, query, frag)) auth_url = self.auth_url - if self.version == "v2.0": + if 'v2' in self.ks_version or 'v3' in self.ks_version: while auth_url: if not self.auth_system or self.auth_system == 'keystone': - auth_url = self._v2_auth(auth_url) - else: - auth_url = self._plugin_auth(auth_url) + auth_url = self._v2_or_v3_auth(auth_url) # Are we acting on behalf of another user via an # existing token? If so, our actual endpoints may # be different than that of the admin token. if self.proxy_token: - if self.bypass_url: - self.set_management_url(self.bypass_url) + if self.os_endpoint: + self.set_management_url(self.os_endpoint) else: self._fetch_endpoints_from_auth(admin_url) # Since keystone no longer returns the user token @@ -442,10 +618,10 @@ def authenticate(self): except exceptions.AuthorizationFailure: if auth_url.find('v2.0') < 0: auth_url = auth_url + '/v2.0' - self._v2_auth(auth_url) + self._v2_or_v3_auth(auth_url) - if self.bypass_url: - self.set_management_url(self.bypass_url) + if self.os_endpoint: + self.set_management_url(self.os_endpoint) elif not self.management_url: raise exceptions.Unauthorized('Cinder Client') @@ -472,26 +648,43 @@ def _v1_auth(self, url): else: raise exceptions.from_response(resp, body) - def _plugin_auth(self, auth_url): - return self.auth_plugin.authenticate(self, auth_url) - - def _v2_auth(self, url): + def _v2_or_v3_auth(self, url): """Authenticate against a v2.0 auth service.""" - body = {"auth": { - "passwordCredentials": {"username": self.user, - "password": self.password}}} + if self.ks_version == "v3": + body = { + "auth": { + "identity": { + "methods": ["password"], + "password": {"user": { + "domain": {"name": self.user_domain_name}, + "name": self.user, + "password": self.password}}}, + } + } + scope = {"project": {"domain": {"name": self.project_domain_name}}} + if self.projectid: + scope['project']['name'] = self.projectid + elif self.tenant_id: + scope['project']['id'] = self.tenant_id - if self.projectid: - body['auth']['tenantName'] = self.projectid - elif self.tenant_id: - body['auth']['tenantId'] = self.tenant_id + body["auth"]["scope"] = scope + else: + body = {"auth": { + "passwordCredentials": {"username": self.user, + "password": self.password}}} - self._authenticate(url, body) + if self.projectid: + body['auth']['tenantName'] = self.projectid + elif self.tenant_id: + body['auth']['tenantId'] = self.tenant_id + return self._authenticate(url, body) def _authenticate(self, url, body): """Authenticate and extract the service catalog.""" - token_url = url + "/tokens" - + if self.ks_version == 'v3': + token_url = url + "/auth/tokens" + else: + token_url = url + "/tokens" # Make sure we follow redirects when trying to reach Keystone resp, body = self.request( token_url, @@ -508,27 +701,32 @@ def _construct_http_client(username=None, password=None, project_id=None, region_name=None, endpoint_type='publicURL', service_type='volume', service_name=None, volume_service_name=None, - bypass_url=None, retries=None, + os_endpoint=None, retries=None, http_log_debug=False, auth_system='keystone', auth_plugin=None, - cacert=None, tenant_id=None, + cacert=None, cert=None, tenant_id=None, session=None, - auth=None, + auth=None, api_version=None, **kwargs): - # Don't use sessions if third party plugin is used - if session and not auth_plugin: + if session: kwargs.setdefault('user_agent', 'python-cinderclient') kwargs.setdefault('interface', endpoint_type) + kwargs.setdefault('endpoint_override', os_endpoint) + return SessionClient(session=session, auth=auth, service_type=service_type, service_name=service_name, region_name=region_name, + retries=retries, + api_version=api_version, + http_log_debug=http_log_debug, **kwargs) else: # FIXME(jamielennox): username and password are now optional. Need # to test that they were provided in this mode. + logger = kwargs.get('logger') return HTTPClient(username, password, projectid=project_id, @@ -543,19 +741,33 @@ def _construct_http_client(username=None, password=None, project_id=None, service_type=service_type, service_name=service_name, volume_service_name=volume_service_name, - bypass_url=bypass_url, + os_endpoint=os_endpoint, retries=retries, http_log_debug=http_log_debug, cacert=cacert, + cert=cert, auth_system=auth_system, auth_plugin=auth_plugin, + logger=logger, + api_version=api_version ) +def _get_client_class_and_version(version): + if not isinstance(version, api_versions.APIVersion): + version = api_versions.get_api_version(version) + else: + api_versions.check_major_version(version) + if version.is_latest(): + raise exceptions.UnsupportedVersion( + _("The version should be explicit, not latest.")) + return version, importutils.import_class( + "cinderclient.v%s.client.Client" % version.ver_major) + + def get_client_class(version): version_map = { - '1': 'cinderclient.v1.client.Client', - '2': 'cinderclient.v2.client.Client', + '3': 'cinderclient.v3.client.Client', } try: client_path = version_map[str(version)] @@ -567,6 +779,74 @@ def get_client_class(version): return importutils.import_class(client_path) +def discover_extensions(version): + extensions = [] + for name, module in itertools.chain( + _discover_via_python_path(), + _discover_via_contrib_path(version)): + + extension = cinderclient.extension.Extension(name, module) + extensions.append(extension) + + return extensions + + +def _discover_via_python_path(): + for (module_loader, name, ispkg) in pkgutil.iter_modules(): + if name.endswith('cinderclient_ext'): + if not hasattr(module_loader, 'load_module'): + module_loader = module_loader.find_module(name) + module = module_loader.load_module(name) + yield name, module + + +def load_module(name, path): + module_spec = importlib.util.spec_from_file_location( + name, path + ) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +def _discover_via_contrib_path(version): + module_path = os.path.dirname(os.path.abspath(__file__)) + version_str = "v%s" % version.replace('.', '_') + ext_path = os.path.join(module_path, version_str, 'contrib') + ext_glob = os.path.join(ext_path, "*.py") + + for ext_path in glob.iglob(ext_glob): + name = os.path.basename(ext_path)[:-3] + + if name == "__init__": + continue + + module = load_module(name, ext_path) + yield name, module + + def Client(version, *args, **kwargs): - client_class = get_client_class(version) - return client_class(*args, version=version, **kwargs) + """Initialize client object based on given version. + + HOW-TO: + The simplest way to create a client instance is initialization with your + credentials:: + + .. code-block:: python + + >>> from cinderclient import client + >>> cinder = client.Client(VERSION, USERNAME, PASSWORD, + ... PROJECT_NAME, AUTH_URL) + + Here ``VERSION`` can be a string or + ``cinderclient.api_versions.APIVersion`` obj. If you prefer string value, + you can use ``3`` or ``3.X`` (where X is a microversion). + + + Alternatively, you can create a client instance using the keystoneclient + session API. See "The cinderclient Python API" page at + python-cinderclient's doc. + """ + api_version, client_class = _get_client_class_and_version(version) + return client_class(api_version=api_version, + *args, **kwargs) diff --git a/cinderclient/openstack/common/apiclient/__init__.py b/cinderclient/contrib/__init__.py similarity index 100% rename from cinderclient/openstack/common/apiclient/__init__.py rename to cinderclient/contrib/__init__.py diff --git a/cinderclient/contrib/noauth.py b/cinderclient/contrib/noauth.py new file mode 100644 index 000000000..59cc51e70 --- /dev/null +++ b/cinderclient/contrib/noauth.py @@ -0,0 +1,77 @@ +# 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 os + +from keystoneauth1 import loading +from keystoneauth1 import plugin + + +class CinderNoAuthPlugin(plugin.BaseAuthPlugin): + def __init__(self, user_id, project_id=None, roles=None, endpoint=None): + self._user_id = user_id + self._project_id = project_id if project_id else user_id + self._endpoint = endpoint + self._roles = roles + self.auth_token = '%s:%s' % (self._user_id, + self._project_id) + + def get_headers(self, session, **kwargs): + return {'x-user-id': self._user_id, + 'x-project-id': self._project_id, + 'X-Auth-Token': self.auth_token} + + def get_user_id(self, session, **kwargs): + return self._user_id + + def get_project_id(self, session, **kwargs): + return self._project_id + + def get_endpoint(self, session, **kwargs): + return '%s/%s' % (self._endpoint, self._project_id) + + def invalidate(self): + pass + + +class CinderOpt(loading.Opt): + @property + def argparse_args(self): + return ['--%s' % o.name for o in self._all_opts] + + @property + def argparse_default(self): + # select the first ENV that is not false-y or return None + for o in self._all_opts: + v = os.environ.get('Cinder_%s' % o.name.replace('-', '_').upper()) + if v: + return v + return self.default + + +class CinderNoAuthLoader(loading.BaseLoader): + plugin_class = CinderNoAuthPlugin + + def get_options(self): + options = super(CinderNoAuthLoader, self).get_options() + options.extend([ + CinderOpt('user-id', help='User ID', required=True, + metavar=""), + CinderOpt('project-id', help='Project ID', + metavar=""), + CinderOpt('endpoint', help='Cinder endpoint', + dest="endpoint", required=True, + metavar=""), + ]) + return options diff --git a/cinderclient/exceptions.py b/cinderclient/exceptions.py index cf259bf82..4f0227ea9 100644 --- a/cinderclient/exceptions.py +++ b/cinderclient/exceptions.py @@ -16,6 +16,32 @@ """ Exception definitions. """ +from datetime import datetime + +from oslo_utils import timeutils + + +class ResourceInErrorState(Exception): + """When resource is in Error state""" + def __init__(self, obj, fault_msg): + msg = "'%s' resource is in the error state" % obj.__class__.__name__ + if fault_msg: + msg += " due to '%s'" % fault_msg + self.message = "%s." % msg + + def __str__(self): + return self.message + + +class TimeoutException(Exception): + """When an action exceeds the timeout period to complete the action""" + def __init__(self, obj, action): + self.message = ("The '%(action)s' of the '%(object_name)s' exceeded " + "the timeout period." % {"action": action, + "object_name": obj.__class__.__name__}) + + def __str__(self): + return self.message class UnsupportedVersion(Exception): @@ -25,6 +51,34 @@ class UnsupportedVersion(Exception): pass +class UnsupportedAttribute(AttributeError): + """Indicates that the user is trying to transmit the argument to a method, + which is not supported by selected version. + """ + + def __init__(self, argument_name, start_version, end_version): + if start_version and end_version: + self.message = ( + "'%(name)s' argument is only allowed for microversions " + "%(start)s - %(end)s." % {"name": argument_name, + "start": start_version.get_string(), + "end": end_version.get_string()}) + elif start_version: + self.message = ( + "'%(name)s' argument is only allowed since microversion " + "%(start)s." % {"name": argument_name, + "start": start_version.get_string()}) + + elif end_version: + self.message = ( + "'%(name)s' argument is not allowed after microversion " + "%(end)s." % {"name": argument_name, + "end": end_version.get_string()}) + + def __str__(self): + return self.message + + class InvalidAPIVersion(Exception): pass @@ -80,14 +134,20 @@ class ClientException(Exception): """ The base exception class for all exceptions this library raises. """ - def __init__(self, code, message=None, details=None, request_id=None): + def __init__(self, code, message=None, details=None, + request_id=None, response=None): self.code = code - self.message = message or self.__class__.message + # NOTE(mriedem): Use getattr on self.__class__.message since + # BaseException.message was dropped in python 3, see PEP 0352. + self.message = message or getattr(self.__class__, 'message', None) self.details = details self.request_id = request_id def __str__(self): - formatted_string = "%s (HTTP %s)" % (self.message, self.code) + formatted_string = "%s" % self.message + if self.code >= 100: + # HTTP codes start at 100. + formatted_string += " (HTTP %s)" % self.code if self.request_id: formatted_string += " (Request-ID: %s)" % self.request_id @@ -127,6 +187,14 @@ class NotFound(ClientException): message = "Not found" +class NotAcceptable(ClientException): + """ + HTTP 406 - Not Acceptable + """ + http_status = 406 + message = "Not Acceptable" + + class OverLimit(ClientException): """ HTTP 413 - Over limit: you're over the API limits for this time period. @@ -134,6 +202,27 @@ class OverLimit(ClientException): http_status = 413 message = "Over limit" + def __init__(self, code, message=None, details=None, + request_id=None, response=None): + super(OverLimit, self).__init__(code, message=message, + details=details, request_id=request_id, + response=response) + self.retry_after = 0 + self._get_rate_limit(response) + + def _get_rate_limit(self, resp): + if (resp is not None) and resp.headers: + utc_now = timeutils.utcnow() + value = resp.headers.get('Retry-After', '0') + try: + value = datetime.strptime(value, '%a, %d %b %Y %H:%M:%S %Z') + if value > utc_now: + self.retry_after = ((value - utc_now).seconds) + else: + self.retry_after = 0 + except ValueError: + self.retry_after = int(value) + # NotImplemented is a python keyword. class HTTPNotImplemented(ClientException): @@ -152,19 +241,20 @@ class HTTPNotImplemented(ClientException): # Instead, we have to hardcode it: _code_map = dict((c.http_status, c) for c in [BadRequest, Unauthorized, Forbidden, NotFound, + NotAcceptable, OverLimit, HTTPNotImplemented]) def from_response(response, body): """ - Return an instance of an ClientException or subclass - based on an requests response. + Return an instance of a ClientException or subclass + based on a requests response. Usage:: resp, body = requests.request(...) if resp.status_code != 200: - raise exception_from_response(resp, rest.text) + raise exceptions.from_response(resp, resp.text) """ cls = _code_map.get(response.status_code, ClientException) if response.headers: @@ -175,11 +265,27 @@ def from_response(response, body): message = "n/a" details = "n/a" if hasattr(body, 'keys'): - error = body[list(body)[0]] - message = error.get('message', None) - details = error.get('details', None) + # Only in webob>=1.6.0 + if 'message' in body: + message = body.get('message') + details = body.get('details') + else: + error = body[list(body)[0]] + message = error.get('message', message) + details = error.get('details', details) return cls(code=response.status_code, message=message, details=details, - request_id=request_id) + request_id=request_id, response=response) else: return cls(code=response.status_code, request_id=request_id, - message=response.reason) + message=response.reason, response=response) + + +class VersionNotFoundForAPIMethod(Exception): + msg_fmt = "API version '%(vers)s' is not supported on '%(method)s' method." + + def __init__(self, version, method): + self.version = version + self.method = method + + def __str__(self): + return self.msg_fmt % {"vers": self.version, "method": self.method} diff --git a/cinderclient/extension.py b/cinderclient/extension.py index 84c67e979..a74cb91ef 100644 --- a/cinderclient/extension.py +++ b/cinderclient/extension.py @@ -13,11 +13,12 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient.apiclient import base as common_base from cinderclient import base from cinderclient import utils -class Extension(utils.HookableMixin): +class Extension(common_base.HookableMixin): """Extension descriptor.""" SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') diff --git a/cinderclient/openstack/common/apiclient/auth.py b/cinderclient/openstack/common/apiclient/auth.py deleted file mode 100644 index 1a713b0e1..000000000 --- a/cinderclient/openstack/common/apiclient/auth.py +++ /dev/null @@ -1,221 +0,0 @@ -# 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 os - -import six -from stevedore import extension - -from cinderclient.openstack.common.apiclient import exceptions - - -_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 six.iteritems(_discovered_plugins): - 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 required 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(six.iterkeys(_discovered_plugins)): - 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"]) - - -@six.add_metaclass(abc.ABCMeta) -class BaseAuthPlugin(object): - """Base class for authentication plugins. - - An authentication plugin needs to override at least the authenticate - method to be a valid plugin. - """ - - 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/client.py b/cinderclient/openstack/common/apiclient/client.py deleted file mode 100644 index da2e17738..000000000 --- a/cinderclient/openstack/common/apiclient/client.py +++ /dev/null @@ -1,358 +0,0 @@ -# Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack Foundation -# 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 exceptions 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/fake_client.py b/cinderclient/openstack/common/apiclient/fake_client.py deleted file mode 100644 index dcc2a5be6..000000000 --- a/cinderclient/openstack/common/apiclient/fake_client.py +++ /dev/null @@ -1,173 +0,0 @@ -# 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 requests -import six -from six.moves.urllib import parse - -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 = {} - if six.PY3 and isinstance(self._content, six.string_types): - self._content = self._content.encode('utf-8', 'strict') - 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 = parse.parse_qsl(parse.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 deleted file mode 100644 index 1516be14a..000000000 --- a/cinderclient/openstack/common/gettextutils.py +++ /dev/null @@ -1,479 +0,0 @@ -# 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 openstack.common.gettextutils import _ -""" - -import copy -import gettext -import locale -from logging import handlers -import os - -from babel import localedata -import six - -_AVAILABLE_LANGUAGES = {} - -# FIXME(dhellmann): Remove this when moving to oslo.i18n. -USE_LAZY = False - - -class TranslatorFactory(object): - """Create translator functions - """ - - def __init__(self, domain, localedir=None): - """Establish a set of translation functions for the domain. - - :param domain: Name of translation domain, - specifying a message catalog. - :type domain: str - :param lazy: Delays translation until a message is emitted. - Defaults to False. - :type lazy: Boolean - :param localedir: Directory with translation catalogs. - :type localedir: str - """ - self.domain = domain - if localedir is None: - localedir = os.environ.get(domain.upper() + '_LOCALEDIR') - self.localedir = localedir - - def _make_translation_func(self, domain=None): - """Return a new translation function ready for use. - - Takes into account whether or not lazy translation is being - done. - - The domain can be specified to override the default from the - factory, but the localedir from the factory is always used - because we assume the log-level translation catalogs are - installed in the same directory as the main application - catalog. - - """ - if domain is None: - domain = self.domain - t = gettext.translation(domain, - localedir=self.localedir, - fallback=True) - # Use the appropriate method of the translation object based - # on the python version. - m = t.gettext if six.PY3 else t.ugettext - - def f(msg): - """oslo.i18n.gettextutils translation function.""" - if USE_LAZY: - return Message(msg, domain=domain) - return m(msg) - return f - - @property - def primary(self): - "The default translation function." - return self._make_translation_func() - - def _make_log_translation_func(self, level): - return self._make_translation_func(self.domain + '-log-' + level) - - @property - def log_info(self): - "Translate info-level log messages." - return self._make_log_translation_func('info') - - @property - def log_warning(self): - "Translate warning-level log messages." - return self._make_log_translation_func('warning') - - @property - def log_error(self): - "Translate error-level log messages." - return self._make_log_translation_func('error') - - @property - def log_critical(self): - "Translate critical-level log messages." - return self._make_log_translation_func('critical') - - -# NOTE(dhellmann): When this module moves out of the incubator into -# oslo.i18n, these global variables can be moved to an integration -# module within each application. - -# Create the global translation functions. -_translators = TranslatorFactory('cinderclient') - -# The primary translation function using the well-known name "_" -_ = _translators.primary - -# Translators for log levels. -# -# The abbreviated names are meant to reflect the usual use of a short -# name like '_'. The "L" is for "log" and the other letter comes from -# the level. -_LI = _translators.log_info -_LW = _translators.log_warning -_LE = _translators.log_error -_LC = _translators.log_critical - -# NOTE(dhellmann): End of globals that will move to the application's -# integration module. - - -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 install(domain): - """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). - - Note that to enable lazy translation, enable_lazy must be - called. - - :param domain: the translation domain - """ - from six import moves - tf = TranslatorFactory(domain) - moves.builtins.__dict__['_'] = tf.primary - - -class Message(six.text_type): - """A Message object is a unicode object that can be translated. - - Translation of Message is done explicitly using the translate() method. - For all non-translation intents and purposes, a Message is simply unicode, - and can be treated as such. - """ - - def __new__(cls, msgid, msgtext=None, params=None, - domain='cinderclient', *args): - """Create a new Message object. - - In order for translation to work gettext requires a message ID, this - msgid will be used as the base unicode text. It is also possible - for the msgid and the base unicode text to be different by passing - the msgtext parameter. - """ - # If the base msgtext is not given, we use the default translation - # of the msgid (which is in English) just in case the system locale is - # not English, so that the base text will be in that locale by default. - if not msgtext: - msgtext = Message._translate_msgid(msgid, domain) - # We want to initialize the parent unicode with the actual object that - # would have been plain unicode if 'Message' was not enabled. - msg = super(Message, cls).__new__(cls, msgtext) - msg.msgid = msgid - msg.domain = domain - msg.params = params - return msg - - def translate(self, desired_locale=None): - """Translate this message to the desired locale. - - :param desired_locale: The desired locale to translate the message to, - if no locale is provided the message will be - translated to the system's default locale. - - :returns: the translated message in unicode - """ - - translated_message = Message._translate_msgid(self.msgid, - self.domain, - desired_locale) - if self.params is None: - # No need for more translation - return translated_message - - # This Message object may have been formatted with one or more - # Message objects as substitution arguments, given either as a single - # argument, part of a tuple, or as one or more values in a dictionary. - # When translating this Message we need to translate those Messages too - translated_params = _translate_args(self.params, desired_locale) - - translated_message = translated_message % translated_params - - return translated_message - - @staticmethod - def _translate_msgid(msgid, domain, desired_locale=None): - if not desired_locale: - system_locale = locale.getdefaultlocale() - # If the system locale is not available to the runtime use English - if not system_locale[0]: - desired_locale = 'en_US' - else: - desired_locale = system_locale[0] - - locale_dir = os.environ.get(domain.upper() + '_LOCALEDIR') - lang = gettext.translation(domain, - localedir=locale_dir, - languages=[desired_locale], - fallback=True) - if six.PY3: - translator = lang.gettext - else: - translator = lang.ugettext - - translated_message = translator(msgid) - return translated_message - - def __mod__(self, other): - # When we mod a Message we want the actual operation to be performed - # by the parent class (i.e. unicode()), the only thing we do here is - # save the original msgid and the parameters in case of a translation - params = self._sanitize_mod_params(other) - unicode_mod = super(Message, self).__mod__(params) - modded = Message(self.msgid, - msgtext=unicode_mod, - params=params, - domain=self.domain) - return modded - - def _sanitize_mod_params(self, other): - """Sanitize the object being modded with this Message. - - - Add support for modding 'None' so translation supports it - - Trim the modded object, which can be a large dictionary, to only - those keys that would actually be used in a translation - - Snapshot the object being modded, in case the message is - translated, it will be used as it was when the Message was created - """ - if other is None: - params = (other,) - elif isinstance(other, dict): - # Merge the dictionaries - # Copy each item in case one does not support deep copy. - params = {} - if isinstance(self.params, dict): - for key, val in self.params.items(): - params[key] = self._copy_param(val) - for key, val in other.items(): - params[key] = self._copy_param(val) - else: - params = self._copy_param(other) - return params - - def _copy_param(self, param): - try: - return copy.deepcopy(param) - except Exception: - # Fallback to casting to unicode this will handle the - # python code-like objects that can't be deep-copied - return six.text_type(param) - - def __add__(self, other): - msg = _('Message objects do not support addition.') - raise TypeError(msg) - - def __radd__(self, other): - return self.__add__(other) - - if six.PY2: - def __str__(self): - # NOTE(luisg): Logging in python 2.6 tries to str() log records, - # and it expects specifically a UnicodeError in order to proceed. - msg = _('Message objects do not support str() because they may ' - 'contain non-ascii characters. ' - 'Please use unicode() or translate() instead.') - raise UnicodeError(msg) - - -def get_available_languages(domain): - """Lists the available languages for the given translation domain. - - :param domain: the domain to get languages for - """ - if domain in _AVAILABLE_LANGUAGES: - return copy.copy(_AVAILABLE_LANGUAGES[domain]) - - 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 - 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 - # this check when the master list updates to >=1.0, and update all projects - 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: - language_list.append(i) - - # NOTE(luisg): Babel>=1.0,<1.3 has a bug where some OpenStack supported - # locales (e.g. 'zh_CN', and 'zh_TW') aren't supported even though they - # are perfectly legitimate locales: - # https://github.com/mitsuhiko/babel/issues/37 - # In Babel 1.3 they fixed the bug and they support these locales, but - # they are still not explicitly "listed" by locale_identifiers(). - # That is why we add the locales here explicitly if necessary so that - # they are listed as supported. - aliases = {'zh': 'zh_CN', - 'zh_Hant_HK': 'zh_HK', - 'zh_Hant': 'zh_TW', - 'fil': 'tl_PH'} - for (locale_, alias) in six.iteritems(aliases): - if locale_ in language_list and alias not in language_list: - language_list.append(alias) - - _AVAILABLE_LANGUAGES[domain] = language_list - return copy.copy(language_list) - - -def translate(obj, desired_locale=None): - """Gets the translated unicode representation of the given object. - - If the object is not translatable it is returned as-is. - If the locale is None the object is translated to the system locale. - - :param obj: the object to translate - :param desired_locale: the locale to translate the message to, if None the - default system locale will be used - :returns: the translated object in unicode, or the original object if - it could not be translated - """ - message = obj - if not isinstance(message, Message): - # If the object to translate is not already translatable, - # let's first get its unicode representation - message = six.text_type(obj) - if isinstance(message, Message): - # Even after unicoding() we still need to check if we are - # running with translatable unicode before translating - return message.translate(desired_locale) - return obj - - -def _translate_args(args, desired_locale=None): - """Translates all the translatable elements of the given arguments object. - - This method is used for translating the translatable values in method - arguments which include values of tuples or dictionaries. - If the object is not a tuple or a dictionary the object itself is - translated if it is translatable. - - If the locale is None the object is translated to the system locale. - - :param args: the args to translate - :param desired_locale: the locale to translate the args to, if None the - default system locale will be used - :returns: a new args object with the translated contents of the original - """ - if isinstance(args, tuple): - return tuple(translate(v, desired_locale) for v in args) - if isinstance(args, dict): - translated_dict = {} - for (k, v) in six.iteritems(args): - translated_v = translate(v, desired_locale) - translated_dict[k] = translated_v - return translated_dict - return translate(args, desired_locale) - - -class TranslationHandler(handlers.MemoryHandler): - """Handler that translates records before logging them. - - The TranslationHandler takes a locale and a target logging.Handler object - to forward LogRecord objects to after translating them. This handler - depends on Message objects being logged, instead of regular strings. - - The handler can be configured declaratively in the logging.conf as follows: - - [handlers] - keys = translatedlog, translator - - [handler_translatedlog] - class = handlers.WatchedFileHandler - args = ('/var/log/api-localized.log',) - formatter = context - - [handler_translator] - class = openstack.common.log.TranslationHandler - target = translatedlog - args = ('zh_CN',) - - If the specified locale is not available in the system, the handler will - log in the default locale. - """ - - def __init__(self, locale=None, target=None): - """Initialize a TranslationHandler - - :param locale: locale to use for translating messages - :param target: logging.Handler object to forward - LogRecord objects to after translation - """ - # NOTE(luisg): In order to allow this handler to be a wrapper for - # other handlers, such as a FileHandler, and still be able to - # configure it using logging.conf, this handler has to extend - # MemoryHandler because only the MemoryHandlers' logging.conf - # parsing is implemented such that it accepts a target handler. - handlers.MemoryHandler.__init__(self, capacity=0, target=target) - self.locale = locale - - def setFormatter(self, fmt): - self.target.setFormatter(fmt) - - def emit(self, record): - # We save the message from the original record to restore it - # after translation, so other handlers are not affected by this - original_msg = record.msg - original_args = record.args - - try: - self._translate_and_log_record(record) - finally: - record.msg = original_msg - record.args = original_args - - def _translate_and_log_record(self, record): - record.msg = translate(record.msg, self.locale) - - # In addition to translating the message, we also need to translate - # arguments that were passed to the log method that were not part - # of the main message e.g., log.info(_('Some message %s'), this_one)) - record.args = _translate_args(record.args, self.locale) - - self.target.emit(record) diff --git a/cinderclient/openstack/common/importutils.py b/cinderclient/openstack/common/importutils.py deleted file mode 100644 index 4fd9ae2bc..000000000 --- a/cinderclient/openstack/common/importutils.py +++ /dev/null @@ -1,66 +0,0 @@ -# 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 deleted file mode 100644 index dcccf6162..000000000 --- a/cinderclient/openstack/common/strutils.py +++ /dev/null @@ -1,295 +0,0 @@ -# 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. - -""" -System-level utilities and helper functions. -""" - -import math -import re -import sys -import unicodedata - -import six - -from cinderclient.openstack.common.gettextutils import _ - - -UNIT_PREFIX_EXPONENT = { - 'k': 1, - 'K': 1, - 'Ki': 1, - 'M': 2, - 'Mi': 2, - 'G': 3, - 'Gi': 3, - 'T': 4, - 'Ti': 4, -} -UNIT_SYSTEM_INFO = { - 'IEC': (1024, re.compile(r'(^[-+]?\d*\.?\d+)([KMGT]i?)?(b|bit|B)$')), - 'SI': (1000, re.compile(r'(^[-+]?\d*\.?\d+)([kMGT])?(b|bit|B)$')), -} - -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]+") - - -# NOTE(flaper87): The following 3 globals are used by `mask_password` -_SANITIZE_KEYS = ['adminPass', 'admin_pass', 'password', 'admin_password'] - -# NOTE(ldbragst): Let's build a list of regex objects using the list of -# _SANITIZE_KEYS we already have. This way, we only have to add the new key -# to the list of _SANITIZE_KEYS and we can generate regular expressions -# for XML and JSON automatically. -_SANITIZE_PATTERNS = [] -_FORMAT_PATTERNS = [r'(%(key)s\s*[=]\s*[\"\']).*?([\"\'])', - r'(<%(key)s>).*?()', - r'([\"\']%(key)s[\"\']\s*:\s*[\"\']).*?([\"\'])', - r'([\'"].*?%(key)s[\'"]\s*:\s*u?[\'"]).*?([\'"])', - r'([\'"].*?%(key)s[\'"]\s*,\s*\'--?[A-z]+\'\s*,\s*u?[\'"])' - '.*?([\'"])', - r'(%(key)s\s*--?[A-z]+\s*)\S+(\s*)'] - -for key in _SANITIZE_KEYS: - for pattern in _FORMAT_PATTERNS: - reg_ex = re.compile(pattern % {'key': key}, re.DOTALL) - _SANITIZE_PATTERNS.append(reg_ex) - - -def int_from_bool_as_string(subject): - """Interpret a string as a boolean and return either 1 or 0. - - Any string value in: - - ('True', 'true', 'On', 'on', '1') - - is interpreted as a boolean True. - - Useful for JSON-decoded stuff and config file parsing - """ - return bool_from_string(subject) and 1 or 0 - - -def bool_from_string(subject, strict=False, default=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 returns the value specified by 'default'. - - 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'. - """ - if not isinstance(subject, six.string_types): - subject = six.text_type(subject) - - lowered = subject.strip().lower() - - 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 default - - -def safe_decode(text, incoming=None, errors='strict'): - """Decodes incoming text/bytes string 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 instance of str - """ - if not isinstance(text, (six.string_types, six.binary_type)): - raise TypeError("%s can't be decoded" % type(text)) - - if isinstance(text, six.text_type): - return text - - if not incoming: - incoming = (sys.stdin.encoding or - sys.getdefaultencoding()) - - try: - return text.decode(incoming, errors) - except UnicodeDecodeError: - # Note(flaper87) If we get here, it means that - # sys.stdin.encoding / sys.getdefaultencoding - # didn't return a suitable encoding to decode - # text. This happens mostly when global LANG - # var is not set correctly and there's no - # default encoding. In this case, most likely - # python will use ASCII or ANSI encoders as - # default encodings but they won't be capable - # of decoding non-ASCII characters. - # - # Also, UTF-8 is being used since it's an ASCII - # extension. - return text.decode('utf-8', errors) - - -def safe_encode(text, incoming=None, - encoding='utf-8', errors='strict'): - """Encodes incoming text/bytes string 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) - :param errors: Errors handling policy. See here for valid - 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 instance of str - """ - if not isinstance(text, (six.string_types, six.binary_type)): - raise TypeError("%s can't be encoded" % type(text)) - - if not incoming: - incoming = (sys.stdin.encoding or - sys.getdefaultencoding()) - - if isinstance(text, six.text_type): - return text.encode(encoding, errors) - elif text and encoding != incoming: - # Decode text before encoding it with `encoding` - text = safe_decode(text, incoming, errors) - return text.encode(encoding, errors) - else: - return text - - -def string_to_bytes(text, unit_system='IEC', return_int=False): - """Converts a string into an float representation of bytes. - - The units supported for IEC :: - - Kb(it), Kib(it), Mb(it), Mib(it), Gb(it), Gib(it), Tb(it), Tib(it) - KB, KiB, MB, MiB, GB, GiB, TB, TiB - - The units supported for SI :: - - kb(it), Mb(it), Gb(it), Tb(it) - kB, MB, GB, TB - - Note that the SI unit system does not support capital letter 'K' - - :param text: String input for bytes size conversion. - :param unit_system: Unit system for byte size conversion. - :param return_int: If True, returns integer representation of text - in bytes. (default: decimal) - :returns: Numerical representation of text in bytes. - :raises ValueError: If text has an invalid value. - - """ - try: - base, reg_ex = UNIT_SYSTEM_INFO[unit_system] - except KeyError: - msg = _('Invalid unit system: "%s"') % unit_system - raise ValueError(msg) - match = reg_ex.match(text) - if match: - magnitude = float(match.group(1)) - unit_prefix = match.group(2) - if match.group(3) in ['b', 'bit']: - magnitude /= 8 - else: - msg = _('Invalid string format: %s') % text - raise ValueError(msg) - if not unit_prefix: - res = magnitude - else: - res = magnitude * pow(base, UNIT_PREFIX_EXPONENT[unit_prefix]) - if return_int: - return int(math.ceil(res)) - return res - - -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) - - -def mask_password(message, secret="***"): - """Replace password with 'secret' in message. - - :param message: The string which includes security information. - :param secret: value with which to replace passwords. - :returns: The unicode value of message with the password fields masked. - - For example: - - >>> mask_password("'adminPass' : 'aaaaa'") - "'adminPass' : '***'" - >>> mask_password("'admin_pass' : 'aaaaa'") - "'admin_pass' : '***'" - >>> mask_password('"password" : "aaaaa"') - '"password" : "***"' - >>> mask_password("'original_password' : 'aaaaa'") - "'original_password' : '***'" - >>> mask_password("u'original_password' : u'aaaaa'") - "u'original_password' : u'***'" - """ - message = six.text_type(message) - - # NOTE(ldbragst): Check to see if anything in message contains any key - # specified in _SANITIZE_KEYS, if not then just return the message since - # we don't have to mask any passwords. - if not any(key in message for key in _SANITIZE_KEYS): - return message - - secret = r'\g<1>' + secret + r'\g<2>' - for pattern in _SANITIZE_PATTERNS: - message = re.sub(pattern, secret, message) - return message diff --git a/cinderclient/service_catalog.py b/cinderclient/service_catalog.py deleted file mode 100644 index ce78b47be..000000000 --- a/cinderclient/service_catalog.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright (c) 2011 OpenStack Foundation -# Copyright 2011, Piston Cloud Computing, 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. - - -import cinderclient.exceptions - - -class ServiceCatalog(object): - """Helper methods for dealing with a Keystone Service Catalog.""" - - def __init__(self, resource_dict): - self.catalog = resource_dict - - def get_token(self): - return self.catalog['access']['token']['id'] - - def url_for(self, attr=None, filter_value=None, - service_type=None, endpoint_type='publicURL', - service_name=None, volume_service_name=None): - """Fetch the public URL from the Compute service for - a particular endpoint attribute. If none given, return - the first. See tests for sample service catalog. - """ - matching_endpoints = [] - if 'endpoints' in self.catalog: - # We have a bastardized service catalog. Treat it special. :/ - for endpoint in self.catalog['endpoints']: - if not filter_value or endpoint[attr] == filter_value: - matching_endpoints.append(endpoint) - if not matching_endpoints: - raise cinderclient.exceptions.EndpointNotFound() - - # We don't always get a service catalog back ... - if 'serviceCatalog' not in self.catalog['access']: - return None - - # Full catalog ... - catalog = self.catalog['access']['serviceCatalog'] - - for service in catalog: - - # 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 (volume_service_name and service_type in ('volume', 'volumev2') - and service.get('name') != volume_service_name): - continue - - endpoints = service['endpoints'] - for endpoint in endpoints: - if not filter_value or endpoint.get(attr) == filter_value: - endpoint["serviceName"] = service.get("name") - matching_endpoints.append(endpoint) - - if not matching_endpoints: - raise cinderclient.exceptions.EndpointNotFound() - elif len(matching_endpoints) > 1: - try: - eplist = [ep[attr] for ep in matching_endpoints] - except KeyError: - eplist = matching_endpoints - raise cinderclient.exceptions.AmbiguousEndpoints(endpoints=eplist) - else: - return matching_endpoints[0][endpoint_type] diff --git a/cinderclient/shell.py b/cinderclient/shell.py index da562e10f..ae473839b 100644 --- a/cinderclient/shell.py +++ b/cinderclient/shell.py @@ -1,4 +1,3 @@ - # Copyright 2011-2014 OpenStack Foundation # All Rights Reserved. # @@ -18,44 +17,67 @@ Command-line interface to the OpenStack Cinder API. """ -from __future__ import print_function - import argparse +import collections import getpass -import glob -import imp -import itertools import logging -import os -import pkgutil import sys - +from urllib import parse as urlparse + +from keystoneauth1 import discover +from keystoneauth1 import exceptions +from keystoneauth1.identity import v2 as v2_auth +from keystoneauth1.identity import v3 as v3_auth +from keystoneauth1 import loading +from keystoneauth1 import session +from oslo_utils import importutils import requests +import cinderclient +from cinderclient._i18n import _ +from cinderclient import api_versions from cinderclient import client from cinderclient import exceptions as exc from cinderclient import utils -import cinderclient.auth_plugin -import cinderclient.extension -from cinderclient.openstack.common import importutils -from cinderclient.openstack.common import strutils -from cinderclient.openstack.common.gettextutils import _ -from cinderclient.v1 import shell as shell_v1 -from cinderclient.v2 import shell as shell_v2 - -from keystoneclient import adapter -from keystoneclient import discover -from keystoneclient import session -from keystoneclient.auth.identity import v2 as v2_auth -from keystoneclient.auth.identity import v3 as v3_auth -from keystoneclient import exceptions as keystoneclient_exc -import six.moves.urllib.parse as urlparse - -osprofiler_profiler = importutils.try_import("osprofiler.profiler") - -DEFAULT_OS_VOLUME_API_VERSION = "1" + +try: + osprofiler_profiler = importutils.try_import("osprofiler.profiler") +except Exception: + pass + + +DEFAULT_MAJOR_OS_VOLUME_API_VERSION = "3" DEFAULT_CINDER_ENDPOINT_TYPE = 'publicURL' -DEFAULT_CINDER_SERVICE_TYPE = 'volume' +V3_SHELL = 'cinderclient.v3.shell' +HINT_HELP_MSG = (" [hint: use '--os-volume-api-version' flag to show help " + "message for proper version]") + +FILTER_CHECK = ["type-list", + "backup-list", + "get-pools", + "list", + "group-list", + "group-snapshot-list", + "message-list", + "snapshot-list", + "attachment-list"] + +RESOURCE_FILTERS = { + "list": ["name", "status", "metadata", + "bootable", "migration_status", "availability_zone", + "group_id", "size"], + "backup-list": ["name", "status", "volume_id"], + "snapshot-list": ["name", "status", "volume_id", "metadata", + "availability_zone"], + "group-list": ["name"], + "group-snapshot-list": ["name", "status", "group_id"], + "attachment-list": ["volume_id", "status", "instance_id", "attach_status"], + "message-list": ["resource_uuid", "resource_type", "event_id", + "request_id", "message_level"], + "get-pools": ["name", "volume_type"], + "type-list": ["is_public"] +} + logging.basicConfig() logger = logging.getLogger(__name__) @@ -108,11 +130,16 @@ def _get_option_tuples(self, option_string): class OpenStackCinderShell(object): + def __init__(self): + self.ks_logger = None + self.client_logger = None + self.extensions = [] + def get_base_parser(self): parser = CinderClientArgumentParser( prog='cinder', description=__doc__.strip(), - epilog='Run "cinder help SUBCOMMAND" for help on a subcommand.', + epilog=_('Run "cinder help SUBCOMMAND" for help on a subcommand.'), add_help=False, formatter_class=OpenStackHelpFormatter, ) @@ -130,251 +157,181 @@ def get_base_parser(self): action='store_true', default=utils.env('CINDERCLIENT_DEBUG', default=False), - help="Shows debugging output.") - - parser.add_argument('--os-auth-system', - metavar='', - default=utils.env('OS_AUTH_SYSTEM'), - help='Defaults to env[OS_AUTH_SYSTEM].') - parser.add_argument('--os_auth_system', - help=argparse.SUPPRESS) + help=_('Shows debugging output.')) parser.add_argument('--service-type', metavar='', - help='Service type. ' - 'For most actions, default is volume.') + help=_('Service type. ' + 'For most actions, default is volume.')) parser.add_argument('--service_type', help=argparse.SUPPRESS) parser.add_argument('--service-name', metavar='', default=utils.env('CINDER_SERVICE_NAME'), - help='Service name. ' - 'Default=env[CINDER_SERVICE_NAME].') + help=_('Service name. ' + 'Default=env[CINDER_SERVICE_NAME].')) parser.add_argument('--service_name', help=argparse.SUPPRESS) parser.add_argument('--volume-service-name', metavar='', default=utils.env('CINDER_VOLUME_SERVICE_NAME'), - help='Volume service name. ' - 'Default=env[CINDER_VOLUME_SERVICE_NAME].') + help=_('Volume service name. ' + 'Default=env[CINDER_VOLUME_SERVICE_NAME].')) parser.add_argument('--volume_service_name', help=argparse.SUPPRESS) - parser.add_argument('--endpoint-type', - metavar='', + parser.add_argument('--os-endpoint-type', + metavar='', default=utils.env('CINDER_ENDPOINT_TYPE', - default=DEFAULT_CINDER_ENDPOINT_TYPE), - help='Endpoint type, which is publicURL or ' + default=utils.env('OS_ENDPOINT_TYPE', + default=DEFAULT_CINDER_ENDPOINT_TYPE)), + help=_('Endpoint type, which is publicURL or ' 'internalURL. ' - 'Default=nova env[CINDER_ENDPOINT_TYPE] or ' - + DEFAULT_CINDER_ENDPOINT_TYPE + '.') - - parser.add_argument('--endpoint_type', + 'Default=env[OS_ENDPOINT_TYPE] or ' + 'nova env[CINDER_ENDPOINT_TYPE] or %s.') + % DEFAULT_CINDER_ENDPOINT_TYPE) + parser.add_argument('--os_endpoint_type', help=argparse.SUPPRESS) parser.add_argument('--os-volume-api-version', metavar='', default=utils.env('OS_VOLUME_API_VERSION', default=None), - help='Block Storage API version. ' - 'Valid values are 1 or 2. ' - 'Default=env[OS_VOLUME_API_VERSION].') + help=_('Block Storage API version. ' + 'Accepts X, X.Y (where X is major and Y is minor ' + 'part). NOTE: this client accepts only \'3\' for ' + 'the major version. ' + 'Default=env[OS_VOLUME_API_VERSION].')) parser.add_argument('--os_volume_api_version', help=argparse.SUPPRESS) - parser.add_argument('--bypass-url', - metavar='', - dest='bypass_url', - default=utils.env('CINDERCLIENT_BYPASS_URL'), - help="Use this API endpoint instead of the " + parser.add_argument('--os-endpoint', + metavar='', + dest='os_endpoint', + default=utils.env('CINDER_ENDPOINT'), + help=_("Use this API endpoint instead of the " "Service Catalog. Defaults to " - "env[CINDERCLIENT_BYPASS_URL]") - parser.add_argument('--bypass_url', + "env[CINDER_ENDPOINT].")) + parser.add_argument('--os_endpoint', help=argparse.SUPPRESS) parser.add_argument('--retries', metavar='', type=int, default=0, - help='Number of retries.') + help=_('Number of retries.')) + + parser.set_defaults(func=self.do_help) + parser.set_defaults(command='') if osprofiler_profiler: parser.add_argument('--profile', metavar='HMAC_KEY', - help='HMAC key to use for encrypting context ' - 'data for performance profiling of operation. ' - 'This key needs to match the one configured ' - 'on the cinder api server. ' + default=utils.env('OS_PROFILE'), + help=_('HMAC key to use for encrypting ' + 'context data for performance profiling ' + 'of operation. This key needs to match the ' + 'one configured on the cinder api server. ' 'Without key the profiling will not be ' 'triggered even if osprofiler is enabled ' - 'on server side.') + 'on server side. Defaults to ' + 'env[OS_PROFILE].')) self._append_global_identity_args(parser) - # The auth-system-plugins might require some extra options - cinderclient.auth_plugin.discover_auth_systems() - cinderclient.auth_plugin.load_auth_system_opts(parser) - return parser def _append_global_identity_args(self, parser): - # FIXME(bklei): these are global identity (Keystone) arguments which - # should be consistent and shared by all service clients. Therefore, - # they should be provided by python-keystoneclient. We will need to - # refactor this code once this functionality is available in - # python-keystoneclient. + loading.register_session_argparse_arguments(parser) + + # Use "password" auth plugin as default and keep the explicit + # "--os-token" arguments below for backward compatibility. + default_auth_plugin = 'password' + + # Passing [] to loading.register_auth_argparse_arguments to avoid + # the auth_type being overridden by the command line. + loading.register_auth_argparse_arguments( + parser, [], default=default_auth_plugin) parser.add_argument( '--os-auth-strategy', metavar='', default=utils.env('OS_AUTH_STRATEGY', default='keystone'), help=_('Authentication strategy (Env: OS_AUTH_STRATEGY' ', default keystone). For now, any other value will' - ' disable the authentication')) + ' disable the authentication.')) parser.add_argument( '--os_auth_strategy', help=argparse.SUPPRESS) - parser.add_argument('--os-username', - metavar='', - default=utils.env('OS_USERNAME', - 'CINDER_USERNAME'), - help='OpenStack user name. ' - 'Default=env[OS_USERNAME].') + # Change os_auth_type default value defined by + # register_auth_argparse_arguments to be backward compatible + # with OS_AUTH_SYSTEM. + env_plugin = utils.env('OS_AUTH_TYPE', + 'OS_AUTH_PLUGIN', + 'OS_AUTH_SYSTEM') + parser.set_defaults(os_auth_type=env_plugin) + parser.add_argument('--os_auth_type', + help=argparse.SUPPRESS) + + parser.set_defaults(os_username=utils.env('OS_USERNAME', + 'CINDER_USERNAME')) parser.add_argument('--os_username', help=argparse.SUPPRESS) - parser.add_argument('--os-password', - metavar='', - default=utils.env('OS_PASSWORD', - 'CINDER_PASSWORD'), - help='Password for OpenStack user. ' - 'Default=env[OS_PASSWORD].') + parser.set_defaults(os_password=utils.env('OS_PASSWORD', + 'CINDER_PASSWORD')) parser.add_argument('--os_password', help=argparse.SUPPRESS) - parser.add_argument('--os-tenant-name', - metavar='', - default=utils.env('OS_TENANT_NAME', - 'CINDER_PROJECT_ID'), - help='Tenant name. ' - 'Default=env[OS_TENANT_NAME].') - parser.add_argument('--os_tenant_name', - help=argparse.SUPPRESS) + parser.set_defaults(os_project_name=utils.env('OS_PROJECT_NAME', + 'CINDER_PROJECT_ID')) + parser.add_argument( + '--os_project_name', + help=argparse.SUPPRESS) - parser.add_argument('--os-tenant-id', - metavar='', - default=utils.env('OS_TENANT_ID', - 'CINDER_TENANT_ID'), - help='ID for the tenant. ' - 'Default=env[OS_TENANT_ID].') - parser.add_argument('--os_tenant_id', - help=argparse.SUPPRESS) + parser.set_defaults(os_project_id=utils.env('OS_PROJECT_ID', + 'CINDER_PROJECT_ID')) + parser.add_argument( + '--os_project_id', + help=argparse.SUPPRESS) - parser.add_argument('--os-auth-url', - metavar='', - default=utils.env('OS_AUTH_URL', - 'CINDER_URL'), - help='URL for the authentication service. ' - 'Default=env[OS_AUTH_URL].') + parser.set_defaults(os_auth_url=utils.env('OS_AUTH_URL', + 'CINDER_URL')) parser.add_argument('--os_auth_url', help=argparse.SUPPRESS) - parser.add_argument( - '--os-user-id', metavar='', - default=utils.env('OS_USER_ID'), - help=_('Authentication user ID (Env: OS_USER_ID)')) - + parser.set_defaults(os_user_id=utils.env('OS_USER_ID')) parser.add_argument( '--os_user_id', help=argparse.SUPPRESS) - parser.add_argument( - '--os-user-domain-id', - metavar='', - default=utils.env('OS_USER_DOMAIN_ID'), - help='OpenStack user domain ID. ' - 'Defaults to env[OS_USER_DOMAIN_ID].') - + parser.set_defaults( + os_user_domain_id=utils.env('OS_USER_DOMAIN_ID')) parser.add_argument( '--os_user_domain_id', help=argparse.SUPPRESS) - parser.add_argument( - '--os-user-domain-name', - metavar='', - default=utils.env('OS_USER_DOMAIN_NAME'), - help='OpenStack user domain name. ' - 'Defaults to env[OS_USER_DOMAIN_NAME].') - + parser.set_defaults( + os_user_domain_name=utils.env('OS_USER_DOMAIN_NAME')) parser.add_argument( '--os_user_domain_name', help=argparse.SUPPRESS) - parser.add_argument( - '--os-project-id', - metavar='', - default=utils.env('OS_PROJECT_ID'), - help='Another way to specify tenant ID. ' - 'This option is mutually exclusive with ' - ' --os-tenant-id. ' - 'Defaults to env[OS_PROJECT_ID].') - - parser.add_argument( - '--os_project_id', - help=argparse.SUPPRESS) + parser.set_defaults( + os_project_domain_id=utils.env('OS_PROJECT_DOMAIN_ID')) - parser.add_argument( - '--os-project-name', - metavar='', - default=utils.env('OS_PROJECT_NAME'), - help='Another way to specify tenant name. ' - 'This option is mutually exclusive with ' - ' --os-tenant-name. ' - 'Defaults to env[OS_PROJECT_NAME].') - - parser.add_argument( - '--os_project_name', - help=argparse.SUPPRESS) - - parser.add_argument( - '--os-project-domain-id', - metavar='', - default=utils.env('OS_PROJECT_DOMAIN_ID'), - help='Defaults to env[OS_PROJECT_DOMAIN_ID].') - - parser.add_argument( - '--os-project-domain-name', - metavar='', - default=utils.env('OS_PROJECT_DOMAIN_NAME'), - help='Defaults to env[OS_PROJECT_DOMAIN_NAME].') - - parser.add_argument( - '--os-cert', - metavar='', - default=utils.env('OS_CERT'), - help='Defaults to env[OS_CERT].') + parser.set_defaults( + os_project_domain_name=utils.env('OS_PROJECT_DOMAIN_NAME')) - parser.add_argument( - '--os-key', - metavar='', - default=utils.env('OS_KEY'), - help='Defaults to env[OS_KEY].') - - parser.add_argument('--os-region-name', - metavar='', - default=utils.env('OS_REGION_NAME', - 'CINDER_REGION_NAME'), - help='Region name. ' - 'Default=env[OS_REGION_NAME].') + parser.set_defaults( + os_region_name=utils.env('OS_REGION_NAME', + 'CINDER_REGION_NAME')) parser.add_argument('--os_region_name', help=argparse.SUPPRESS) - parser.add_argument( - '--os-token', metavar='', - default=utils.env('OS_TOKEN'), - help=_('Defaults to env[OS_TOKEN]')) + parser.set_defaults(os_token=utils.env('OS_TOKEN')) parser.add_argument( '--os_token', help=argparse.SUPPRESS) @@ -382,85 +339,34 @@ def _append_global_identity_args(self, parser): parser.add_argument( '--os-url', metavar='', default=utils.env('OS_URL'), - help=_('Defaults to env[OS_URL]')) + help=_('Defaults to env[OS_URL].')) parser.add_argument( '--os_url', help=argparse.SUPPRESS) - parser.add_argument( - '--os-cacert', - metavar='', - default=utils.env('OS_CACERT', default=None), - help=_("Specify a CA bundle file to use in " - "verifying a TLS (https) server certificate. " - "Defaults to env[OS_CACERT]")) - - parser.add_argument('--insecure', - default=utils.env('CINDERCLIENT_INSECURE', - default=False), - action='store_true', - help=argparse.SUPPRESS) + parser.set_defaults(insecure=utils.env('CINDERCLIENT_INSECURE', + default=False)) - def get_subcommand_parser(self, version): + def get_subcommand_parser(self, version, do_help=False, input_args=None): parser = self.get_base_parser() self.subcommands = {} subparsers = parser.add_subparsers(metavar='') - try: - actions_module = { - '1.1': shell_v1, - '2': shell_v2, - }[version] - except KeyError: - actions_module = shell_v1 + actions_module = importutils.import_module(V3_SHELL) - self._find_actions(subparsers, actions_module) - self._find_actions(subparsers, self) + self._find_actions(subparsers, actions_module, version, do_help, + input_args) + self._find_actions(subparsers, self, version, do_help, input_args) for extension in self.extensions: - self._find_actions(subparsers, extension.module) + self._find_actions(subparsers, extension.module, version, do_help, + input_args) self._add_bash_completion_subparser(subparsers) return parser - def _discover_extensions(self, version): - extensions = [] - for name, module in itertools.chain( - self._discover_via_python_path(version), - self._discover_via_contrib_path(version)): - - extension = cinderclient.extension.Extension(name, module) - extensions.append(extension) - - return extensions - - def _discover_via_python_path(self, version): - for (module_loader, name, ispkg) in pkgutil.iter_modules(): - if name.endswith('python_cinderclient_ext'): - if not hasattr(module_loader, 'load_module'): - # Python 2.6 compat: actually get an ImpImporter obj - module_loader = module_loader.find_module(name) - - module = module_loader.load_module(name) - yield name, module - - def _discover_via_contrib_path(self, version): - module_path = os.path.dirname(os.path.abspath(__file__)) - version_str = "v%s" % version.replace('.', '_') - ext_path = os.path.join(module_path, version_str, 'contrib') - ext_glob = os.path.join(ext_path, "*.py") - - for ext_path in glob.iglob(ext_glob): - name = os.path.basename(ext_path)[:-3] - - if name == "__init__": - continue - - module = imp.load_source(name, ext_path) - yield name, module - def _add_bash_completion_subparser(self, subparsers): subparser = subparsers.add_parser( 'bash_completion', @@ -470,18 +376,54 @@ def _add_bash_completion_subparser(self, subparsers): self.subcommands['bash_completion'] = subparser subparser.set_defaults(func=self.do_bash_completion) - def _find_actions(self, subparsers, actions_module): + def _build_versioned_help_message(self, start_version, end_version): + if start_version and end_version: + msg = (_(" (Supported by API versions %(start)s - %(end)s)") + % {"start": start_version.get_string(), + "end": end_version.get_string()}) + elif start_version: + msg = (_(" (Supported by API version %(start)s and later)") + % {"start": start_version.get_string()}) + else: + msg = (_(" (Supported until API version %(end)s)") + % {"end": end_version.get_string()}) + return str(msg) + + def _find_actions(self, subparsers, actions_module, version, + do_help, input_args): for attr in (a for a in dir(actions_module) if a.startswith('do_')): # I prefer to be hyphen-separated instead of underscores. command = attr[3:].replace('_', '-') callback = getattr(actions_module, attr) desc = callback.__doc__ or '' - help = desc.strip().split('\n')[0] + action_help = desc.strip().split('\n')[0] + if hasattr(callback, "versioned"): + additional_msg = "" + subs = api_versions.get_substitutions( + utils.get_function_name(callback)) + if do_help: + additional_msg = self._build_versioned_help_message( + subs[0].start_version, subs[-1].end_version) + if version.is_latest(): + additional_msg += HINT_HELP_MSG + subs = [versioned_method for versioned_method in subs + if version.matches(versioned_method.start_version, + versioned_method.end_version)] + if not subs: + # There is no proper versioned method. + continue + # Use the "latest" substitution. + callback = subs[-1].func + desc = callback.__doc__ or desc + action_help = desc.strip().split('\n')[0] + action_help += additional_msg + + exclusive_args = getattr(callback, 'exclusive_args', {}) arguments = getattr(callback, 'arguments', []) subparser = subparsers.add_parser( command, - help=help, + help=action_help, description=desc, add_help=False, formatter_class=OpenStackHelpFormatter) @@ -491,10 +433,59 @@ def _find_actions(self, subparsers, actions_module): help=argparse.SUPPRESS,) self.subcommands[command] = subparser - for (args, kwargs) in arguments: - subparser.add_argument(*args, **kwargs) + self._add_subparser_args(subparser, arguments, version, do_help, + input_args, command) + self._add_subparser_exclusive_args(subparser, exclusive_args, + version, do_help, input_args, + command) subparser.set_defaults(func=callback) + def _add_subparser_args(self, subparser, arguments, version, do_help, + input_args, command): + # NOTE(ntpttr): We get a counter for each argument in this + # command here because during the microversion check we only + # want to raise an exception if no version of the argument + # matches the current microversion. The exception will only + # be raised after the last instance of a particular argument + # fails the check. + arg_counter = collections.defaultdict(int) + for (args, kwargs) in arguments: + arg_counter[args[0]] += 1 + + for (args, kwargs) in arguments: + start_version = kwargs.get("start_version", None) + start_version = api_versions.APIVersion(start_version) + end_version = kwargs.get('end_version', None) + end_version = api_versions.APIVersion(end_version) + if do_help and (start_version or end_version): + kwargs["help"] = kwargs.get("help", "") + ( + self._build_versioned_help_message(start_version, + end_version)) + if not version.matches(start_version, end_version): + if args[0] in input_args and command == input_args[0]: + if arg_counter[args[0]] == 1: + # This is the last version of this argument, + # raise the exception. + raise exc.UnsupportedAttribute(args[0], + start_version, end_version) + arg_counter[args[0]] -= 1 + continue + kw = kwargs.copy() + kw.pop("start_version", None) + kw.pop("end_version", None) + subparser.add_argument(*args, **kw) + + def _add_subparser_exclusive_args(self, subparser, exclusive_args, + version, do_help, input_args, command): + for group_name, arguments in exclusive_args.items(): + if group_name == '__required__': + continue + required = exclusive_args['__required__'][group_name] + exclusive_group = subparser.add_mutually_exclusive_group( + required=required) + self._add_subparser_args(exclusive_group, arguments, + version, do_help, input_args, command) + def setup_debugging(self, debug): if not debug: return @@ -502,18 +493,18 @@ def setup_debugging(self, debug): streamhandler = logging.StreamHandler() streamformat = "%(levelname)s (%(module)s:%(lineno)d) %(message)s" streamhandler.setFormatter(logging.Formatter(streamformat)) - logger.setLevel(logging.WARNING) + logger.setLevel(logging.DEBUG if debug else logging.WARNING) logger.addHandler(streamhandler) - client_logger = logging.getLogger(client.__name__) + self.client_logger = logging.getLogger(client.__name__) ch = logging.StreamHandler() - client_logger.setLevel(logging.DEBUG) - client_logger.addHandler(ch) + self.client_logger.setLevel(logging.DEBUG) + self.client_logger.addHandler(ch) if hasattr(requests, 'logging'): requests.logging.getLogger(requests.__name__).addHandler(ch) - # required for logging when using a keystone session - ks_logger = logging.getLogger("keystoneclient") - ks_logger.setLevel(logging.DEBUG) + + self.ks_logger = logging.getLogger("keystoneauth") + self.ks_logger.setLevel(logging.DEBUG) def _delimit_metadata_args(self, argv): """This function adds -- separator at the appropriate spot @@ -537,33 +528,69 @@ def _delimit_metadata_args(self, argv): else: return argv + @staticmethod + def _validate_input_api_version(options): + if not options.os_volume_api_version: + api_version = api_versions.APIVersion(api_versions.MAX_VERSION) + else: + api_version = api_versions.get_api_version( + options.os_volume_api_version) + return api_version + + @staticmethod + def downgrade_warning(requested, discovered): + logger.warning("API version %s requested, " % requested.get_string()) + logger.warning("downgrading to %s based on server support." % + discovered.get_string()) + + def check_duplicate_filters(self, argv, filter): + resource = RESOURCE_FILTERS[filter] + filters = [] + for opt in range(len(argv)): + if argv[opt].startswith('--'): + if argv[opt] == '--filters': + key, __ = argv[opt + 1].split('=') + if key in resource: + filters.append(key) + elif argv[opt][2:] in resource: + filters.append(argv[opt][2:]) + + if len(set(filters)) != len(filters): + raise exc.CommandError( + "Filters are only allowed to be passed once.") + def main(self, argv): # Parse args once to find version and debug settings + for filter in FILTER_CHECK: + if filter in argv: + self.check_duplicate_filters(argv, filter) + break parser = self.get_base_parser() (options, args) = parser.parse_known_args(argv) self.setup_debugging(options.debug) api_version_input = True - service_type_input = True self.options = options - if not options.os_volume_api_version: - # Environment variable OS_VOLUME_API_VERSION was - # not set and '--os-volume-api-version' option doesn't - # specify a value. Fall back to default. - options.os_volume_api_version = DEFAULT_OS_VOLUME_API_VERSION - api_version_input = False + do_help = ('help' in argv) or ( + '--help' in argv) or ('-h' in argv) or not argv - version = (options.os_volume_api_version,) + api_version = self._validate_input_api_version(options) # build available subcommands based on version - self.extensions = self._discover_extensions( - options.os_volume_api_version) + major_version_string = "%s" % api_version.ver_major + self.extensions = client.discover_extensions(major_version_string) self._run_extension_hooks('__pre_parse_args__') - subcommand_parser = self.get_subcommand_parser( - options.os_volume_api_version) + subcommand_parser = self.get_subcommand_parser(api_version, + do_help, args) self.parser = subcommand_parser + if argv and len(argv) > 1 and '--help' in argv: + argv = [x for x in argv if x != '--help'] + if argv[0] in self.subcommands: + self.subcommands[argv[0]].print_help() + return 0 + if options.help or not argv: subcommand_parser.print_help() return 0 @@ -580,50 +607,54 @@ def main(self, argv): self.do_bash_completion(args) return 0 - (os_username, os_password, os_tenant_name, os_auth_url, - os_region_name, os_tenant_id, endpoint_type, insecure, - service_type, service_name, volume_service_name, bypass_url, - cacert, os_auth_system) = ( + (os_username, os_password, os_project_name, os_auth_url, + os_region_name, os_project_id, endpoint_type, + service_type, service_name, volume_service_name, os_endpoint, + cacert, os_auth_type) = ( args.os_username, args.os_password, - args.os_tenant_name, args.os_auth_url, - args.os_region_name, args.os_tenant_id, - args.endpoint_type, args.insecure, + args.os_project_name, args.os_auth_url, + args.os_region_name, args.os_project_id, + args.os_endpoint_type, args.service_type, args.service_name, args.volume_service_name, - args.bypass_url, args.os_cacert, - args.os_auth_system) - if os_auth_system and os_auth_system != "keystone": - auth_plugin = cinderclient.auth_plugin.load_plugin(os_auth_system) + args.os_endpoint, args.os_cacert, + args.os_auth_type) + auth_session = None + + if os_auth_type and os_auth_type != "keystone": + auth_plugin = loading.load_auth_from_argparse_arguments( + self.options) + auth_session = loading.load_session_from_argparse_arguments( + self.options, auth=auth_plugin) else: auth_plugin = None - if not endpoint_type: - endpoint_type = DEFAULT_CINDER_ENDPOINT_TYPE - if not service_type: - service_type = DEFAULT_CINDER_SERVICE_TYPE - service_type = utils.get_service_type(args.func) or service_type - service_type_input = False + service_type = client.SERVICE_TYPES[major_version_string] # FIXME(usrleon): Here should be restrict for project id same as # for os_username or os_password but for compatibility it is not. - if not utils.isunauthenticated(args.func): - if auth_plugin: - auth_plugin.parse_opts(args) - - if not auth_plugin or not auth_plugin.opts: - if not os_username: - raise exc.CommandError("You must provide a user name " - "through --os-username or " - "env[OS_USERNAME].") - + # V3 stuff + project_info_provided = ((self.options.os_project_name and + (self.options.os_project_domain_name or + self.options.os_project_domain_id)) or + self.options.os_project_id or + self.options.os_project_name) + + # NOTE(e0ne): if auth_session exists it means auth plugin created + # session and we don't need to check for password and other + # authentification-related things. + if not utils.isunauthenticated(args.func) and not auth_session: if not os_password: # No password, If we've got a tty, try prompting for it if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty(): # Check for Ctl-D try: os_password = getpass.getpass('OS Password: ') + # Initialize options.os_password with password + # input from tty. It is used in _get_keystone_session. + options.os_password = os_password except EOFError: pass # No password because we didn't have a tty or the @@ -634,122 +665,72 @@ def main(self, argv): "env[OS_PASSWORD] " "or, prompted response.") - if not (os_tenant_name or os_tenant_id): - raise exc.CommandError("You must provide a tenant ID " - "through --os-tenant-id or " - "env[OS_TENANT_ID].") - - # V3 stuff - project_info_provided = self.options.os_tenant_name or \ - self.options.os_tenant_id or \ - (self.options.os_project_name and - (self.options.project_domain_name or - self.options.project_domain_id)) or \ - self.options.os_project_id - - if (not project_info_provided): - raise exc.CommandError( - _("You must provide a tenant_name, tenant_id, " - "project_id or project_name (with " - "project_domain_name or project_domain_id) via " - " --os-tenant-name (env[OS_TENANT_NAME])," - " --os-tenant-id (env[OS_TENANT_ID])," - " --os-project-id (env[OS_PROJECT_ID])" - " --os-project-name (env[OS_PROJECT_NAME])," - " --os-project-domain-id " - "(env[OS_PROJECT_DOMAIN_ID])" - " --os-project-domain-name " - "(env[OS_PROJECT_DOMAIN_NAME])")) - - if not os_auth_url: - if os_auth_system and os_auth_system != 'keystone': - os_auth_url = auth_plugin.get_auth_url() + if not project_info_provided: + raise exc.CommandError(_( + "You must provide a project_id or project_name (with " + "project_domain_name or project_domain_id) via " + " --os-project-id (env[OS_PROJECT_ID])" + " --os-project-name (env[OS_PROJECT_NAME])," + " --os-project-domain-id " + "(env[OS_PROJECT_DOMAIN_ID])" + " --os-project-domain-name " + "(env[OS_PROJECT_DOMAIN_NAME])" + )) if not os_auth_url: raise exc.CommandError( "You must provide an authentication URL " "through --os-auth-url or env[OS_AUTH_URL].") - if not (os_tenant_name or os_tenant_id): - raise exc.CommandError( - "You must provide a tenant ID " - "through --os-tenant-id or env[OS_TENANT_ID].") - - if not os_auth_url: + if not project_info_provided: + raise exc.CommandError(_( + "You must provide a project_id or project_name (with " + "project_domain_name or project_domain_id) via " + " --os-project-id (env[OS_PROJECT_ID])" + " --os-project-name (env[OS_PROJECT_NAME])," + " --os-project-domain-id " + "(env[OS_PROJECT_DOMAIN_ID])" + " --os-project-domain-name " + "(env[OS_PROJECT_DOMAIN_NAME])" + )) + + if not os_auth_url and not auth_plugin: raise exc.CommandError( "You must provide an authentication URL " "through --os-auth-url or env[OS_AUTH_URL].") - auth_session = self._get_keystone_session() - if not service_type_input or not api_version_input: - # NOTE(thingee): Unfortunately the v2 shell is tied to volumev2 - # service_type. If the service_catalog just contains service_type - # volume with x.x.x.x:8776 for discovery, and the user sets version - # 2 for the client, it'll default to volumev2 and raise - # EndpointNotFound. This is a workaround until we feel comfortable - # with removing the volumev2 assumption. - keystone_adapter = adapter.Adapter(auth_session) - try: - # Try just the client's defaults - endpoint = keystone_adapter.get_endpoint( - service_type=service_type, - version=version, - interface='public') - - # Service was found, but wrong version. Lets try a different - # version, if the user did not specify one. - if not endpoint and not api_version_input: - if version == ('1',): - version = ('2',) - else: - version = ('1',) - - endpoint = keystone_adapter.get_endpoint( - service_type=service_type, version=version, - interface='public') - - except keystoneclient_exc.EndpointNotFound as e: - # No endpoint found with that service_type, lets fall back to - # other service_types if the user did not specify one. - if not service_type_input: - if service_type == 'volume': - service_type = 'volumev2' - else: - service_type = 'volume' - - try: - endpoint = keystone_adapter.get_endpoint( - version=version, - service_type=service_type, interface='public') - - # Service was found, but wrong version. Lets try - # a different version, if the user did not specify one. - if not endpoint and not api_version_input: - if version == ('1',): - version = ('2',) - else: - version = ('1',) - - endpoint = keystone_adapter.get_endpoint( - service_type=service_type, version=version, - interface='public') - - except keystoneclient_exc.EndpointNotFound: - raise e - - self.cs = client.Client(version[0], os_username, os_password, - os_tenant_name, os_auth_url, - region_name=os_region_name, - tenant_id=os_tenant_id, - endpoint_type=endpoint_type, - extensions=self.extensions, - service_type=service_type, - service_name=service_name, - volume_service_name=volume_service_name, - bypass_url=bypass_url, retries=options.retries, - http_log_debug=args.debug, cacert=cacert, - auth_system=os_auth_system, - auth_plugin=auth_plugin, session=auth_session) + if not auth_session: + auth_session = self._get_keystone_session() + + # collect_timing is a keystone session option + if (not isinstance(auth_session, session.Session) + and getattr(args, 'collect_timing', False) is True): + raise exc.AuthorizationFailure("Provided auth plugin doesn't " + "support collect_timing option") + + insecure = self.options.insecure + + client_args = dict( + region_name=os_region_name, + tenant_id=os_project_id, + endpoint_type=endpoint_type, + extensions=self.extensions, + service_type=service_type, + service_name=service_name, + volume_service_name=volume_service_name, + os_endpoint=os_endpoint, + retries=options.retries, + http_log_debug=args.debug, + insecure=insecure, + cacert=cacert, auth_system=os_auth_type, + auth_plugin=auth_plugin, + session=auth_session, + logger=self.ks_logger if auth_session else self.client_logger) + + self.cs = client.Client( + api_version, os_username, + os_password, os_project_name, os_auth_url, + **client_args) try: if not utils.isunauthenticated(args.func): @@ -759,6 +740,10 @@ def main(self, argv): except exc.AuthorizationFailure: raise exc.CommandError("Unable to authorize user.") + # FIXME: this section figuring out the api version could use + # analysis and refactoring. See + # https://review.opendev.org/c/openstack/python-cinderclient/+/766882/ + # for some ideas. endpoint_api_version = None # Try to get the API version from the endpoint URL. If that fails fall # back to trying to use what the user specified via @@ -767,38 +752,122 @@ def main(self, argv): try: endpoint_api_version = \ self.cs.get_volume_api_version_from_endpoint() - if (endpoint_api_version != options.os_volume_api_version - and api_version_input): - msg = (("OpenStack Block Storage API version is set to %s " - "but you are accessing a %s endpoint. " - "Change its value through --os-volume-api-version " - "or env[OS_VOLUME_API_VERSION].") - % (options.os_volume_api_version, endpoint_api_version)) - raise exc.InvalidAPIVersion(msg) except exc.UnsupportedVersion: endpoint_api_version = options.os_volume_api_version - if api_version_input: + # FIXME: api_version_input is initialized as True at the beginning + # of this function and never modified + if api_version_input and endpoint_api_version: logger.warning("Cannot determine the API version from " "the endpoint URL. Falling back to the " - "user-specified version: %s" % + "user-specified version: %s", endpoint_api_version) - else: + elif endpoint_api_version: logger.warning("Cannot determine the API version from the " "endpoint URL or user input. Falling back " - "to the default API version: %s" % + "to the default API version: %s", endpoint_api_version) + else: + msg = _("Cannot determine API version. Please specify by " + "using --os-volume-api-version option.") + raise exc.UnsupportedVersion(msg) + + API_MIN_VERSION = api_versions.APIVersion(api_versions.MIN_VERSION) + # FIXME: the endpoint_api_version[0] can ONLY be '3' now, so the + # above line should probably be ripped out and this condition removed + if endpoint_api_version[0] == '3': + disc_client = client.Client(API_MIN_VERSION, + os_username, + os_password, + os_project_name, + os_auth_url, + **client_args) + self.cs, discovered_version = self._discover_client( + disc_client, + api_version, + args.os_endpoint_type, + args.service_type, + os_username, + os_password, + os_project_name, + os_auth_url, + client_args) + + if discovered_version < api_version: + self.downgrade_warning(api_version, discovered_version) profile = osprofiler_profiler and options.profile if profile: osprofiler_profiler.init(options.profile) - args.func(self.cs, args) - - if profile: - trace_id = osprofiler_profiler.get().get_base_id() - print("Trace ID: %s" % trace_id) - print("To display trace use next command:\n" - "osprofiler trace show --html %s " % trace_id) + try: + args.func(self.cs, args) + finally: + if profile: + trace_id = osprofiler_profiler.get().get_base_id() + print("Trace ID: %s" % trace_id) + print("To display trace use next command:\n" + "osprofiler trace show --html %s " % trace_id) + + if getattr(args, 'collect_timing', False) is True: + self._print_timings(auth_session) + + def _print_timings(self, session): + timings = session.get_timings() + utils.print_list( + timings, + fields=('method', 'url', 'seconds'), + sortby_index=None, + formatters={'seconds': lambda r: r.elapsed.total_seconds()}) + + def _discover_client(self, + current_client, + os_api_version, + os_endpoint_type, + os_service_type, + os_username, + os_password, + os_project_name, + os_auth_url, + client_args): + + discovered_version = api_versions.discover_version( + current_client, + os_api_version) + + if not os_endpoint_type: + os_endpoint_type = DEFAULT_CINDER_ENDPOINT_TYPE + + if not os_service_type: + os_service_type = self._discover_service_type(discovered_version) + + API_MIN_VERSION = api_versions.APIVersion(api_versions.MIN_VERSION) + + if (discovered_version != API_MIN_VERSION or + os_service_type != 'volume' or + os_endpoint_type != DEFAULT_CINDER_ENDPOINT_TYPE): + client_args['service_type'] = os_service_type + client_args['endpoint_type'] = os_endpoint_type + + return (client.Client(discovered_version, + os_username, + os_password, + os_project_name, + os_auth_url, + **client_args), + discovered_version) + else: + return current_client, discovered_version + + def _discover_service_type(self, discovered_version): + # FIXME: this function is either no longer needed or could use a + # refactoring. The official service type is 'block-storage', + # which isn't even present here. (Devstack creates 2 service + # types which it maps to v3: 'block-storage' and 'volumev3'. + # The default 'catalog_type' in tempest is 'volumev3'.) + SERVICE_TYPES = {'1': 'volume', '2': 'volumev2', '3': 'volumev3'} + major_version = discovered_version.get_major_version() + service_type = SERVICE_TYPES[major_version] + return service_type def _run_extension_hooks(self, hook_type, *args, **kwargs): """Runs hooks for all registered extensions.""" @@ -841,8 +910,8 @@ def get_v2_auth(self, v2_auth_url): username = self.options.os_username password = self.options.os_password - tenant_id = self.options.os_tenant_id - tenant_name = self.options.os_tenant_name + tenant_id = self.options.os_project_id + tenant_name = self.options.os_project_name return v2_auth.Password( v2_auth_url, @@ -858,9 +927,8 @@ def get_v3_auth(self, v3_auth_url): user_domain_name = self.options.os_user_domain_name user_domain_id = self.options.os_user_domain_id password = self.options.os_password - project_id = self.options.os_project_id or self.options.os_tenant_id - project_name = (self.options.os_project_name - or self.options.os_tenant_name) + project_id = self.options.os_project_id + project_name = self.options.os_project_name project_domain_name = self.options.os_project_domain_name project_domain_id = self.options.os_project_domain_id @@ -883,10 +951,10 @@ def _discover_auth_versions(self, session, auth_url): v2_auth_url = None v3_auth_url = None try: - ks_discover = discover.Discover(session=session, auth_url=auth_url) + ks_discover = discover.Discover(session=session, url=auth_url) v2_auth_url = ks_discover.url_for('2.0') v3_auth_url = ks_discover.url_for('3.0') - except keystoneclient_exc.DiscoveryFailure: + except exceptions.DiscoveryFailure: # Discovery response mismatch. Raise the error raise except Exception: @@ -911,6 +979,9 @@ def _get_keystone_session(self, **kwargs): # first create a Keystone session cacert = self.options.os_cacert or None cert = self.options.os_cert or None + if cert and self.options.os_key: + cert = cert, self.options.os_key + insecure = self.options.insecure or False if insecure: @@ -960,23 +1031,19 @@ class OpenStackHelpFormatter(argparse.HelpFormatter): def start_section(self, heading): # Title-case the headings - heading = '%s%s' % (heading[0].upper(), heading[1:]) + heading = heading.title() super(OpenStackHelpFormatter, self).start_section(heading) def main(): try: - if sys.version_info >= (3, 0): - OpenStackCinderShell().main(sys.argv[1:]) - else: - OpenStackCinderShell().main(map(strutils.safe_decode, - sys.argv[1:])) + OpenStackCinderShell().main(sys.argv[1:]) except KeyboardInterrupt: print("... terminating cinder client", file=sys.stderr) sys.exit(130) except Exception as e: logger.debug(e, exc_info=1) - print("ERROR: %s" % strutils.six.text_type(e), file=sys.stderr) + print("ERROR: %s" % str(e), file=sys.stderr) sys.exit(1) diff --git a/cinderclient/shell_utils.py b/cinderclient/shell_utils.py new file mode 100644 index 000000000..65e840057 --- /dev/null +++ b/cinderclient/shell_utils.py @@ -0,0 +1,419 @@ +# 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 sys +import time + +import prettytable + +from cinderclient import exceptions +from cinderclient import utils + +_quota_resources = ['volumes', 'snapshots', 'gigabytes', + 'backups', 'backup_gigabytes', + 'per_volume_gigabytes', 'groups', ] +_quota_infos = ['Type', 'In_use', 'Reserved', 'Limit', 'Allocated'] + + +def _print(pt, order): + print(pt.get_string(sortby=order)) + + +def _pretty_format_dict(data_dict): + formatted_data = [] + + for k in sorted(data_dict): + formatted_data.append("%s : %s" % (k, data_dict[k])) + + return "\n".join(formatted_data) + + +def print_list(objs, fields, exclude_unavailable=False, formatters=None, + sortby_index=0): + '''Prints a list of objects. + + @param objs: Objects to print + @param fields: Fields on each object to be printed + @param exclude_unavailable: Boolean to decide if unavailable fields are + removed + @param formatters: Custom field formatters + @param sortby_index: Results sorted against the key in the fields list at + this index; if None then the object order is not + altered + ''' + formatters = formatters or {} + mixed_case_fields = ['serverId'] + removed_fields = [] + rows = [] + + for o in objs: + row = [] + for field in fields: + if field in removed_fields: + continue + if field in formatters: + row.append(formatters[field](o)) + else: + if field in mixed_case_fields: + field_name = field.replace(' ', '_') + else: + field_name = field.lower().replace(' ', '_') + if isinstance(o, dict) and field in o: + data = o[field] + else: + if not hasattr(o, field_name) and exclude_unavailable: + removed_fields.append(field) + continue + else: + data = getattr(o, field_name, '') + if data is None: + data = '-' + if isinstance(data, str) and "\r" in data: + data = data.replace("\r", " ") + row.append(data) + rows.append(row) + + for f in removed_fields: + fields.remove(f) + + pt = prettytable.PrettyTable((f for f in fields), caching=False) + pt.align = 'l' + for row in rows: + count = 0 + # Converts unicode values in dictionary to string + for part in row: + count = count + 1 + if isinstance(part, dict): + row[count - 1] = part + pt.add_row(row) + + if sortby_index is None: + order_by = None + else: + order_by = fields[sortby_index] + _print(pt, order_by) + + +def print_dict(d, property="Property", formatters=None): + pt = prettytable.PrettyTable([property, 'Value'], caching=False) + pt.align = 'l' + formatters = formatters or {} + + for r in d.items(): + r = list(r) + + if r[0] in formatters: + if isinstance(r[1], dict): + r[1] = _pretty_format_dict(r[1]) + if isinstance(r[1], str) and "\r" in r[1]: + r[1] = r[1].replace("\r", " ") + pt.add_row(r) + _print(pt, property) + + +def print_volume_image(image_resp_tuple): + # image_resp_tuple = tuple (response, body) + image = image_resp_tuple[1] + vt = image['os-volume_upload_image'].get('volume_type') + if vt is not None: + image['os-volume_upload_image']['volume_type'] = vt.get('name') + print_dict(image['os-volume_upload_image']) + + +def poll_for_status(poll_fn, obj_id, action, final_ok_states, + poll_period=5, show_progress=True): + """Blocks while an action occurs. Periodically shows progress.""" + def print_progress(progress): + if show_progress: + msg = ('\rInstance %(action)s... %(progress)s%% complete' + % dict(action=action, progress=progress)) + else: + msg = '\rInstance %(action)s...' % dict(action=action) + + sys.stdout.write(msg) + sys.stdout.flush() + + print() + while True: + obj = poll_fn(obj_id) + status = obj.status.lower() + progress = getattr(obj, 'progress', None) or 0 + if status in final_ok_states: + print_progress(100) + print("\nFinished") + break + elif status == "error": + print("\nError %(action)s instance" % {'action': action}) + break + else: + print_progress(progress) + time.sleep(poll_period) + + +def find_volume_snapshot(cs, snapshot): + """Gets a volume snapshot by name or ID.""" + return utils.find_resource(cs.volume_snapshots, snapshot) + + +def find_vtype(cs, vtype): + """Gets a volume type by name or ID.""" + return utils.find_resource(cs.volume_types, vtype) + + +def find_gtype(cs, gtype): + """Gets a group type by name or ID.""" + return utils.find_resource(cs.group_types, gtype) + + +def find_backup(cs, backup): + """Gets a backup by name or ID.""" + return utils.find_resource(cs.backups, backup) + + +def find_consistencygroup(cs, consistencygroup): + """Gets a consistency group by name or ID.""" + return utils.find_resource(cs.consistencygroups, consistencygroup) + + +def find_group(cs, group, **kwargs): + """Gets a group by name or ID.""" + kwargs['is_group'] = True + return utils.find_resource(cs.groups, group, **kwargs) + + +def find_cgsnapshot(cs, cgsnapshot): + """Gets a cgsnapshot by name or ID.""" + return utils.find_resource(cs.cgsnapshots, cgsnapshot) + + +def find_group_snapshot(cs, group_snapshot): + """Gets a group_snapshot by name or ID.""" + return utils.find_resource(cs.group_snapshots, group_snapshot) + + +def find_transfer(cs, transfer): + """Gets a transfer by name or ID.""" + return utils.find_resource(cs.transfers, transfer) + + +def find_qos_specs(cs, qos_specs): + """Gets a qos specs by ID.""" + return utils.find_resource(cs.qos_specs, qos_specs) + + +def find_message(cs, message): + """Gets a message by ID.""" + return utils.find_resource(cs.messages, message) + + +def print_volume_snapshot(snapshot): + print_dict(snapshot._info) + + +def translate_keys(collection, convert): + for item in collection: + keys = item.__dict__ + for from_key, to_key in convert: + if from_key in keys and to_key not in keys: + setattr(item, to_key, item._info[from_key]) + + +def translate_volume_keys(collection): + convert = [('volumeType', 'volume_type'), + ('os-vol-tenant-attr:tenant_id', 'tenant_id')] + translate_keys(collection, convert) + + +def translate_volume_snapshot_keys(collection): + convert = [('volumeId', 'volume_id')] + translate_keys(collection, convert) + + +def translate_availability_zone_keys(collection): + convert = [('zoneName', 'name'), ('zoneState', 'status')] + translate_keys(collection, convert) + + +def extract_filters(args): + filters = {} + for f in args: + if '=' in f: + (key, value) = f.split('=', 1) + if value.startswith('{') and value.endswith('}'): + value = _build_internal_dict(value[1:-1]) + filters[key] = value + else: + print("WARNING: Ignoring the filter %s while showing result." % f) + + return filters + + +def _build_internal_dict(content): + result = {} + for pair in content.split(','): + k, v = pair.split(':', 1) + result.update({k.strip(): v.strip()}) + return result + + +def extract_metadata(args, type='user_metadata'): + metadata = {} + if type == 'image_metadata': + args_metadata = args.image_metadata + else: + args_metadata = args.metadata + 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) + else: + key = metadatum + value = None + + metadata[key] = value + return metadata + + +def print_volume_type_list(vtypes): + print_list(vtypes, ['ID', 'Name', 'Description', 'Is_Public']) + + +def print_group_type_list(gtypes): + print_list(gtypes, ['ID', 'Name', 'Description']) + + +def print_resource_filter_list(filters): + formatter = {'Filters': lambda resource: ', '.join(resource.filters)} + print_list(filters, ['Resource', 'Filters'], formatters=formatter) + + +def quota_show(quotas): + quotas_info_dict = quotas._info + quota_dict = {} + for resource in quotas_info_dict.keys(): + good_name = False + for name in _quota_resources: + if resource.startswith(name): + good_name = True + if not good_name: + continue + quota_dict[resource] = getattr(quotas, resource, None) + print_dict(quota_dict) + + +def quota_usage_show(quotas): + quota_list = [] + quotas_info_dict = quotas._info + for resource in quotas_info_dict.keys(): + good_name = False + for name in _quota_resources: + if resource.startswith(name): + good_name = True + if not good_name: + continue + quota_info = getattr(quotas, resource, None) + quota_info['Type'] = resource + quota_info = dict((k.capitalize(), v) for k, v in quota_info.items()) + quota_list.append(quota_info) + print_list(quota_list, _quota_infos) + + +def quota_update(manager, identifier, args): + updates = {} + for resource in _quota_resources: + val = getattr(args, resource, None) + if val is not None: + if args.volume_type: + resource = resource + '_%s' % args.volume_type + updates[resource] = val + + if updates: + skip_validation = getattr(args, 'skip_validation', True) + if not skip_validation: + updates['skip_validation'] = skip_validation + quota_show(manager.update(identifier, **updates)) + else: + msg = 'Must supply at least one quota field to update.' + raise exceptions.ClientException(code=1, message=msg) + + +def find_volume_type(cs, vtype): + """Gets a volume type by name or ID.""" + return utils.find_resource(cs.volume_types, vtype) + + +def find_group_type(cs, gtype): + """Gets a group type by name or ID.""" + return utils.find_resource(cs.group_types, gtype) + + +def print_volume_encryption_type_list(encryption_types): + """ + Lists volume encryption types. + + :param encryption_types: a list of :class: VolumeEncryptionType instances + """ + print_list(encryption_types, ['Volume Type ID', 'Provider', + 'Cipher', 'Key Size', + 'Control Location']) + + +def print_qos_specs(qos_specs): + # formatters defines field to be converted from unicode to string + print_dict(qos_specs._info, formatters=['specs']) + + +def print_qos_specs_list(q_specs): + print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def print_qos_specs_and_associations_list(q_specs): + print_list(q_specs, ['ID', 'Name', 'Consumer', 'specs']) + + +def print_associations_list(associations): + print_list(associations, ['Association_Type', 'Name', 'ID']) + + +def _poll_for_status(poll_fn, obj_id, info, action, final_ok_states, + timeout_period, global_request_id=None, messages=None, + poll_period=2, status_field="status"): + """Block while an action is being performed.""" + time_elapsed = 0 + while True: + time.sleep(poll_period) + time_elapsed += poll_period + obj = poll_fn(obj_id) + status = getattr(obj, status_field) + info[status_field] = status + if status: + status = status.lower() + + if status in final_ok_states: + break + elif status == "error": + print_dict(info) + if global_request_id: + search_opts = { + 'request_id': global_request_id + } + message_list = messages.list(search_opts=search_opts) + try: + fault_msg = message_list[0].user_message + except IndexError: + fault_msg = "Unknown error. Operation failed." + raise exceptions.ResourceInErrorState(obj, fault_msg) + elif time_elapsed == timeout_period: + print_dict(info) + raise exceptions.TimeoutException(obj, action) diff --git a/cinderclient/tests/functional/base.py b/cinderclient/tests/functional/base.py index 02c07f904..2f03475a6 100644 --- a/cinderclient/tests/functional/base.py +++ b/cinderclient/tests/functional/base.py @@ -10,10 +10,47 @@ # License for the specific language governing permissions and limitations # under the License. +import configparser import os +import time -from tempest_lib.cli import base -from tempest_lib.cli import output_parser +from tempest.lib.cli import base +from tempest.lib.cli import output_parser +from tempest.lib import exceptions + +_CREDS_FILE = 'functional_creds.conf' + + +def credentials(): + """Retrieves credentials to run functional tests + + Credentials are either read from the environment or from a config file + ('functional_creds.conf'). Environment variables override those from the + config file. + + The 'functional_creds.conf' file is the clean and new way to use (by + default tox 2.0 does not pass environment variables). + """ + + username = os.environ.get('OS_USERNAME') + password = os.environ.get('OS_PASSWORD') + tenant_name = (os.environ.get('OS_TENANT_NAME') or + os.environ.get('OS_PROJECT_NAME')) + auth_url = os.environ.get('OS_AUTH_URL') + + config = configparser.RawConfigParser() + if config.read(_CREDS_FILE): + username = username or config.get('admin', 'user') + password = password or config.get('admin', 'pass') + tenant_name = tenant_name or config.get('admin', 'tenant') + auth_url = auth_url or config.get('auth', 'uri') + + return { + 'username': username, + 'password': password, + 'tenant_name': tenant_name, + 'uri': auth_url + } class ClientTestBase(base.ClientTestBase): @@ -30,27 +67,107 @@ def _get_clients(self): 'OS_CINDERCLIENT_EXEC_DIR', os.path.join(os.path.abspath('.'), '.tox/functional/bin')) - return base.CLIClient( - username=os.environ.get('OS_USERNAME'), - password=os.environ.get('OS_PASSWORD'), - tenant_name=os.environ.get('OS_TENANT_NAME'), - uri=os.environ.get('OS_AUTH_URL'), - cli_dir=cli_dir) + return base.CLIClient(cli_dir=cli_dir, **credentials()) def cinder(self, *args, **kwargs): return self.clients.cinder(*args, **kwargs) - def assertTableStruct(self, items, field_names): - """Verify that all items has keys listed in field_names. + def assertTableHeaders(self, output_lines, field_names): + """Verify that output table has headers item listed in field_names. - :param items: items to assert are field names in the output table - :type items: list + :param output_lines: output table from cmd :param field_names: field names from the output table of the cmd - :type field_names: list """ - # Strip off the --- if present + table = self.parser.table(output_lines) + headers = table['headers'] + for field in field_names: + self.assertIn(field, headers) + def assert_object_details(self, expected, items): + """Check presence of common object properties. + + :param expected: expected object properties + :param items: object properties + """ + for value in expected: + self.assertIn(value, items) + + def _get_property_from_output(self, output): + """Create a dictionary from an output + + :param output: the output of the cmd + """ + obj = {} + items = self.parser.listing(output) for item in items: - for field in field_names: - self.assertIn(field, item) + obj[item['Property']] = str(item['Value']) + return obj + + def object_cmd(self, object_name, cmd): + return (object_name + '-' + cmd if object_name != 'volume' else cmd) + + def wait_for_object_status(self, object_name, object_id, status, + timeout=120, interval=3): + """Wait until object reaches given status. + + :param object_name: object name + :param object_id: uuid4 id of an object + :param status: expected status of an object + :param timeout: timeout in seconds + """ + cmd = self.object_cmd(object_name, 'show') + start_time = time.time() + while time.time() - start_time < timeout: + if status in self.cinder(cmd, params=object_id): + break + time.sleep(interval) + else: + self.fail("%s %s did not reach status %s after %d seconds." + % (object_name, object_id, status, timeout)) + + def check_object_deleted(self, object_name, object_id, timeout=60): + """Check that object deleted successfully. + + :param object_name: object name + :param object_id: uuid4 id of an object + :param timeout: timeout in seconds + """ + cmd = self.object_cmd(object_name, 'show') + try: + start_time = time.time() + while time.time() - start_time < timeout: + if object_id not in self.cinder(cmd, params=object_id): + break + except exceptions.CommandFailed: + pass + else: + self.fail("%s %s not deleted after %d seconds." + % (object_name, object_id, timeout)) + + def object_create(self, object_name, params): + """Create an object. + + :param object_name: object name + :param params: parameters to cinder command + :return: object dictionary + """ + cmd = self.object_cmd(object_name, 'create') + output = self.cinder(cmd, params=params) + object = self._get_property_from_output(output) + self.addCleanup(self.object_delete, object_name, object['id']) + if object_name in ('volume', 'snapshot', 'backup'): + self.wait_for_object_status( + object_name, object['id'], 'available') + return object + + def object_delete(self, object_name, object_id): + """Delete specified object by ID. + + :param object_name: object name + :param object_id: uuid4 id of an object + """ + cmd = self.object_cmd(object_name, 'list') + cmd_delete = self.object_cmd(object_name, 'delete') + if object_id in self.cinder(cmd): + self.cinder(cmd_delete, params=object_id) diff --git a/cinderclient/tests/functional/test_cli.py b/cinderclient/tests/functional/test_cli.py new file mode 100644 index 000000000..5f4a64a79 --- /dev/null +++ b/cinderclient/tests/functional/test_cli.py @@ -0,0 +1,137 @@ +# 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.functional import base + + +class CinderVolumeTests(base.ClientTestBase): + """Check of base cinder volume commands.""" + + CREATE_VOLUME_PROPERTY = ( + 'attachments', + 'os-vol-tenant-attr:tenant_id', + 'availability_zone', 'bootable', + 'created_at', 'description', 'encrypted', 'id', + 'metadata', 'name', 'size', 'status', + 'user_id', 'volume_type') + + SHOW_VOLUME_PROPERTY = ('attachment_ids', 'attached_servers', + 'availability_zone', 'bootable', + 'created_at', 'description', 'encrypted', 'id', + 'metadata', 'name', 'size', 'status', + 'user_id', 'volume_type') + + def test_volume_create_delete_id(self): + """Create and delete a volume by ID.""" + volume = self.object_create('volume', params='1') + self.assert_object_details(self.CREATE_VOLUME_PROPERTY, volume.keys()) + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) + + def test_volume_create_delete_name(self): + """Create and delete a volume by name.""" + volume = self.object_create('volume', + params='1 --name TestVolumeNamedCreate') + + self.cinder('delete', params='TestVolumeNamedCreate') + self.check_object_deleted('volume', volume['id']) + + def test_volume_show(self): + """Show volume details.""" + volume = self.object_create('volume', params='1 --name TestVolumeShow') + output = self.cinder('show', params='TestVolumeShow') + volume = self._get_property_from_output(output) + self.assertEqual('TestVolumeShow', volume['name']) + self.assert_object_details(self.SHOW_VOLUME_PROPERTY, volume.keys()) + + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) + + def test_volume_extend(self): + """Extend a volume size.""" + volume = self.object_create('volume', + params='1 --name TestVolumeExtend') + self.cinder('extend', params="%s %s" % (volume['id'], 2)) + self.wait_for_object_status('volume', volume['id'], 'available') + output = self.cinder('show', params=volume['id']) + volume = self._get_property_from_output(output) + self.assertEqual('2', volume['size']) + + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) + + +class CinderSnapshotTests(base.ClientTestBase): + """Check of base cinder snapshot commands.""" + + SNAPSHOT_PROPERTY = ('created_at', 'description', 'metadata', 'id', + 'name', 'size', 'status', 'volume_id') + + def test_snapshot_create_and_delete(self): + """Create a volume snapshot and then delete.""" + volume = self.object_create('volume', params='1') + snapshot = self.object_create('snapshot', params=volume['id']) + self.assert_object_details(self.SNAPSHOT_PROPERTY, snapshot.keys()) + self.object_delete('snapshot', snapshot['id']) + self.check_object_deleted('snapshot', snapshot['id']) + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) + + +class CinderBackupTests(base.ClientTestBase): + """Check of base cinder backup commands.""" + + BACKUP_PROPERTY = ('id', 'name', 'volume_id') + + def test_backup_create_and_delete(self): + """Create a volume backup and then delete.""" + volume = self.object_create('volume', params='1') + backup = self.object_create('backup', params=volume['id']) + self.assert_object_details(self.BACKUP_PROPERTY, backup.keys()) + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) + self.object_delete('backup', backup['id']) + self.check_object_deleted('backup', backup['id']) + + +class VolumeTransferTests(base.ClientTestBase): + """Check of base cinder volume transfers command""" + + TRANSFER_PROPERTY = ('created_at', 'volume_id', 'id', 'auth_key', 'name') + TRANSFER_SHOW_PROPERTY = ('created_at', 'volume_id', 'id', 'name') + + def test_transfer_create_delete(self): + """Create and delete a volume transfer""" + volume = self.object_create('volume', params='1') + transfer = self.object_create('transfer', params=volume['id']) + self.assert_object_details(self.TRANSFER_PROPERTY, transfer.keys()) + self.object_delete('transfer', transfer['id']) + self.check_object_deleted('transfer', transfer['id']) + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) + + def test_transfer_show_delete_by_name(self): + """Show volume transfer by name""" + volume = self.object_create('volume', params='1') + self.object_create( + 'transfer', + params=('%s --name TEST_TRANSFER_SHOW' % volume['id'])) + output = self.cinder('transfer-show', params='TEST_TRANSFER_SHOW') + transfer = self._get_property_from_output(output) + self.assertEqual('TEST_TRANSFER_SHOW', transfer['name']) + self.assert_object_details(self.TRANSFER_SHOW_PROPERTY, + transfer.keys()) + self.object_delete('transfer', 'TEST_TRANSFER_SHOW') + self.check_object_deleted('transfer', 'TEST_TRANSFER_SHOW') + self.object_delete('volume', volume['id']) + self.check_object_deleted('volume', volume['id']) diff --git a/cinderclient/tests/functional/test_readonly_cli.py b/cinderclient/tests/functional/test_readonly_cli.py index 64a190b52..87578a0b5 100644 --- a/cinderclient/tests/functional/test_readonly_cli.py +++ b/cinderclient/tests/functional/test_readonly_cli.py @@ -28,62 +28,65 @@ class CinderClientReadOnlyTests(base.ClientTestBase): # Commands in order listed in 'cinder help' def test_absolute_limits(self): - limits = self.parser.listing(self.cinder('absolute-limits')) - self.assertTableStruct(limits, ['Name', 'Value']) + limits = self.cinder('absolute-limits') + self.assertTableHeaders(limits, ['Name', 'Value']) def test_availability_zones(self): - zone_list = self.parser.listing(self.cinder('availability-zone-list')) - self.assertTableStruct(zone_list, ['Name', 'Status']) + zone_list = self.cinder('availability-zone-list') + self.assertTableHeaders(zone_list, ['Name', 'Status']) def test_backup_list(self): - backup_list = self.parser.listing(self.cinder('backup-list')) - self.assertTableStruct(backup_list, ['ID', 'Volume ID', 'Status', - 'Name', 'Size', 'Object Count', - 'Container']) + backup_list = self.cinder('backup-list') + self.assertTableHeaders(backup_list, ['ID', 'Volume ID', 'Status', + 'Name', 'Size', 'Object Count', + 'Container']) def test_encryption_type_list(self): - encrypt_list = self.parser.listing(self.cinder('encryption-type-list')) - self.assertTableStruct(encrypt_list, ['Volume Type ID', 'Provider', - 'Cipher', 'Key Size', - 'Control Location']) - - def test_endpoints(self): - out = self.cinder('endpoints') - tables = self.parser.tables(out) - for table in tables: - headers = table['headers'] - self.assertTrue(2 >= len(headers)) - self.assertEqual('Value', headers[1]) + encrypt_list = self.cinder('encryption-type-list') + self.assertTableHeaders(encrypt_list, ['Volume Type ID', 'Provider', + 'Cipher', 'Key Size', + 'Control Location']) + + def test_extra_specs_list(self): + extra_specs_list = self.cinder('extra-specs-list') + self.assertTableHeaders(extra_specs_list, ['ID', 'Name', + 'extra_specs']) def test_list(self): - list = self.parser.listing(self.cinder('list')) - self.assertTableStruct(list, ['ID', 'Status', 'Name', 'Size', - 'Volume Type', 'Bootable', - 'Attached to']) + list = self.cinder('list') + self.assertTableHeaders(list, ['ID', 'Status', 'Name', 'Size', + 'Volume Type', 'Bootable', + 'Attached to']) def test_qos_list(self): - qos_list = self.parser.listing(self.cinder('qos-list')) - self.assertTableStruct(qos_list, ['ID', 'Name', 'Consumer', 'specs']) + qos_list = self.cinder('qos-list') + self.assertTableHeaders(qos_list, ['ID', 'Name', 'Consumer', 'specs']) def test_rate_limits(self): - rate_limits = self.parser.listing(self.cinder('rate-limits')) - self.assertTableStruct(rate_limits, ['Verb', 'URI', 'Value', 'Remain', - 'Unit', 'Next_Available']) + rate_limits = self.cinder('rate-limits') + self.assertTableHeaders(rate_limits, ['Verb', 'URI', 'Value', 'Remain', + 'Unit', 'Next_Available']) def test_service_list(self): - service_list = self.parser.listing(self.cinder('service-list')) - self.assertTableStruct(service_list, ['Binary', 'Host', 'Zone', - 'Status', 'State', 'Updated_at']) + service_list = self.cinder('service-list') + self.assertTableHeaders(service_list, ['Binary', 'Host', 'Zone', + 'Status', 'State', + 'Updated_at']) def test_snapshot_list(self): - snapshot_list = self.parser.listing(self.cinder('snapshot-list')) - self.assertTableStruct(snapshot_list, ['ID', 'Volume ID', 'Status', - 'Name', 'Size']) + snapshot_list = self.cinder('snapshot-list') + self.assertTableHeaders(snapshot_list, ['ID', 'Volume ID', 'Status', + 'Name', 'Size']) def test_transfer_list(self): - transfer_list = self.parser.listing(self.cinder('transfer-list')) - self.assertTableStruct(transfer_list, ['ID', 'Volume ID', 'Name']) + transfer_list = self.cinder('transfer-list') + self.assertTableHeaders(transfer_list, ['ID', 'Volume ID', 'Name']) def test_type_list(self): - type_list = self.parser.listing(self.cinder('type-list')) - self.assertTableStruct(type_list, ['ID', 'Name']) + type_list = self.cinder('type-list') + self.assertTableHeaders(type_list, ['ID', 'Name']) + + def test_list_extensions(self): + list_extensions = self.cinder('list-extensions') + self.assertTableHeaders(list_extensions, ['Name', 'Summary', 'Alias', + 'Updated']) diff --git a/cinderclient/tests/functional/test_snapshot_create_cli.py b/cinderclient/tests/functional/test_snapshot_create_cli.py new file mode 100644 index 000000000..4c0bd1204 --- /dev/null +++ b/cinderclient/tests/functional/test_snapshot_create_cli.py @@ -0,0 +1,53 @@ +# 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.functional import base + + +class CinderSnapshotTests(base.ClientTestBase): + """Check of cinder snapshot commands.""" + def setUp(self): + super(CinderSnapshotTests, self).setUp() + self.volume = self.object_create('volume', params='1') + + def test_snapshot_create_description(self): + """Test steps: + + 1) create volume in Setup() + 2) create snapshot with description + 3) check that snapshot has right description + """ + description = 'test_description' + snapshot = self.object_create('snapshot', + params='--description {0} {1}'. + format(description, self.volume['id'])) + self.assertEqual(description, snapshot['description']) + self.object_delete('snapshot', snapshot['id']) + self.check_object_deleted('snapshot', snapshot['id']) + + def test_snapshot_create_metadata(self): + """Test steps: + + 1) create volume in Setup() + 2) create snapshot with metadata + 3) check that metadata complies entered + """ + snapshot = self.object_create( + 'snapshot', + params='--metadata test_metadata=test_date {0}'.format( + self.volume['id'])) + self.assertEqual(str({'test_metadata': 'test_date'}), + snapshot['metadata']) + self.object_delete('snapshot', snapshot['id']) + self.check_object_deleted('snapshot', snapshot['id']) diff --git a/cinderclient/tests/functional/test_volume_create_cli.py b/cinderclient/tests/functional/test_volume_create_cli.py new file mode 100644 index 000000000..9c9fc0d47 --- /dev/null +++ b/cinderclient/tests/functional/test_volume_create_cli.py @@ -0,0 +1,99 @@ +# 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 ddt +from tempest.lib import exceptions + +from cinderclient.tests.functional import base + + +@ddt.ddt +class CinderVolumeNegativeTests(base.ClientTestBase): + """Check of cinder volume create commands.""" + + @ddt.data( + ('', (r'Size is a required parameter')), + ('-1', (r'Invalid input for field/attribute size')), + ('0', (r"Invalid input for field/attribute size")), + ('size', (r'invalid int value')), + ('0.2', (r'invalid int value')), + ('2 GB', (r'unrecognized arguments')), + ('999999999', (r'VolumeSizeExceedsAvailableQuota')), + ) + @ddt.unpack + def test_volume_create_with_incorrect_size(self, value, ex_text): + self.assertRaisesRegex(exceptions.CommandFailed, + ex_text, self.object_create, + 'volume', params=value) + + +class CinderVolumeTests(base.ClientTestBase): + """Check of cinder volume create commands.""" + def setUp(self): + super(CinderVolumeTests, self).setUp() + self.volume = self.object_create('volume', params='1') + + def test_volume_create_from_snapshot(self): + """Test steps: + + 1) create volume in Setup() + 2) create snapshot + 3) create volume from snapshot + 4) check that volume from snapshot has been successfully created + """ + snapshot = self.object_create('snapshot', params=self.volume['id']) + volume_from_snapshot = self.object_create('volume', + params='--snapshot-id {0} 1'. + format(snapshot['id'])) + self.object_delete('snapshot', snapshot['id']) + self.check_object_deleted('snapshot', snapshot['id']) + cinder_list = self.cinder('list') + self.assertIn(volume_from_snapshot['id'], cinder_list) + + def test_volume_create_from_volume(self): + """Test steps: + + 1) create volume in Setup() + 2) create volume from volume + 3) check that volume from volume has been successfully created + """ + volume_from_volume = self.object_create('volume', + params='--source-volid {0} 1'. + format(self.volume['id'])) + cinder_list = self.cinder('list') + self.assertIn(volume_from_volume['id'], cinder_list) + + +class CinderVolumeTestsWithParameters(base.ClientTestBase): + """Check of cinder volume create commands with parameters.""" + def test_volume_create_description(self): + """Test steps: + + 1) create volume with description + 2) check that volume has right description + """ + volume_description = 'test_description' + volume = self.object_create('volume', + params='--description {0} 1'. + format(volume_description)) + self.assertEqual(volume_description, volume['description']) + + def test_volume_create_metadata(self): + """Test steps: + + 1) create volume with metadata + 2) check that metadata complies entered + """ + volume = self.object_create( + 'volume', params='--metadata test_metadata=test_date 1') + self.assertEqual(str({'test_metadata': 'test_date'}), + volume['metadata']) diff --git a/cinderclient/tests/functional/test_volume_extend_cli.py b/cinderclient/tests/functional/test_volume_extend_cli.py new file mode 100644 index 000000000..6a5c99cbf --- /dev/null +++ b/cinderclient/tests/functional/test_volume_extend_cli.py @@ -0,0 +1,54 @@ +# 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 ddt +from tempest.lib import exceptions + +from cinderclient.tests.functional import base + + +@ddt.ddt +class CinderVolumeExtendNegativeTests(base.ClientTestBase): + """Check of cinder volume extend command.""" + + def setUp(self): + super(CinderVolumeExtendNegativeTests, self).setUp() + self.volume = self.object_create('volume', params='1') + + @ddt.data( + ('', (r'too few arguments|the following arguments are required')), + ('-1', (r'Invalid input for field/attribute new_size. Value: -1. ' + r'-1 is less than the minimum of 1')), + ('0', (r'Invalid input for field/attribute new_size. Value: 0. ' + r'0 is less than the minimum of 1')), + ('size', (r'invalid int value')), + ('0.2', (r'invalid int value')), + ('2 GB', (r'unrecognized arguments')), + ('999999999', (r'VolumeSizeExceedsAvailableQuota')), + ) + @ddt.unpack + def test_volume_extend_with_incorrect_size(self, value, ex_text): + self.assertRaisesRegex( + exceptions.CommandFailed, ex_text, self.cinder, 'extend', + params='{0} {1}'.format(self.volume['id'], value)) + + @ddt.data( + ('', (r'too few arguments|the following arguments are required')), + ('1234-1234-1234', (r'No volume with a name or ID of')), + ('my_volume', (r'No volume with a name or ID of')), + ('1234 1234', (r'unrecognized arguments')) + ) + @ddt.unpack + def test_volume_extend_with_incorrect_volume_id(self, value, ex_text): + self.assertRaisesRegex( + exceptions.CommandFailed, ex_text, self.cinder, 'extend', + params='{0} 2'.format(value)) diff --git a/cinderclient/tests/unit/fake_actions_module.py b/cinderclient/tests/unit/fake_actions_module.py new file mode 100644 index 000000000..a2c4bf79c --- /dev/null +++ b/cinderclient/tests/unit/fake_actions_module.py @@ -0,0 +1,65 @@ +# Copyright 2016 FUJITSU LIMITED +# 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 import api_versions +from cinderclient import utils + + +@api_versions.wraps("3.0", "3.1") +def do_fake_action(): + """help message + + This will not show up in help message + """ + return "fake_action 3.0 to 3.1" + + +@api_versions.wraps("3.2", "3.3") +def do_fake_action(): # noqa: F811 + return "fake_action 3.2 to 3.3" + + +@api_versions.wraps("3.6") +@utils.arg( + '--foo', + start_version='3.7') +def do_another_fake_action(): + return "another_fake_action" + + +@utils.arg( + '--foo', + start_version='3.1', + end_version='3.2') +@utils.arg( + '--bar', + help='bar help', + start_version='3.3', + end_version='3.4') +def do_fake_action2(): + return "fake_action2" + + +@utils.arg( + '--foo', + help='first foo', + start_version='3.6', + end_version='3.7') +@utils.arg( + '--foo', + help='second foo', + start_version='3.8') +def do_fake_action3(): + return "fake_action3" diff --git a/cinderclient/tests/unit/fakes.py b/cinderclient/tests/unit/fakes.py index 24ef8b33f..018a75d69 100644 --- a/cinderclient/tests/unit/fakes.py +++ b/cinderclient/tests/unit/fakes.py @@ -19,8 +19,6 @@ places where actual behavior differs from the spec. """ -from __future__ import print_function - def assert_has_keys(dict, required=None, optional=None): required = required or [] @@ -42,7 +40,7 @@ def _dict_match(self, partial, real): result = True try: for key, value in partial.items(): - if type(value) is dict: + if isinstance(value, dict): result = self._dict_match(value, real[key]) else: assert real[key] == value @@ -51,22 +49,31 @@ def _dict_match(self, partial, real): result = False return result + def assert_in_call(self, url_part): + """Assert a call contained a part in its URL.""" + assert self.client.callstack, "Expected call but no calls were made" + + called = self.client.callstack[-1][1] + assert url_part in called, 'Expected %s in call but found %s' % ( + url_part, called) + def assert_called(self, method, url, body=None, partial_body=None, pos=-1, **kwargs): - """ - Assert than an API method was just called. - """ + """Assert than an API method was just called.""" expected = (method, url) - called = self.client.callstack[pos][0:2] - assert self.client.callstack, ("Expected %s %s but no calls " "were made." % expected) + called = self.client.callstack[pos][0:2] + assert expected == called, 'Expected %s %s; got %s %s' % ( expected + called) if body is not None: - assert self.client.callstack[pos][2] == body + actual_body = self.client.callstack[pos][2] + assert actual_body == body, ("body mismatch. expected:\n" + + str(body) + "\n" + + "actual:\n" + str(actual_body)) if partial_body is not None: try: diff --git a/cinderclient/tests/unit/fixture_data/availability_zones.py b/cinderclient/tests/unit/fixture_data/availability_zones.py index 124e897c3..6f197ce0a 100644 --- a/cinderclient/tests/unit/fixture_data/availability_zones.py +++ b/cinderclient/tests/unit/fixture_data/availability_zones.py @@ -11,10 +11,12 @@ # under the License. from datetime import datetime + from cinderclient.tests.unit.fixture_data import base # FIXME(jamielennox): use timeutils from oslo FORMAT = '%Y-%m-%d %H:%M:%S' +REQUEST_ID = 'req-test-request-id' class Fixture(base.Fixture): @@ -38,7 +40,10 @@ def setUp(self): }, ] } - self.requests.register_uri('GET', self.url(), json=get_availability) + self.requests.register_uri( + 'GET', self.url(), json=get_availability, + headers={'x-openstack-request-id': REQUEST_ID} + ) updated_1 = datetime(2012, 12, 26, 14, 45, 25, 0).strftime(FORMAT) updated_2 = datetime(2012, 12, 26, 14, 45, 24, 0).strftime(FORMAT) @@ -77,4 +82,7 @@ def setUp(self): }, ] } - self.requests.register_uri('GET', self.url('detail'), json=get_detail) + self.requests.register_uri( + 'GET', self.url('detail'), json=get_detail, + headers={'x-openstack-request-id': REQUEST_ID} + ) diff --git a/cinderclient/tests/unit/fixture_data/base.py b/cinderclient/tests/unit/fixture_data/base.py index e30b78181..9406daf9e 100644 --- a/cinderclient/tests/unit/fixture_data/base.py +++ b/cinderclient/tests/unit/fixture_data/base.py @@ -14,42 +14,6 @@ IDENTITY_URL = 'http://identityserver:5000/v2.0' VOLUME_URL = 'http://volume.host' -TENANT_ID = 'b363706f891f48019483f8bd6503c54b' - -VOLUME_V1_URL = '%(volume_url)s/v1/%(tenant_id)s' % {'volume_url': VOLUME_URL, - 'tenant_id': TENANT_ID} -VOLUME_V2_URL = '%(volume_url)s/v2/%(tenant_id)s' % {'volume_url': VOLUME_URL, - 'tenant_id': TENANT_ID} - - -def generate_version_output(v1=True, v2=True): - v1_dict = { - "status": "SUPPORTED", - "updated": "2014-06-28T12:20:21Z", - "id": "v1.0", - "links": [{ - "href": "http://127.0.0.1:8776/v1/", - "rel": "self" - }] - } - - v2_dict = { - "status": "CURRENT", - "updated": "2012-11-21T11:33:21Z", - "id": "v2.0", "links": [{ - "href": "http://127.0.0.1:8776/v2/", - "rel": "self" - }] - } - - versions = [] - if v1: - versions.append(v1_dict) - - if v2: - versions.append(v2_dict) - - return {"versions": versions} class Fixture(fixtures.Fixture): diff --git a/cinderclient/tests/unit/fixture_data/client.py b/cinderclient/tests/unit/fixture_data/client.py index 310ca4fce..9fe975666 100644 --- a/cinderclient/tests/unit/fixture_data/client.py +++ b/cinderclient/tests/unit/fixture_data/client.py @@ -10,11 +10,10 @@ # License for the specific language governing permissions and limitations # under the License. -from keystoneclient import fixture +from keystoneauth1 import fixture from cinderclient.tests.unit.fixture_data import base -from cinderclient.v1 import client as v1client -from cinderclient.v2 import client as v2client +from cinderclient.v3 import client as v3client class Base(base.Fixture): @@ -34,31 +33,16 @@ def setUp(self): headers=self.json_headers) -class V1(Base): +class V3(Base): def __init__(self, *args, **kwargs): - super(V1, self).__init__(*args, **kwargs) + super(V3, self).__init__(*args, **kwargs) - svc = self.token.add_service('volume') + svc = self.token.add_service('volumev3') svc.add_endpoint(self.volume_url) def new_client(self): - return v1client.Client(username='xx', - api_key='xx', - project_id='xx', - auth_url=self.identity_url) - - -class V2(Base): - - def __init__(self, *args, **kwargs): - super(V2, self).__init__(*args, **kwargs) - - svc = self.token.add_service('volumev2') - svc.add_endpoint(self.volume_url) - - def new_client(self): - return v2client.Client(username='xx', + return v3client.Client(username='xx', api_key='xx', project_id='xx', auth_url=self.identity_url) diff --git a/cinderclient/tests/unit/fixture_data/keystone_client.py b/cinderclient/tests/unit/fixture_data/keystone_client.py index dc4c6c6d1..81767c556 100644 --- a/cinderclient/tests/unit/fixture_data/keystone_client.py +++ b/cinderclient/tests/unit/fixture_data/keystone_client.py @@ -12,7 +12,8 @@ import copy import json -import uuid + +from oslo_utils import uuidutils # these are copied from python-keystoneclient tests @@ -75,19 +76,27 @@ def _create_single_version(version): def _get_normalized_token_data(**kwargs): ref = copy.deepcopy(kwargs) # normalized token data - ref['user_id'] = ref.get('user_id', uuid.uuid4().hex) - ref['username'] = ref.get('username', uuid.uuid4().hex) + ref['user_id'] = ref.get('user_id', uuidutils.generate_uuid(dashed=False)) + ref['username'] = ref.get('username', + uuidutils.generate_uuid(dashed=False)) ref['project_id'] = ref.get('project_id', - ref.get('tenant_id', uuid.uuid4().hex)) - ref['project_name'] = ref.get('tenant_name', - ref.get('tenant_name', uuid.uuid4().hex)) - ref['user_domain_id'] = ref.get('user_domain_id', uuid.uuid4().hex) - ref['user_domain_name'] = ref.get('user_domain_name', uuid.uuid4().hex) - ref['project_domain_id'] = ref.get('project_domain_id', uuid.uuid4().hex) + ref.get('tenant_id', uuidutils.generate_uuid( + dashed=False))) + ref['project_name'] = ref.get('project_name', + ref.get('tenant_name', + uuidutils.generate_uuid( + dashed=False))) + ref['user_domain_id'] = ref.get('user_domain_id', + uuidutils.generate_uuid(dashed=False)) + ref['user_domain_name'] = ref.get('user_domain_name', + uuidutils.generate_uuid(dashed=False)) + ref['project_domain_id'] = ref.get('project_domain_id', + uuidutils.generate_uuid(dashed=False)) ref['project_domain_name'] = ref.get('project_domain_name', - uuid.uuid4().hex) - ref['roles'] = ref.get('roles', [{'name': uuid.uuid4().hex, - 'id': uuid.uuid4().hex}]) + uuidutils.generate_uuid(dashed=False)) + ref['roles'] = ref.get('roles', + [{'name': uuidutils.generate_uuid(dashed=False), + 'id': uuidutils.generate_uuid(dashed=False)}]) ref['roles_link'] = ref.get('roles_link', []) ref['cinder_url'] = ref.get('cinder_url', CINDER_ENDPOINT) @@ -97,7 +106,7 @@ def _get_normalized_token_data(**kwargs): def generate_v2_project_scoped_token(**kwargs): """Generate a Keystone V2 token based on auth request.""" ref = _get_normalized_token_data(**kwargs) - token = uuid.uuid4().hex + token = uuidutils.generate_uuid(dashed=False) o = {'access': {'token': {'id': token, 'expires': '2099-05-22T00:02:43.941430Z', @@ -108,35 +117,56 @@ def generate_v2_project_scoped_token(**kwargs): } }, 'user': {'id': ref.get('user_id'), - 'name': uuid.uuid4().hex, + 'name': uuidutils.generate_uuid(dashed=False), 'username': ref.get('username'), 'roles': ref.get('roles'), 'roles_links': ref.get('roles_links') } }} - # we only care about Neutron and Keystone endpoints + # Add endpoint Keystone o['access']['serviceCatalog'] = [ - {'endpoints': [ - {'publicURL': 'public_' + ref.get('cinder_url'), - 'internalURL': 'internal_' + ref.get('cinder_url'), - 'adminURL': 'admin_' + (ref.get('auth_url') or ""), - 'id': uuid.uuid4().hex, - 'region': 'RegionOne' - }], - 'endpoints_links': [], - 'name': 'Neutron', - 'type': 'network'}, - {'endpoints': [ - {'publicURL': ref.get('auth_url'), - 'adminURL': ref.get('auth_url'), - 'internalURL': ref.get('auth_url'), - 'id': uuid.uuid4().hex, - 'region': 'RegionOne' - }], - 'endpoint_links': [], - 'name': 'keystone', - 'type': 'identity'}] + { + 'endpoints': [ + { + 'publicURL': ref.get('auth_url'), + 'adminURL': ref.get('auth_url'), + 'internalURL': ref.get('auth_url'), + 'id': uuidutils.generate_uuid(dashed=False), + 'region': 'RegionOne' + }], + 'endpoint_links': [], + 'name': 'keystone', + 'type': 'identity' + } + ] + + cinder_endpoint = { + 'endpoints': [ + { + 'publicURL': 'public_' + ref.get('cinder_url'), + 'internalURL': 'internal_' + ref.get('cinder_url'), + 'adminURL': 'admin_' + (ref.get('auth_url') or ""), + 'id': uuidutils.generate_uuid(dashed=False), + 'region': 'RegionOne' + } + ], + 'endpoints_links': [], + 'name': None, + 'type': 'volumev3' + } + + # Add multiple Cinder endpoints + for count in range(1, 4): + # Copy the endpoint and create a service name + endpoint_copy = copy.deepcopy(cinder_endpoint) + name = "cinder%i" % count + # Assign the service name and a unique endpoint + endpoint_copy['endpoints'][0]['publicURL'] = \ + 'http://%s.api.com/v3' % name + endpoint_copy['name'] = name + + o['access']['serviceCatalog'].append(endpoint_copy) return token, o @@ -168,44 +198,44 @@ def generate_v3_project_scoped_token(**kwargs): o['token']['catalog'] = [ {'endpoints': [ { - 'id': uuid.uuid4().hex, + 'id': uuidutils.generate_uuid(dashed=False), 'interface': 'public', 'region': 'RegionOne', 'url': 'public_' + ref.get('cinder_url') }, { - 'id': uuid.uuid4().hex, + 'id': uuidutils.generate_uuid(dashed=False), 'interface': 'internal', 'region': 'RegionOne', 'url': 'internal_' + ref.get('cinder_url') }, { - 'id': uuid.uuid4().hex, + 'id': uuidutils.generate_uuid(dashed=False), 'interface': 'admin', 'region': 'RegionOne', 'url': 'admin_' + ref.get('cinder_url') }], - 'id': uuid.uuid4().hex, + 'id': uuidutils.generate_uuid(dashed=False), 'type': 'network'}, {'endpoints': [ { - 'id': uuid.uuid4().hex, + 'id': uuidutils.generate_uuid(dashed=False), 'interface': 'public', 'region': 'RegionOne', 'url': ref.get('auth_url') }, { - 'id': uuid.uuid4().hex, + 'id': uuidutils.generate_uuid(dashed=False), 'interface': 'admin', 'region': 'RegionOne', 'url': ref.get('auth_url') }], - 'id': uuid.uuid4().hex, + 'id': uuidutils.generate_uuid(dashed=False), 'type': 'identity'}] # token ID is conveyed via the X-Subject-Token header so we are generating # one to stash there - token_id = uuid.uuid4().hex + token_id = uuidutils.generate_uuid(dashed=False) return token_id, o @@ -218,12 +248,15 @@ def keystone_request_callback(request, context): elif request.url == BASE_URL + "/v2.0": token_id, token_data = generate_v2_project_scoped_token() return token_data + elif request.url.startswith("http://multiple.service.names"): + token_id, token_data = generate_v2_project_scoped_token() + return json.dumps(token_data) elif request.url == BASE_URL + "/v3": token_id, token_data = generate_v3_project_scoped_token() context.headers["X-Subject-Token"] = token_id context.status_code = 201 return token_data - elif "WrongDiscoveryResponse.discovery.com" in request.url: + elif "wrongdiscoveryresponse.discovery.com" in request.url: return str(WRONG_VERSION_RESPONSE) else: context.status_code = 500 diff --git a/cinderclient/tests/unit/fixture_data/snapshots.py b/cinderclient/tests/unit/fixture_data/snapshots.py index ec4d3b392..83adf487f 100644 --- a/cinderclient/tests/unit/fixture_data/snapshots.py +++ b/cinderclient/tests/unit/fixture_data/snapshots.py @@ -10,11 +10,12 @@ # License for the specific language governing permissions and limitations # under the License. -import json - from cinderclient.tests.unit.fixture_data import base +REQUEST_ID = 'req-test-request-id' + + def _stub_snapshot(**kwargs): snapshot = { "created_at": "2012-08-28T16:30:31.000000", @@ -37,20 +38,29 @@ def setUp(self): super(Fixture, self).setUp() snapshot_1234 = _stub_snapshot(id='1234') - self.requests.register_uri('GET', self.url('1234'), - json={'snapshot': snapshot_1234}) + self.requests.register_uri( + 'GET', self.url('1234'), + json={'snapshot': snapshot_1234}, + headers={'x-openstack-request-id': REQUEST_ID} + ) def action_1234(request, context): return '' - body = json.loads(request.body.decode('utf-8')) - assert len(list(body)) == 1 - action = list(body)[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) - return '' - self.requests.register_uri('POST', self.url('1234', 'action'), - text=action_1234, status_code=202) + + self.requests.register_uri( + 'POST', self.url('1234', 'action'), + text=action_1234, status_code=202, + headers={'x-openstack-request-id': REQUEST_ID} + ) + + self.requests.register_uri( + 'GET', self.url('detail?limit=2&marker=1234'), + status_code=200, json={'snapshots': []}, + headers={'x-openstack-request-id': REQUEST_ID} + ) + + self.requests.register_uri( + 'GET', self.url('detail?sort=id'), + status_code=200, json={'snapshots': []}, + headers={'x-openstack-request-id': REQUEST_ID} + ) diff --git a/cinderclient/tests/unit/test_api_versions.py b/cinderclient/tests/unit/test_api_versions.py new file mode 100644 index 000000000..f56336ccc --- /dev/null +++ b/cinderclient/tests/unit/test_api_versions.py @@ -0,0 +1,276 @@ +# Copyright 2016 Mirantis +# 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 unittest import mock + +import ddt + +from cinderclient import api_versions +from cinderclient import exceptions +from cinderclient.tests.unit import test_utils +from cinderclient.tests.unit import utils +from cinderclient.v3 import client + + +@ddt.ddt +class APIVersionTestCase(utils.TestCase): + def test_valid_version_strings(self): + def _test_string(version, exp_major, exp_minor): + v = api_versions.APIVersion(version) + self.assertEqual(v.ver_major, exp_major) + self.assertEqual(v.ver_minor, exp_minor) + + _test_string("1.1", 1, 1) + _test_string("2.10", 2, 10) + _test_string("5.234", 5, 234) + _test_string("12.5", 12, 5) + _test_string("2.0", 2, 0) + _test_string("2.200", 2, 200) + + def test_null_version(self): + v = api_versions.APIVersion() + self.assertFalse(v) + + def test_not_null_version(self): + v = api_versions.APIVersion('1.1') + self.assertTrue(v) + + @ddt.data("2", "200", "2.1.4", "200.23.66.3", "5 .3", "5. 3", "5.03", + "02.1", "2.001", "", " 2.1", "2.1 ") + def test_invalid_version_strings(self, version_string): + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.APIVersion, version_string) + + def test_version_comparisons(self): + v1 = api_versions.APIVersion("2.0") + v2 = api_versions.APIVersion("2.5") + v3 = api_versions.APIVersion("5.23") + v4 = api_versions.APIVersion("2.0") + v_null = api_versions.APIVersion() + + self.assertLess(v1, v2) + self.assertGreater(v3, v2) + self.assertNotEqual(v1, v2) + self.assertEqual(v1, v4) + self.assertNotEqual(v1, v_null) + self.assertEqual(v_null, v_null) + self.assertRaises(TypeError, v1.__le__, "2.1") + + def test_version_matches(self): + v1 = api_versions.APIVersion("2.0") + v2 = api_versions.APIVersion("2.5") + v3 = api_versions.APIVersion("2.45") + v4 = api_versions.APIVersion("3.3") + v5 = api_versions.APIVersion("3.23") + v6 = api_versions.APIVersion("2.0") + v7 = api_versions.APIVersion("3.3") + v8 = api_versions.APIVersion("4.0") + v_null = api_versions.APIVersion() + + self.assertTrue(v2.matches(v1, v3)) + self.assertTrue(v2.matches(v1, v_null)) + self.assertTrue(v1.matches(v6, v2)) + self.assertTrue(v4.matches(v2, v7)) + self.assertTrue(v4.matches(v_null, v7)) + self.assertTrue(v4.matches(v_null, v8)) + self.assertFalse(v1.matches(v2, v3)) + self.assertFalse(v5.matches(v2, v4)) + self.assertFalse(v2.matches(v3, v1)) + + self.assertRaises(ValueError, v_null.matches, v1, v3) + + def test_get_string(self): + v1_string = "3.23" + v1 = api_versions.APIVersion(v1_string) + self.assertEqual(v1_string, v1.get_string()) + + self.assertRaises(ValueError, + api_versions.APIVersion().get_string) + + +class ManagerTest(utils.TestCase): + def test_api_version(self): + # The function manager.return_api_version has two versions, + # when called with api version 3.1 it should return the + # string '3.1' and when called with api version 3.2 or higher + # it should return the string '3.2'. + version = api_versions.APIVersion('3.1') + api = client.Client(api_version=version) + manager = test_utils.FakeManagerWithApi(api) + self.assertEqual('3.1', manager.return_api_version()) + + version = api_versions.APIVersion('3.2') + api = client.Client(api_version=version) + manager = test_utils.FakeManagerWithApi(api) + self.assertEqual('3.2', manager.return_api_version()) + + # pick up the highest version + version = api_versions.APIVersion('3.3') + api = client.Client(api_version=version) + manager = test_utils.FakeManagerWithApi(api) + self.assertEqual('3.2', manager.return_api_version()) + + version = api_versions.APIVersion('3.0') + api = client.Client(api_version=version) + manager = test_utils.FakeManagerWithApi(api) + # An exception will be returned here because the function + # return_api_version doesn't support version 3.0 + self.assertRaises(exceptions.VersionNotFoundForAPIMethod, + manager.return_api_version) + + +class UpdateHeadersTestCase(utils.TestCase): + def test_api_version_is_null(self): + headers = {} + api_versions.update_headers(headers, api_versions.APIVersion()) + self.assertEqual({}, headers) + + def test_api_version_is_major(self): + headers = {} + api_versions.update_headers(headers, api_versions.APIVersion("7.0")) + self.assertEqual({}, headers) + + def test_api_version_is_not_null(self): + api_version = api_versions.APIVersion("2.3") + headers = {} + api_versions.update_headers(headers, api_version) + self.assertEqual( + {"OpenStack-API-Version": "volume " + api_version.get_string()}, + headers) + + +class GetAPIVersionTestCase(utils.TestCase): + def test_get_available_client_versions(self): + output = api_versions.get_available_major_versions() + self.assertNotEqual([], output) + + def test_wrong_format(self): + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.get_api_version, "something_wrong") + + def test_wrong_major_version(self): + self.assertRaises(exceptions.UnsupportedVersion, + api_versions.get_api_version, "4") + + @mock.patch("cinderclient.api_versions.get_available_major_versions") + @mock.patch("cinderclient.api_versions.APIVersion") + def test_only_major_part_is_presented(self, mock_apiversion, + mock_get_majors): + mock_get_majors.return_value = [ + str(mock_apiversion.return_value.ver_major)] + version = 7 + self.assertEqual(mock_apiversion.return_value, + api_versions.get_api_version(version)) + mock_apiversion.assert_called_once_with("%s.0" % str(version)) + + @mock.patch("cinderclient.api_versions.get_available_major_versions") + @mock.patch("cinderclient.api_versions.APIVersion") + def test_major_and_minor_parts_is_presented(self, mock_apiversion, + mock_get_majors): + version = "2.7" + mock_get_majors.return_value = [ + str(mock_apiversion.return_value.ver_major)] + self.assertEqual(mock_apiversion.return_value, + api_versions.get_api_version(version)) + mock_apiversion.assert_called_once_with(version) + + +@ddt.ddt +class DiscoverVersionTestCase(utils.TestCase): + def setUp(self): + super(DiscoverVersionTestCase, self).setUp() + self.orig_max = api_versions.MAX_VERSION + self.orig_min = api_versions.MIN_VERSION or None + self.addCleanup(self._clear_fake_version) + self.fake_client = mock.MagicMock() + + def _clear_fake_version(self): + api_versions.MAX_VERSION = self.orig_max + api_versions.MIN_VERSION = self.orig_min + + def _mock_returned_server_version(self, server_version, + server_min_version): + version_mock = mock.MagicMock(version=server_version, + min_version=server_min_version, + status='CURRENT') + val = [version_mock] + if not server_version and not server_min_version: + val = [] + self.fake_client.services.server_api_version.return_value = val + + @ddt.data( + # what the data mean: + # items 1, 2: client min, max + # items 3, 4: server min, max + # item 5: user's requested API version + # item 6: should this raise an exception? + # item 7: version that should be returned when no exception + # item 8: what client.services.server_api_version should return + # when called by _get_server_version_range in discover_version + ("3.1", "3.3", "3.4", "3.7", "3.3", True), # Server too new + ("3.9", "3.10", "3.0", "3.3", "3.10", True), # Server too old + ("3.3", "3.9", "3.7", "3.17", "3.9", False), # Requested < server + # downgraded because of server: + ("3.5", "3.8", "3.0", "3.7", "3.8", False, "3.7"), + # downgraded because of client: + ("3.5", "3.8", "3.0", "3.9", "3.9", False, "3.8"), + # downgraded because of both: + ("3.5", "3.7", "3.0", "3.8", "3.9", False, "3.7"), + ("3.5", "3.5", "3.0", "3.5", "3.5", False), # Server & client same + ("3.5", "3.5", None, None, "3.5", True, None, []), # Pre-micro + ("3.1", "3.11", "3.4", "3.7", "3.7", False), # Requested in range + ("3.5", "3.5", "3.0", "3.5", "1.0", True) # Requested too old + ) + @ddt.unpack + def test_microversion(self, client_min, client_max, server_min, server_max, + requested_version, exp_range, end_version=None, + ret_val=None): + if ret_val is not None: + self.fake_client.services.server_api_version.return_value = ret_val + else: + self._mock_returned_server_version(server_max, server_min) + + api_versions.MAX_VERSION = client_max + api_versions.MIN_VERSION = client_min + + if exp_range: + exc = self.assertRaises(exceptions.UnsupportedVersion, + api_versions.discover_version, + self.fake_client, + api_versions.APIVersion(requested_version)) + if ret_val is not None: + self.assertIn("Server does not support microversions", + str(exc)) + else: + self.assertIn("range is '%s' to '%s'" % + (server_min, server_max), str(exc)) + else: + discovered_version = api_versions.discover_version( + self.fake_client, + api_versions.APIVersion(requested_version)) + + version = requested_version + if end_version is not None: + version = end_version + self.assertEqual(version, + discovered_version.get_string()) + self.assertTrue( + self.fake_client.services.server_api_version.called) + + def test_get_highest_version(self): + self._mock_returned_server_version("3.14", "3.0") + highest_version = api_versions.get_highest_version(self.fake_client) + self.assertEqual("3.14", highest_version.get_string()) + self.assertTrue(self.fake_client.services.server_api_version.called) diff --git a/cinderclient/tests/unit/test_auth_plugins.py b/cinderclient/tests/unit/test_auth_plugins.py index 91adfa9e2..5653a7b41 100644 --- a/cinderclient/tests/unit/test_auth_plugins.py +++ b/cinderclient/tests/unit/test_auth_plugins.py @@ -13,334 +13,31 @@ # License for the specific language governing permissions and limitations # under the License. -import argparse -import mock -import pkg_resources -import requests - -try: - import json -except ImportError: - import simplejson as json - -from cinderclient import auth_plugin -from cinderclient import exceptions +from cinderclient.contrib import noauth from cinderclient.tests.unit import utils -from cinderclient.v1 import client - - -def mock_http_request(resp=None): - """Mock an HTTP Request.""" - if not resp: - resp = { - "access": { - "token": { - "expires": "12345", - "id": "FAKE_ID", - "tenant": { - "id": "FAKE_TENANT_ID", - } - }, - "serviceCatalog": [ - { - "type": "volume", - "endpoints": [ - { - "region": "RegionOne", - "adminURL": "http://localhost:8774/v1.1", - "internalURL": "http://localhost:8774/v1.1", - "publicURL": "http://localhost:8774/v1.1/", - }, - ], - }, - ], - }, - } - - auth_response = utils.TestResponse({ - "status_code": 200, - "text": json.dumps(resp), - }) - return mock.Mock(return_value=(auth_response)) - - -def requested_headers(cs): - """Return requested passed headers.""" - return { - 'User-Agent': cs.client.USER_AGENT, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } - - -class DeprecatedAuthPluginTest(utils.TestCase): - def test_auth_system_success(self): - class MockEntrypoint(pkg_resources.EntryPoint): - def load(self): - return self.authenticate - - def authenticate(self, cls, auth_url): - cls._authenticate(auth_url, {"fake": "me"}) - - def mock_iter_entry_points(_type, name): - if _type == 'openstack.client.authenticate': - return [MockEntrypoint("fake", "fake", ["fake"])] - else: - return [] - - mock_request = mock_http_request() - - @mock.patch.object(pkg_resources, "iter_entry_points", - mock_iter_entry_points) - @mock.patch.object(requests, "request", mock_request) - def test_auth_call(): - plugin = auth_plugin.DeprecatedAuthPlugin("fake") - cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", auth_system="fake", - auth_plugin=plugin) - cs.client.authenticate() - - headers = requested_headers(cs) - token_url = cs.client.auth_url + "/tokens" - - mock_request.assert_called_with( - "POST", - token_url, - headers=headers, - data='{"fake": "me"}', - allow_redirects=True, - **self.TEST_REQUEST_BASE) - - test_auth_call() - - def test_auth_system_not_exists(self): - def mock_iter_entry_points(_t, name=None): - return [pkg_resources.EntryPoint("fake", "fake", ["fake"])] - - mock_request = mock_http_request() - - @mock.patch.object(pkg_resources, "iter_entry_points", - mock_iter_entry_points) - @mock.patch.object(requests.Session, "request", mock_request) - def test_auth_call(): - auth_plugin.discover_auth_systems() - plugin = auth_plugin.DeprecatedAuthPlugin("notexists") - cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", auth_system="notexists", - auth_plugin=plugin) - self.assertRaises(exceptions.AuthSystemNotFound, - cs.client.authenticate) - - test_auth_call() - - def test_auth_system_defining_auth_url(self): - class MockAuthUrlEntrypoint(pkg_resources.EntryPoint): - def load(self): - return self.auth_url - - def auth_url(self): - return "http://faked/v2.0" - - class MockAuthenticateEntrypoint(pkg_resources.EntryPoint): - def load(self): - return self.authenticate - - def authenticate(self, cls, auth_url): - cls._authenticate(auth_url, {"fake": "me"}) - - def mock_iter_entry_points(_type, name): - if _type == 'openstack.client.auth_url': - return [MockAuthUrlEntrypoint("fakewithauthurl", - "fakewithauthurl", - ["auth_url"])] - elif _type == 'openstack.client.authenticate': - return [MockAuthenticateEntrypoint("fakewithauthurl", - "fakewithauthurl", - ["authenticate"])] - else: - return [] - - mock_request = mock_http_request() - - @mock.patch.object(pkg_resources, "iter_entry_points", - mock_iter_entry_points) - @mock.patch.object(requests.Session, "request", mock_request) - def test_auth_call(): - plugin = auth_plugin.DeprecatedAuthPlugin("fakewithauthurl") - cs = client.Client("username", "password", "project_id", - auth_system="fakewithauthurl", - auth_plugin=plugin) - cs.client.authenticate() - self.assertEqual(cs.client.auth_url, "http://faked/v2.0") - - test_auth_call() - - @mock.patch.object(pkg_resources, "iter_entry_points") - def test_client_raises_exc_without_auth_url(self, mock_iter_entry_points): - class MockAuthUrlEntrypoint(pkg_resources.EntryPoint): - def load(self): - return self.auth_url - - def auth_url(self): - return None - - mock_iter_entry_points.side_effect = lambda _t, name: [ - MockAuthUrlEntrypoint("fakewithauthurl", - "fakewithauthurl", - ["auth_url"])] - - plugin = auth_plugin.DeprecatedAuthPlugin("fakewithauthurl") - self.assertRaises( - exceptions.EndpointNotFound, - client.Client, "username", "password", "project_id", - auth_system="fakewithauthurl", auth_plugin=plugin) - - -class AuthPluginTest(utils.TestCase): - @mock.patch.object(requests, "request") - @mock.patch.object(pkg_resources, "iter_entry_points") - def test_auth_system_success(self, mock_iter_entry_points, mock_request): - """Test that we can authenticate using the auth system.""" - class MockEntrypoint(pkg_resources.EntryPoint): - def load(self): - return FakePlugin - - class FakePlugin(auth_plugin.BaseAuthPlugin): - def authenticate(self, cls, auth_url): - cls._authenticate(auth_url, {"fake": "me"}) - - mock_iter_entry_points.side_effect = lambda _t: [ - MockEntrypoint("fake", "fake", ["FakePlugin"])] - - mock_request.side_effect = mock_http_request() - - auth_plugin.discover_auth_systems() - plugin = auth_plugin.load_plugin("fake") - cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", auth_system="fake", - auth_plugin=plugin) - cs.client.authenticate() - - headers = requested_headers(cs) - token_url = cs.client.auth_url + "/tokens" - - mock_request.assert_called_with( - "POST", - token_url, - headers=headers, - data='{"fake": "me"}', - allow_redirects=True, - **self.TEST_REQUEST_BASE) - - @mock.patch.object(pkg_resources, "iter_entry_points") - def test_discover_auth_system_options(self, mock_iter_entry_points): - """Test that we can load the auth system options.""" - class FakePlugin(auth_plugin.BaseAuthPlugin): - @staticmethod - def add_opts(parser): - parser.add_argument('--auth_system_opt', - default=False, - action='store_true', - help="Fake option") - return parser - - class MockEntrypoint(pkg_resources.EntryPoint): - def load(self): - return FakePlugin - - mock_iter_entry_points.side_effect = lambda _t: [ - MockEntrypoint("fake", "fake", ["FakePlugin"])] - - parser = argparse.ArgumentParser() - auth_plugin.discover_auth_systems() - auth_plugin.load_auth_system_opts(parser) - opts, args = parser.parse_known_args(['--auth_system_opt']) - - self.assertTrue(opts.auth_system_opt) - - @mock.patch.object(pkg_resources, "iter_entry_points") - def test_parse_auth_system_options(self, mock_iter_entry_points): - """Test that we can parse the auth system options.""" - class MockEntrypoint(pkg_resources.EntryPoint): - def load(self): - return FakePlugin - - class FakePlugin(auth_plugin.BaseAuthPlugin): - def __init__(self): - self.opts = {"fake_argument": True} - - def parse_opts(self, args): - return self.opts - - mock_iter_entry_points.side_effect = lambda _t: [ - MockEntrypoint("fake", "fake", ["FakePlugin"])] - - auth_plugin.discover_auth_systems() - plugin = auth_plugin.load_plugin("fake") - - plugin.parse_opts([]) - self.assertIn("fake_argument", plugin.opts) - - @mock.patch.object(pkg_resources, "iter_entry_points") - def test_auth_system_defining_url(self, mock_iter_entry_points): - """Test the auth_system defining an url.""" - class MockEntrypoint(pkg_resources.EntryPoint): - def load(self): - return FakePlugin - - class FakePlugin(auth_plugin.BaseAuthPlugin): - def get_auth_url(self): - return "http://faked/v2.0" - - mock_iter_entry_points.side_effect = lambda _t: [ - MockEntrypoint("fake", "fake", ["FakePlugin"])] - - auth_plugin.discover_auth_systems() - plugin = auth_plugin.load_plugin("fake") - - cs = client.Client("username", "password", "project_id", - auth_system="fakewithauthurl", - auth_plugin=plugin) - self.assertEqual(cs.client.auth_url, "http://faked/v2.0") - - @mock.patch.object(pkg_resources, "iter_entry_points") - def test_exception_if_no_authenticate(self, mock_iter_entry_points): - """Test that no authenticate raises a proper exception.""" - class MockEntrypoint(pkg_resources.EntryPoint): - def load(self): - return FakePlugin - - class FakePlugin(auth_plugin.BaseAuthPlugin): - pass - - mock_iter_entry_points.side_effect = lambda _t: [ - MockEntrypoint("fake", "fake", ["FakePlugin"])] - - auth_plugin.discover_auth_systems() - plugin = auth_plugin.load_plugin("fake") - self.assertRaises( - exceptions.EndpointNotFound, - client.Client, "username", "password", "project_id", - auth_system="fake", auth_plugin=plugin) - @mock.patch.object(pkg_resources, "iter_entry_points") - def test_exception_if_no_url(self, mock_iter_entry_points): - """Test that no auth_url at all raises exception.""" - class MockEntrypoint(pkg_resources.EntryPoint): - def load(self): - return FakePlugin +class CinderNoAuthPluginTest(utils.TestCase): + def setUp(self): + super(CinderNoAuthPluginTest, self).setUp() + self.plugin = noauth.CinderNoAuthPlugin('user', 'project', + endpoint='example.com') - class FakePlugin(auth_plugin.BaseAuthPlugin): - pass + def test_auth_token(self): + auth_token = 'user:project' + self.assertEqual(auth_token, self.plugin.auth_token) - mock_iter_entry_points.side_effect = lambda _t: [ - MockEntrypoint("fake", "fake", ["FakePlugin"])] + def test_auth_token_no_project(self): + auth_token = 'user:user' + plugin = noauth.CinderNoAuthPlugin('user') + self.assertEqual(auth_token, plugin.auth_token) - auth_plugin.discover_auth_systems() - plugin = auth_plugin.load_plugin("fake") + def test_get_headers(self): + headers = {'x-user-id': 'user', + 'x-project-id': 'project', + 'X-Auth-Token': 'user:project'} + self.assertEqual(headers, self.plugin.get_headers(None)) - self.assertRaises( - exceptions.EndpointNotFound, - client.Client, "username", "password", "project_id", - auth_system="fake", auth_plugin=plugin) + def test_get_endpoint(self): + endpoint = 'example.com/project' + self.assertEqual(endpoint, self.plugin.get_endpoint(None)) diff --git a/cinderclient/tests/unit/test_base.py b/cinderclient/tests/unit/test_base.py index e4eba0dcd..36503e152 100644 --- a/cinderclient/tests/unit/test_base.py +++ b/cinderclient/tests/unit/test_base.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # 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 @@ -11,21 +12,49 @@ # See the License for the specific language governing permissions and # limitations under the License. +from unittest import mock + +import requests + +from cinderclient import api_versions +from cinderclient.apiclient import base as common_base from cinderclient import base from cinderclient import exceptions -from cinderclient.v1 import volumes +from cinderclient.tests.unit import test_utils from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes - +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import client +from cinderclient.v3 import volumes cs = fakes.FakeClient() +REQUEST_ID = test_utils.REQUEST_ID + + +def create_response_obj_with_header(): + resp = requests.Response() + resp.headers['x-openstack-request-id'] = REQUEST_ID + resp.headers['Etag'] = 'd5103bf7b26ff0310200d110da3ed186' + resp.status_code = 200 + return resp + class BaseTest(utils.TestCase): def test_resource_repr(self): r = base.Resource(None, dict(foo="bar", baz="spam")) self.assertEqual("", repr(r)) + self.assertNotIn("x_openstack_request_ids", repr(r)) + + def test_add_non_ascii_attr_to_resource(self): + info = {'gigabytes_тест': -1, + 'volumes_тест': -1, + 'id': 'admin'} + + res = base.Resource(None, info) + + for key, value in info.items(): + self.assertEqual(value, getattr(res, key, None)) def test_getid(self): self.assertEqual(4, base.getid(4)) @@ -35,12 +64,17 @@ class TmpObject(object): self.assertEqual(4, base.getid(TmpObject)) def test_eq(self): - # Two resources of the same type with the same id: equal + # Two resources with same ID: never equal if their info is not equal r1 = base.Resource(None, {'id': 1, 'name': 'hi'}) r2 = base.Resource(None, {'id': 1, 'name': 'hello'}) + self.assertNotEqual(r1, r2) + + # Two resources with same ID: equal if their info is equal + r1 = base.Resource(None, {'id': 1, 'name': 'hello'}) + r2 = base.Resource(None, {'id': 1, 'name': 'hello'}) self.assertEqual(r1, r2) - # Two resoruces of different types: never equal + # Two resources of different types: never equal r1 = base.Resource(None, {'id': 1}) r2 = volumes.Volume(None, {'id': 1}) self.assertNotEqual(r1, r2) @@ -59,3 +93,80 @@ def test_findall_invalid_attribute(self): self.assertRaises(exceptions.NotFound, cs.volumes.find, vegetable='carrot') + + def test_to_dict(self): + r1 = base.Resource(None, {'id': 1, 'name': 'hi'}) + self.assertEqual({'id': 1, 'name': 'hi'}, r1.to_dict()) + + def test_resource_object_with_request_ids(self): + resp_obj = create_response_obj_with_header() + r = base.Resource(None, {"name": "1"}, resp=resp_obj) + self.assertEqual([REQUEST_ID], r.request_ids) + + def test_api_version(self): + version = api_versions.APIVersion('3.1') + api = client.Client(api_version=version) + manager = test_utils.FakeManagerWithApi(api) + r1 = base.Resource(manager, {'id': 1}) + self.assertEqual(version, r1.api_version) + + def test__list_no_link(self): + api = mock.Mock() + api.client.get.return_value = (mock.sentinel.resp, + {'resp_keys': [{'name': '1'}]}) + manager = test_utils.FakeManager(api) + res = manager._list(mock.sentinel.url, 'resp_keys') + api.client.get.assert_called_once_with(mock.sentinel.url) + result = [r.name for r in res] + self.assertListEqual(['1'], result) + + def test__list_with_link(self): + api = mock.Mock() + api.client.get.side_effect = [ + (mock.sentinel.resp, + {'resp_keys': [{'name': '1'}], + 'resp_keys_links': [{'rel': 'next', 'href': mock.sentinel.u2}]}), + (mock.sentinel.resp, + {'resp_keys': [{'name': '2'}], + 'resp_keys_links': [{'rel': 'next', 'href': mock.sentinel.u3}]}), + (mock.sentinel.resp, + {'resp_keys': [{'name': '3'}], + 'resp_keys_links': [{'rel': 'next', 'href': None}]}), + ] + manager = test_utils.FakeManager(api) + res = manager._list(mock.sentinel.url, 'resp_keys') + api.client.get.assert_has_calls([mock.call(mock.sentinel.url), + mock.call(mock.sentinel.u2), + mock.call(mock.sentinel.u3)]) + result = [r.name for r in res] + self.assertListEqual(['1', '2', '3'], result) + + +class ListWithMetaTest(utils.TestCase): + def test_list_with_meta(self): + resp = create_response_obj_with_header() + obj = common_base.ListWithMeta([], resp) + self.assertEqual([], obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual([REQUEST_ID], obj.request_ids) + + +class DictWithMetaTest(utils.TestCase): + def test_dict_with_meta(self): + resp = create_response_obj_with_header() + obj = common_base.DictWithMeta([], resp) + self.assertEqual({}, obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual([REQUEST_ID], obj.request_ids) + + +class TupleWithMetaTest(utils.TestCase): + def test_tuple_with_meta(self): + resp = create_response_obj_with_header() + obj = common_base.TupleWithMeta((), resp) + self.assertEqual((), obj) + # Check request_ids attribute is added to obj + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual([REQUEST_ID], obj.request_ids) diff --git a/cinderclient/tests/unit/test_client.py b/cinderclient/tests/unit/test_client.py index b9dfaf527..1ed615f05 100644 --- a/cinderclient/tests/unit/test_client.py +++ b/cinderclient/tests/unit/test_client.py @@ -11,36 +11,48 @@ # See the License for the specific language governing permissions and # limitations under the License. -import logging import json +import logging +from unittest import mock +import ddt import fixtures -import mock +from keystoneauth1 import adapter +from keystoneauth1 import exceptions as keystone_exception +from oslo_serialization import jsonutils +from cinderclient import api_versions import cinderclient.client -import cinderclient.v1.client -import cinderclient.v2.client from cinderclient import exceptions -from cinderclient.tests.unit.fixture_data import base as fixture_base from cinderclient.tests.unit import utils -from keystoneclient import adapter -from keystoneclient import exceptions as keystone_exception +from cinderclient.tests.unit.v3 import fakes +@ddt.ddt class ClientTest(utils.TestCase): - def test_get_client_class_v1(self): - output = cinderclient.client.get_client_class('1') - self.assertEqual(cinderclient.v1.client.Client, output) - def test_get_client_class_v2(self): - output = cinderclient.client.get_client_class('2') - self.assertEqual(cinderclient.v2.client.Client, output) + self.assertRaises(cinderclient.exceptions.UnsupportedVersion, + cinderclient.client.get_client_class, + '2') def test_get_client_class_unknown(self): self.assertRaises(cinderclient.exceptions.UnsupportedVersion, cinderclient.client.get_client_class, '0') + @mock.patch.object(cinderclient.client.HTTPClient, '__init__') + @mock.patch('cinderclient.client.SessionClient') + def test_construct_http_client_endpoint_url( + self, session_mock, httpclient_mock): + os_endpoint = 'http://example.com/' + httpclient_mock.return_value = None + cinderclient.client._construct_http_client( + os_endpoint=os_endpoint) + self.assertTrue(httpclient_mock.called) + self.assertEqual(os_endpoint, + httpclient_mock.call_args[1].get('os_endpoint')) + session_mock.assert_not_called() + def test_log_req(self): self.logger = self.useFixture( fixtures.FakeLogger( @@ -50,11 +62,12 @@ def test_log_req(self): ) ) - kwargs = {} - kwargs['headers'] = {"X-Foo": "bar"} - kwargs['data'] = ('{"auth": {"tenantName": "fakeService",' - ' "passwordCredentials": {"username": "fakeUser",' - ' "password": "fakePassword"}}}') + kwargs = { + 'headers': {"X-Foo": "bar"}, + 'data': ('{"auth": {"tenantName": "fakeService",' + ' "passwordCredentials": {"username": "fakeUser",' + ' "password": "fakePassword"}}}') + } cs = cinderclient.client.HTTPClient("user", None, None, "http://127.0.0.1:5000") @@ -67,19 +80,37 @@ def test_log_req(self): self.assertIn("fakeUser", output[1]) def test_versions(self): - v1_url = fixture_base.VOLUME_V1_URL - v2_url = fixture_base.VOLUME_V2_URL + v2_url = 'http://fakeurl/v2/tenants' + v3_url = 'http://fakeurl/v3/tenants' unknown_url = 'http://fakeurl/v9/tenants' - self.assertEqual('1', - cinderclient.client.get_volume_api_from_url(v1_url)) - self.assertEqual('2', - cinderclient.client.get_volume_api_from_url(v2_url)) + self.assertRaises(cinderclient.exceptions.UnsupportedVersion, + cinderclient.client.get_volume_api_from_url, + v2_url) + self.assertEqual('3', + cinderclient.client.get_volume_api_from_url(v3_url)) self.assertRaises(cinderclient.exceptions.UnsupportedVersion, cinderclient.client.get_volume_api_from_url, unknown_url) - @mock.patch.object(adapter.Adapter, 'request') + @mock.patch('cinderclient.client.SessionClient.get_endpoint') + @ddt.data( + ('http://192.168.1.1:8776/v2', 'http://192.168.1.1:8776/'), + ('http://192.168.1.1:8776/v3/e5526285ebd741b1819393f772f11fc3', + 'http://192.168.1.1:8776/'), + ('https://192.168.1.1:8080/volumes/v3/' + 'e5526285ebd741b1819393f772f11fc3', + 'https://192.168.1.1:8080/volumes/'), + ('http://192.168.1.1/volumes/v3/e5526285ebd741b1819393f772f11fc3', + 'http://192.168.1.1/volumes/'), + ('https://volume.example.com/', 'https://volume.example.com/')) + @ddt.unpack + def test_get_base_url(self, url, expected_base, mock_get_endpoint): + mock_get_endpoint.return_value = url + cs = cinderclient.client.SessionClient(self, api_version='3.0') + self.assertEqual(expected_base, cs._get_base_url()) + + @mock.patch.object(adapter.LegacyJsonAdapter, '_request') @mock.patch.object(exceptions, 'from_response') def test_sessionclient_request_method( self, mock_from_resp, mock_request): @@ -105,26 +136,26 @@ def test_sessionclient_request_method( "code": 202 } + request_id = "req-f551871a-4950-4225-9b2c-29a14c8f075e" mock_response = utils.TestResponse({ "status_code": 202, - "text": json.dumps(resp), + "text": json.dumps(resp).encode("latin-1"), + "headers": {"x-openstack-request-id": request_id}, }) # 'request' method of Adaptor will return 202 response mock_request.return_value = mock_response - mock_session = mock.Mock() - mock_session.get_endpoint.return_value = fixture_base.VOLUME_V1_URL - session_client = cinderclient.client.SessionClient( - session=mock_session) - response, body = session_client.request(fixture_base.VOLUME_V1_URL, + session_client = cinderclient.client.SessionClient(session=mock.Mock()) + response, body = session_client.request(mock.sentinel.url, 'POST', **kwargs) + self.assertIsNotNone(session_client._logger) # In this case, from_response method will not get called # because response status_code is < 400 self.assertEqual(202, response.status_code) self.assertFalse(mock_from_resp.called) - @mock.patch.object(adapter.Adapter, 'request') + @mock.patch.object(adapter.LegacyJsonAdapter, '_request') def test_sessionclient_request_method_raises_badrequest( self, mock_request): kwargs = { @@ -148,20 +179,43 @@ def test_sessionclient_request_method_raises_badrequest( mock_response = utils.TestResponse({ "status_code": 400, - "text": json.dumps(resp), + "text": json.dumps(resp).encode("latin-1"), }) # 'request' method of Adaptor will return 400 response mock_request.return_value = mock_response - mock_session = mock.Mock() - mock_session.get_endpoint.return_value = fixture_base.VOLUME_V1_URL session_client = cinderclient.client.SessionClient( - session=mock_session) + session=mock.Mock()) # 'from_response' method will raise BadRequest because # resp.status_code is 400 self.assertRaises(exceptions.BadRequest, session_client.request, - fixture_base.VOLUME_V1_URL, 'POST', **kwargs) + mock.sentinel.url, 'POST', **kwargs) + self.assertIsNotNone(session_client._logger) + + @mock.patch.object(adapter.LegacyJsonAdapter, '_request') + def test_sessionclient_request_method_raises_overlimit( + self, mock_request): + resp = { + "overLimitFault": { + "message": "This request was rate-limited.", + "code": 413 + } + } + + mock_response = utils.TestResponse({ + "status_code": 413, + "text": json.dumps(resp).encode("latin-1"), + }) + + # 'request' method of Adaptor will return 413 response + mock_request.return_value = mock_response + session_client = cinderclient.client.SessionClient( + session=mock.Mock()) + + self.assertRaises(exceptions.OverLimit, session_client.request, + mock.sentinel.url, 'GET') + self.assertIsNotNone(session_client._logger) @mock.patch.object(exceptions, 'from_response') def test_keystone_request_raises_auth_failure_exception( @@ -178,18 +232,244 @@ def test_keystone_request_raises_auth_failure_exception( } } - with mock.patch.object(adapter.Adapter, 'request', + with mock.patch.object(adapter.LegacyJsonAdapter, '_request', side_effect= keystone_exception.AuthorizationFailure()): - mock_session = mock.Mock() - mock_session.get_endpoint.return_value = fixture_base.VOLUME_V1_URL session_client = cinderclient.client.SessionClient( - session=mock_session) + session=mock.Mock()) self.assertRaises(keystone_exception.AuthorizationFailure, session_client.request, - fixture_base.VOLUME_V1_URL, 'POST', **kwargs) + mock.sentinel.url, 'POST', **kwargs) # As keystonesession.request method will raise # AuthorizationFailure exception, check exceptions.from_response # is not getting called. self.assertFalse(mock_from_resp.called) + + @mock.patch('keystoneauth1.adapter.LegacyJsonAdapter.request', + return_value=(mock.Mock(), mock.Mock())) + @ddt.data(True, False, None) + def test_http_log_debug_request(self, http_log_debug, mock_request): + args_req = (mock.sentinel.url, mock.sentinel.OP) + kwargs_req = {'raise_exc': False} + kwargs_expect = {'authenticated': False} + kwargs_expect.update(kwargs_req) + + kwargs = {'api_version': '3.0'} + if isinstance(http_log_debug, bool): + kwargs['http_log_debug'] = http_log_debug + if http_log_debug: + kwargs_expect['logger'] = mock.ANY + + cs = cinderclient.client.SessionClient(self, **kwargs) + + res = cs.request(*args_req, **kwargs_req) + + mock_request.assert_called_once_with(*args_req, **kwargs_expect) + self.assertEqual(mock_request.return_value, res) + + +class ClientTestSensitiveInfo(utils.TestCase): + def test_req_does_not_log_sensitive_info(self): + self.logger = self.useFixture( + fixtures.FakeLogger( + format="%(message)s", + level=logging.DEBUG, + nuke_handlers=True + ) + ) + + secret_auth_token = "MY_SECRET_AUTH_TOKEN" + kwargs = { + 'headers': {"X-Auth-Token": secret_auth_token}, + 'data': ('{"auth": {"tenantName": "fakeService",' + ' "passwordCredentials": {"username": "fakeUser",' + ' "password": "fakePassword"}}}') + } + + cs = cinderclient.client.HTTPClient("user", None, None, + "http://127.0.0.1:5000") + cs.http_log_debug = True + cs.http_log_req('PUT', kwargs) + + output = self.logger.output.split('\n') + self.assertNotIn(secret_auth_token, output[1]) + + def test_resp_does_not_log_sensitive_info(self): + self.logger = self.useFixture( + fixtures.FakeLogger( + format="%(message)s", + level=logging.DEBUG, + nuke_handlers=True + ) + ) + cs = cinderclient.client.HTTPClient("user", None, None, + "http://127.0.0.1:5000") + resp = mock.Mock() + resp.status_code = 200 + resp.headers = { + 'x-compute-request-id': 'req-f551871a-4950-4225-9b2c-29a14c8f075e' + } + auth_password = "kk4qD6CpKFLyz9JD" + body = { + "connection_info": { + "driver_volume_type": "iscsi", + "data": { + "auth_password": auth_password, + "target_discovered": False, + "encrypted": False, + "qos_specs": None, + "target_iqn": ("iqn.2010-10.org.openstack:volume-" + "a2f33dcc-1bb7-45ba-b8fc-5b38179120f8"), + "target_portal": "10.0.100.186:3260", + "volume_id": "a2f33dcc-1bb7-45ba-b8fc-5b38179120f8", + "target_lun": 1, + "access_mode": "rw", + "auth_username": "s4BfSfZ67Bo2mnpuFWY8", + "auth_method": "CHAP" + } + } + } + resp.text = jsonutils.dumps(body) + cs.http_log_debug = True + cs.http_log_resp(resp) + + output = self.logger.output.split('\n') + self.assertIn('***', output[1], output) + self.assertNotIn(auth_password, output[1], output) + + +@ddt.ddt +class GetAPIVersionTestCase(utils.TestCase): + + @mock.patch('cinderclient.client.requests.get') + def test_get_server_version_v2(self, mock_request): + # Why are we testing this? Because we can! + + mock_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(fakes.fake_request_get_no_v3()) + }) + + mock_request.return_value = mock_response + + url = "http://192.168.122.127:8776/v2/e5526285ebd741b1819393f772f11fc3" + + min_version, max_version = cinderclient.client.get_server_version(url) + + self.assertEqual(api_versions.APIVersion('2.0'), min_version) + self.assertEqual(api_versions.APIVersion('2.0'), max_version) + + @mock.patch('cinderclient.client.requests.get') + @ddt.data( + 'http://192.168.122.127:8776/v3/e5526285ebd741b1819393f772f11fc3', + 'https://192.168.122.127:8776/v3/e55285ebd741b1819393f772f11fc3', + 'http://192.168.122.127/volumesv3/e5526285ebd741b1819393f772f11fc3' + ) + def test_get_server_version(self, url, mock_request): + mock_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(fakes.fake_request_get()) + }) + + mock_request.return_value = mock_response + + min_version, max_version = cinderclient.client.get_server_version(url) + self.assertEqual(min_version, api_versions.APIVersion('3.0')) + self.assertEqual(max_version, api_versions.APIVersion('3.16')) + + @mock.patch('cinderclient.client.requests.get') + def test_get_server_version_insecure(self, mock_request): + mock_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(fakes.fake_request_get_no_v3()) + }) + + mock_request.return_value = mock_response + + url = ( + "https://192.168.122.127:8776/v3/e5526285ebd741b1819393f772f11fc3") + expected_url = "https://192.168.122.127:8776/" + + cinderclient.client.get_server_version(url, True) + + mock_request.assert_called_once_with(expected_url, + verify=False, + cert=None) + + @mock.patch('cinderclient.client.requests.get') + def test_get_server_version_cacert(self, mock_request): + mock_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(fakes.fake_request_get_no_v3()) + }) + + mock_request.return_value = mock_response + + url = ( + "https://192.168.122.127:8776/v3/e5526285ebd741b1819393f772f11fc3") + expected_url = "https://192.168.122.127:8776/" + + cacert = '/path/to/cert' + cinderclient.client.get_server_version(url, cacert=cacert) + + mock_request.assert_called_once_with(expected_url, + verify=cacert, + cert=None) + + @mock.patch('cinderclient.client.requests.get') + def test_get_server_version_cert(self, mock_request): + mock_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(fakes.fake_request_get_no_v3()) + }) + + mock_request.return_value = mock_response + + url = ( + "https://192.168.122.127:8776/v3/e5526285ebd741b1819393f772f11fc3") + expected_url = "https://192.168.122.127:8776/" + + client_cert = '/path/to/cert' + cinderclient.client.get_server_version(url, cert=client_cert) + + mock_request.assert_called_once_with(expected_url, + verify=True, + cert=client_cert) + + @mock.patch('cinderclient.client.requests.get') + @ddt.data('3.12', '3.40') + def test_get_highest_client_server_version(self, version, mock_request): + + mock_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(fakes.fake_request_get()) + }) + + mock_request.return_value = mock_response + + url = "http://192.168.122.127:8776/v3/e5526285ebd741b1819393f772f11fc3" + + with mock.patch.object(api_versions, 'MAX_VERSION', version): + highest = ( + cinderclient.client.get_highest_client_server_version(url)) + expected = version if version == '3.12' else '3.16' + self.assertEqual(expected, highest) + + @mock.patch('cinderclient.client.requests.get') + def test_get_highest_client_server_version_negative(self, + mock_request): + + mock_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(fakes.fake_request_get_no_v3()) + }) + + mock_request.return_value = mock_response + + url = "http://192.168.122.127:8776/v3/e5526285ebd741b1819393f772f11fc3" + + self.assertRaises(exceptions.UnsupportedVersion, + cinderclient.client. + get_highest_client_server_version, + url) diff --git a/cinderclient/tests/unit/test_exceptions.py b/cinderclient/tests/unit/test_exceptions.py new file mode 100644 index 000000000..0ecda02da --- /dev/null +++ b/cinderclient/tests/unit/test_exceptions.py @@ -0,0 +1,65 @@ +# Copyright 2015 IBM Corp. +# +# 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. + +"""Tests the cinderclient.exceptions module.""" + +import datetime +from unittest import mock + +import requests + +from cinderclient import exceptions +from cinderclient.tests.unit import utils + + +class ExceptionsTest(utils.TestCase): + + def test_from_response_no_body_message(self): + # Tests that we get ClientException back since we don't have 500 mapped + response = requests.Response() + response.status_code = 500 + body = {'keys': ({})} + ex = exceptions.from_response(response, body) + self.assertIs(exceptions.ClientException, type(ex)) + self.assertEqual('n/a', ex.message) + + def test_from_response_overlimit(self): + response = requests.Response() + response.status_code = 413 + response.headers = {"Retry-After": '10'} + body = {'keys': ({})} + ex = exceptions.from_response(response, body) + self.assertEqual(10, ex.retry_after) + self.assertIs(exceptions.OverLimit, type(ex)) + + @mock.patch('oslo_utils.timeutils.utcnow', + return_value=datetime.datetime(2016, 6, 30, 12, 41, 55)) + def test_from_response_overlimit_gmt(self, mock_utcnow): + response = requests.Response() + response.status_code = 413 + response.headers = {"Retry-After": "Thu, 30 Jun 2016 12:43:20 GMT"} + body = {'keys': ({})} + ex = exceptions.from_response(response, body) + self.assertEqual(85, ex.retry_after) + self.assertIs(exceptions.OverLimit, type(ex)) + self.assertTrue(mock_utcnow.called) + + def test_from_response_overlimit_without_header(self): + response = requests.Response() + response.status_code = 413 + response.headers = {} + body = {'keys': ({})} + ex = exceptions.from_response(response, body) + self.assertEqual(0, ex.retry_after) + self.assertIs(exceptions.OverLimit, type(ex)) diff --git a/cinderclient/tests/unit/test_http.py b/cinderclient/tests/unit/test_http.py index f9997bc49..75276f8f0 100644 --- a/cinderclient/tests/unit/test_http.py +++ b/cinderclient/tests/unit/test_http.py @@ -11,7 +11,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import mock +import json +from unittest import mock +import uuid import requests @@ -20,22 +22,49 @@ from cinderclient.tests.unit import utils +fake_auth_response = { + "access": { + "token": { + "expires": "2014-11-01T03:32:15-05:00", + "id": "FAKE_ID", + }, + "serviceCatalog": [ + { + "type": "volumev2", + "endpoints": [ + { + "adminURL": "http://localhost:8776/v2", + "region": "RegionOne", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", + }, + ], + }, + ], + }, +} + fake_response = utils.TestResponse({ "status_code": 200, "text": '{"hi": "there"}', }) +mock_request = mock.Mock(return_value=(fake_response)) -fake_response_empty = utils.TestResponse({ - "status_code": 200, - "text": '{"access": {}}' +fake_201_response = utils.TestResponse({ + "status_code": 201, + "text": json.dumps(fake_auth_response), }) +mock_201_request = mock.Mock(return_value=(fake_201_response)) -mock_request = mock.Mock(return_value=(fake_response)) -mock_request_empty = mock.Mock(return_value=(fake_response_empty)) +refused_response = utils.TestResponse({ + "status_code": 400, + "text": '[Errno 111] Connection refused', +}) +refused_mock_request = mock.Mock(return_value=(refused_response)) bad_400_response = utils.TestResponse({ "status_code": 400, - "text": '{"error": {"message": "n/a", "details": "Terrible!"}}', + "text": '', }) bad_400_request = mock.Mock(return_value=(bad_400_response)) @@ -45,6 +74,12 @@ }) bad_401_request = mock.Mock(return_value=(bad_401_response)) +bad_413_response = utils.TestResponse({ + "status_code": 413, + "headers": {"Retry-After": "1", "x-compute-request-id": "1234"}, +}) +bad_413_request = mock.Mock(return_value=(bad_413_response)) + bad_500_response = utils.TestResponse({ "status_code": 500, "text": '{"error": {"message": "FAILED!", "details": "DETAILS!"}}', @@ -58,23 +93,25 @@ side_effect=requests.exceptions.Timeout) -def get_client(retries=0): +def get_client(retries=0, **kwargs): cl = client.HTTPClient("username", "password", - "project_id", "auth_test", retries=retries) + "project_id", "auth_test", retries=retries, + **kwargs) return cl -def get_authed_client(retries=0): - cl = get_client(retries=retries) +def get_authed_client(retries=0, **kwargs): + cl = get_client(retries=retries, **kwargs) cl.management_url = "http://example.com" cl.auth_token = "token" + cl.get_service_url = mock.Mock(return_value="http://example.com") return cl -def get_authed_bypass_url(retries=0): +def get_authed_endpoint_url(retries=0): cl = client.HTTPClient("username", "password", "project_id", "auth_test", - bypass_url="volume/v100/", retries=retries) + os_endpoint="volume/v100/", retries=retries) cl.auth_token = "token" return cl @@ -102,6 +139,29 @@ def test_get_call(): test_get_call() + def test_get_global_id(self): + global_id = "req-%s" % uuid.uuid4() + cl = get_authed_client(global_request_id=global_id) + + @mock.patch.object(requests, "request", mock_request) + @mock.patch('time.time', mock.Mock(return_value=1234)) + def test_get_call(): + resp, body = cl.get("/hi") + headers = {"X-Auth-Token": "token", + "X-Auth-Project-Id": "project_id", + "X-OpenStack-Request-ID": global_id, + "User-Agent": cl.USER_AGENT, + 'Accept': 'application/json', } + mock_request.assert_called_with( + "GET", + "http://example.com/hi", + headers=headers, + **self.TEST_REQUEST_BASE) + # Automatic JSON parsing + self.assertEqual({"hi": "there"}, body) + + test_get_call() + def test_get_reauth_0_retries(self): cl = get_authed_client(retries=0) @@ -135,6 +195,7 @@ def request(*args, **kwargs): @mock.patch.object(requests, "request", request) @mock.patch('time.time', mock.Mock(return_value=1234)) + @mock.patch.object(client, 'sleep', mock.Mock()) def test_get_call(): resp, body = cl.get("/hi") @@ -152,11 +213,52 @@ def request(*args, **kwargs): @mock.patch.object(requests, "request", request) @mock.patch('time.time', mock.Mock(return_value=1234)) + @mock.patch.object(client, 'sleep', mock.Mock()) def test_get_call(): resp, body = cl.get("/hi") test_get_call() - self.assertEqual(self.requests, []) + self.assertEqual([], self.requests) + + def test_rate_limit_overlimit_exception(self): + cl = get_authed_client(retries=1) + + self.requests = [bad_413_request, + bad_413_request, + mock_request] + + @mock.patch.object(client, 'sleep', mock.Mock()) + def request(*args, **kwargs): + next_request = self.requests.pop(0) + return next_request(*args, **kwargs) + + @mock.patch.object(requests, "request", request) + @mock.patch('time.time', mock.Mock(return_value=1234)) + @mock.patch.object(client, 'sleep', mock.Mock()) + def test_get_call(): + resp, body = cl.get("/hi") + self.assertRaises(exceptions.OverLimit, test_get_call) + self.assertEqual([mock_request], self.requests) + + def test_rate_limit(self): + cl = get_authed_client(retries=1) + + self.requests = [bad_413_request, mock_request] + + def request(*args, **kwargs): + next_request = self.requests.pop(0) + return next_request(*args, **kwargs) + + @mock.patch.object(requests, "request", request) + @mock.patch('time.time', mock.Mock(return_value=1234)) + @mock.patch.object(client, 'sleep', mock.Mock()) + def test_get_call(): + resp, body = cl.get("/hi") + return resp, body + + resp, body = test_get_call() + self.assertEqual(200, resp.status_code) + self.assertEqual([], self.requests) def test_retry_limit(self): cl = get_authed_client(retries=1) @@ -169,6 +271,7 @@ def request(*args, **kwargs): @mock.patch.object(requests, "request", request) @mock.patch('time.time', mock.Mock(return_value=1234)) + @mock.patch.object(client, 'sleep', mock.Mock()) def test_get_call(): resp, body = cl.get("/hi") @@ -203,6 +306,7 @@ def request(*args, **kwargs): @mock.patch.object(requests, "request", request) @mock.patch('time.time', mock.Mock(return_value=1234)) + @mock.patch.object(client, 'sleep', mock.Mock()) def test_get_call(): resp, body = cl.get("/hi") @@ -235,30 +339,65 @@ def test_post_call(): test_post_call() - def test_bypass_url(self): - cl = get_authed_bypass_url() - self.assertEqual("volume/v100", cl.bypass_url) + def test_os_endpoint_url(self): + cl = get_authed_endpoint_url() + self.assertEqual("volume/v100", cl.os_endpoint) self.assertEqual("volume/v100", cl.management_url) def test_auth_failure(self): cl = get_client() # response must not have x-server-management-url header - @mock.patch.object(requests, "request", mock_request_empty) + @mock.patch.object(requests, "request", mock_request) def test_auth_call(): self.assertRaises(exceptions.AuthorizationFailure, cl.authenticate) test_auth_call() - def test_auth_not_implemented(self): - cl = get_client() + def test_auth_with_keystone_v3(self): + cl = get_authed_client() + cl.auth_url = 'http://example.com:5000/v3' - # response must not have x-server-management-url header - # {'hi': 'there'} is neither V2 or V3 - @mock.patch.object(requests, "request", mock_request) + @mock.patch.object(requests, "request", mock_201_request) def test_auth_call(): - self.assertRaises(NotImplementedError, cl.authenticate) + cl.authenticate() + headers = { + "Content-Type": "application/json", + 'Accept': 'application/json', + "User-Agent": cl.USER_AGENT + } + data = { + "auth": { + "scope": { + "project": { + "domain": {"name": "Default"}, + "name": "project_id" + } + }, + "identity": { + "methods": ["password"], + "password": { + "user": {"domain": {"name": "Default"}, + "password": "password", "name": "username" + } + } + } + } + } + + # Check data, we cannot do it on the call because the JSON + # dictionary to string can generated different strings. + actual_data = mock_201_request.call_args[1]['data'] + self.assertDictEqual(data, json.loads(actual_data)) + + mock_201_request.assert_called_with( + "POST", + "http://example.com:5000/v3/auth/tokens", + headers=headers, + allow_redirects=True, + data=actual_data, + **self.TEST_REQUEST_BASE) test_auth_call() @@ -273,8 +412,9 @@ def request(*args, **kwargs): @mock.patch.object(requests, "request", request) @mock.patch('time.time', mock.Mock(return_value=1234)) + @mock.patch.object(client, 'sleep', mock.Mock()) def test_get_call(): resp, body = cl.get("/hi") test_get_call() - self.assertEqual(self.requests, []) + self.assertEqual([], self.requests) diff --git a/cinderclient/tests/unit/test_service_catalog.py b/cinderclient/tests/unit/test_service_catalog.py deleted file mode 100644 index f7f35b953..000000000 --- a/cinderclient/tests/unit/test_service_catalog.py +++ /dev/null @@ -1,275 +0,0 @@ -# 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 import exceptions -from cinderclient import service_catalog -from cinderclient.tests.unit import utils - - -# Taken directly from keystone/content/common/samples/auth.json -# Do not edit this structure. Instead, grab the latest from there. - -SERVICE_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", - "type": "volume", - "endpoints": [ - { - "tenantId": "1", - "publicURL": "https://volume1.host/v1/1234", - "internalURL": "https://volume1.host/v1/1234", - "region": "South", - "versionId": "1.0", - "versionInfo": "uri", - "versionList": "uri" - }, - { - "tenantId": "2", - "publicURL": "https://volume1.host/v1/3456", - "internalURL": "https://volume1.host/v1/3456", - "region": "South", - "versionId": "1.1", - "versionInfo": "https://volume1.host/v1/", - "versionList": "https://volume1.host/" - }, - ], - "endpoints_links": [ - { - "rel": "next", - "href": "https://identity1.host/v2.0/endpoints" - }, - ], - }, - { - "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": [ - { - "rel": "next", - "href": "https://identity.host/v2.0/endpoints?session=2hfh8Ar", - }, - ], - }, -} - - -class ServiceCatalogTest(utils.TestCase): - def test_building_a_service_catalog(self): - sc = service_catalog.ServiceCatalog(SERVICE_CATALOG) - - self.assertRaises(exceptions.AmbiguousEndpoints, sc.url_for, - service_type='compute') - self.assertEqual("https://compute1.host/v1/1234", - sc.url_for('tenantId', '1', service_type='compute')) - self.assertEqual("https://compute1.host/v1/3456", - sc.url_for('tenantId', '2', service_type='compute')) - - self.assertRaises(exceptions.EndpointNotFound, sc.url_for, - "region", "South", service_type='compute') - - def test_alternate_service_type(self): - sc = service_catalog.ServiceCatalog(SERVICE_CATALOG) - - self.assertRaises(exceptions.AmbiguousEndpoints, sc.url_for, - service_type='volume') - self.assertEqual("https://volume1.host/v1/1234", - sc.url_for('tenantId', '1', service_type='volume')) - self.assertEqual("https://volume1.host/v1/3456", - sc.url_for('tenantId', '2', service_type='volume')) - - self.assertEqual("https://volume1.host/v2/3456", - sc.url_for('tenantId', '2', service_type='volumev2')) - self.assertEqual("https://volume1.host/v2/3456", - sc.url_for('tenantId', '2', service_type='volumev2')) - - 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.assertEqual("https://volume1.host/v2/1234", - sc.url_for('tenantId', '1', service_type='volume')) - self.assertEqual("https://volume1.host/v2/3456", - sc.url_for('tenantId', '2', service_type='volume')) diff --git a/cinderclient/tests/unit/test_shell.py b/cinderclient/tests/unit/test_shell.py index 964c3d87f..574351042 100644 --- a/cinderclient/tests/unit/test_shell.py +++ b/cinderclient/tests/unit/test_shell.py @@ -12,37 +12,46 @@ # limitations under the License. import argparse +import io +import json import re import sys +from unittest import mock +import ddt import fixtures -from keystoneclient import fixture as keystone_client_fixture -import mock +import keystoneauth1.exceptions as ks_exc +from keystoneauth1.exceptions import DiscoveryFailure +from keystoneauth1.identity.generic.password import Password as ks_password +from keystoneauth1 import session import requests_mock -from six import moves from testtools import matchers +import cinderclient +from cinderclient import api_versions +from cinderclient.contrib import noauth from cinderclient import exceptions from cinderclient import shell -from cinderclient.tests.unit.fixture_data import base as fixture_base +from cinderclient.tests.unit import fake_actions_module from cinderclient.tests.unit.fixture_data import keystone_client from cinderclient.tests.unit import utils -import keystoneclient.exceptions as ks_exc -from keystoneclient.exceptions import DiscoveryFailure +from cinderclient.tests.unit.v3 import fakes +@ddt.ddt class ShellTest(utils.TestCase): FAKE_ENV = { 'OS_USERNAME': 'username', 'OS_PASSWORD': 'password', - 'OS_TENANT_NAME': 'tenant_name', - 'OS_AUTH_URL': '%s/v2.0' % keystone_client.BASE_HOST, + 'OS_PROJECT_NAME': 'tenant_name', + 'OS_AUTH_URL': 'http://no.where/v2.0', } # Patch os.environ to avoid required auth info. - def make_env(self, exclude=None): + def make_env(self, exclude=None, include=None): env = dict((k, v) for k, v in self.FAKE_ENV.items() if k != exclude) + env.update(include or {}) self.useFixture(fixtures.MonkeyPatch('os.environ', env)) def setUp(self): @@ -51,10 +60,12 @@ def setUp(self): self.useFixture(fixtures.EnvironmentVariable(var, self.FAKE_ENV[var])) + self.mock_completion() + def shell(self, argstr): orig = sys.stdout try: - sys.stdout = moves.StringIO() + sys.stdout = io.StringIO() _shell = shell.OpenStackCinderShell() _shell.main(argstr.split()) except SystemExit: @@ -67,14 +78,51 @@ def shell(self, argstr): return out + def test_default_auth_env(self): + _shell = shell.OpenStackCinderShell() + args, __ = _shell.get_base_parser().parse_known_args([]) + self.assertEqual('', args.os_auth_type) + + def test_auth_type_env(self): + self.make_env(exclude='OS_PASSWORD', + include={'OS_AUTH_SYSTEM': 'non existent auth', + 'OS_AUTH_TYPE': 'noauth'}) + _shell = shell.OpenStackCinderShell() + args, __ = _shell.get_base_parser().parse_known_args([]) + self.assertEqual('noauth', args.os_auth_type) + + def test_auth_system_env(self): + self.make_env(exclude='OS_PASSWORD', + include={'OS_AUTH_SYSTEM': 'noauth'}) + _shell = shell.OpenStackCinderShell() + args, __ = _shell.get_base_parser().parse_known_args([]) + self.assertEqual('noauth', args.os_auth_type) + + @mock.patch.object(cinderclient.shell.OpenStackCinderShell, + '_get_keystone_session') + @mock.patch.object(cinderclient.client.SessionClient, 'authenticate', + side_effect=RuntimeError()) + def test_password_auth_type(self, mock_authenticate, + mock_get_session): + self.make_env(include={'OS_AUTH_TYPE': 'password'}) + _shell = shell.OpenStackCinderShell() + + # We crash the command after Client instantiation because this test + # focuses only keystoneauth1 indentity cli opts parsing. + self.assertRaises(RuntimeError, _shell.main, ['list']) + self.assertIsInstance(_shell.cs.client.session.auth, + ks_password) + def test_help_unknown_command(self): self.assertRaises(exceptions.CommandError, self.shell, 'help foofoo') def test_help(self): + # Some expected help output, including microversioned commands required = [ - '.*?^usage: ', - '.*?(?m)^\s+create\s+Creates a volume.', - '.*?(?m)^Run "cinder help SUBCOMMAND" for help on a subcommand.', + r'.*?^usage: ', + r'.*?^\s+create\s+Creates a volume.', + r'.*?^\s+summary\s+Get volumes summary.', + r'.*?^Run "cinder help SUBCOMMAND" for help on a subcommand.', ] help_text = self.shell('help') for r in required: @@ -83,14 +131,45 @@ def test_help(self): def test_help_on_subcommand(self): required = [ - '.*?^usage: cinder list', - '.*?(?m)^Lists all volumes.', + r'.*?^usage: cinder list', + r'.*?^Lists all volumes.', ] help_text = self.shell('help list') for r in required: self.assertThat(help_text, matchers.MatchesRegex(r, re.DOTALL | re.MULTILINE)) + def test_help_on_subcommand_mv(self): + required = [ + r'.*?^usage: cinder summary', + r'.*?^Get volumes summary.', + ] + help_text = self.shell('help summary') + for r in required: + self.assertThat(help_text, + matchers.MatchesRegex(r, re.DOTALL | re.MULTILINE)) + + def test_help_arg_no_subcommand(self): + required = [ + r'.*?^usage: ', + r'.*?^\s+create\s+Creates a volume.', + r'.*?^\s+summary\s+Get volumes summary.', + r'.*?^Run "cinder help SUBCOMMAND" for help on a subcommand.', + ] + help_text = self.shell('--os-volume-api-version 3.40') + for r in required: + self.assertThat(help_text, + matchers.MatchesRegex(r, re.DOTALL | re.MULTILINE)) + + @ddt.data('backup-create --help', '--help backup-create') + def test_dash_dash_help_on_subcommand(self, cmd): + required = ['.*?^Creates a volume backup.'] + help_text = self.shell(cmd) + + for r in required: + self.assertThat(help_text, + matchers.MatchesRegex(r, re.DOTALL | re.MULTILINE)) + def register_keystone_auth_fixture(self, mocker, url): mocker.register_uri('GET', url, text=keystone_client.keystone_request_callback) @@ -98,234 +177,157 @@ def register_keystone_auth_fixture(self, mocker, url): @requests_mock.Mocker() def test_version_discovery(self, mocker): _shell = shell.OpenStackCinderShell() + sess = session.Session() - os_auth_url = "https://WrongDiscoveryResponse.discovery.com:35357/v2.0" + os_auth_url = "https://wrongdiscoveryresponse.discovery.com:35357/v2.0" self.register_keystone_auth_fixture(mocker, os_auth_url) - self.assertRaises(DiscoveryFailure, _shell._discover_auth_versions, - None, auth_url=os_auth_url) + + self.assertRaises(DiscoveryFailure, + _shell._discover_auth_versions, + sess, + auth_url=os_auth_url) os_auth_url = "https://DiscoveryNotSupported.discovery.com:35357/v2.0" self.register_keystone_auth_fixture(mocker, os_auth_url) - v2_url, v3_url = _shell._discover_auth_versions( - None, auth_url=os_auth_url) - self.assertEqual(v2_url, os_auth_url, "Expected v2 url") - self.assertEqual(v3_url, None, "Expected no v3 url") + v2_url, v3_url = _shell._discover_auth_versions(sess, + auth_url=os_auth_url) + self.assertEqual(os_auth_url, v2_url, "Expected v2 url") + self.assertIsNone(v3_url, "Expected no v3 url") os_auth_url = "https://DiscoveryNotSupported.discovery.com:35357/v3.0" self.register_keystone_auth_fixture(mocker, os_auth_url) - v2_url, v3_url = _shell._discover_auth_versions( - None, auth_url=os_auth_url) - self.assertEqual(v3_url, os_auth_url, "Expected v3 url") - self.assertEqual(v2_url, None, "Expected no v2 url") - - @requests_mock.Mocker() - def test_cinder_version_legacy_endpoint_v1_and_v2(self, mocker): - """Verify that legacy endpoint settings still work. - - Legacy endpoints that are not using version discovery is - ://(tenant_id)s. For this unit test, we fill - in the tenant_id for mocking purposes. - """ - token = keystone_client_fixture.V2Token() - cinder_url = 'http://127.0.0.1:8776/' - - volume_service = token.add_service('volume', 'Cinder v1') - volume_service.add_endpoint(public=cinder_url, region='RegionOne') - - volumev2_service = token.add_service('volumev2', 'Cinder v2') - volumev2_service.add_endpoint(public=cinder_url, region='RegionOne') - - mocker.post(keystone_client.BASE_HOST + '/v2.0/tokens', - json=token) - mocker.get(cinder_url, json=fixture_base.generate_version_output()) - volume_request = mocker.get('http://127.0.0.1:8776/v1/volumes/detail', - json={'volumes': {}}) - - self.shell('list') - self.assertTrue(volume_request.called) - - @requests_mock.Mocker() - def test_cinder_version_legacy_endpoint_only_v1(self, mocker): - """Verify that v1 legacy endpoint settings still work. - - Legacy endpoints that are not using version discovery is - ://(tenant_id)s. For this unit test, we fill - in the tenant_id for mocking purposes. - """ - token = keystone_client_fixture.V2Token() - cinder_url = 'http://127.0.0.1:8776/' - - volume_service = token.add_service('volume', 'Cinder v1') - volume_service.add_endpoint( - public=cinder_url, - region='RegionOne' - ) - - mocker.get( - cinder_url, - json=fixture_base.generate_version_output(v1=True, v2=False) - ) - mocker.post(keystone_client.BASE_HOST + '/v2.0/tokens', - json=token) - volume_request = mocker.get('http://127.0.0.1:8776/v1/volumes/detail', - json={'volumes': {}}) - - self.shell('list') - self.assertTrue(volume_request.called) - - @requests_mock.Mocker() - def test_cinder_version_legacy_endpoint_only_v2(self, mocker): - """Verify that v2 legacy endpoint settings still work. - - Legacy endpoints that are not using version discovery is - ://(tenant_id)s. For this unit test, we fill - in the tenant_id for mocking purposes. - """ - token = keystone_client_fixture.V2Token() - cinder_url = 'http://127.0.0.1:8776/' - - volumev2_service = token.add_service('volumev2', 'Cinder v2') - volumev2_service.add_endpoint( - public=cinder_url, - region='RegionOne' - ) - - mocker.post(keystone_client.BASE_HOST + '/v2.0/tokens', - json=token) - - mocker.get( - cinder_url, - json=fixture_base.generate_version_output(v1=False, v2=True) - ) - volume_request = mocker.get('http://127.0.0.1:8776/v2/volumes/detail', - json={'volumes': {}}) - - self.shell('list') - self.assertTrue(volume_request.called) - - @requests_mock.Mocker() - def test_cinder_version_discovery(self, mocker): - """Verify client works two endpoints enabled under one service.""" - token = keystone_client_fixture.V2Token() - - volume_service = token.add_service('volume', 'Cinder') - volume_service.add_endpoint(public='http://127.0.0.1:8776', - region='RegionOne') - - mocker.post(keystone_client.BASE_HOST + '/v2.0/tokens', - json=token) - mocker.get( - 'http://127.0.0.1:8776/', - json=fixture_base.generate_version_output(v1=True, v2=True) - ) - - v1_request = mocker.get('http://127.0.0.1:8776/v1/volumes/detail', - json={'volumes': {}}) - v2_request = mocker.get('http://127.0.0.1:8776/v2/volumes/detail', - json={'volumes': {}}) - - self.shell('list') - self.assertTrue(v1_request.called) - - self.shell('--os-volume-api-version 2 list') - self.assertTrue(v2_request.called) - - @requests_mock.Mocker() - def test_cinder_version_discovery_only_v1(self, mocker): - """Verify when v1 is only enabled, the client discovers it.""" - token = keystone_client_fixture.V2Token() - - volume_service = token.add_service('volume', 'Cinder') - volume_service.add_endpoint(public='http://127.0.0.1:8776', - region='RegionOne') - - mocker.post(keystone_client.BASE_HOST + '/v2.0/tokens', - json=token) - mocker.get( - 'http://127.0.0.1:8776/', - json=fixture_base.generate_version_output(v1=True, v2=True) - ) - volume_request = mocker.get('http://127.0.0.1:8776/v1/volumes/detail', - json={'volumes': {}}) - - self.shell('list') - self.assertTrue(volume_request.called) + v2_url, v3_url = _shell._discover_auth_versions(sess, + auth_url=os_auth_url) + self.assertEqual(os_auth_url, v3_url, "Expected v3 url") + self.assertIsNone(v2_url, "Expected no v2 url") @requests_mock.Mocker() - def test_cinder_version_discovery_only_v2(self, mocker): - """Verify when v2 is enabled, the client discovers it.""" - token = keystone_client_fixture.V2Token() - - volumev2_service = token.add_service('volume', 'Cinder') - volumev2_service.add_endpoint(public='http://127.0.0.1:8776', - region='RegionOne') - - mocker.post(keystone_client.BASE_HOST + '/v2.0/tokens', - json=token) - - mocker.get( - 'http://127.0.0.1:8776/', - json=fixture_base.generate_version_output(v1=False, v2=True) - ) - volume_request = mocker.get('http://127.0.0.1:8776/v2/volumes/detail', - json={'volumes': {}}) - - self.shell('list') - self.assertTrue(volume_request.called) + def list_volumes_on_service(self, count, mocker): + os_auth_url = "http://multiple.service.names/v2.0" + mocker.register_uri('POST', os_auth_url + "/tokens", + text=keystone_client.keystone_request_callback) + # microversion support requires us to make a versions request + # to the endpoint to see exactly what is supported by the server + mocker.register_uri('GET', + "http://cinder%i.api.com/" + % count, text=json.dumps(fakes.fake_request_get())) + mocker.register_uri('GET', + "http://cinder%i.api.com/v3/volumes/detail" + % count, text='{"volumes": []}') + self.make_env(include={'OS_AUTH_URL': os_auth_url, + 'CINDER_SERVICE_NAME': 'cinder%i' % count}) + _shell = shell.OpenStackCinderShell() + _shell.main(['list']) + def test_duplicate_filters(self): + _shell = shell.OpenStackCinderShell() + self.assertRaises(exceptions.CommandError, + _shell.main, + ['list', '--name', 'abc', '--filters', 'name=xyz']) + + def test_cinder_service_name(self): + # Failing with 'No mock address' means we are not + # choosing the correct endpoint + for count in range(1, 4): + self.list_volumes_on_service(count) + + @mock.patch('keystoneauth1.identity.v2.Password') + @mock.patch('keystoneauth1.adapter.LegacyJsonAdapter.get_token', + side_effect=ks_exc.ConnectFailure()) + @mock.patch('keystoneauth1.discover.Discover', + side_effect=ks_exc.ConnectFailure()) + @mock.patch('sys.stdin', side_effect=mock.Mock) + @mock.patch('getpass.getpass', return_value='password') + def test_password_prompted(self, mock_getpass, mock_stdin, mock_discover, + mock_token, mock_password): + self.make_env(exclude='OS_PASSWORD') + _shell = shell.OpenStackCinderShell() + self.assertRaises(ks_exc.ConnectFailure, _shell.main, ['list']) + mock_getpass.assert_called_with('OS Password: ') + # Verify that Password() is called with value of param 'password' + # equal to mock_getpass.return_value. + mock_password.assert_called_with( + self.FAKE_ENV['OS_AUTH_URL'], + password=mock_getpass.return_value, + tenant_id='', + tenant_name=self.FAKE_ENV['OS_PROJECT_NAME'], + username=self.FAKE_ENV['OS_USERNAME']) + + @mock.patch('cinderclient.api_versions.discover_version', + return_value=api_versions.APIVersion("3.0")) @requests_mock.Mocker() - def test_cinder_version_discovery_fallback(self, mocker): - """Client defaults to v1, but v2 is only available, fallback to v2.""" - token = keystone_client_fixture.V2Token() - - volumev2_service = token.add_service('volumev2', 'Cinder v2') - volumev2_service.add_endpoint(public='http://127.0.0.1:8776', - region='RegionOne') + def test_noauth_plugin(self, mock_disco, mocker): + # just to prove i'm not crazy about the mock parameter ordering + self.assertTrue(requests_mock.mocker.Mocker, type(mocker)) + + os_volume_url = "http://example.com/volumes/v3" + mocker.register_uri('GET', + "%s/volumes/detail" + % os_volume_url, text='{"volumes": []}') + _shell = shell.OpenStackCinderShell() + args = ['--os-endpoint', os_volume_url, + '--os-auth-type', 'noauth', '--os-user-id', + 'admin', '--os-project-id', 'admin', 'list'] + _shell.main(args) + self.assertIsInstance(_shell.cs.client.session.auth, + noauth.CinderNoAuthPlugin) + + @mock.patch.object(cinderclient.client.HTTPClient, 'authenticate', + side_effect=exceptions.Unauthorized('No')) + # Easiest way to make cinderclient use httpclient is a None session + @mock.patch.object(cinderclient.shell.OpenStackCinderShell, + '_get_keystone_session', return_value=None) + def test_http_client_insecure(self, mock_authenticate, mock_session): + self.make_env(include={'CINDERCLIENT_INSECURE': True}) - mocker.post(keystone_client.BASE_HOST + '/v2.0/tokens', - json=token) + _shell = shell.OpenStackCinderShell() - mocker.get( - 'http://127.0.0.1:8776/', - json=fixture_base.generate_version_output(v1=False, v2=True) - ) - volume_request = mocker.get('http://127.0.0.1:8776/v2/volumes/detail', - json={'volumes': {}}) + # This "fails" but instantiates the client. + self.assertRaises(exceptions.CommandError, _shell.main, ['list']) - self.shell('list') - self.assertTrue(volume_request.called) + self.assertEqual(False, _shell.cs.client.verify_cert) - @requests_mock.Mocker() - def test_cinder_version_discovery_unsupported_version(self, mocker): - """Try a version from the client that's not enabled in Cinder.""" - token = keystone_client_fixture.V2Token() + @mock.patch.object(cinderclient.client.SessionClient, 'authenticate', + side_effect=exceptions.Unauthorized('No')) + def test_session_client_debug_logger(self, mock_session): + _shell = shell.OpenStackCinderShell() + # This "fails" but instantiates the client. + self.assertRaises(exceptions.CommandError, _shell.main, + ['--debug', 'list']) + # In case of SessionClient when --debug switch is specified + # 'keystoneauth' logger should be initialized. + self.assertEqual('keystoneauth', _shell.cs.client.logger.name) + + @mock.patch('keystoneauth1.session.Session.__init__', + side_effect=RuntimeError()) + def test_http_client_with_cert(self, mock_session): + _shell = shell.OpenStackCinderShell() - volume_service = token.add_service('volume', 'Cinder') - volume_service.add_endpoint(public='http://127.0.0.1:8776', - region='RegionOne') + # We crash the command after Session instantiation because this test + # focuses only on arguments provided to Session.__init__ + args = '--os-cert', 'minnie', 'list' + self.assertRaises(RuntimeError, _shell.main, args) + mock_session.assert_called_once_with(cert='minnie', verify=mock.ANY) - mocker.post(keystone_client.BASE_HOST + '/v2.0/tokens', - json=token) + @mock.patch('keystoneauth1.session.Session.__init__', + side_effect=RuntimeError()) + def test_http_client_with_cert_and_key(self, mock_session): + _shell = shell.OpenStackCinderShell() - mocker.get( - 'http://127.0.0.1:8776/', - json=fixture_base.generate_version_output(v1=False, v2=True) - ) + # We crash the command after Session instantiation because this test + # focuses only on arguments provided to Session.__init__ + args = '--os-cert', 'minnie', '--os-key', 'mickey', 'list' + self.assertRaises(RuntimeError, _shell.main, args) + mock_session.assert_called_once_with(cert=('minnie', 'mickey'), + verify=mock.ANY) - self.assertRaises(exceptions.InvalidAPIVersion, - self.shell, '--os-volume-api-version 1 list') - @mock.patch('sys.stdin', side_effect=mock.MagicMock) - @mock.patch('getpass.getpass', return_value='password') - def test_password_prompted(self, mock_getpass, mock_stdin): - self.make_env(exclude='OS_PASSWORD') - # We should get a Connection Refused because there is no keystone. - self.assertRaises(ks_exc.ConnectionRefused, self.shell, 'list') - # Make sure we are actually prompted. - mock_getpass.assert_called_with('OS Password: ') +class CinderClientArgumentParserTest(utils.TestCase): + def setUp(self): + super(CinderClientArgumentParserTest, self).setUp() -class CinderClientArgumentParserTest(utils.TestCase): + self.mock_completion() def test_ambiguity_solved_for_one_visible_argument(self): parser = shell.CinderClientArgumentParser(add_help=False) @@ -366,3 +368,215 @@ def test_raise_ambiguity_error_two_hidden_argument(self): help=argparse.SUPPRESS) self.assertRaises(SystemExit, parser.parse_args, ['--test']) + + +class TestLoadVersionedActions(utils.TestCase): + def setUp(self): + super(TestLoadVersionedActions, self).setUp() + + self.mock_completion() + + def test_load_versioned_actions_v3_0(self): + parser = cinderclient.shell.CinderClientArgumentParser() + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.0"), False, []) + self.assertIn('fake-action', shell.subcommands.keys()) + self.assertEqual( + "fake_action 3.0 to 3.1", + shell.subcommands['fake-action'].get_default('func')()) + + def test_load_versioned_actions_v3_2(self): + parser = cinderclient.shell.CinderClientArgumentParser() + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.2"), False, []) + self.assertIn('fake-action', shell.subcommands.keys()) + self.assertEqual( + "fake_action 3.2 to 3.3", + shell.subcommands['fake-action'].get_default('func')()) + + self.assertIn('fake-action2', shell.subcommands.keys()) + self.assertEqual( + "fake_action2", + shell.subcommands['fake-action2'].get_default('func')()) + + def test_load_versioned_actions_not_in_version_range(self): + parser = cinderclient.shell.CinderClientArgumentParser() + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion('3.10000'), False, []) + self.assertNotIn('fake-action', shell.subcommands.keys()) + self.assertIn('fake-action2', shell.subcommands.keys()) + + def test_load_versioned_actions_unsupported_input(self): + parser = cinderclient.shell.CinderClientArgumentParser() + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + self.assertRaises(exceptions.UnsupportedAttribute, + shell._find_actions, subparsers, fake_actions_module, + api_versions.APIVersion('3.6'), False, + ['another-fake-action', '--foo']) + + def test_load_versioned_actions_with_help(self): + parser = cinderclient.shell.CinderClientArgumentParser() + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + with mock.patch.object(subparsers, 'add_parser') as mock_add_parser: + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.1"), True, []) + self.assertIn('fake-action', shell.subcommands.keys()) + expected_help = ("help message (Supported by API versions " + "%(start)s - %(end)s)") % { + 'start': '3.0', 'end': '3.3'} + self.assertIn('help message', + mock_add_parser.call_args_list[0][1]['description']) + self.assertIn('This will not show up in help message', + mock_add_parser.call_args_list[0][1]['description']) + mock_add_parser.assert_any_call( + 'fake-action', + help=expected_help, + description=mock.ANY, + add_help=False, + formatter_class=cinderclient.shell.OpenStackHelpFormatter) + + def test_load_versioned_actions_with_help_on_latest(self): + parser = cinderclient.shell.CinderClientArgumentParser() + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + with mock.patch.object(subparsers, 'add_parser') as mock_add_parser: + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.latest"), True, []) + self.assertIn('another-fake-action', shell.subcommands.keys()) + expected_help = (" (Supported by API versions %(start)s - " + "%(end)s)%(hint)s") % { + 'start': '3.6', 'end': '3.latest', + 'hint': cinderclient.shell.HINT_HELP_MSG} + mock_add_parser.assert_any_call( + 'another-fake-action', + help=expected_help, + description='', + add_help=False, + formatter_class=cinderclient.shell.OpenStackHelpFormatter) + + @mock.patch.object(cinderclient.shell.CinderClientArgumentParser, + 'add_argument') + def test_load_versioned_actions_with_args(self, mock_add_arg): + parser = cinderclient.shell.CinderClientArgumentParser(add_help=False) + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.1"), False, []) + self.assertIn('fake-action2', shell.subcommands.keys()) + mock_add_arg.assert_has_calls([ + mock.call('-h', '--help', action='help', help='==SUPPRESS=='), + mock.call('--foo')]) + + @mock.patch.object(cinderclient.shell.CinderClientArgumentParser, + 'add_argument') + def test_load_versioned_actions_with_args2(self, mock_add_arg): + parser = cinderclient.shell.CinderClientArgumentParser(add_help=False) + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.4"), False, []) + self.assertIn('fake-action2', shell.subcommands.keys()) + mock_add_arg.assert_has_calls([ + mock.call('-h', '--help', action='help', help='==SUPPRESS=='), + mock.call('--bar', help="bar help")]) + + @mock.patch.object(cinderclient.shell.CinderClientArgumentParser, + 'add_argument') + def test_load_versioned_actions_with_args_not_in_version_range( + self, mock_add_arg): + parser = cinderclient.shell.CinderClientArgumentParser(add_help=False) + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.10000"), False, []) + self.assertIn('fake-action2', shell.subcommands.keys()) + mock_add_arg.assert_has_calls([ + mock.call('-h', '--help', action='help', help='==SUPPRESS==')]) + + @mock.patch.object(cinderclient.shell.CinderClientArgumentParser, + 'add_argument') + def test_load_versioned_actions_with_args_and_help(self, mock_add_arg): + parser = cinderclient.shell.CinderClientArgumentParser(add_help=False) + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.4"), True, []) + mock_add_arg.assert_has_calls([ + mock.call('-h', '--help', action='help', help='==SUPPRESS=='), + mock.call('--bar', + help="bar help (Supported by API versions" + " 3.3 - 3.4)")]) + + @mock.patch.object(cinderclient.shell.CinderClientArgumentParser, + 'add_argument') + def test_load_actions_with_versioned_args_v36(self, mock_add_arg): + parser = cinderclient.shell.CinderClientArgumentParser(add_help=False) + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.6"), False, []) + self.assertIn(mock.call('--foo', help="first foo"), + mock_add_arg.call_args_list) + self.assertNotIn(mock.call('--foo', help="second foo"), + mock_add_arg.call_args_list) + + @mock.patch.object(cinderclient.shell.CinderClientArgumentParser, + 'add_argument') + def test_load_actions_with_versioned_args_v39(self, mock_add_arg): + parser = cinderclient.shell.CinderClientArgumentParser(add_help=False) + subparsers = parser.add_subparsers(metavar='') + shell = cinderclient.shell.OpenStackCinderShell() + shell.subcommands = {} + shell._find_actions(subparsers, fake_actions_module, + api_versions.APIVersion("3.9"), False, []) + self.assertNotIn(mock.call('--foo', help="first foo"), + mock_add_arg.call_args_list) + self.assertIn(mock.call('--foo', help="second foo"), + mock_add_arg.call_args_list) + + +class ShellUtilsTest(utils.TestCase): + + @mock.patch.object(cinderclient.shell_utils, 'print_dict') + def test_print_volume_image(self, mock_print_dict): + response = {'os-volume_upload_image': {'name': 'myimg1'}} + image_resp_tuple = (202, response) + cinderclient.shell_utils.print_volume_image(image_resp_tuple) + + response = {'os-volume_upload_image': + {'name': 'myimg2', + 'volume_type': None}} + image_resp_tuple = (202, response) + cinderclient.shell_utils.print_volume_image(image_resp_tuple) + + response = {'os-volume_upload_image': + {'name': 'myimg3', + 'volume_type': {'id': '1234', 'name': 'sometype'}}} + image_resp_tuple = (202, response) + cinderclient.shell_utils.print_volume_image(image_resp_tuple) + + mock_print_dict.assert_has_calls( + (mock.call({'name': 'myimg1'}), + mock.call({'name': 'myimg2', + 'volume_type': None}), + mock.call({'name': 'myimg3', + 'volume_type': 'sometype'}))) diff --git a/cinderclient/tests/unit/test_utils.py b/cinderclient/tests/unit/test_utils.py index 0228cf672..69b0d0454 100644 --- a/cinderclient/tests/unit/test_utils.py +++ b/cinderclient/tests/unit/test_utils.py @@ -12,31 +12,36 @@ # limitations under the License. import collections +import io import sys +from unittest import mock -import mock -from six import moves +import ddt -from cinderclient import exceptions -from cinderclient import utils +from cinderclient import api_versions +from cinderclient.apiclient import base as common_base from cinderclient import base +from cinderclient import exceptions +from cinderclient import shell_utils from cinderclient.tests.unit import utils as test_utils +from cinderclient import utils +REQUEST_ID = 'req-test-request-id' UUID = '8e8ec658-c7b0-4243-bdf8-6f7f2952c0d0' class FakeResource(object): + NAME_ATTR = 'name' - def __init__(self, _id, properties): + def __init__(self, _id, properties, **kwargs): self.id = _id try: self.name = properties['name'] except KeyError: pass - try: - self.display_name = properties['display_name'] - except KeyError: - pass + + def append_request_ids(self, resp): + pass class FakeManager(base.ManagerWithFind): @@ -46,18 +51,51 @@ class FakeManager(base.ManagerWithFind): resources = [ FakeResource('1234', {'name': 'entity_one'}), FakeResource(UUID, {'name': 'entity_two'}), - FakeResource('4242', {'display_name': 'entity_three'}), FakeResource('5678', {'name': '9876'}) ] - def get(self, resource_id): + def get(self, resource_id, **kwargs): for resource in self.resources: if resource.id == str(resource_id): return resource raise exceptions.NotFound(resource_id) - def list(self, search_opts): - return self.resources + def list(self, search_opts, **kwargs): + return common_base.ListWithMeta(self.resources, REQUEST_ID) + + +class FakeManagerWithApi(base.Manager): + + @api_versions.wraps('3.1') + def return_api_version(self): + return '3.1' + + @api_versions.wraps('3.2') + def return_api_version(self): # noqa: F811 + return '3.2' + + +class FakeDisplayResource(object): + NAME_ATTR = 'display_name' + + def __init__(self, _id, properties): + self.id = _id + try: + self.display_name = properties['display_name'] + except KeyError: + pass + + def append_request_ids(self, resp): + pass + + +class FakeDisplayManager(FakeManager): + + resource_class = FakeDisplayResource + + resources = [ + FakeDisplayResource('4242', {'display_name': 'entity_three'}), + ] class FindResourceTestCase(test_utils.TestCase): @@ -72,7 +110,7 @@ def test_find_none(self): utils.find_resource, self.manager, 'asdf') - self.assertEqual(3, self.manager.find.call_count) + self.assertEqual(2, self.manager.find.call_count) def test_find_by_integer_id(self): output = utils.find_resource(self.manager, 1234) @@ -91,15 +129,28 @@ def test_find_by_str_name(self): self.assertEqual(self.manager.get('1234'), output) def test_find_by_str_displayname(self): - output = utils.find_resource(self.manager, 'entity_three') - self.assertEqual(self.manager.get('4242'), output) + display_manager = FakeDisplayManager(None) + output = utils.find_resource(display_manager, 'entity_three') + self.assertEqual(display_manager.get('4242'), output) + + def test_find_by_group_id(self): + output = utils.find_resource(self.manager, 1234, is_group=True, + list_volume=True) + self.assertEqual(self.manager.get('1234', list_volume=True), output) + + def test_find_by_group_name(self): + display_manager = FakeDisplayManager(None) + output = utils.find_resource(display_manager, 'entity_three', + is_group=True, list_volume=True) + self.assertEqual(display_manager.get('4242', list_volume=True), + output) class CaptureStdout(object): """Context manager for capturing stdout from statements in its block.""" def __enter__(self): self.real_stdout = sys.stdout - self.stringio = moves.StringIO() + self.stringio = io.StringIO() sys.stdout = self.stringio return self @@ -109,13 +160,67 @@ def __exit__(self, *args): self.read = self.stringio.read +@ddt.ddt +class BuildQueryParamTestCase(test_utils.TestCase): + + def test_build_param_without_sort_switch(self): + dict_param = { + 'key1': 'val1', + 'key2': 'val2', + 'key3': 'val3', + } + result = utils.build_query_param(dict_param, True) + + self.assertIn('key1=val1', result) + self.assertIn('key2=val2', result) + self.assertIn('key3=val3', result) + + def test_build_param_with_sort_switch(self): + dict_param = { + 'key1': 'val1', + 'key2': 'val2', + 'key3': 'val3', + } + result = utils.build_query_param(dict_param, True) + + expected = "?key1=val1&key2=val2&key3=val3" + self.assertEqual(expected, result) + + @ddt.data({}, + None, + {'key1': 'val1', 'key2': None, 'key3': False, 'key4': ''}) + def test_build_param_with_nones(self, dict_param): + result = utils.build_query_param(dict_param) + + expected = ("key1=val1", "key3=False") if dict_param else () + for exp in expected: + self.assertIn(exp, result) + if not expected: + self.assertEqual("", result) + + +@ddt.ddt +class ExtractFilterTestCase(test_utils.TestCase): + + @ddt.data({'content': ['key1=value1'], + 'expected': {'key1': 'value1'}}, + {'content': ['key1={key2:value2}'], + 'expected': {'key1': {'key2': 'value2'}}}, + {'content': ['key1=value1', 'key2={key22:value22}'], + 'expected': {'key1': 'value1', 'key2': {'key22': 'value22'}}}) + @ddt.unpack + def test_extract_filters(self, content, expected): + result = shell_utils.extract_filters(content) + self.assertEqual(expected, result) + + class PrintListTestCase(test_utils.TestCase): def test_print_list_with_list(self): Row = collections.namedtuple('Row', ['a', 'b']) to_print = [Row(a=3, b=4), Row(a=1, b=2)] with CaptureStdout() as cso: - utils.print_list(to_print, ['a', 'b']) + shell_utils.print_list(to_print, ['a', 'b']) # Output should be sorted by the first key (a) self.assertEqual("""\ +---+---+ @@ -130,7 +235,7 @@ def test_print_list_with_None_data(self): Row = collections.namedtuple('Row', ['a', 'b']) to_print = [Row(a=3, b=None), Row(a=1, b=2)] with CaptureStdout() as cso: - utils.print_list(to_print, ['a', 'b']) + shell_utils.print_list(to_print, ['a', 'b']) # Output should be sorted by the first key (a) self.assertEqual("""\ +---+---+ @@ -145,7 +250,7 @@ def test_print_list_with_list_sortby(self): Row = collections.namedtuple('Row', ['a', 'b']) to_print = [Row(a=4, b=3), Row(a=2, b=1)] with CaptureStdout() as cso: - utils.print_list(to_print, ['a', 'b'], sortby_index=1) + shell_utils.print_list(to_print, ['a', 'b'], sortby_index=1) # Output should be sorted by the second key (b) self.assertEqual("""\ +---+---+ @@ -160,7 +265,7 @@ def test_print_list_with_list_no_sort(self): Row = collections.namedtuple('Row', ['a', 'b']) to_print = [Row(a=3, b=4), Row(a=1, b=2)] with CaptureStdout() as cso: - utils.print_list(to_print, ['a', 'b'], sortby_index=None) + shell_utils.print_list(to_print, ['a', 'b'], sortby_index=None) # Output should be in the order given self.assertEqual("""\ +---+---+ @@ -178,7 +283,7 @@ def gen_rows(): for row in [Row(a=1, b=2), Row(a=3, b=4)]: yield row with CaptureStdout() as cso: - utils.print_list(gen_rows(), ['a', 'b']) + shell_utils.print_list(gen_rows(), ['a', 'b']) self.assertEqual("""\ +---+---+ | a | b | @@ -186,4 +291,60 @@ def gen_rows(): | 1 | 2 | | 3 | 4 | +---+---+ +""", cso.read()) + + def test_print_list_with_return(self): + Row = collections.namedtuple('Row', ['a', 'b']) + to_print = [Row(a=3, b='a\r'), Row(a=1, b='c\rd')] + with CaptureStdout() as cso: + shell_utils.print_list(to_print, ['a', 'b']) + # Output should be sorted by the first key (a) + self.assertEqual("""\ ++---+-----+ +| a | b | ++---+-----+ +| 1 | c d | +| 3 | a | ++---+-----+ +""", cso.read()) + + +class PrintDictTestCase(test_utils.TestCase): + + def test__pretty_format_dict(self): + content = {'key1': 'value1', 'key2': 'value2'} + expected = "key1 : value1\nkey2 : value2" + result = shell_utils._pretty_format_dict(content) + self.assertEqual(expected, result) + + def test_print_dict_with_return(self): + d = {'a': 'A', 'b': 'B', 'c': 'C', 'd': 'test\rcarriage\n\rreturn'} + with CaptureStdout() as cso: + shell_utils.print_dict(d) + self.assertEqual("""\ ++----------+---------------+ +| Property | Value | ++----------+---------------+ +| a | A | +| b | B | +| c | C | +| d | test carriage | +| | return | ++----------+---------------+ +""", cso.read()) + + def test_print_dict_with_dict_inside(self): + content = {'a': 'A', 'b': 'B', 'f_key': + {'key1': 'value1', 'key2': 'value2'}} + with CaptureStdout() as cso: + shell_utils.print_dict(content, formatters='f_key') + self.assertEqual("""\ ++----------+---------------+ +| Property | Value | ++----------+---------------+ +| a | A | +| b | B | +| f_key | key1 : value1 | +| | key2 : value2 | ++----------+---------------+ """, cso.read()) diff --git a/cinderclient/tests/unit/utils.py b/cinderclient/tests/unit/utils.py index 25e7ee0d1..2bf242fad 100644 --- a/cinderclient/tests/unit/utils.py +++ b/cinderclient/tests/unit/utils.py @@ -13,17 +13,21 @@ import json import os +from unittest import mock import fixtures import requests from requests_mock.contrib import fixture as requests_mock_fixture -import six import testtools +REQUEST_ID = ['req-test-request-id'] + + class TestCase(testtools.TestCase): TEST_REQUEST_BASE = { 'verify': True, + 'cert': None } def setUp(self): @@ -37,6 +41,28 @@ def setUp(self): stderr = self.useFixture(fixtures.StringStream('stderr')).stream self.useFixture(fixtures.MonkeyPatch('sys.stderr', stderr)) + # FIXME(eharney) - this should only be needed for shell tests + self.mock_completion() + + def _assert_request_id(self, obj, count=1): + self.assertTrue(hasattr(obj, 'request_ids')) + self.assertEqual(REQUEST_ID * count, obj.request_ids) + + def assert_called_anytime(self, method, url, body=None, + partial_body=None): + return self.shell.cs.assert_called_anytime(method, url, body, + partial_body) + + def mock_completion(self): + patcher = mock.patch( + 'cinderclient.base.Manager.write_to_completion_cache') + patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch('cinderclient.base.Manager.completion_cache') + patcher.start() + self.addCleanup(patcher.stop) + class FixturedTestCase(TestCase): @@ -61,39 +87,48 @@ def setUp(self): self.data_fixture = self.useFixture(fix) def assert_called(self, method, path, body=None): - self.assertEqual(self.requests.last_request.method, method) - self.assertEqual(self.requests.last_request.path_url, path) + self.assertEqual(method, self.requests.last_request.method) + self.assertEqual(path, self.requests.last_request.path_url) if body: req_data = self.requests.last_request.body - if isinstance(req_data, six.binary_type): + if isinstance(req_data, bytes): req_data = req_data.decode('utf-8') - if not isinstance(body, six.string_types): + if not isinstance(body, str): # json load if the input body to match against is not a string req_data = json.loads(req_data) - self.assertEqual(req_data, body) + self.assertEqual(body, req_data) class TestResponse(requests.Response): - """Class used to wrap requests.Response and provide some - convenience to initialize with a dict. + """Class used to wrap requests.Response. + + Provides some convenience to initialize with a dict. """ def __init__(self, data): + super(TestResponse, self).__init__() + self._content = None self._text = None - super(TestResponse, self) + if isinstance(data, dict): self.status_code = data.get('status_code', None) self.headers = data.get('headers', None) self.reason = data.get('reason', '') - # Fake the text attribute to streamline Response creation - self._text = data.get('text', None) + # Fake text and content attributes to streamline Response creation + text = data.get('text', None) + self._content = text + self._text = text else: self.status_code = data def __eq__(self, other): return self.__dict__ == other.__dict__ + @property + def content(self): + return self._content + @property def text(self): return self._text diff --git a/cinderclient/tests/unit/v1/contrib/test_list_extensions.py b/cinderclient/tests/unit/v1/contrib/test_list_extensions.py deleted file mode 100644 index 989cc902c..000000000 --- a/cinderclient/tests/unit/v1/contrib/test_list_extensions.py +++ /dev/null @@ -1,34 +0,0 @@ -# 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 import extension -from cinderclient.v1.contrib import list_extensions - -from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes - - -extensions = [ - extension.Extension(list_extensions.__name__.split(".")[-1], - list_extensions), -] -cs = fakes.FakeClient(extensions=extensions) - - -class ListExtensionsTests(utils.TestCase): - def test_list_extensions(self): - all_exts = cs.list_extensions.show_all() - cs.assert_called('GET', '/extensions') - self.assertTrue(len(all_exts) > 0) - for r in all_exts: - self.assertTrue(len(r.summary) > 0) diff --git a/cinderclient/tests/unit/v1/fakes.py b/cinderclient/tests/unit/v1/fakes.py deleted file mode 100644 index a5cb0f5d4..000000000 --- a/cinderclient/tests/unit/v1/fakes.py +++ /dev/null @@ -1,805 +0,0 @@ -# Copyright (c) 2011 X.commerce, a business unit of eBay Inc. -# 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. -# 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 datetime import datetime - -try: - import urlparse -except ImportError: - import urllib.parse as urlparse - -from cinderclient import client as base_client -from cinderclient.tests.unit import fakes -import cinderclient.tests.unit.utils as utils -from cinderclient.v1 import client - - -def _stub_volume(**kwargs): - volume = { - 'id': '1234', - 'display_name': None, - 'display_description': None, - "attachments": [], - "bootable": "false", - "availability_zone": "cinder", - "created_at": "2012-08-27T00:00:00.000000", - "id": '00000000-0000-0000-0000-000000000000', - "metadata": {}, - "size": 1, - "snapshot_id": None, - "status": "available", - "volume_type": "None", - } - volume.update(kwargs) - return volume - - -def _stub_snapshot(**kwargs): - snapshot = { - "created_at": "2012-08-28T16:30:31.000000", - "display_description": None, - "display_name": None, - "id": '11111111-1111-1111-1111-111111111111', - "size": 1, - "status": "available", - "volume_id": '00000000-0000-0000-0000-000000000000', - } - snapshot.update(kwargs) - return snapshot - - -def _self_href(base_uri, tenant_id, backup_id): - return '%s/v1/%s/backups/%s' % (base_uri, tenant_id, backup_id) - - -def _bookmark_href(base_uri, tenant_id, backup_id): - return '%s/%s/backups/%s' % (base_uri, tenant_id, backup_id) - - -def _stub_backup_full(id, base_uri, tenant_id): - return { - 'id': id, - 'name': 'backup', - 'description': 'nightly backup', - 'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b', - 'container': 'volumebackups', - 'object_count': 220, - 'size': 10, - 'availability_zone': 'az1', - 'created_at': '2013-04-12T08:16:37.000000', - 'status': 'available', - 'links': [ - { - 'href': _self_href(base_uri, tenant_id, id), - 'rel': 'self' - }, - { - 'href': _bookmark_href(base_uri, tenant_id, id), - 'rel': 'bookmark' - } - ] - } - - -def _stub_backup(id, base_uri, tenant_id): - return { - 'id': id, - 'name': 'backup', - 'links': [ - { - 'href': _self_href(base_uri, tenant_id, id), - 'rel': 'self' - }, - { - 'href': _bookmark_href(base_uri, tenant_id, id), - 'rel': 'bookmark' - } - ] - } - - -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, - 'name': 'transfer', - 'volume_id': '8c05f861-6052-4df6-b3e0-0aebfbe686cc', - 'created_at': '2013-04-12T08:16:37.000000', - 'auth_key': '123456', - 'links': [ - { - 'href': _self_href(base_uri, tenant_id, id), - 'rel': 'self' - }, - { - 'href': _bookmark_href(base_uri, tenant_id, id), - 'rel': 'bookmark' - } - ] - } - - -def _stub_transfer(id, base_uri, tenant_id): - return { - 'id': id, - 'name': 'transfer', - 'volume_id': '8c05f861-6052-4df6-b3e0-0aebfbe686cc', - 'links': [ - { - 'href': _self_href(base_uri, tenant_id, id), - 'rel': 'self' - }, - { - 'href': _bookmark_href(base_uri, tenant_id, id), - 'rel': 'bookmark' - } - ] - } - - -def _stub_extend(id, new_size): - return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} - - -class FakeClient(fakes.FakeClient, client.Client): - - def __init__(self, *args, **kwargs): - client.Client.__init__(self, 'username', 'password', - 'project_id', 'auth_url', - extensions=kwargs.get('extensions')) - self.client = FakeHTTPClient(**kwargs) - - def get_volume_api_version_from_endpoint(self): - return self.client.get_volume_api_version_from_endpoint() - - -class FakeHTTPClient(base_client.HTTPClient): - - def __init__(self, **kwargs): - self.username = 'username' - self.password = 'password' - self.auth_url = 'auth_url' - self.callstack = [] - self.management_url = 'http://10.0.2.15:8776/v1/fake' - - def _cs_request(self, url, method, **kwargs): - # Check that certain things are called correctly - if method in ['GET', 'DELETE']: - assert 'body' not in kwargs - elif method == 'PUT': - assert 'body' in kwargs - - # 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)) - - # Note the call - self.callstack.append((method, url, kwargs.get('body', None))) - status, headers, body = getattr(self, callback)(**kwargs) - r = utils.TestResponse({ - "status_code": status, - "text": body, - "headers": headers, - }) - return r, body - - if hasattr(status, 'items'): - return utils.TestResponse(status), body - else: - return utils.TestResponse({"status": status}), body - - def get_volume_api_version_from_endpoint(self): - magic_tuple = urlparse.urlsplit(self.management_url) - scheme, netloc, path, query, frag = magic_tuple - return path.lstrip('/').split('/')[0][1:] - - # - # Snapshots - # - - def get_snapshots_detail(self, **kw): - return (200, {}, {'snapshots': [ - _stub_snapshot(), - ]}) - - def get_snapshots_1234(self, **kw): - return (200, {}, {'snapshot': _stub_snapshot(id='1234')}) - - def get_snapshots_5678(self, **kw): - return (200, {}, {'snapshot': _stub_snapshot(id='5678')}) - - def put_snapshots_1234(self, **kw): - snapshot = _stub_snapshot(id='1234') - snapshot.update(kw['body']['snapshot']) - return (200, {}, {'snapshot': snapshot}) - - def post_snapshots_1234_action(self, body, **kw): - _body = None - resp = 202 - assert len(list(body)) == 1 - action = list(body)[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) - return (resp, {}, _body) - - def post_snapshots_5678_action(self, body, **kw): - return self.post_snapshots_1234_action(body, **kw) - - def delete_snapshots_1234(self, **kw): - return (202, {}, {}) - - def delete_snapshots_5678(self, **kw): - return (202, {}, {}) - - # - # Volumes - # - - def put_volumes_1234(self, **kw): - volume = _stub_volume(id='1234') - volume.update(kw['body']['volume']) - return (200, {}, {'volume': volume}) - - def get_volumes(self, **kw): - return (200, {}, {"volumes": [ - {'id': 1234, 'name': 'sample-volume'}, - {'id': 5678, 'name': 'sample-volume2'} - ]}) - - # TODO(jdg): This will need to change - # at the very least it's not complete - def get_volumes_detail(self, **kw): - return (200, {}, {"volumes": [ - {'id': kw.get('id', 1234), - 'name': 'sample-volume', - 'attachments': [{'server_id': 1234}]}, - ]}) - - def get_volumes_1234(self, **kw): - r = {'volume': self.get_volumes_detail(id=1234)[2]['volumes'][0]} - return (200, {}, r) - - def get_volumes_5678(self, **kw): - r = {'volume': self.get_volumes_detail(id=5678)[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 - assert len(list(body)) == 1 - action = list(body)[0] - if action == 'os-attach': - assert sorted(list(body[action])) == ['instance_uuid', - 'mode', - 'mountpoint'] - elif action == 'os-detach': - assert body[action] is None - elif action == 'os-reserve': - assert body[action] is None - elif action == 'os-unreserve': - assert body[action] is None - elif action == 'os-initialize_connection': - assert list(body[action]) == ['connector'] - return (202, {}, {'connection_info': 'foos'}) - elif action == 'os-terminate_connection': - assert list(body[action]) == ['connector'] - elif action == 'os-begin_detaching': - assert body[action] is None - elif action == 'os-roll_detaching': - assert body[action] is None - elif action == 'os-reset_status': - assert 'status' in body[action] - elif action == 'os-extend': - assert list(body[action]) == ['new_size'] - elif action == 'os-migrate_volume': - assert 'host' in body[action] - assert 'force_host_copy' in body[action] - elif action == 'os-update_readonly_flag': - assert list(body[action]) == ['readonly'] - elif action == 'os-set_bootable': - assert list(body[action]) == ['bootable'] - else: - raise AssertionError("Unexpected action: %s" % action) - return (resp, {}, _body) - - def post_volumes_5678_action(self, body, **kw): - return self.post_volumes_1234_action(body, **kw) - - def post_volumes(self, **kw): - return (202, {}, {'volume': {}}) - - def delete_volumes_1234(self, **kw): - return (202, {}, None) - - def delete_volumes_5678(self, **kw): - return (202, {}, None) - - # - # Quotas - # - - def get_os_quota_sets_test(self, **kw): - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1, - 'backups': 1, - 'backup_gigabytes': 1}}) - - def get_os_quota_sets_test_defaults(self): - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1, - 'backups': 1, - 'backup_gigabytes': 1}}) - - def put_os_quota_sets_test(self, body, **kw): - assert list(body) == ['quota_set'] - fakes.assert_has_keys(body['quota_set'], - required=['tenant_id']) - return (200, {}, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'volumes': 2, - 'snapshots': 2, - 'gigabytes': 1, - 'backups': 1, - 'backup_gigabytes': 1}}) - - def delete_os_quota_sets_1234(self, **kw): - return (200, {}, {}) - - def delete_os_quota_sets_test(self, **kw): - return (200, {}, {}) - - # - # Quota Classes - # - - def get_os_quota_class_sets_test(self, **kw): - return (200, {}, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], - 'volumes': 1, - 'snapshots': 1, - 'gigabytes': 1, - 'backups': 1, - 'backup_gigabytes': 1}}) - - def put_os_quota_class_sets_test(self, body, **kw): - assert list(body) == ['quota_class_set'] - fakes.assert_has_keys(body['quota_class_set'], - required=['class_name']) - return (200, {}, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], - 'volumes': 2, - 'snapshots': 2, - 'gigabytes': 1, - 'backups': 1, - 'backup_gigabytes': 1}}) - - # - # VolumeTypes - # - def get_types(self, **kw): - return (200, {}, { - 'volume_types': [{'id': 1, - 'name': 'test-type-1', - 'extra_specs': {}}, - {'id': 2, - 'name': 'test-type-2', - 'extra_specs': {}}]}) - - def get_types_1(self, **kw): - return (200, {}, {'volume_type': {'id': 1, - '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', - 'extra_specs': {}}}) - - def post_types_1_extra_specs(self, body, **kw): - assert list(body) == ['extra_specs'] - return (200, {}, {'extra_specs': {'k': 'v'}}) - - def delete_types_1_extra_specs_k(self, **kw): - return(204, {}, None) - - 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-end'}) - - def get_types_2_encryption(self, **kw): - return (200, {}, {}) - - def post_types_2_encryption(self, body, **kw): - return (200, {}, {'encryption': body}) - - def put_types_1_encryption_1(self, body, **kw): - return (200, {}, {}) - - def delete_types_1_encryption_provider(self, **kw): - return (202, {}, None) - - # - # Set/Unset metadata - # - def delete_volumes_1234_metadata_test_key(self, **kw): - return (204, {}, None) - - def delete_volumes_1234_metadata_key1(self, **kw): - return (204, {}, None) - - def delete_volumes_1234_metadata_key2(self, **kw): - return (204, {}, None) - - def post_volumes_1234_metadata(self, **kw): - return (204, {}, {'metadata': {'test_key': 'test_value'}}) - - # - # List all extensions - # - def get_extensions(self, **kw): - exts = [ - { - "alias": "FAKE-1", - "description": "Fake extension number 1", - "links": [], - "name": "Fake1", - "namespace": ("http://docs.openstack.org/" - "/ext/fake1/api/v1.1"), - "updated": "2011-06-09T00:00:00+00:00" - }, - { - "alias": "FAKE-2", - "description": "Fake extension number 2", - "links": [], - "name": "Fake2", - "namespace": ("http://docs.openstack.org/" - "/ext/fake1/api/v1.1"), - "updated": "2011-06-09T00:00:00+00:00" - }, - ] - return (200, {}, {"extensions": exts, }) - - # - # VolumeBackups - # - - def get_backups_76a17945_3c6f_435c_975b_b5685db10b62(self, **kw): - base_uri = 'http://localhost:8776' - tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' - backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' - return (200, {}, - {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) - - def get_backups_detail(self, **kw): - base_uri = 'http://localhost:8776' - tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' - backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' - backup2 = 'd09534c6-08b8-4441-9e87-8976f3a8f699' - return (200, {}, - {'backups': [ - _stub_backup_full(backup1, base_uri, tenant_id), - _stub_backup_full(backup2, base_uri, tenant_id)]}) - - def delete_backups_76a17945_3c6f_435c_975b_b5685db10b62(self, **kw): - return (202, {}, None) - - def post_backups(self, **kw): - base_uri = 'http://localhost:8776' - tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' - backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' - return (202, {}, - {'backup': _stub_backup(backup1, base_uri, tenant_id)}) - - def post_backups_76a17945_3c6f_435c_975b_b5685db10b62_restore(self, **kw): - return (200, {}, - {'restore': _stub_restore()}) - - def post_backups_1234_restore(self, **kw): - 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 - # - - def get_os_volume_transfer_5678(self, **kw): - base_uri = 'http://localhost:8776' - tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' - transfer1 = '5678' - return (200, {}, - {'transfer': - _stub_transfer_full(transfer1, base_uri, tenant_id)}) - - def get_os_volume_transfer_detail(self, **kw): - base_uri = 'http://localhost:8776' - tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' - transfer1 = '5678' - transfer2 = 'f625ec3e-13dd-4498-a22a-50afd534cc41' - return (200, {}, - {'transfers': [ - _stub_transfer_full(transfer1, base_uri, tenant_id), - _stub_transfer_full(transfer2, base_uri, tenant_id)]}) - - def delete_os_volume_transfer_5678(self, **kw): - return (202, {}, None) - - def post_os_volume_transfer(self, **kw): - base_uri = 'http://localhost:8776' - tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' - transfer1 = '5678' - return (202, {}, - {'transfer': _stub_transfer(transfer1, base_uri, tenant_id)}) - - def post_os_volume_transfer_5678_accept(self, **kw): - base_uri = 'http://localhost:8776' - tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' - transfer1 = '5678' - return (200, {}, - {'transfer': _stub_transfer(transfer1, base_uri, tenant_id)}) - - # - # Services - # - def get_os_services(self, **kw): - host = kw.get('host', None) - binary = kw.get('binary', None) - services = [ - { - 'binary': 'cinder-volume', - 'host': 'host1', - 'zone': 'cinder', - 'status': 'enabled', - 'state': 'up', - 'updated_at': datetime(2012, 10, 29, 13, 42, 2) - }, - { - 'binary': 'cinder-volume', - 'host': 'host2', - 'zone': 'cinder', - 'status': 'disabled', - 'state': 'down', - 'updated_at': datetime(2012, 9, 18, 8, 3, 38) - }, - { - 'binary': 'cinder-scheduler', - 'host': 'host2', - 'zone': 'cinder', - 'status': 'disabled', - 'state': 'down', - 'updated_at': datetime(2012, 9, 18, 8, 3, 38) - }, - ] - if host: - services = filter(lambda i: i['host'] == host, services) - if binary: - services = filter(lambda i: i['binary'] == binary, services) - return (200, {}, {'services': services}) - - def put_os_services_enable(self, body, **kw): - return (200, {}, {'host': body['host'], 'binary': body['binary'], - 'status': 'enabled'}) - - def put_os_services_disable(self, body, **kw): - return (200, {}, {'host': body['host'], 'binary': body['binary'], - 'status': 'disabled'}) - - def put_os_services_disable_log_reason(self, body, **kw): - return (200, {}, {'host': body['host'], 'binary': body['binary'], - 'status': 'disabled', - 'disabled_reason': body['disabled_reason']}) - - def get_os_availability_zone(self, **kw): - return (200, {}, { - "availabilityZoneInfo": [ - { - "zoneName": "zone-1", - "zoneState": {"available": True}, - "hosts": None, - }, - { - "zoneName": "zone-2", - "zoneState": {"available": False}, - "hosts": None, - }, - ] - }) - - def get_os_availability_zone_detail(self, **kw): - return (200, {}, { - "availabilityZoneInfo": [ - { - "zoneName": "zone-1", - "zoneState": {"available": True}, - "hosts": { - "fake_host-1": { - "cinder-volume": { - "active": True, - "available": True, - "updated_at": - datetime(2012, 12, 26, 14, 45, 25, 0) - } - } - } - }, - { - "zoneName": "internal", - "zoneState": {"available": True}, - "hosts": { - "fake_host-1": { - "cinder-sched": { - "active": True, - "available": True, - "updated_at": - datetime(2012, 12, 26, 14, 45, 24, 0) - } - } - } - }, - { - "zoneName": "zone-2", - "zoneState": {"available": False}, - "hosts": None, - }, - ] - }) - - def post_snapshots_1234_metadata(self, **kw): - return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) - - def delete_snapshots_1234_metadata_key1(self, **kw): - return (200, {}, None) - - def delete_snapshots_1234_metadata_key2(self, **kw): - return (200, {}, None) - - def put_volumes_1234_metadata(self, **kw): - return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) - - def put_snapshots_1234_metadata(self, **kw): - return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) diff --git a/cinderclient/tests/unit/v1/test_auth.py b/cinderclient/tests/unit/v1/test_auth.py deleted file mode 100644 index 5864b969e..000000000 --- a/cinderclient/tests/unit/v1/test_auth.py +++ /dev/null @@ -1,338 +0,0 @@ -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or -# implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json -import mock - -import requests - -from cinderclient.v1 import client -from cinderclient import exceptions -from cinderclient.tests.unit import utils - - -class AuthenticateAgainstKeystoneTests(utils.TestCase): - def test_authenticate_success(self): - cs = client.Client("username", "password", "project_id", - "http://localhost:8776/v1", service_type='volume') - resp = { - "access": { - "token": { - "expires": "2014-11-01T03:32:15-05:00", - "id": "FAKE_ID", - }, - "serviceCatalog": [ - { - "type": "volume", - "endpoints": [ - { - "region": "RegionOne", - "adminURL": "http://localhost:8776/v1", - "internalURL": "http://localhost:8776/v1", - "publicURL": "http://localhost:8776/v1", - }, - ], - }, - ], - }, - } - auth_response = utils.TestResponse({ - "status_code": 200, - "text": json.dumps(resp), - }) - - mock_request = mock.Mock(return_value=(auth_response)) - - @mock.patch.object(requests, "request", mock_request) - def test_auth_call(): - cs.client.authenticate() - headers = { - 'User-Agent': cs.client.USER_AGENT, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } - body = { - 'auth': { - 'passwordCredentials': { - 'username': cs.client.user, - 'password': cs.client.password, - }, - 'tenantName': cs.client.projectid, - }, - } - - token_url = cs.client.auth_url + "/tokens" - mock_request.assert_called_with( - "POST", - token_url, - headers=headers, - data=json.dumps(body), - allow_redirects=True, - **self.TEST_REQUEST_BASE) - - endpoints = resp["access"]["serviceCatalog"][0]['endpoints'] - public_url = endpoints[0]["publicURL"].rstrip('/') - self.assertEqual(public_url, cs.client.management_url) - token_id = resp["access"]["token"]["id"] - self.assertEqual(token_id, cs.client.auth_token) - - test_auth_call() - - def test_authenticate_tenant_id(self): - cs = client.Client("username", "password", - auth_url="http://localhost:8776/v1", - tenant_id='tenant_id', service_type='volume') - resp = { - "access": { - "token": { - "expires": "2014-11-01T03:32:15-05:00", - "id": "FAKE_ID", - "tenant": { - "description": None, - "enabled": True, - "id": "tenant_id", - "name": "demo" - } # tenant associated with token - }, - "serviceCatalog": [ - { - "type": "volume", - "endpoints": [ - { - "region": "RegionOne", - "adminURL": "http://localhost:8776/v1", - "internalURL": "http://localhost:8776/v1", - "publicURL": "http://localhost:8776/v1", - }, - ], - }, - ], - }, - } - auth_response = utils.TestResponse({ - "status_code": 200, - "text": json.dumps(resp), - }) - - mock_request = mock.Mock(return_value=(auth_response)) - - @mock.patch.object(requests, "request", mock_request) - def test_auth_call(): - cs.client.authenticate() - headers = { - 'User-Agent': cs.client.USER_AGENT, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } - body = { - 'auth': { - 'passwordCredentials': { - 'username': cs.client.user, - 'password': cs.client.password, - }, - 'tenantId': cs.client.tenant_id, - }, - } - - token_url = cs.client.auth_url + "/tokens" - mock_request.assert_called_with( - "POST", - token_url, - headers=headers, - data=json.dumps(body), - allow_redirects=True, - **self.TEST_REQUEST_BASE) - - endpoints = resp["access"]["serviceCatalog"][0]['endpoints'] - public_url = endpoints[0]["publicURL"].rstrip('/') - self.assertEqual(public_url, cs.client.management_url) - token_id = resp["access"]["token"]["id"] - self.assertEqual(token_id, cs.client.auth_token) - tenant_id = resp["access"]["token"]["tenant"]["id"] - self.assertEqual(tenant_id, cs.client.tenant_id) - - test_auth_call() - - def test_authenticate_failure(self): - cs = client.Client("username", "password", "project_id", - "http://localhost:8776/v1") - resp = {"unauthorized": {"message": "Unauthorized", "code": "401"}} - auth_response = utils.TestResponse({ - "status_code": 401, - "text": json.dumps(resp), - }) - - mock_request = mock.Mock(return_value=(auth_response)) - - @mock.patch.object(requests, "request", mock_request) - def test_auth_call(): - self.assertRaises(exceptions.Unauthorized, cs.client.authenticate) - - test_auth_call() - - def test_auth_redirect(self): - cs = client.Client("username", "password", "project_id", - "http://localhost:8776/v1", service_type='volume') - dict_correct_response = { - "access": { - "token": { - "expires": "2014-11-01T03:32:15-05:00", - "id": "FAKE_ID", - }, - "serviceCatalog": [ - { - "type": "volume", - "endpoints": [ - { - "adminURL": "http://localhost:8776/v1", - "region": "RegionOne", - "internalURL": "http://localhost:8776/v1", - "publicURL": "http://localhost:8776/v1/", - }, - ], - }, - ], - }, - } - correct_response = json.dumps(dict_correct_response) - dict_responses = [ - {"headers": {'location': 'http://127.0.0.1:5001'}, - "status_code": 305, - "text": "Use proxy"}, - # Configured on admin port, cinder redirects to v2.0 port. - # When trying to connect on it, keystone auth succeed by v1.0 - # protocol (through headers) but tokens are being returned in - # body (looks like keystone bug). Leaved for compatibility. - {"headers": {}, - "status_code": 200, - "text": correct_response}, - {"headers": {}, - "status_code": 200, - "text": correct_response} - ] - - responses = [(utils.TestResponse(resp)) for resp in dict_responses] - - def side_effect(*args, **kwargs): - return responses.pop(0) - - mock_request = mock.Mock(side_effect=side_effect) - - @mock.patch.object(requests, "request", mock_request) - def test_auth_call(): - cs.client.authenticate() - headers = { - 'User-Agent': cs.client.USER_AGENT, - 'Content-Type': 'application/json', - 'Accept': 'application/json', - } - body = { - 'auth': { - 'passwordCredentials': { - 'username': cs.client.user, - 'password': cs.client.password, - }, - 'tenantName': cs.client.projectid, - }, - } - - token_url = cs.client.auth_url + "/tokens" - mock_request.assert_called_with( - "POST", - token_url, - headers=headers, - data=json.dumps(body), - allow_redirects=True, - **self.TEST_REQUEST_BASE) - - resp = dict_correct_response - endpoints = resp["access"]["serviceCatalog"][0]['endpoints'] - public_url = endpoints[0]["publicURL"].rstrip('/') - self.assertEqual(public_url, cs.client.management_url) - token_id = resp["access"]["token"]["id"] - self.assertEqual(token_id, cs.client.auth_token) - - test_auth_call() - - -class AuthenticationTests(utils.TestCase): - def test_authenticate_success(self): - cs = client.Client("username", "password", "project_id", "auth_url") - management_url = 'https://localhost/v1.1/443470' - auth_response = utils.TestResponse({ - 'status_code': 204, - 'headers': { - 'x-server-management-url': management_url, - 'x-auth-token': '1b751d74-de0c-46ae-84f0-915744b582d1', - }, - }) - mock_request = mock.Mock(return_value=(auth_response)) - - @mock.patch.object(requests, "request", mock_request) - def test_auth_call(): - cs.client.authenticate() - headers = { - 'Accept': 'application/json', - 'X-Auth-User': 'username', - 'X-Auth-Key': 'password', - 'X-Auth-Project-Id': 'project_id', - 'User-Agent': cs.client.USER_AGENT - } - mock_request.assert_called_with( - "GET", - cs.client.auth_url, - headers=headers, - **self.TEST_REQUEST_BASE) - - self.assertEqual(auth_response.headers['x-server-management-url'], - cs.client.management_url) - self.assertEqual(auth_response.headers['x-auth-token'], - cs.client.auth_token) - - test_auth_call() - - def test_authenticate_failure(self): - cs = client.Client("username", "password", "project_id", "auth_url") - auth_response = utils.TestResponse({"status_code": 401}) - mock_request = mock.Mock(return_value=(auth_response)) - - @mock.patch.object(requests, "request", mock_request) - def test_auth_call(): - self.assertRaises(exceptions.Unauthorized, cs.client.authenticate) - - test_auth_call() - - def test_auth_automatic(self): - cs = client.Client("username", "password", "project_id", "auth_url") - http_client = cs.client - http_client.management_url = '' - mock_request = mock.Mock(return_value=(None, None)) - - @mock.patch.object(http_client, 'request', mock_request) - @mock.patch.object(http_client, 'authenticate') - def test_auth_call(m): - http_client.get('/') - m.assert_called() - mock_request.assert_called() - - test_auth_call() - - def test_auth_manual(self): - cs = client.Client("username", "password", "project_id", "auth_url") - - @mock.patch.object(cs.client, 'authenticate') - def test_auth_call(m): - cs.authenticate() - m.assert_called() - - test_auth_call() diff --git a/cinderclient/tests/unit/v1/test_availability_zone.py b/cinderclient/tests/unit/v1/test_availability_zone.py deleted file mode 100644 index 667b89f2f..000000000 --- a/cinderclient/tests/unit/v1/test_availability_zone.py +++ /dev/null @@ -1,88 +0,0 @@ -# Copyright 2011-2013 OpenStack Foundation -# 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. - -import six - -from cinderclient.v1 import availability_zones -from cinderclient.v1 import shell -from cinderclient.tests.unit.fixture_data import client -from cinderclient.tests.unit.fixture_data import availability_zones as azfixture # noqa -from cinderclient.tests.unit import utils - - -class AvailabilityZoneTest(utils.FixturedTestCase): - - client_fixture_class = client.V1 - data_fixture_class = azfixture.Fixture - - def _assertZone(self, zone, name, status): - self.assertEqual(name, zone.zoneName) - self.assertEqual(status, zone.zoneState) - - def test_list_availability_zone(self): - zones = self.cs.availability_zones.list(detailed=False) - self.assert_called('GET', '/os-availability-zone') - - for zone in zones: - self.assertIsInstance(zone, - availability_zones.AvailabilityZone) - - self.assertEqual(2, len(zones)) - - l0 = [six.u('zone-1'), six.u('available')] - l1 = [six.u('zone-2'), six.u('not available')] - - z0 = shell._treeizeAvailabilityZone(zones[0]) - z1 = shell._treeizeAvailabilityZone(zones[1]) - - self.assertEqual((1, 1), (len(z0), len(z1))) - - self._assertZone(z0[0], l0[0], l0[1]) - self._assertZone(z1[0], l1[0], l1[1]) - - def test_detail_availability_zone(self): - zones = self.cs.availability_zones.list(detailed=True) - self.assert_called('GET', '/os-availability-zone/detail') - - for zone in zones: - self.assertIsInstance(zone, - availability_zones.AvailabilityZone) - - self.assertEqual(3, len(zones)) - - l0 = [six.u('zone-1'), six.u('available')] - l1 = [six.u('|- fake_host-1'), six.u('')] - l2 = [six.u('| |- cinder-volume'), - six.u('enabled :-) 2012-12-26 14:45:25')] - l3 = [six.u('internal'), six.u('available')] - l4 = [six.u('|- fake_host-1'), six.u('')] - l5 = [six.u('| |- cinder-sched'), - six.u('enabled :-) 2012-12-26 14:45:24')] - l6 = [six.u('zone-2'), six.u('not available')] - - z0 = shell._treeizeAvailabilityZone(zones[0]) - z1 = shell._treeizeAvailabilityZone(zones[1]) - z2 = shell._treeizeAvailabilityZone(zones[2]) - - self.assertEqual((3, 3, 1), (len(z0), len(z1), len(z2))) - - self._assertZone(z0[0], l0[0], l0[1]) - self._assertZone(z0[1], l1[0], l1[1]) - self._assertZone(z0[2], l2[0], l2[1]) - self._assertZone(z1[0], l3[0], l3[1]) - self._assertZone(z1[1], l4[0], l4[1]) - self._assertZone(z1[2], l5[0], l5[1]) - self._assertZone(z2[0], l6[0], l6[1]) diff --git a/cinderclient/tests/unit/v1/test_limits.py b/cinderclient/tests/unit/v1/test_limits.py deleted file mode 100644 index 72808dff9..000000000 --- a/cinderclient/tests/unit/v1/test_limits.py +++ /dev/null @@ -1,164 +0,0 @@ -# Copyright 2014 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 a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or -# implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import mock - -from cinderclient.tests.unit import utils -from cinderclient.v1 import limits - - -def _get_default_RateLimit(verb="verb1", uri="uri1", regex="regex1", - value="value1", - remain="remain1", unit="unit1", - next_available="next1"): - return limits.RateLimit(verb, uri, regex, value, remain, unit, - next_available) - - -class TestLimits(utils.TestCase): - def test_repr(self): - l = limits.Limits(None, {"foo": "bar"}) - self.assertEqual("", repr(l)) - - def test_absolute(self): - l = limits.Limits(None, - {"absolute": {"name1": "value1", "name2": "value2"}}) - l1 = limits.AbsoluteLimit("name1", "value1") - l2 = limits.AbsoluteLimit("name2", "value2") - for item in l.absolute: - self.assertIn(item, [l1, l2]) - - def test_rate(self): - l = limits.Limits(None, - { - "rate": [ - { - "uri": "uri1", - "regex": "regex1", - "limit": [ - { - "verb": "verb1", - "value": "value1", - "remaining": "remain1", - "unit": "unit1", - "next-available": "next1", - }, - ], - }, - { - "uri": "uri2", - "regex": "regex2", - "limit": [ - { - "verb": "verb2", - "value": "value2", - "remaining": "remain2", - "unit": "unit2", - "next-available": "next2", - }, - ], - }, - ], - }) - l1 = limits.RateLimit("verb1", "uri1", "regex1", "value1", "remain1", - "unit1", "next1") - l2 = limits.RateLimit("verb2", "uri2", "regex2", "value2", "remain2", - "unit2", "next2") - for item in l.rate: - self.assertIn(item, [l1, l2]) - - -class TestRateLimit(utils.TestCase): - def test_equal(self): - l1 = _get_default_RateLimit() - l2 = _get_default_RateLimit() - self.assertEqual(l1, l2) - - def test_not_equal_verbs(self): - l1 = _get_default_RateLimit() - l2 = _get_default_RateLimit(verb="verb2") - self.assertNotEqual(l1, l2) - - def test_not_equal_uris(self): - l1 = _get_default_RateLimit() - l2 = _get_default_RateLimit(uri="uri2") - self.assertNotEqual(l1, l2) - - def test_not_equal_regexps(self): - l1 = _get_default_RateLimit() - l2 = _get_default_RateLimit(regex="regex2") - self.assertNotEqual(l1, l2) - - def test_not_equal_values(self): - l1 = _get_default_RateLimit() - l2 = _get_default_RateLimit(value="value2") - self.assertNotEqual(l1, l2) - - def test_not_equal_remains(self): - l1 = _get_default_RateLimit() - l2 = _get_default_RateLimit(remain="remain2") - self.assertNotEqual(l1, l2) - - def test_not_equal_units(self): - l1 = _get_default_RateLimit() - l2 = _get_default_RateLimit(unit="unit2") - self.assertNotEqual(l1, l2) - - def test_not_equal_next_available(self): - l1 = _get_default_RateLimit() - l2 = _get_default_RateLimit(next_available="next2") - self.assertNotEqual(l1, l2) - - def test_repr(self): - l1 = _get_default_RateLimit() - self.assertEqual("", repr(l1)) - - -class TestAbsoluteLimit(utils.TestCase): - def test_equal(self): - l1 = limits.AbsoluteLimit("name1", "value1") - l2 = limits.AbsoluteLimit("name1", "value1") - self.assertEqual(l1, l2) - - def test_not_equal_values(self): - l1 = limits.AbsoluteLimit("name1", "value1") - l2 = limits.AbsoluteLimit("name1", "value2") - self.assertNotEqual(l1, l2) - - def test_not_equal_names(self): - l1 = limits.AbsoluteLimit("name1", "value1") - l2 = limits.AbsoluteLimit("name2", "value1") - self.assertNotEqual(l1, l2) - - def test_repr(self): - l1 = limits.AbsoluteLimit("name1", "value1") - self.assertEqual("", repr(l1)) - - -class TestLimitsManager(utils.TestCase): - def test_get(self): - api = mock.Mock() - api.client.get.return_value = ( - None, - {"limits": {"absolute": {"name1": "value1", }}, - "no-limits": {"absolute": {"name2": "value2", }}}) - l1 = limits.AbsoluteLimit("name1", "value1") - limitsManager = limits.LimitsManager(api) - - lim = limitsManager.get() - - self.assertIsInstance(lim, limits.Limits) - for l in lim.absolute: - self.assertEqual(l1, l) diff --git a/cinderclient/tests/unit/v1/test_quota_classes.py b/cinderclient/tests/unit/v1/test_quota_classes.py deleted file mode 100644 index 8ed91b7c3..000000000 --- a/cinderclient/tests/unit/v1/test_quota_classes.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) 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. - -from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes - - -cs = fakes.FakeClient() - - -class QuotaClassSetsTest(utils.TestCase): - - def test_class_quotas_get(self): - class_name = 'test' - cs.quota_classes.get(class_name) - cs.assert_called('GET', '/os-quota-class-sets/%s' % class_name) - - def test_update_quota(self): - q = cs.quota_classes.get('test') - q.update(volumes=2, snapshots=2, gigabytes=2000, - backups=2, backup_gigabytes=2000) - cs.assert_called('PUT', '/os-quota-class-sets/test') - - def test_refresh_quota(self): - q = cs.quota_classes.get('test') - q2 = cs.quota_classes.get('test') - self.assertEqual(q.volumes, q2.volumes) - self.assertEqual(q.snapshots, q2.snapshots) - self.assertEqual(q.gigabytes, q2.gigabytes) - self.assertEqual(q.backups, q2.backups) - self.assertEqual(q.backup_gigabytes, q2.backup_gigabytes) - q2.volumes = 0 - self.assertNotEqual(q.volumes, q2.volumes) - q2.snapshots = 0 - self.assertNotEqual(q.snapshots, q2.snapshots) - q2.gigabytes = 0 - self.assertNotEqual(q.gigabytes, q2.gigabytes) - q2.backups = 0 - self.assertNotEqual(q.backups, q2.backups) - q2.backup_gigabytes = 0 - self.assertNotEqual(q.backup_gigabytes, q2.backup_gigabytes) - q2.get() - self.assertEqual(q.volumes, q2.volumes) - self.assertEqual(q.snapshots, q2.snapshots) - self.assertEqual(q.gigabytes, q2.gigabytes) - self.assertEqual(q.backups, q2.backups) - self.assertEqual(q.backup_gigabytes, q2.backup_gigabytes) diff --git a/cinderclient/tests/unit/v1/test_quotas.py b/cinderclient/tests/unit/v1/test_quotas.py deleted file mode 100644 index aebff63f0..000000000 --- a/cinderclient/tests/unit/v1/test_quotas.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright (c) 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. - -from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes - - -cs = fakes.FakeClient() - - -class QuotaSetsTest(utils.TestCase): - - def test_tenant_quotas_get(self): - tenant_id = 'test' - cs.quotas.get(tenant_id) - cs.assert_called('GET', '/os-quota-sets/%s?usage=False' % tenant_id) - - def test_tenant_quotas_defaults(self): - tenant_id = 'test' - cs.quotas.defaults(tenant_id) - cs.assert_called('GET', '/os-quota-sets/%s/defaults' % tenant_id) - - def test_update_quota(self): - q = cs.quotas.get('test') - q.update(volumes=2) - q.update(snapshots=2) - q.update(backups=2) - cs.assert_called('PUT', '/os-quota-sets/test') - - def test_refresh_quota(self): - q = cs.quotas.get('test') - q2 = cs.quotas.get('test') - self.assertEqual(q.volumes, q2.volumes) - self.assertEqual(q.snapshots, q2.snapshots) - self.assertEqual(q.backups, q2.backups) - q2.volumes = 0 - self.assertNotEqual(q.volumes, q2.volumes) - q2.snapshots = 0 - self.assertNotEqual(q.snapshots, q2.snapshots) - q2.backups = 0 - self.assertNotEqual(q.backups, q2.backups) - q2.get() - self.assertEqual(q.volumes, q2.volumes) - self.assertEqual(q.snapshots, q2.snapshots) - self.assertEqual(q.backups, q2.backups) - - def test_delete_quota(self): - tenant_id = 'test' - cs.quotas.delete(tenant_id) - cs.assert_called('DELETE', '/os-quota-sets/test') diff --git a/cinderclient/tests/unit/v1/test_services.py b/cinderclient/tests/unit/v1/test_services.py deleted file mode 100644 index e0bfa7779..000000000 --- a/cinderclient/tests/unit/v1/test_services.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright (c) 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. - -from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes -from cinderclient.v1 import services - - -cs = fakes.FakeClient() - - -class ServicesTest(utils.TestCase): - - def test_list_services(self): - svs = cs.services.list() - cs.assert_called('GET', '/os-services') - self.assertEqual(3, len(svs)) - [self.assertIsInstance(s, services.Service) for s in svs] - - def test_list_services_with_hostname(self): - svs = cs.services.list(host='host2') - cs.assert_called('GET', '/os-services?host=host2') - self.assertEqual(2, len(svs)) - [self.assertIsInstance(s, services.Service) for s in svs] - [self.assertEqual('host2', s.host) for s in svs] - - def test_list_services_with_binary(self): - svs = cs.services.list(binary='cinder-volume') - cs.assert_called('GET', '/os-services?binary=cinder-volume') - self.assertEqual(2, len(svs)) - [self.assertIsInstance(s, services.Service) for s in svs] - [self.assertEqual('cinder-volume', s.binary) for s in svs] - - def test_list_services_with_host_binary(self): - svs = cs.services.list('host2', 'cinder-volume') - cs.assert_called('GET', '/os-services?host=host2&binary=cinder-volume') - self.assertEqual(1, len(svs)) - [self.assertIsInstance(s, services.Service) for s in svs] - [self.assertEqual('host2', s.host) for s in svs] - [self.assertEqual('cinder-volume', s.binary) for s in svs] - - def test_services_enable(self): - s = cs.services.enable('host1', 'cinder-volume') - values = {"host": "host1", 'binary': 'cinder-volume'} - cs.assert_called('PUT', '/os-services/enable', values) - self.assertIsInstance(s, services.Service) - self.assertEqual('enabled', s.status) - - def test_services_disable(self): - s = cs.services.disable('host1', 'cinder-volume') - values = {"host": "host1", 'binary': 'cinder-volume'} - cs.assert_called('PUT', '/os-services/disable', values) - self.assertIsInstance(s, services.Service) - self.assertEqual('disabled', s.status) - - def test_services_disable_log_reason(self): - s = cs.services.disable_log_reason( - 'host1', 'cinder-volume', 'disable bad host') - values = {"host": "host1", 'binary': 'cinder-volume', - "disabled_reason": "disable bad host"} - cs.assert_called('PUT', '/os-services/disable-log-reason', values) - self.assertIsInstance(s, services.Service) - self.assertEqual('disabled', s.status) diff --git a/cinderclient/tests/unit/v1/test_shell.py b/cinderclient/tests/unit/v1/test_shell.py deleted file mode 100644 index 4eb749daf..000000000 --- a/cinderclient/tests/unit/v1/test_shell.py +++ /dev/null @@ -1,511 +0,0 @@ -# Copyright 2010 Jacob Kaplan-Moss - -# Copyright (c) 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 fixtures -from keystoneclient import fixture as keystone_client_fixture -from requests_mock.contrib import fixture as requests_mock_fixture - -from cinderclient import client -from cinderclient import exceptions -from cinderclient import shell -from cinderclient.tests.unit.fixture_data import base as fixture_base -from cinderclient.tests.unit.fixture_data import keystone_client -from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes -from cinderclient.v1 import shell as shell_v1 - - -class ShellTest(utils.TestCase): - - FAKE_ENV = { - 'CINDER_USERNAME': 'username', - 'CINDER_PASSWORD': 'password', - 'CINDER_PROJECT_ID': 'project_id', - 'OS_VOLUME_API_VERSION': '1', - 'CINDER_URL': keystone_client.BASE_URL, - } - - # Patch os.environ to avoid required auth info. - def setUp(self): - """Run before each test.""" - super(ShellTest, self).setUp() - for var in self.FAKE_ENV: - self.useFixture(fixtures.EnvironmentVariable(var, - self.FAKE_ENV[var])) - - self.shell = shell.OpenStackCinderShell() - - # HACK(bcwaldon): replace this when we start using stubs - self.old_get_client_class = client.get_client_class - client.get_client_class = lambda *_: fakes.FakeClient - - self.requests = self.useFixture(requests_mock_fixture.Fixture()) - self.requests.register_uri( - 'GET', keystone_client.BASE_URL, - text=keystone_client.keystone_request_callback - ) - token = keystone_client_fixture.V2Token() - s = token.add_service('volume', 'cinder') - s.add_endpoint(public='http://127.0.0.1:8776') - - self.requests.post(keystone_client.BASE_URL + 'v2.0/tokens', - json=token) - self.requests.get( - 'http://127.0.0.1:8776', - json=fixture_base.generate_version_output() - ) - - def tearDown(self): - # For some method like test_image_meta_bad_action we are - # testing a SystemExit to be thrown and object self.shell has - # no time to get instantatiated which is OK in this case, so - # we make sure the method is there before launching it. - if hasattr(self.shell, 'cs'): - self.shell.cs.clear_callstack() - - # HACK(bcwaldon): replace this when we start using stubs - client.get_client_class = self.old_get_client_class - super(ShellTest, self).tearDown() - - def run_command(self, cmd): - self.shell.main(cmd.split()) - - def assert_called(self, method, url, body=None, **kwargs): - return self.shell.cs.assert_called(method, url, body, **kwargs) - - def assert_called_anytime(self, method, url, body=None): - return self.shell.cs.assert_called_anytime(method, url, body) - - def test_extract_metadata(self): - # mimic the result of argparse's parse_args() method - class Arguments: - - def __init__(self, metadata=None): - self.metadata = metadata or [] - - inputs = [ - ([], {}), - (["key=value"], {"key": "value"}), - (["key"], {"key": None}), - (["k1=v1", "k2=v2"], {"k1": "v1", "k2": "v2"}), - (["k1=v1", "k2"], {"k1": "v1", "k2": None}), - (["k1", "k2=v2"], {"k1": None, "k2": "v2"}) - ] - - for input in inputs: - args = Arguments(metadata=input[0]) - self.assertEqual(input[1], shell_v1._extract_metadata(args)) - - def test_translate_volume_keys(self): - cs = fakes.FakeClient() - v = cs.volumes.list()[0] - setattr(v, 'os-vol-tenant-attr:tenant_id', 'fake_tenant') - setattr(v, '_info', {'attachments': [{'server_id': 1234}], - 'id': 1234, 'name': 'sample-volume', - 'os-vol-tenant-attr:tenant_id': 'fake_tenant'}) - shell_v1._translate_volume_keys([v]) - self.assertEqual(v.tenant_id, 'fake_tenant') - - def test_list(self): - self.run_command('list') - # NOTE(jdg): we default to detail currently - self.assert_called('GET', '/volumes/detail') - - def test_list_filter_tenant_with_all_tenants(self): - self.run_command('list --tenant=123 --all-tenants 1') - self.assert_called('GET', - '/volumes/detail?all_tenants=1&project_id=123') - - def test_list_filter_tenant_without_all_tenants(self): - self.run_command('list --tenant=123') - self.assert_called('GET', - '/volumes/detail?all_tenants=1&project_id=123') - - def test_metadata_args_with_limiter(self): - self.run_command('create --metadata key1="--test1" 1') - expected = {'volume': {'snapshot_id': None, - 'display_description': None, - 'source_volid': None, - 'status': 'creating', - 'size': 1, - 'volume_type': None, - 'imageRef': None, - 'availability_zone': None, - 'attach_status': 'detached', - 'user_id': None, - 'project_id': None, - 'metadata': {'key1': '"--test1"'}, - 'display_name': None}} - self.assert_called_anytime('POST', '/volumes', expected) - - def test_metadata_args_limiter_display_name(self): - self.run_command('create --metadata key1="--t1" --display-name="t" 1') - expected = {'volume': {'snapshot_id': None, - 'display_description': None, - 'source_volid': None, - 'status': 'creating', - 'size': 1, - 'volume_type': None, - 'imageRef': None, - 'availability_zone': None, - 'attach_status': 'detached', - 'user_id': None, - 'project_id': None, - 'metadata': {'key1': '"--t1"'}, - 'display_name': '"t"'}} - self.assert_called_anytime('POST', '/volumes', expected) - - def test_delimit_metadata_args(self): - self.run_command('create --metadata key1="test1" key2="test2" 1') - expected = {'volume': {'snapshot_id': None, - 'display_description': None, - 'source_volid': None, - 'status': 'creating', - 'size': 1, - 'volume_type': None, - 'imageRef': None, - 'availability_zone': None, - 'attach_status': 'detached', - 'user_id': None, - 'project_id': None, - 'metadata': {'key1': '"test1"', - 'key2': '"test2"'}, - 'display_name': None}} - self.assert_called_anytime('POST', '/volumes', expected) - - def test_delimit_metadata_args_display_name(self): - self.run_command('create --metadata key1="t1" --display-name="t" 1') - expected = {'volume': {'snapshot_id': None, - 'display_description': None, - 'source_volid': None, - 'status': 'creating', - 'size': 1, - 'volume_type': None, - 'imageRef': None, - 'availability_zone': None, - 'attach_status': 'detached', - 'user_id': None, - 'project_id': None, - 'metadata': {'key1': '"t1"'}, - 'display_name': '"t"'}} - self.assert_called_anytime('POST', '/volumes', expected) - - def test_list_filter_status(self): - self.run_command('list --status=available') - self.assert_called('GET', '/volumes/detail?status=available') - - def test_list_filter_display_name(self): - self.run_command('list --display-name=1234') - self.assert_called('GET', '/volumes/detail?display_name=1234') - - def test_list_all_tenants(self): - self.run_command('list --all-tenants=1') - self.assert_called('GET', '/volumes/detail?all_tenants=1') - - def test_list_availability_zone(self): - self.run_command('availability-zone-list') - self.assert_called('GET', '/os-availability-zone') - - def test_list_limit(self): - self.run_command('list --limit=10') - self.assert_called('GET', '/volumes/detail?limit=10') - - def test_show(self): - self.run_command('show 1234') - self.assert_called('GET', '/volumes/1234') - - def test_delete(self): - self.run_command('delete 1234') - self.assert_called('DELETE', '/volumes/1234') - - def test_delete_by_name(self): - self.run_command('delete sample-volume') - self.assert_called_anytime('GET', '/volumes/detail?all_tenants=1') - self.assert_called('DELETE', '/volumes/1234') - - def test_delete_multiple(self): - self.run_command('delete 1234 5678') - self.assert_called_anytime('DELETE', '/volumes/1234') - self.assert_called('DELETE', '/volumes/5678') - - def test_backup(self): - self.run_command('backup-create 1234') - self.assert_called('POST', '/backups') - - def test_restore(self): - self.run_command('backup-restore 1234') - self.assert_called('POST', '/backups/1234/restore') - - def test_snapshot_list_filter_volume_id(self): - self.run_command('snapshot-list --volume-id=1234') - self.assert_called('GET', '/snapshots/detail?volume_id=1234') - - def test_snapshot_list_filter_status_and_volume_id(self): - self.run_command('snapshot-list --status=available --volume-id=1234') - self.assert_called('GET', '/snapshots/detail?' - 'status=available&volume_id=1234') - - def test_rename(self): - # 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) - # change description only - self.run_command('rename 1234 --display-description=new-description') - expected = {'volume': {'display_description': 'new-description'}} - self.assert_called('PUT', '/volumes/1234', body=expected) - # rename and change description - self.run_command('rename 1234 new-name ' - '--display-description=new-description') - expected = {'volume': { - 'display_name': 'new-name', - 'display_description': 'new-description', - }} - self.assert_called('PUT', '/volumes/1234', body=expected) - - # Call rename with no arguments - self.assertRaises(SystemExit, self.run_command, 'rename') - - def test_rename_snapshot(self): - # 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) - # change description only - self.run_command('snapshot-rename 1234 ' - '--display-description=new-description') - expected = {'snapshot': {'display_description': 'new-description'}} - self.assert_called('PUT', '/snapshots/1234', body=expected) - # snapshot-rename and change description - self.run_command('snapshot-rename 1234 new-name ' - '--display-description=new-description') - expected = {'snapshot': { - 'display_name': 'new-name', - 'display_description': 'new-description', - }} - self.assert_called('PUT', '/snapshots/1234', body=expected) - - # 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') - self.assert_called('POST', '/volumes/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_set_metadata_delete_dict(self): - self.run_command('metadata 1234 unset key1=val1 key2=val2') - self.assert_called('DELETE', '/volumes/1234/metadata/key1') - self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) - - def test_set_metadata_delete_keys(self): - self.run_command('metadata 1234 unset key1 key2') - self.assert_called('DELETE', '/volumes/1234/metadata/key1') - self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) - - def test_reset_state(self): - self.run_command('reset-state 1234') - expected = {'os-reset_status': {'status': 'available'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_reset_state_attach(self): - self.run_command('reset-state --state in-use 1234') - expected = {'os-reset_status': {'status': 'in-use'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_reset_state_with_flag(self): - self.run_command('reset-state --state error 1234') - expected = {'os-reset_status': {'status': 'error'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_reset_state_multiple(self): - self.run_command('reset-state 1234 5678 --state error') - expected = {'os-reset_status': {'status': 'error'}} - self.assert_called_anytime('POST', '/volumes/1234/action', - body=expected) - self.assert_called_anytime('POST', '/volumes/5678/action', - body=expected) - - def test_reset_state_two_with_one_nonexistent(self): - cmd = 'reset-state 1234 123456789' - self.assertRaises(exceptions.CommandError, self.run_command, cmd) - expected = {'os-reset_status': {'status': 'available'}} - self.assert_called_anytime('POST', '/volumes/1234/action', - body=expected) - - def test_reset_state_one_with_one_nonexistent(self): - cmd = 'reset-state 123456789' - self.assertRaises(exceptions.CommandError, self.run_command, cmd) - - def test_snapshot_reset_state(self): - self.run_command('snapshot-reset-state 1234') - expected = {'os-reset_status': {'status': 'available'}} - self.assert_called('POST', '/snapshots/1234/action', body=expected) - - def test_snapshot_reset_state_with_flag(self): - 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_snapshot_reset_state_multiple(self): - self.run_command('snapshot-reset-state 1234 5678') - expected = {'os-reset_status': {'status': 'available'}} - self.assert_called_anytime('POST', '/snapshots/1234/action', - body=expected) - self.assert_called_anytime('POST', '/snapshots/5678/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': 'front-end'}} - 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. - - Verify one GET/one DELETE requests are made per command invocation: - - one GET request to retrieve the relevant volume type information - - one DELETE request to delete the encryption type information - """ - self.run_command('encryption-type-delete 1') - self.assert_called('DELETE', '/types/1/encryption/provider') - self.assert_called_anytime('GET', '/types/1') - - 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) - - def test_snapshot_metadata_set(self): - self.run_command('snapshot-metadata 1234 set key1=val1 key2=val2') - self.assert_called('POST', '/snapshots/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_snapshot_metadata_unset_dict(self): - self.run_command('snapshot-metadata 1234 unset key1=val1 key2=val2') - self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key1') - self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key2') - - def test_snapshot_metadata_unset_keys(self): - self.run_command('snapshot-metadata 1234 unset key1 key2') - self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key1') - self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key2') - - def test_volume_metadata_update_all(self): - self.run_command('metadata-update-all 1234 key1=val1 key2=val2') - self.assert_called('PUT', '/volumes/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_snapshot_metadata_update_all(self): - self.run_command('snapshot-metadata-update-all\ - 1234 key1=val1 key2=val2') - self.assert_called('PUT', '/snapshots/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_readonly_mode_update(self): - self.run_command('readonly-mode-update 1234 True') - expected = {'os-update_readonly_flag': {'readonly': True}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - self.run_command('readonly-mode-update 1234 False') - expected = {'os-update_readonly_flag': {'readonly': False}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_service_disable(self): - self.run_command('service-disable host cinder-volume') - self.assert_called('PUT', '/os-services/disable', - {"binary": "cinder-volume", "host": "host"}) - - def test_services_disable_with_reason(self): - cmd = 'service-disable host cinder-volume --reason no_reason' - self.run_command(cmd) - body = {'host': 'host', 'binary': 'cinder-volume', - 'disabled_reason': 'no_reason'} - self.assert_called('PUT', '/os-services/disable-log-reason', body) - - def test_service_enable(self): - self.run_command('service-enable host cinder-volume') - self.assert_called('PUT', '/os-services/enable', - {"binary": "cinder-volume", "host": "host"}) - - def test_snapshot_delete(self): - self.run_command('snapshot-delete 1234') - self.assert_called('DELETE', '/snapshots/1234') - - def test_quota_delete(self): - self.run_command('quota-delete 1234') - self.assert_called('DELETE', '/os-quota-sets/1234') - - def test_snapshot_delete_multiple(self): - self.run_command('snapshot-delete 1234 5678') - self.assert_called('DELETE', '/snapshots/5678') - - def test_list_transfer(self): - self.run_command('transfer-list') - self.assert_called('GET', '/os-volume-transfer/detail') - - def test_list_transfer_all_tenants(self): - self.run_command('transfer-list --all-tenants=1') - self.assert_called('GET', '/os-volume-transfer/detail?all_tenants=1') diff --git a/cinderclient/tests/unit/v1/test_types.py b/cinderclient/tests/unit/v1/test_types.py deleted file mode 100644 index 824140015..000000000 --- a/cinderclient/tests/unit/v1/test_types.py +++ /dev/null @@ -1,47 +0,0 @@ -# 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 import volume_types -from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes - -cs = fakes.FakeClient() - - -class TypesTest(utils.TestCase): - def test_list_types(self): - tl = cs.volume_types.list() - cs.assert_called('GET', '/types') - for t in tl: - self.assertIsInstance(t, volume_types.VolumeType) - - def test_create(self): - t = cs.volume_types.create('test-type-3') - cs.assert_called('POST', '/types') - self.assertIsInstance(t, volume_types.VolumeType) - - def test_set_key(self): - t = cs.volume_types.get(1) - t.set_keys({'k': 'v'}) - cs.assert_called('POST', - '/types/1/extra_specs', - {'extra_specs': {'k': 'v'}}) - - def test_unsset_keys(self): - t = cs.volume_types.get(1) - t.unset_keys(['k']) - cs.assert_called('DELETE', '/types/1/extra_specs/k') - - def test_delete(self): - cs.volume_types.delete(1) - cs.assert_called('DELETE', '/types/1') diff --git a/cinderclient/tests/unit/v1/test_volume_backups.py b/cinderclient/tests/unit/v1/test_volume_backups.py deleted file mode 100644 index 94020fa39..000000000 --- a/cinderclient/tests/unit/v1/test_volume_backups.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. -# 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.unit import utils -from cinderclient.tests.unit.v1 import fakes - - -cs = fakes.FakeClient() - - -class VolumeBackupsTest(utils.TestCase): - - def test_create(self): - cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4') - cs.assert_called('POST', '/backups') - - def test_get(self): - backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' - cs.backups.get(backup_id) - cs.assert_called('GET', '/backups/%s' % backup_id) - - def test_list(self): - cs.backups.list() - cs.assert_called('GET', '/backups/detail') - - def test_delete(self): - b = cs.backups.list()[0] - b.delete() - cs.assert_called('DELETE', - '/backups/76a17945-3c6f-435c-975b-b5685db10b62') - cs.backups.delete('76a17945-3c6f-435c-975b-b5685db10b62') - cs.assert_called('DELETE', - '/backups/76a17945-3c6f-435c-975b-b5685db10b62') - cs.backups.delete(b) - cs.assert_called('DELETE', - '/backups/76a17945-3c6f-435c-975b-b5685db10b62') - - def test_restore(self): - backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' - cs.restores.restore(backup_id) - cs.assert_called('POST', '/backups/%s/restore' % backup_id) diff --git a/cinderclient/tests/unit/v1/test_volume_encryption_types.py b/cinderclient/tests/unit/v1/test_volume_encryption_types.py deleted file mode 100644 index 04093aeb9..000000000 --- a/cinderclient/tests/unit/v1/test_volume_encryption_types.py +++ /dev/null @@ -1,99 +0,0 @@ -# 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.unit import utils -from cinderclient.tests.unit.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, {'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 - - Verify that one DELETE request is made for encryption type deletion - Verify that encryption type deletion returns None - """ - result = cs.volume_encryption_types.delete(1) - cs.assert_called('DELETE', '/types/1/encryption/provider') - self.assertIsNone(result, "delete result must be None") diff --git a/cinderclient/tests/unit/v1/test_volume_transfers.py b/cinderclient/tests/unit/v1/test_volume_transfers.py deleted file mode 100644 index 4b27cb3f1..000000000 --- a/cinderclient/tests/unit/v1/test_volume_transfers.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. -# 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.unit import utils -from cinderclient.tests.unit.v1 import fakes - - -cs = fakes.FakeClient() - - -class VolumeTransfersTest(utils.TestCase): - - def test_create(self): - cs.transfers.create('1234') - cs.assert_called('POST', '/os-volume-transfer') - - def test_get(self): - transfer_id = '5678' - cs.transfers.get(transfer_id) - cs.assert_called('GET', '/os-volume-transfer/%s' % transfer_id) - - def test_list(self): - cs.transfers.list() - cs.assert_called('GET', '/os-volume-transfer/detail') - - def test_delete(self): - b = cs.transfers.list()[0] - b.delete() - cs.assert_called('DELETE', '/os-volume-transfer/5678') - cs.transfers.delete('5678') - cs.assert_called('DELETE', '/os-volume-transfer/5678') - cs.transfers.delete(b) - cs.assert_called('DELETE', '/os-volume-transfer/5678') - - def test_accept(self): - transfer_id = '5678' - auth_key = '12345' - cs.transfers.accept(transfer_id, auth_key) - cs.assert_called('POST', '/os-volume-transfer/%s/accept' % transfer_id) diff --git a/cinderclient/tests/unit/v1/test_volumes.py b/cinderclient/tests/unit/v1/test_volumes.py deleted file mode 100644 index 75c81bd5f..000000000 --- a/cinderclient/tests/unit/v1/test_volumes.py +++ /dev/null @@ -1,113 +0,0 @@ -# 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.unit import utils -from cinderclient.tests.unit.v1 import fakes - - -cs = fakes.FakeClient() - - -class VolumesTest(utils.TestCase): - - def test_delete_volume(self): - v = cs.volumes.list()[0] - v.delete() - cs.assert_called('DELETE', '/volumes/1234') - cs.volumes.delete('1234') - cs.assert_called('DELETE', '/volumes/1234') - cs.volumes.delete(v) - cs.assert_called('DELETE', '/volumes/1234') - - def test_create_volume(self): - cs.volumes.create(1) - cs.assert_called('POST', '/volumes') - - def test_attach(self): - v = cs.volumes.get('1234') - cs.volumes.attach(v, 1, '/dev/vdc', mode='rw') - cs.assert_called('POST', '/volumes/1234/action') - - def test_detach(self): - v = cs.volumes.get('1234') - cs.volumes.detach(v) - cs.assert_called('POST', '/volumes/1234/action') - - def test_reserve(self): - v = cs.volumes.get('1234') - cs.volumes.reserve(v) - cs.assert_called('POST', '/volumes/1234/action') - - def test_unreserve(self): - v = cs.volumes.get('1234') - cs.volumes.unreserve(v) - cs.assert_called('POST', '/volumes/1234/action') - - def test_begin_detaching(self): - v = cs.volumes.get('1234') - cs.volumes.begin_detaching(v) - cs.assert_called('POST', '/volumes/1234/action') - - def test_roll_detaching(self): - v = cs.volumes.get('1234') - cs.volumes.roll_detaching(v) - cs.assert_called('POST', '/volumes/1234/action') - - def test_initialize_connection(self): - v = cs.volumes.get('1234') - cs.volumes.initialize_connection(v, {}) - cs.assert_called('POST', '/volumes/1234/action') - - def test_terminate_connection(self): - v = cs.volumes.get('1234') - cs.volumes.terminate_connection(v, {}) - cs.assert_called('POST', '/volumes/1234/action') - - def test_set_metadata(self): - cs.volumes.set_metadata(1234, {'k1': 'v1'}) - cs.assert_called('POST', '/volumes/1234/metadata', - {'metadata': {'k1': 'v1'}}) - - def test_delete_metadata(self): - keys = ['key1'] - cs.volumes.delete_metadata(1234, keys) - cs.assert_called('DELETE', '/volumes/1234/metadata/key1') - - def test_extend(self): - 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') - - def test_migrate(self): - v = cs.volumes.get('1234') - cs.volumes.migrate_volume(v, 'dest', False) - cs.assert_called('POST', '/volumes/1234/action') - - def test_metadata_update_all(self): - cs.volumes.update_all_metadata(1234, {'k1': 'v1'}) - cs.assert_called('PUT', '/volumes/1234/metadata', - {'metadata': {'k1': 'v1'}}) - - def test_readonly_mode_update(self): - v = cs.volumes.get('1234') - cs.volumes.update_readonly_flag(v, True) - cs.assert_called('POST', '/volumes/1234/action') - - def test_set_bootable(self): - v = cs.volumes.get('1234') - cs.volumes.set_bootable(v, True) - cs.assert_called('POST', '/volumes/1234/action') diff --git a/cinderclient/tests/unit/v1/testfile.txt b/cinderclient/tests/unit/v1/testfile.txt deleted file mode 100644 index e4e860f38..000000000 --- a/cinderclient/tests/unit/v1/testfile.txt +++ /dev/null @@ -1 +0,0 @@ -BLAH diff --git a/cinderclient/tests/unit/v2/test_qos.py b/cinderclient/tests/unit/v2/test_qos.py deleted file mode 100644 index bd303f5cc..000000000 --- a/cinderclient/tests/unit/v2/test_qos.py +++ /dev/null @@ -1,79 +0,0 @@ -# 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.unit import utils -from cinderclient.tests.unit.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/tests/unit/v2/test_shell.py b/cinderclient/tests/unit/v2/test_shell.py deleted file mode 100644 index f9ee58ec0..000000000 --- a/cinderclient/tests/unit/v2/test_shell.py +++ /dev/null @@ -1,873 +0,0 @@ -# Copyright (c) 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. - -import fixtures -from keystoneclient import fixture as keystone_client_fixture -import mock -from requests_mock.contrib import fixture as requests_mock_fixture -from six.moves.urllib import parse - -from cinderclient import client -from cinderclient import exceptions -from cinderclient import shell -from cinderclient.tests.unit.fixture_data import base as fixture_base -from cinderclient.tests.unit.fixture_data import keystone_client -from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes - - -class ShellTest(utils.TestCase): - - FAKE_ENV = { - 'CINDER_USERNAME': 'username', - 'CINDER_PASSWORD': 'password', - 'CINDER_PROJECT_ID': 'project_id', - 'OS_VOLUME_API_VERSION': '2', - 'CINDER_URL': keystone_client.BASE_URL, - } - - # Patch os.environ to avoid required auth info. - def setUp(self): - """Run before each test.""" - super(ShellTest, self).setUp() - for var in self.FAKE_ENV: - self.useFixture(fixtures.EnvironmentVariable(var, - self.FAKE_ENV[var])) - - self.shell = shell.OpenStackCinderShell() - - # HACK(bcwaldon): replace this when we start using stubs - self.old_get_client_class = client.get_client_class - client.get_client_class = lambda *_: fakes.FakeClient - - self.requests = self.useFixture(requests_mock_fixture.Fixture()) - self.requests.register_uri( - 'GET', keystone_client.BASE_URL, - text=keystone_client.keystone_request_callback - ) - token = keystone_client_fixture.V2Token() - s = token.add_service('volume', 'cinder') - s.add_endpoint(public='http://127.0.0.1:8776') - - self.requests.post(keystone_client.BASE_URL + 'v2.0/tokens', - json=token) - self.requests.get( - 'http://127.0.0.1:8776', - json=fixture_base.generate_version_output() - ) - - def tearDown(self): - # For some methods like test_image_meta_bad_action we are - # testing a SystemExit to be thrown and object self.shell has - # no time to get instantiated, which is OK in this case, so - # we make sure the method is there before launching it. - if hasattr(self.shell, 'cs'): - self.shell.cs.clear_callstack() - - # HACK(bcwaldon): replace this when we start using stubs - client.get_client_class = self.old_get_client_class - super(ShellTest, self).tearDown() - - def run_command(self, cmd): - self.shell.main(cmd.split()) - - def assert_called(self, method, url, body=None, - partial_body=None, **kwargs): - return self.shell.cs.assert_called(method, url, body, - partial_body, **kwargs) - - def assert_called_anytime(self, method, url, body=None, - partial_body=None): - return self.shell.cs.assert_called_anytime(method, url, body, - partial_body) - - def test_list(self): - self.run_command('list') - # NOTE(jdg): we default to detail currently - self.assert_called('GET', '/volumes/detail') - - def test_list_filter_tenant_with_all_tenants(self): - self.run_command('list --all-tenants=1 --tenant 123') - self.assert_called('GET', - '/volumes/detail?all_tenants=1&project_id=123') - - def test_list_filter_tenant_without_all_tenants(self): - self.run_command('list --tenant 123') - self.assert_called('GET', - '/volumes/detail?all_tenants=1&project_id=123') - - def test_metadata_args_with_limiter(self): - self.run_command('create --metadata key1="--test1" 1') - self.assert_called('GET', '/volumes/1234') - expected = {'volume': {'imageRef': None, - 'project_id': None, - 'status': 'creating', - 'size': 1, - 'user_id': None, - 'availability_zone': None, - 'source_replica': None, - 'attach_status': 'detached', - 'source_volid': None, - 'consistencygroup_id': None, - 'name': None, - 'snapshot_id': None, - 'metadata': {'key1': '"--test1"'}, - 'volume_type': None, - 'description': None}} - self.assert_called_anytime('POST', '/volumes', expected) - - def test_metadata_args_limiter_display_name(self): - self.run_command('create --metadata key1="--t1" --name="t" 1') - self.assert_called('GET', '/volumes/1234') - expected = {'volume': {'imageRef': None, - 'project_id': None, - 'status': 'creating', - 'size': 1, - 'user_id': None, - 'availability_zone': None, - 'source_replica': None, - 'attach_status': 'detached', - 'source_volid': None, - 'consistencygroup_id': None, - 'name': '"t"', - 'snapshot_id': None, - 'metadata': {'key1': '"--t1"'}, - 'volume_type': None, - 'description': None}} - self.assert_called_anytime('POST', '/volumes', expected) - - def test_delimit_metadata_args(self): - self.run_command('create --metadata key1="test1" key2="test2" 1') - expected = {'volume': {'imageRef': None, - 'project_id': None, - 'status': 'creating', - 'size': 1, - 'user_id': None, - 'availability_zone': None, - 'source_replica': None, - 'attach_status': 'detached', - 'source_volid': None, - 'consistencygroup_id': None, - 'name': None, - 'snapshot_id': None, - 'metadata': {'key1': '"test1"', - 'key2': '"test2"'}, - 'volume_type': None, - 'description': None}} - self.assert_called_anytime('POST', '/volumes', expected) - - def test_delimit_metadata_args_display_name(self): - self.run_command('create --metadata key1="t1" --name="t" 1') - self.assert_called('GET', '/volumes/1234') - expected = {'volume': {'imageRef': None, - 'project_id': None, - 'status': 'creating', - 'size': 1, - 'user_id': None, - 'availability_zone': None, - 'source_replica': None, - 'attach_status': 'detached', - 'source_volid': None, - 'consistencygroup_id': None, - 'name': '"t"', - 'snapshot_id': None, - 'metadata': {'key1': '"t1"'}, - 'volume_type': None, - 'description': None}} - self.assert_called_anytime('POST', '/volumes', expected) - - def test_list_filter_status(self): - self.run_command('list --status=available') - self.assert_called('GET', '/volumes/detail?status=available') - - def test_list_filter_name(self): - self.run_command('list --name=1234') - self.assert_called('GET', '/volumes/detail?name=1234') - - def test_list_all_tenants(self): - self.run_command('list --all-tenants=1') - self.assert_called('GET', '/volumes/detail?all_tenants=1') - - def test_list_marker(self): - self.run_command('list --marker=1234') - self.assert_called('GET', '/volumes/detail?marker=1234') - - def test_list_limit(self): - self.run_command('list --limit=10') - self.assert_called('GET', '/volumes/detail?limit=10') - - def test_list_sort_valid(self): - self.run_command('list --sort_key=id --sort_dir=asc') - self.assert_called('GET', '/volumes/detail?sort_dir=asc&sort_key=id') - - def test_list_sort_key_name(self): - # Client 'name' key is mapped to 'display_name' - self.run_command('list --sort_key=name') - self.assert_called('GET', '/volumes/detail?sort_key=display_name') - - def test_list_sort_name(self): - # Client 'name' key is mapped to 'display_name' - self.run_command('list --sort=name') - self.assert_called('GET', '/volumes/detail?sort=display_name') - - def test_list_sort_key_invalid(self): - self.assertRaises(ValueError, - self.run_command, - 'list --sort_key=foo --sort_dir=asc') - - def test_list_sort_dir_invalid(self): - self.assertRaises(ValueError, - self.run_command, - 'list --sort_key=id --sort_dir=foo') - - def test_list_mix_sort_args(self): - cmds = ['list --sort name:desc --sort_key=status', - 'list --sort name:desc --sort_dir=asc', - 'list --sort name:desc --sort_key=status --sort_dir=asc'] - for cmd in cmds: - self.assertRaises(exceptions.CommandError, self.run_command, cmd) - - def test_list_sort_single_key_only(self): - self.run_command('list --sort=id') - self.assert_called('GET', '/volumes/detail?sort=id') - - def test_list_sort_single_key_trailing_colon(self): - self.run_command('list --sort=id:') - self.assert_called('GET', '/volumes/detail?sort=id') - - def test_list_sort_single_key_and_dir(self): - self.run_command('list --sort=id:asc') - url = '/volumes/detail?%s' % parse.urlencode([('sort', 'id:asc')]) - self.assert_called('GET', url) - - def test_list_sort_multiple_keys_only(self): - self.run_command('list --sort=id,status,size') - url = ('/volumes/detail?%s' % - parse.urlencode([('sort', 'id,status,size')])) - self.assert_called('GET', url) - - def test_list_sort_multiple_keys_and_dirs(self): - self.run_command('list --sort=id:asc,status,size:desc') - url = ('/volumes/detail?%s' % - parse.urlencode([('sort', 'id:asc,status,size:desc')])) - self.assert_called('GET', url) - - def test_list_reorder_with_sort(self): - # sortby_index is None if there is sort information - for cmd in ['list --sort_key=name', - 'list --sort_dir=asc', - 'list --sort_key=name --sort_dir=asc', - 'list --sort=name', - 'list --sort=name:asc']: - with mock.patch('cinderclient.utils.print_list') as mock_print: - self.run_command(cmd) - mock_print.assert_called_once_with( - mock.ANY, mock.ANY, sortby_index=None) - - def test_list_reorder_without_sort(self): - # sortby_index is 0 without sort information - for cmd in ['list', 'list --all-tenants']: - with mock.patch('cinderclient.utils.print_list') as mock_print: - self.run_command(cmd) - mock_print.assert_called_once_with( - mock.ANY, mock.ANY, sortby_index=0) - - def test_list_availability_zone(self): - self.run_command('availability-zone-list') - self.assert_called('GET', '/os-availability-zone') - - def test_create_volume_from_snapshot(self): - expected = {'volume': {'size': None}} - - expected['volume']['snapshot_id'] = '1234' - self.run_command('create --snapshot-id=1234') - self.assert_called_anytime('POST', '/volumes', partial_body=expected) - self.assert_called('GET', '/volumes/1234') - - expected['volume']['size'] = 2 - self.run_command('create --snapshot-id=1234 2') - self.assert_called_anytime('POST', '/volumes', partial_body=expected) - self.assert_called('GET', '/volumes/1234') - - def test_create_volume_from_volume(self): - expected = {'volume': {'size': None}} - - expected['volume']['source_volid'] = '1234' - self.run_command('create --source-volid=1234') - self.assert_called_anytime('POST', '/volumes', partial_body=expected) - self.assert_called('GET', '/volumes/1234') - - expected['volume']['size'] = 2 - self.run_command('create --source-volid=1234 2') - self.assert_called_anytime('POST', '/volumes', partial_body=expected) - self.assert_called('GET', '/volumes/1234') - - def test_create_volume_from_replica(self): - expected = {'volume': {'size': None}} - - expected['volume']['source_replica'] = '1234' - self.run_command('create --source-replica=1234') - self.assert_called_anytime('POST', '/volumes', partial_body=expected) - self.assert_called('GET', '/volumes/1234') - - def test_create_volume_from_image(self): - expected = {'volume': {'status': 'creating', - 'size': 1, - 'imageRef': '1234', - 'attach_status': 'detached'}} - self.run_command('create --image=1234 1') - self.assert_called_anytime('POST', '/volumes', partial_body=expected) - self.assert_called('GET', '/volumes/1234') - - def test_create_size_required_if_not_snapshot_or_clone(self): - self.assertRaises(SystemExit, self.run_command, 'create') - - def test_show(self): - self.run_command('show 1234') - self.assert_called('GET', '/volumes/1234') - - def test_delete(self): - self.run_command('delete 1234') - self.assert_called('DELETE', '/volumes/1234') - - def test_delete_by_name(self): - self.run_command('delete sample-volume') - self.assert_called_anytime('GET', '/volumes/detail?all_tenants=1') - self.assert_called('DELETE', '/volumes/1234') - - def test_delete_multiple(self): - self.run_command('delete 1234 5678') - self.assert_called_anytime('DELETE', '/volumes/1234') - self.assert_called('DELETE', '/volumes/5678') - - def test_backup(self): - self.run_command('backup-create 1234') - self.assert_called('POST', '/backups') - - def test_backup_incremental(self): - self.run_command('backup-create 1234 --incremental') - self.assert_called('POST', '/backups') - - def test_restore(self): - self.run_command('backup-restore 1234') - self.assert_called('POST', '/backups/1234/restore') - - def test_record_export(self): - self.run_command('backup-export 1234') - self.assert_called('GET', '/backups/1234/export_record') - - def test_record_import(self): - self.run_command('backup-import fake.driver URL_STRING') - expected = {'backup-record': {'backup_service': 'fake.driver', - 'backup_url': 'URL_STRING'}} - self.assert_called('POST', '/backups/import_record', expected) - - def test_snapshot_list_filter_volume_id(self): - self.run_command('snapshot-list --volume-id=1234') - self.assert_called('GET', '/snapshots/detail?volume_id=1234') - - def test_snapshot_list_filter_status_and_volume_id(self): - self.run_command('snapshot-list --status=available --volume-id=1234') - self.assert_called('GET', '/snapshots/detail?' - 'status=available&volume_id=1234') - - def test_rename(self): - # 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) - # change description only - self.run_command('rename 1234 --description=new-description') - expected = {'volume': {'description': 'new-description'}} - self.assert_called('PUT', '/volumes/1234', body=expected) - # rename and change description - self.run_command('rename 1234 new-name ' - '--description=new-description') - expected = {'volume': { - 'name': 'new-name', - 'description': 'new-description', - }} - self.assert_called('PUT', '/volumes/1234', body=expected) - - # Call rename with no arguments - self.assertRaises(SystemExit, self.run_command, 'rename') - - def test_rename_snapshot(self): - # 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) - # change description only - self.run_command('snapshot-rename 1234 ' - '--description=new-description') - expected = {'snapshot': {'description': 'new-description'}} - self.assert_called('PUT', '/snapshots/1234', body=expected) - # snapshot-rename and change description - self.run_command('snapshot-rename 1234 new-name ' - '--description=new-description') - expected = {'snapshot': { - 'name': 'new-name', - 'description': 'new-description', - }} - self.assert_called('PUT', '/snapshots/1234', body=expected) - - # 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') - self.assert_called('POST', '/volumes/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_set_metadata_delete_dict(self): - self.run_command('metadata 1234 unset key1=val1 key2=val2') - self.assert_called('DELETE', '/volumes/1234/metadata/key1') - self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) - - def test_set_metadata_delete_keys(self): - self.run_command('metadata 1234 unset key1 key2') - self.assert_called('DELETE', '/volumes/1234/metadata/key1') - self.assert_called('DELETE', '/volumes/1234/metadata/key2', pos=-2) - - def test_reset_state(self): - self.run_command('reset-state 1234') - expected = {'os-reset_status': {'status': 'available'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_reset_state_attach(self): - self.run_command('reset-state --state in-use 1234') - expected = {'os-reset_status': {'status': 'in-use'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_reset_state_with_flag(self): - self.run_command('reset-state --state error 1234') - expected = {'os-reset_status': {'status': 'error'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_reset_state_multiple(self): - self.run_command('reset-state 1234 5678 --state error') - expected = {'os-reset_status': {'status': 'error'}} - self.assert_called_anytime('POST', '/volumes/1234/action', - body=expected) - self.assert_called_anytime('POST', '/volumes/5678/action', - body=expected) - - def test_reset_state_two_with_one_nonexistent(self): - cmd = 'reset-state 1234 123456789' - self.assertRaises(exceptions.CommandError, self.run_command, cmd) - expected = {'os-reset_status': {'status': 'available'}} - self.assert_called_anytime('POST', '/volumes/1234/action', - body=expected) - - def test_reset_state_one_with_one_nonexistent(self): - cmd = 'reset-state 123456789' - self.assertRaises(exceptions.CommandError, self.run_command, cmd) - - def test_snapshot_reset_state(self): - self.run_command('snapshot-reset-state 1234') - expected = {'os-reset_status': {'status': 'available'}} - self.assert_called('POST', '/snapshots/1234/action', body=expected) - - def test_snapshot_reset_state_with_flag(self): - 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_snapshot_reset_state_multiple(self): - self.run_command('snapshot-reset-state 1234 5678') - expected = {'os-reset_status': {'status': 'available'}} - self.assert_called_anytime('POST', '/snapshots/1234/action', - body=expected) - self.assert_called_anytime('POST', '/snapshots/5678/action', - body=expected) - - def test_type_list(self): - self.run_command('type-list') - self.assert_called_anytime('GET', '/types') - - def test_type_list_all(self): - self.run_command('type-list --all') - self.assert_called_anytime('GET', '/types?is_public=None') - - def test_type_create(self): - self.run_command('type-create test-type-1') - self.assert_called('POST', '/types') - - def test_type_create_public(self): - expected = {'volume_type': {'name': 'test-type-1', - 'description': 'test_type-1-desc', - 'os-volume-type-access:is_public': True}} - self.run_command('type-create test-type-1 ' - '--description=test_type-1-desc ' - '--is-public=True') - self.assert_called('POST', '/types', body=expected) - - def test_type_create_private(self): - expected = {'volume_type': {'name': 'test-type-3', - 'description': 'test_type-3-desc', - 'os-volume-type-access:is_public': False}} - self.run_command('type-create test-type-3 ' - '--description=test_type-3-desc ' - '--is-public=False') - self.assert_called('POST', '/types', body=expected) - - def test_type_access_list(self): - self.run_command('type-access-list --volume-type 3') - self.assert_called('GET', '/types/3/os-volume-type-access') - - def test_type_access_add_project(self): - expected = {'addProjectAccess': {'project': '101'}} - self.run_command('type-access-add --volume-type 3 --project-id 101') - self.assert_called_anytime('GET', '/types/3') - self.assert_called('POST', '/types/3/action', - body=expected) - - def test_type_access_remove_project(self): - expected = {'removeProjectAccess': {'project': '101'}} - self.run_command('type-access-remove ' - '--volume-type 3 --project-id 101') - self.assert_called_anytime('GET', '/types/3') - self.assert_called('POST', '/types/3/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': 'front-end'}} - 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. - - Verify one GET/one DELETE requests are made per command invocation: - - one GET request to retrieve the relevant volume type information - - one DELETE request to delete the encryption type information - """ - self.run_command('encryption-type-delete 1') - self.assert_called('DELETE', '/types/1/encryption/provider') - self.assert_called_anytime('GET', '/types/1') - - 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) - - def test_migrate_volume_bool_force(self): - self.run_command('migrate 1234 fakehost --force-host-copy') - expected = {'os-migrate_volume': {'force_host_copy': True, - 'host': 'fakehost'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_snapshot_metadata_set(self): - self.run_command('snapshot-metadata 1234 set key1=val1 key2=val2') - self.assert_called('POST', '/snapshots/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_snapshot_metadata_unset_dict(self): - self.run_command('snapshot-metadata 1234 unset key1=val1 key2=val2') - self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key1') - self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key2') - - def test_snapshot_metadata_unset_keys(self): - self.run_command('snapshot-metadata 1234 unset key1 key2') - self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key1') - self.assert_called_anytime('DELETE', '/snapshots/1234/metadata/key2') - - def test_volume_metadata_update_all(self): - self.run_command('metadata-update-all 1234 key1=val1 key2=val2') - self.assert_called('PUT', '/volumes/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_snapshot_metadata_update_all(self): - self.run_command('snapshot-metadata-update-all\ - 1234 key1=val1 key2=val2') - self.assert_called('PUT', '/snapshots/1234/metadata', - {'metadata': {'key1': 'val1', 'key2': 'val2'}}) - - def test_readonly_mode_update(self): - self.run_command('readonly-mode-update 1234 True') - expected = {'os-update_readonly_flag': {'readonly': True}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - self.run_command('readonly-mode-update 1234 False') - expected = {'os-update_readonly_flag': {'readonly': False}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_service_disable(self): - self.run_command('service-disable host cinder-volume') - self.assert_called('PUT', '/os-services/disable', - {"binary": "cinder-volume", "host": "host"}) - - def test_services_disable_with_reason(self): - cmd = 'service-disable host cinder-volume --reason no_reason' - self.run_command(cmd) - body = {'host': 'host', 'binary': 'cinder-volume', - 'disabled_reason': 'no_reason'} - self.assert_called('PUT', '/os-services/disable-log-reason', body) - - def test_service_enable(self): - self.run_command('service-enable host cinder-volume') - self.assert_called('PUT', '/os-services/enable', - {"binary": "cinder-volume", "host": "host"}) - - def test_retype_with_policy(self): - self.run_command('retype 1234 foo --migration-policy=on-demand') - expected = {'os-retype': {'new_type': 'foo', - 'migration_policy': 'on-demand'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_retype_default_policy(self): - self.run_command('retype 1234 foo') - expected = {'os-retype': {'new_type': 'foo', - 'migration_policy': 'never'}} - self.assert_called('POST', '/volumes/1234/action', body=expected) - - def test_snapshot_delete(self): - self.run_command('snapshot-delete 1234') - self.assert_called('DELETE', '/snapshots/1234') - - def test_quota_delete(self): - self.run_command('quota-delete 1234') - self.assert_called('DELETE', '/os-quota-sets/1234') - - def test_snapshot_delete_multiple(self): - self.run_command('snapshot-delete 5678') - self.assert_called('DELETE', '/snapshots/5678') - - def test_volume_manage(self): - self.run_command('manage host1 some_fake_name ' - '--name foo --description bar ' - '--volume-type baz --availability-zone az ' - '--metadata k1=v1 k2=v2') - expected = {'volume': {'host': 'host1', - 'ref': {'source-name': 'some_fake_name'}, - 'name': 'foo', - 'description': 'bar', - 'volume_type': 'baz', - 'availability_zone': 'az', - 'metadata': {'k1': 'v1', 'k2': 'v2'}, - 'bootable': False}} - self.assert_called_anytime('POST', '/os-volume-manage', body=expected) - - def test_volume_manage_bootable(self): - """ - Tests the --bootable option - - If this flag is specified, then the resulting POST should contain - bootable: True. - """ - self.run_command('manage host1 some_fake_name ' - '--name foo --description bar --bootable ' - '--volume-type baz --availability-zone az ' - '--metadata k1=v1 k2=v2') - expected = {'volume': {'host': 'host1', - 'ref': {'source-name': 'some_fake_name'}, - 'name': 'foo', - 'description': 'bar', - 'volume_type': 'baz', - 'availability_zone': 'az', - 'metadata': {'k1': 'v1', 'k2': 'v2'}, - 'bootable': True}} - self.assert_called_anytime('POST', '/os-volume-manage', body=expected) - - def test_volume_manage_source_name(self): - """ - Tests the --source-name option. - - Checks that the --source-name option correctly updates the - ref structure that is passed in the HTTP POST - """ - self.run_command('manage host1 VolName ' - '--name foo --description bar ' - '--volume-type baz --availability-zone az ' - '--metadata k1=v1 k2=v2') - expected = {'volume': {'host': 'host1', - 'ref': {'source-name': 'VolName'}, - 'name': 'foo', - 'description': 'bar', - 'volume_type': 'baz', - 'availability_zone': 'az', - 'metadata': {'k1': 'v1', 'k2': 'v2'}, - 'bootable': False}} - self.assert_called_anytime('POST', '/os-volume-manage', body=expected) - - def test_volume_manage_source_id(self): - """ - Tests the --source-id option. - - Checks that the --source-id option correctly updates the - ref structure that is passed in the HTTP POST - """ - self.run_command('manage host1 1234 ' - '--id-type source-id ' - '--name foo --description bar ' - '--volume-type baz --availability-zone az ' - '--metadata k1=v1 k2=v2') - expected = {'volume': {'host': 'host1', - 'ref': {'source-id': '1234'}, - 'name': 'foo', - 'description': 'bar', - 'volume_type': 'baz', - 'availability_zone': 'az', - 'metadata': {'k1': 'v1', 'k2': 'v2'}, - 'bootable': False}} - self.assert_called_anytime('POST', '/os-volume-manage', body=expected) - - def test_volume_unmanage(self): - self.run_command('unmanage 1234') - self.assert_called('POST', '/volumes/1234/action', - body={'os-unmanage': None}) - - def test_replication_promote(self): - self.run_command('replication-promote 1234') - self.assert_called('POST', '/volumes/1234/action', - body={'os-promote-replica': None}) - - def test_replication_reenable(self): - self.run_command('replication-reenable 1234') - self.assert_called('POST', '/volumes/1234/action', - body={'os-reenable-replica': None}) - - def test_create_snapshot_from_volume_with_metadata(self): - """ - Tests create snapshot with --metadata parameter. - - Checks metadata params are set during create snapshot - when metadata is passed - """ - expected = {'snapshot': {'volume_id': 1234, - 'metadata': {'k1': 'v1', - 'k2': 'v2'}}} - self.run_command('snapshot-create 1234 --metadata k1=v1 k2=v2 ' - '--force=True') - self.assert_called_anytime('POST', '/snapshots', partial_body=expected) - - def test_create_snapshot_from_volume_with_metadata_bool_force(self): - """ - Tests create snapshot with --metadata parameter. - - Checks metadata params are set during create snapshot - when metadata is passed - """ - expected = {'snapshot': {'volume_id': 1234, - 'metadata': {'k1': 'v1', - 'k2': 'v2'}}} - self.run_command('snapshot-create 1234 --metadata k1=v1 k2=v2 --force') - self.assert_called_anytime('POST', '/snapshots', partial_body=expected) - - def test_get_pools(self): - self.run_command('get-pools') - self.assert_called('GET', '/scheduler-stats/get_pools') - - def test_get_pools_detail(self): - self.run_command('get-pools --detail') - self.assert_called('GET', '/scheduler-stats/get_pools?detail=True') - - def test_list_transfer(self): - self.run_command('transfer-list') - self.assert_called('GET', '/os-volume-transfer/detail') - - def test_list_transfer_all_tenants(self): - self.run_command('transfer-list --all-tenants=1') - self.assert_called('GET', '/os-volume-transfer/detail?all_tenants=1') - - def test_consistencygroup_update(self): - self.run_command('consisgroup-update ' - '--name cg2 --description desc2 ' - '--add-volumes uuid1,uuid2 ' - '--remove-volumes uuid3,uuid4 ' - '1234') - expected = {'consistencygroup': {'name': 'cg2', - 'description': 'desc2', - 'add_volumes': 'uuid1,uuid2', - 'remove_volumes': 'uuid3,uuid4'}} - self.assert_called('PUT', '/consistencygroups/1234', - body=expected) - - def test_consistencygroup_update_bad_request(self): - self.assertRaises(exceptions.BadRequest, - self.run_command, - 'consisgroup-update 1234') - - def test_consistencygroup_create_from_src(self): - self.run_command('consisgroup-create-from-src ' - '--name cg ' - '--cgsnapshot 1234') - expected = { - 'consistencygroup-from-src': { - 'name': 'cg', - 'cgsnapshot_id': '1234', - 'description': None, - 'user_id': None, - 'project_id': None, - 'status': 'creating' - } - } - self.assert_called('POST', '/consistencygroups/create_from_src', - expected) - - def test_consistencygroup_create_from_src_bad_request(self): - self.assertRaises(exceptions.BadRequest, - self.run_command, - 'consisgroup-create-from-src ' - '--name cg') diff --git a/cinderclient/tests/unit/v2/test_snapshot_actions.py b/cinderclient/tests/unit/v2/test_snapshot_actions.py deleted file mode 100644 index 87cc9c8d1..000000000 --- a/cinderclient/tests/unit/v2/test_snapshot_actions.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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.unit import utils -from cinderclient.tests.unit.fixture_data import client -from cinderclient.tests.unit.fixture_data import snapshots - - -class SnapshotActionsTest(utils.FixturedTestCase): - - client_fixture_class = client.V2 - data_fixture_class = snapshots.Fixture - - def test_update_snapshot_status(self): - s = self.cs.volume_snapshots.get('1234') - stat = {'status': 'available'} - self.cs.volume_snapshots.update_snapshot_status(s, stat) - self.assert_called('POST', '/snapshots/1234/action') - - def test_update_snapshot_status_with_progress(self): - s = self.cs.volume_snapshots.get('1234') - stat = {'status': 'available', 'progress': '73%'} - self.cs.volume_snapshots.update_snapshot_status(s, stat) - self.assert_called('POST', '/snapshots/1234/action') diff --git a/cinderclient/tests/unit/v2/test_volume_backups.py b/cinderclient/tests/unit/v2/test_volume_backups.py deleted file mode 100644 index 1b77b9ca8..000000000 --- a/cinderclient/tests/unit/v2/test_volume_backups.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. -# 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.unit import utils -from cinderclient.tests.unit.v2 import fakes - - -cs = fakes.FakeClient() - - -class VolumeBackupsTest(utils.TestCase): - - def test_create(self): - cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4') - cs.assert_called('POST', '/backups') - - def test_create_full(self): - cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4', - None, None, False) - cs.assert_called('POST', '/backups') - - def test_create_incremental(self): - cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4', - None, None, True) - cs.assert_called('POST', '/backups') - - def test_get(self): - backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' - cs.backups.get(backup_id) - cs.assert_called('GET', '/backups/%s' % backup_id) - - def test_list(self): - cs.backups.list() - cs.assert_called('GET', '/backups/detail') - - def test_delete(self): - b = cs.backups.list()[0] - b.delete() - cs.assert_called('DELETE', - '/backups/76a17945-3c6f-435c-975b-b5685db10b62') - cs.backups.delete('76a17945-3c6f-435c-975b-b5685db10b62') - cs.assert_called('DELETE', - '/backups/76a17945-3c6f-435c-975b-b5685db10b62') - cs.backups.delete(b) - cs.assert_called('DELETE', - '/backups/76a17945-3c6f-435c-975b-b5685db10b62') - - def test_restore(self): - backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' - cs.restores.restore(backup_id) - cs.assert_called('POST', '/backups/%s/restore' % backup_id) - - def test_record_export(self): - backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' - cs.backups.export_record(backup_id) - cs.assert_called('GET', - '/backups/%s/export_record' % backup_id) - - def test_record_import(self): - backup_service = 'fake-backup-service' - backup_url = 'fake-backup-url' - expected_body = {'backup-record': {'backup_service': backup_service, - 'backup_url': backup_url}} - cs.backups.import_record(backup_service, backup_url) - cs.assert_called('POST', '/backups/import_record', expected_body) diff --git a/cinderclient/tests/unit/v2/test_volume_transfers.py b/cinderclient/tests/unit/v2/test_volume_transfers.py deleted file mode 100644 index 4b27cb3f1..000000000 --- a/cinderclient/tests/unit/v2/test_volume_transfers.py +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. -# 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.unit import utils -from cinderclient.tests.unit.v1 import fakes - - -cs = fakes.FakeClient() - - -class VolumeTransfersTest(utils.TestCase): - - def test_create(self): - cs.transfers.create('1234') - cs.assert_called('POST', '/os-volume-transfer') - - def test_get(self): - transfer_id = '5678' - cs.transfers.get(transfer_id) - cs.assert_called('GET', '/os-volume-transfer/%s' % transfer_id) - - def test_list(self): - cs.transfers.list() - cs.assert_called('GET', '/os-volume-transfer/detail') - - def test_delete(self): - b = cs.transfers.list()[0] - b.delete() - cs.assert_called('DELETE', '/os-volume-transfer/5678') - cs.transfers.delete('5678') - cs.assert_called('DELETE', '/os-volume-transfer/5678') - cs.transfers.delete(b) - cs.assert_called('DELETE', '/os-volume-transfer/5678') - - def test_accept(self): - transfer_id = '5678' - auth_key = '12345' - cs.transfers.accept(transfer_id, auth_key) - cs.assert_called('POST', '/os-volume-transfer/%s/accept' % transfer_id) diff --git a/cinderclient/tests/unit/v1/__init__.py b/cinderclient/tests/unit/v3/__init__.py similarity index 100% rename from cinderclient/tests/unit/v1/__init__.py rename to cinderclient/tests/unit/v3/__init__.py diff --git a/cinderclient/tests/unit/v1/contrib/__init__.py b/cinderclient/tests/unit/v3/contrib/__init__.py similarity index 100% rename from cinderclient/tests/unit/v1/contrib/__init__.py rename to cinderclient/tests/unit/v3/contrib/__init__.py diff --git a/cinderclient/tests/unit/v2/contrib/test_list_extensions.py b/cinderclient/tests/unit/v3/contrib/test_list_extensions.py similarity index 85% rename from cinderclient/tests/unit/v2/contrib/test_list_extensions.py rename to cinderclient/tests/unit/v3/contrib/test_list_extensions.py index 3e22b7ada..1d7eb357d 100644 --- a/cinderclient/tests/unit/v2/contrib/test_list_extensions.py +++ b/cinderclient/tests/unit/v3/contrib/test_list_extensions.py @@ -15,10 +15,9 @@ # under the License. from cinderclient import extension -from cinderclient.v2.contrib import list_extensions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes - +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3.contrib import list_extensions extensions = [ extension.Extension(list_extensions.__name__.split(".")[-1], @@ -31,6 +30,6 @@ class ListExtensionsTests(utils.TestCase): def test_list_extensions(self): all_exts = cs.list_extensions.show_all() cs.assert_called('GET', '/extensions') - self.assertTrue(len(all_exts) > 0) + self.assertGreater(len(all_exts), 0) for r in all_exts: - self.assertTrue(len(r.summary) > 0) + self.assertGreater(len(r.summary), 0) diff --git a/cinderclient/tests/unit/v3/fakes.py b/cinderclient/tests/unit/v3/fakes.py new file mode 100644 index 000000000..203b3aceb --- /dev/null +++ b/cinderclient/tests/unit/v3/fakes.py @@ -0,0 +1,747 @@ +# 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. +# 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 datetime import datetime + +from cinderclient.tests.unit import fakes +from cinderclient.tests.unit.v3 import fakes_base +from cinderclient.v3 import client + + +fake_attachment = {'attachment': { + 'status': 'reserved', + 'detached_at': '', + 'connection_info': {}, + 'attached_at': '', + 'attach_mode': None, + 'id': 'a232e9ae', + 'instance': 'e84fda45-4de4-4ce4-8f39-fc9d3b0aa05e', + 'volume_id': '557ad76c-ce54-40a3-9e91-c40d21665cc3', }} + +fake_attachment_without_instance_id = {'attachment': { + 'status': 'reserved', + 'detached_at': '', + 'connection_info': {}, + 'attached_at': '', + 'attach_mode': None, + 'id': 'a232e9ae', + 'instance': None, + 'volume_id': '557ad76c-ce54-40a3-9e91-c40d21665cc3', }} + +fake_attachment_list = {'attachments': [ + {'instance': 'instance_1', + 'name': 'attachment-1', + 'volume_id': 'fake_volume_1', + 'status': 'reserved', + 'id': 'attachmentid_1'}, + {'instance': 'instance_2', + 'name': 'attachment-2', + 'volume_id': 'fake_volume_2', + 'status': 'reserverd', + 'id': 'attachmentid_2'}]} + +fake_connection_info = { + 'auth_password': 'i6h9E5HQqSkcGX3H', + 'attachment_id': 'a232e9ae', + 'target_discovered': False, + 'encrypted': False, + 'driver_volume_type': 'iscsi', + 'qos_specs': None, + 'target_iqn': 'iqn.2010-10.org.openstack:volume-557ad76c', + 'target_portal': '10.117.36.28:3260', + 'volume_id': '557ad76c-ce54-40a3-9e91-c40d21665cc3', + 'target_lun': 0, + 'access_mode': 'rw', + 'auth_username': 'MwRrnAFLHN7enw5R95yM', + 'auth_method': 'CHAP'} + +fake_connector = { + 'initiator': 'iqn.1993-08.org.debian:01:b79dbce99387', + 'mount_device': '/dev/vdb', + 'ip': '10.117.36.28', + 'platform': 'x86_64', + 'host': 'os-2', + 'do_local_attach': False, + 'os_type': 'linux2', + 'multipath': False} + + +def _stub_group(detailed=True, **kwargs): + group = { + "name": "test-1", + "id": "1234", + } + if detailed: + details = { + "created_at": "2012-08-28T16:30:31.000000", + "description": "test-1-desc", + "availability_zone": "zone1", + "status": "available", + "group_type": "my_group_type", + } + group.update(details) + group.update(kwargs) + return group + + +def _stub_group_snapshot(detailed=True, **kwargs): + group_snapshot = { + "name": None, + "id": "5678", + } + if detailed: + details = { + "created_at": "2012-08-28T16:30:31.000000", + "description": None, + "name": None, + "id": "5678", + "status": "available", + "group_id": "1234", + } + group_snapshot.update(details) + group_snapshot.update(kwargs) + return group_snapshot + + +def _stub_snapshot(**kwargs): + snapshot = { + "created_at": "2012-08-28T16:30:31.000000", + "display_description": None, + "display_name": None, + "id": '11111111-1111-1111-1111-111111111111', + "size": 1, + "status": "available", + "volume_id": '00000000-0000-0000-0000-000000000000', + } + snapshot.update(kwargs) + return snapshot + + +class FakeClient(fakes.FakeClient, client.Client): + + def __init__(self, api_version=None, *args, **kwargs): + client.Client.__init__(self, 'username', 'password', + 'project_id', 'auth_url', + extensions=kwargs.get('extensions')) + self.api_version = api_version + global_id = "req-f551871a-4950-4225-9b2c-29a14c8f075e" + self.client = FakeHTTPClient(api_version=api_version, + global_request_id=global_id, **kwargs) + + def get_volume_api_version_from_endpoint(self): + return self.client.get_volume_api_version_from_endpoint() + + +class FakeHTTPClient(fakes_base.FakeHTTPClient): + + def __init__(self, **kwargs): + super(FakeHTTPClient, self).__init__() + self.management_url = 'http://10.0.2.15:8776/v3/fake' + vars(self).update(kwargs) + + # + # Services + # + def get_os_services(self, **kw): + host = kw.get('host', None) + binary = kw.get('binary', None) + services = [ + { + 'id': 1, + 'binary': 'cinder-volume', + 'host': 'host1', + 'zone': 'cinder', + 'status': 'enabled', + 'state': 'up', + 'updated_at': datetime(2012, 10, 29, 13, 42, 2), + 'cluster': 'cluster1', + 'backend_state': 'up', + }, + { + 'id': 2, + 'binary': 'cinder-volume', + 'host': 'host2', + 'zone': 'cinder', + 'status': 'disabled', + 'state': 'down', + 'updated_at': datetime(2012, 9, 18, 8, 3, 38), + 'cluster': 'cluster1', + 'backend_state': 'down', + }, + { + 'id': 3, + 'binary': 'cinder-scheduler', + 'host': 'host2', + 'zone': 'cinder', + 'status': 'disabled', + 'state': 'down', + 'updated_at': datetime(2012, 9, 18, 8, 3, 38), + 'cluster': 'cluster2', + }, + ] + if host: + services = [i for i in services if i['host'] == host] + if binary: + services = [i for i in services if i['binary'] == binary] + if not self.api_version.matches('3.7'): + for svc in services: + del svc['cluster'] + + if not self.api_version.matches('3.49'): + for svc in services: + if svc['binary'] == 'cinder-volume': + del svc['backend_state'] + return (200, {}, {'services': services}) + + def put_os_services_enable(self, body, **kw): + return (200, {}, {'host': body['host'], 'binary': body['binary'], + 'status': 'enabled'}) + + def put_os_services_disable(self, body, **kw): + return (200, {}, {'host': body['host'], 'binary': body['binary'], + 'status': 'disabled'}) + + def put_os_services_disable_log_reason(self, body, **kw): + return (200, {}, {'host': body['host'], 'binary': body['binary'], + 'status': 'disabled', + 'disabled_reason': body['disabled_reason']}) + + # + # Clusters + # + def _filter_clusters(self, return_keys, **kw): + date = datetime(2012, 10, 29, 13, 42, 2), + clusters = [ + { + 'id': '1', + 'name': 'cluster1@lvmdriver-1', + 'state': 'up', + 'status': 'enabled', + 'binary': 'cinder-volume', + 'is_up': 'True', + 'disabled': 'False', + 'disabled_reason': None, + 'num_hosts': '3', + 'num_down_hosts': '2', + 'updated_at': date, + 'created_at': date, + 'last_heartbeat': date, + }, + { + 'id': '2', + 'name': 'cluster1@lvmdriver-2', + 'state': 'down', + 'status': 'enabled', + 'binary': 'cinder-volume', + 'is_up': 'False', + 'disabled': 'False', + 'disabled_reason': None, + 'num_hosts': '2', + 'num_down_hosts': '2', + 'updated_at': date, + 'created_at': date, + 'last_heartbeat': date, + }, + { + 'id': '3', + 'name': 'cluster2', + 'state': 'up', + 'status': 'disabled', + 'binary': 'cinder-backup', + 'is_up': 'True', + 'disabled': 'True', + 'disabled_reason': 'Reason', + 'num_hosts': '1', + 'num_down_hosts': '0', + 'updated_at': date, + 'created_at': date, + 'last_heartbeat': date, + }, + ] + + for key, value in kw.items(): + clusters = [cluster for cluster in clusters + if cluster[key] == str(value)] + + result = [] + for cluster in clusters: + result.append({key: cluster[key] for key in return_keys}) + return result + + CLUSTER_SUMMARY_KEYS = ('name', 'binary', 'state', 'status') + CLUSTER_DETAIL_KEYS = (CLUSTER_SUMMARY_KEYS + + ('num_hosts', 'num_down_hosts', 'last_heartbeat', + 'disabled_reason', 'created_at', 'updated_at')) + + def get_clusters(self, **kw): + clusters = self._filter_clusters(self.CLUSTER_SUMMARY_KEYS, **kw) + return (200, {}, {'clusters': clusters}) + + def get_clusters_detail(self, **kw): + clusters = self._filter_clusters(self.CLUSTER_DETAIL_KEYS, **kw) + return (200, {}, {'clusters': clusters}) + + def get_clusters_1(self): + res = self.get_clusters_detail(id=1) + return (200, {}, {'cluster': res[2]['clusters'][0]}) + + def put_clusters_enable(self, body): + res = self.get_clusters(id=1) + return (200, {}, {'cluster': res[2]['clusters'][0]}) + + def put_clusters_disable(self, body): + res = self.get_clusters(id=3) + return (200, {}, {'cluster': res[2]['clusters'][0]}) + + # + # Backups + # + def put_backups_1234(self, **kw): + backup = fakes_base._stub_backup( + id='1234', + base_uri='http://localhost:8776', + tenant_id='0fa851f6668144cf9cd8c8419c1646c1') + return (200, {}, + {'backups': backup}) + + # + # Attachments + # + + def post_attachments(self, **kw): + if kw['body']['attachment'].get('instance_uuid'): + return (200, {}, fake_attachment) + return (200, {}, fake_attachment_without_instance_id) + + def get_attachments(self, **kw): + return (200, {}, fake_attachment_list) + + def post_attachments_a232e9ae_action(self, **kw): # noqa: E501 + attached_fake = fake_attachment + attached_fake['status'] = 'attached' + return (200, {}, attached_fake) + + def post_attachments_1234_action(self, **kw): # noqa: E501 + attached_fake = fake_attachment + attached_fake['status'] = 'attached' + return (200, {}, attached_fake) + + def get_attachments_1234(self, **kw): + return (200, {}, { + 'attachment': {'instance': 1234, + 'name': 'attachment-1', + 'volume_id': 'fake_volume_1', + 'status': 'reserved'}}) + + def put_attachments_1234(self, **kw): + return (200, {}, { + 'attachment': {'instance': 1234, + 'name': 'attachment-1', + 'volume_id': 'fake_volume_1', + 'status': 'reserved'}}) + + def delete_attachments_1234(self, **kw): + return 204, {}, None + + # + # GroupTypes + # + def get_group_types(self, **kw): + return (200, {}, { + 'group_types': [{'id': 1, + 'name': 'test-type-1', + 'description': 'test_type-1-desc', + 'group_specs': {}}, + {'id': 2, + 'name': 'test-type-2', + 'description': 'test_type-2-desc', + 'group_specs': {}}]}) + + def get_group_types_1(self, **kw): + return (200, {}, {'group_type': {'id': 1, + 'name': 'test-type-1', + 'description': 'test_type-1-desc', + 'group_specs': {'key': 'value'}}}) + + def get_group_types_2(self, **kw): + return (200, {}, {'group_type': {'id': 2, + 'name': 'test-type-2', + 'description': 'test_type-2-desc', + 'group_specs': {}}}) + + def get_group_types_3(self, **kw): + return (200, {}, {'group_type': {'id': 3, + 'name': 'test-type-3', + 'description': 'test_type-3-desc', + 'group_specs': {}, + 'is_public': False}}) + + def get_group_types_default(self, **kw): + return self.get_group_types_1() + + def post_group_types(self, body, **kw): + return (202, {}, {'group_type': {'id': 3, + 'name': 'test-type-3', + 'description': 'test_type-3-desc', + 'group_specs': {}}}) + + def post_group_types_1_group_specs(self, body, **kw): + assert list(body) == ['group_specs'] + return (200, {}, {'group_specs': {'k': 'v'}}) + + def delete_group_types_1_group_specs_k(self, **kw): + return (204, {}, None) + + def delete_group_types_1_group_specs_m(self, **kw): + return (204, {}, None) + + def delete_group_types_1(self, **kw): + return (202, {}, None) + + def delete_group_types_3_group_specs_k(self, **kw): + return (204, {}, None) + + def delete_group_types_3(self, **kw): + return (202, {}, None) + + def put_group_types_1(self, **kw): + return self.get_group_types_1() + + # + # Groups + # + def get_groups_detail(self, **kw): + return (200, {}, {"groups": [ + _stub_group(id='1234'), + _stub_group(id='4567')]}) + + def get_groups(self, **kw): + return (200, {}, {"groups": [ + _stub_group(detailed=False, id='1234'), + _stub_group(detailed=False, id='4567')]}) + + def get_groups_1234(self, **kw): + return (200, {}, {'group': + _stub_group(id='1234')}) + + def post_groups(self, **kw): + group = _stub_group(id='1234', group_type='my_group_type', + volume_types=['type1', 'type2']) + return (202, {}, {'group': group}) + + def put_groups_1234(self, **kw): + return (200, {}, {'group': {}}) + + def post_groups_1234_action(self, body, **kw): + resp = 202 + assert len(list(body)) == 1 + action = list(body)[0] + if action == 'delete': + assert 'delete-volumes' in body[action] + elif action in ('enable_replication', 'disable_replication', + 'failover_replication', 'list_replication_targets', + 'reset_status'): + assert action in body + elif action == 'os-reimage': + assert 'image_id' in body[action] + elif action == 'os-extend_volume_completion': + assert 'error' in body[action] + else: + raise AssertionError("Unexpected action: %s" % action) + return (resp, {}, {}) + + def post_groups_action(self, body, **kw): + group = _stub_group(id='1234', group_type='my_group_type', + volume_types=['type1', 'type2']) + resp = 202 + assert len(list(body)) == 1 + action = list(body)[0] + if action == 'create-from-src': + assert ('group_snapshot_id' in body[action] or + 'source_group_id' in body[action]) + else: + raise AssertionError("Unexpected action: %s" % action) + return (resp, {}, {'group': group}) + + # + # group_snapshots + # + + def get_group_snapshots_detail(self, **kw): + return (200, {}, {"group_snapshots": [ + _stub_group_snapshot(id='1234'), + _stub_group_snapshot(id='4567')]}) + + def get_group_snapshots(self, **kw): + return (200, {}, {"group_snapshots": [ + _stub_group_snapshot(detailed=False, id='1234'), + _stub_group_snapshot(detailed=False, id='4567')]}) + + def get_group_snapshots_1234(self, **kw): + return (200, {}, {'group_snapshot': _stub_group_snapshot(id='1234')}) + + def get_group_snapshots_5678(self, **kw): + return (200, {}, {'group_snapshot': _stub_group_snapshot(id='5678')}) + + def post_group_snapshots(self, **kw): + group_snap = _stub_group_snapshot() + return (202, {}, {'group_snapshot': group_snap}) + + def put_group_snapshots_1234(self, **kw): + return (200, {}, {'group_snapshot': {}}) + + def get_groups_5678(self, **kw): + return (200, {}, {'group': + _stub_group(id='5678')}) + + def post_groups_5678_action(self, **kw): + return (202, {}, {}) + + def post_snapshots_1234_action(self, **kw): + return (202, {}, {}) + + def get_snapshots_1234(self, **kw): + return (200, {}, {'snapshot': _stub_snapshot(id='1234')}) + + def post_snapshots_5678_action(self, **kw): + return (202, {}, {}) + + def get_snapshots_5678(self, **kw): + return (200, {}, {'snapshot': _stub_snapshot(id='5678')}) + + def post_group_snapshots_1234_action(self, **kw): + return (202, {}, {}) + + def post_group_snapshots_5678_action(self, **kw): + return (202, {}, {}) + + def delete_group_snapshots_1234(self, **kw): + return (202, {}, {}) + + # + # Manageable volumes/snapshots + # + def get_manageable_volumes(self, **kw): + vol_id = "volume-ffffffff-0000-ffff-0000-ffffffffffff" + vols = [{"size": 4, "safe_to_manage": False, "actual_size": 4.0, + "reference": {"source-name": vol_id}}, + {"size": 5, "safe_to_manage": True, "actual_size": 4.3, + "reference": {"source-name": "myvol"}}] + return (200, {}, {"manageable-volumes": vols}) + + def get_manageable_volumes_detail(self, **kw): + vol_id = "volume-ffffffff-0000-ffff-0000-ffffffffffff" + vols = [{"size": 4, "reason_not_safe": "volume in use", + "safe_to_manage": False, "extra_info": "qos_setting:high", + "reference": {"source-name": vol_id}, + "actual_size": 4.0}, + {"size": 5, "reason_not_safe": None, "safe_to_manage": True, + "extra_info": "qos_setting:low", "actual_size": 4.3, + "reference": {"source-name": "myvol"}}] + return (200, {}, {"manageable-volumes": vols}) + + def get_manageable_snapshots(self, **kw): + snap_id = "snapshot-ffffffff-0000-ffff-0000-ffffffffffff" + snaps = [{"actual_size": 4.0, "size": 4, + "safe_to_manage": False, "source_id_type": "source-name", + "source_cinder_id": "00000000-ffff-0000-ffff-00000000", + "reference": {"source-name": snap_id}, + "source_identifier": "volume-00000000-ffff-0000-ffff-000000"}, + {"actual_size": 4.3, "reference": {"source-name": "mysnap"}, + "source_id_type": "source-name", "source_identifier": "myvol", + "safe_to_manage": True, "source_cinder_id": None, "size": 5}] + return (200, {}, {"manageable-snapshots": snaps}) + + def get_manageable_snapshots_detail(self, **kw): + snap_id = "snapshot-ffffffff-0000-ffff-0000-ffffffffffff" + snaps = [{"actual_size": 4.0, "size": 4, + "safe_to_manage": False, "source_id_type": "source-name", + "source_cinder_id": "00000000-ffff-0000-ffff-00000000", + "reference": {"source-name": snap_id}, + "source_identifier": "volume-00000000-ffff-0000-ffff-000000", + "extra_info": "qos_setting:high", + "reason_not_safe": "snapshot in use"}, + {"actual_size": 4.3, "reference": {"source-name": "mysnap"}, + "safe_to_manage": True, "source_cinder_id": None, + "source_id_type": "source-name", "identifier": "mysnap", + "source_identifier": "myvol", "size": 5, + "extra_info": "qos_setting:low", "reason_not_safe": None}] + return (200, {}, {"manageable-snapshots": snaps}) + + # + # Messages + # + def get_messages(self, **kw): + return 200, {}, {'messages': [ + { + 'id': '1234', + 'event_id': 'VOLUME_000002', + 'user_message': 'Fake Message', + 'created_at': '2012-08-27T00:00:00.000000', + 'guaranteed_until': "2013-11-12T21:00:00.000000", + }, + { + 'id': '12345', + 'event_id': 'VOLUME_000002', + 'user_message': 'Fake Message', + 'created_at': '2012-08-27T00:00:00.000000', + 'guaranteed_until': "2013-11-12T21:00:00.000000", + } + ]} + + def delete_messages_1234(self, **kw): + return 204, {}, None + + def delete_messages_12345(self, **kw): + return 204, {}, None + + def get_messages_1234(self, **kw): + message = { + 'id': '1234', + 'event_id': 'VOLUME_000002', + 'user_message': 'Fake Message', + 'created_at': '2012-08-27T00:00:00.000000', + 'guaranteed_until': "2013-11-12T21:00:00.000000", + } + return 200, {}, {'message': message} + + def get_messages_12345(self, **kw): + message = { + 'id': '12345', + 'event_id': 'VOLUME_000002', + 'user_message': 'Fake Message', + 'created_at': '2012-08-27T00:00:00.000000', + 'guaranteed_until': "2013-11-12T21:00:00.000000", + } + return 200, {}, {'message': message} + + def put_os_services_set_log(self, body): + return (202, {}, {}) + + def put_os_services_get_log(self, body): + levels = [{'binary': 'cinder-api', 'host': 'host1', + 'levels': {'prefix1': 'DEBUG', 'prefix2': 'INFO'}}, + {'binary': 'cinder-volume', 'host': 'host@backend#pool', + 'levels': {'prefix3': 'WARNING', 'prefix4': 'ERROR'}}] + return (200, {}, {'log_levels': levels}) + + def get_volumes_summary(self, **kw): + return 200, {}, {"volume-summary": {'total_size': 5, + 'total_count': 5, + 'metadata': { + "test_key": ["test_value"] + } + } + } + + def post_workers_cleanup(self, **kw): + response = { + 'cleaning': [{'id': '1', 'cluster_name': 'cluster1', + 'host': 'host1', 'binary': 'binary'}, + {'id': '3', 'cluster_name': 'cluster1', + 'host': 'host3', 'binary': 'binary'}], + 'unavailable': [{'id': '2', 'cluster_name': 'cluster2', + 'host': 'host2', 'binary': 'binary'}], + } + return 200, {}, response + + # + # resource filters + # + def get_resource_filters(self, **kw): + return 200, {}, {'resource_filters': []} + + def get_volume_transfers_detail(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + transfer2 = 'f625ec3e-13dd-4498-a22a-50afd534cc41' + return (200, {}, + {'transfers': [ + fakes_base._stub_transfer_full(transfer1, base_uri, + tenant_id), + fakes_base._stub_transfer_full(transfer2, base_uri, + tenant_id)]}) + + def get_volume_transfers_5678(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (200, {}, + {'transfer': + fakes_base._stub_transfer_full(transfer1, base_uri, + tenant_id)}) + + def delete_volume_transfers_5678(self, **kw): + return (202, {}, None) + + def post_volume_transfers(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (202, {}, + {'transfer': fakes_base._stub_transfer(transfer1, base_uri, + tenant_id)}) + + def post_volume_transfers_5678_accept(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '5678' + return (200, {}, + {'transfer': fakes_base._stub_transfer(transfer1, base_uri, + tenant_id)}) + + +def fake_request_get(): + versions = {'versions': [{'id': 'v2.0', + 'links': [{'href': 'http://docs.openstack.org/', + 'rel': 'describedby', + 'type': 'text/html'}, + {'href': 'http://192.168.122.197/v2/', + 'rel': 'self'}], + 'media-types': [{'base': 'application/json', + 'type': 'application/'}], + 'min_version': '', + 'status': 'DEPRECATED', + 'updated': '2014-06-28T12:20:21Z', + 'version': ''}, + {'id': 'v3.0', + 'links': [{'href': 'http://docs.openstack.org/', + 'rel': 'describedby', + 'type': 'text/html'}, + {'href': 'http://192.168.122.197/v3/', + 'rel': 'self'}], + 'media-types': [{'base': 'application/json', + 'type': 'application/'}], + 'min_version': '3.0', + 'status': 'CURRENT', + 'updated': '2016-02-08T12:20:21Z', + 'version': '3.16'}]} + return versions + + +def fake_request_get_no_v3(): + versions = {'versions': [{'id': 'v2.0', + 'links': [{'href': 'http://docs.openstack.org/', + 'rel': 'describedby', + 'type': 'text/html'}, + {'href': 'http://192.168.122.197/v2/', + 'rel': 'self'}], + 'media-types': [{'base': 'application/json', + 'type': 'application/'}], + 'min_version': '', + 'status': 'DEPRECATED', + 'updated': '2014-06-28T12:20:21Z', + 'version': ''}]} + return versions diff --git a/cinderclient/tests/unit/v2/fakes.py b/cinderclient/tests/unit/v3/fakes_base.py similarity index 71% rename from cinderclient/tests/unit/v2/fakes.py rename to cinderclient/tests/unit/v3/fakes_base.py index 82cdc9c57..9702b42c2 100644 --- a/cinderclient/tests/unit/v2/fakes.py +++ b/cinderclient/tests/unit/v3/fakes_base.py @@ -13,33 +13,22 @@ # limitations under the License. from datetime import datetime - -try: - import urlparse -except ImportError: - import urllib.parse as urlparse +from urllib import parse as urlparse from cinderclient import client as base_client from cinderclient.tests.unit import fakes import cinderclient.tests.unit.utils as utils -from cinderclient.v2 import client -def _stub_volume(**kwargs): +REQUEST_ID = 'req-test-request-id' + + +def _stub_volume(*args, **kwargs): volume = { - 'id': '1234', - 'name': None, - 'description': None, - "attachments": [], - "bootable": "false", - "availability_zone": "cinder", - "created_at": "2012-08-27T00:00:00.000000", - "id": '00000000-0000-0000-0000-000000000000', - "metadata": {}, - "size": 1, - "snapshot_id": None, - "status": "available", - "volume_type": "None", + "migration_status": None, + "attachments": [{'server_id': '1234', + 'id': '3f88836f-adde-4296-9f6b-2c59a0bcda9a', + 'attachment_id': '5678'}], "links": [ { "href": "http://localhost/v2/fake/volumes/1234", @@ -50,7 +39,30 @@ def _stub_volume(**kwargs): "rel": "bookmark" } ], - } + "availability_zone": "cinder", + "os-vol-host-attr:host": "ip-192-168-0-2", + "encrypted": "false", + "updated_at": "2013-11-12T21:00:00.000000", + "os-volume-replication:extended_status": "None", + "replication_status": "disabled", + "snapshot_id": None, + 'id': 1234, + "size": 1, + "user_id": "1b2d6e8928954ca4ae7c243863404bdc", + "os-vol-tenant-attr:tenant_id": "eb72eb33a0084acf8eb21356c2b021a7", + "os-vol-mig-status-attr:migstat": None, + "metadata": {}, + "status": "available", + 'description': None, + "os-volume-replication:driver_data": None, + "source_volid": None, + "consistencygroup_id": None, + "os-vol-mig-status-attr:name_id": None, + "name": "sample-volume", + "bootable": "false", + "created_at": "2012-08-27T00:00:00.000000", + "volume_type": "None", + } volume.update(kwargs) return volume @@ -236,21 +248,92 @@ def _stub_extend(id, new_size): return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} -class FakeClient(fakes.FakeClient, client.Client): +def _stub_server_versions(): + return [ + { + "status": "SUPPORTED", + "updated": "2015-07-30T11:33:21Z", + "links": [ + { + "href": "http://docs.openstack.org/", + "type": "text/html", + "rel": "describedby", + }, + { + "href": "http://localhost:8776/v1/", + "rel": "self", + } + ], + "min_version": "", + "version": "", + "id": "v1.0", + }, + { + "status": "SUPPORTED", + "updated": "2015-09-30T11:33:21Z", + "links": [ + { + "href": "http://docs.openstack.org/", + "type": "text/html", + "rel": "describedby", + }, + { + "href": "http://localhost:8776/v2/", + "rel": "self", + } + ], + "min_version": "", + "version": "", + "id": "v2.0", + }, + { + "status": "CURRENT", + "updated": "2016-04-01T11:33:21Z", + "links": [ + { + "href": "http://docs.openstack.org/", + "type": "text/html", + "rel": "describedby", + }, + { + "href": "http://localhost:8776/v3/", + "rel": "self", + } + ], + "min_version": "3.0", + "version": "3.1", + "id": "v3.0", + } + ] + + +def stub_default_type(): + return { + 'default_type': { + 'project_id': '629632e7-99d2-4c40-9ae3-106fa3b1c9b7', + 'volume_type_id': '4c298f16-e339-4c80-b934-6cbfcb7525a0' + } + } - def __init__(self, *args, **kwargs): - client.Client.__init__(self, 'username', 'password', - 'project_id', 'auth_url', - extensions=kwargs.get('extensions')) - self.client = FakeHTTPClient(**kwargs) - def get_volume_api_version_from_endpoint(self): - return self.client.get_volume_api_version_from_endpoint() +def stub_default_types(): + return { + 'default_types': [ + { + 'project_id': '629632e7-99d2-4c40-9ae3-106fa3b1c9b7', + 'volume_type_id': '4c298f16-e339-4c80-b934-6cbfcb7525a0' + }, + { + 'project_id': 'a0c01994-1245-416e-8fc9-1aca86329bfd', + 'volume_type_id': 'ff094b46-f82a-4a74-9d9e-d3d08116ad93' + } + ] + } class FakeHTTPClient(base_client.HTTPClient): - def __init__(self, **kwargs): + def __init__(self, version_header=None, **kwargs): self.username = 'username' self.password = 'password' self.auth_url = 'auth_url' @@ -258,6 +341,7 @@ def __init__(self, **kwargs): self.management_url = 'http://10.0.2.15:8776/v2/fake' self.osapi_max_limit = 1000 self.marker = None + self.version_header = version_header def _cs_request(self, url, method, **kwargs): # Check that certain things are called correctly @@ -292,6 +376,10 @@ def _cs_request(self, url, method, **kwargs): # Note the call self.callstack.append((method, url, kwargs.get('body', None))) status, headers, body = getattr(self, callback)(**kwargs) + # add fake request-id header + headers['x-openstack-request-id'] = REQUEST_ID + if self.version_header: + headers['OpenStack-API-version'] = self.version_header r = utils.TestResponse({ "status_code": status, "text": body, @@ -299,11 +387,6 @@ def _cs_request(self, url, method, **kwargs): }) return r, body - if hasattr(status, 'items'): - return utils.TestResponse(status), body - else: - return utils.TestResponse({"status": status}), body - def get_volume_api_version_from_endpoint(self): magic_tuple = urlparse.urlsplit(self.management_url) scheme, netloc, path, query, frag = magic_tuple @@ -314,9 +397,12 @@ def get_volume_api_version_from_endpoint(self): # def get_snapshots_detail(self, **kw): + if kw.get('with_count', False): + return (200, {}, {'snapshots': [ + _stub_snapshot(), + ], 'count': 1}) return (200, {}, {'snapshots': [ - _stub_snapshot(), - ]}) + _stub_snapshot()]}) def get_snapshots_1234(self, **kw): return (200, {}, {'snapshot': _stub_snapshot(id='1234')}) @@ -345,6 +431,10 @@ def post_snapshots_1234_action(self, body, **kw): assert 'status' in body['os-reset_status'] elif action == 'os-update_snapshot_status': assert 'status' in body['os-update_snapshot_status'] + elif action == 'os-force_delete': + assert body[action] is None + elif action == 'os-unmanage': + assert body[action] is None else: raise AssertionError('Unexpected action: %s' % action) return (resp, {}, _body) @@ -384,13 +474,13 @@ def get_volumes(self, **kw): {'id': 5678, 'name': 'sample-volume2'} ]}) - # TODO(jdg): This will need to change - # at the very least it's not complete def get_volumes_detail(self, **kw): + if kw.get('with_count', False): + return (200, {}, {"volumes": [ + _stub_volume(id=kw.get('id', 1234)) + ], "count": 1}) return (200, {}, {"volumes": [ - {'id': kw.get('id', 1234), - 'name': 'sample-volume', - 'attachments': [{'server_id': 1234}]}, + _stub_volume(id=kw.get('id', 1234)) ]}) def get_volumes_1234(self, **kw): @@ -401,6 +491,10 @@ def get_volumes_5678(self, **kw): r = {'volume': self.get_volumes_detail(id=5678)[2]['volumes'][0]} return (200, {}, r) + def get_volumes_1234_metadata(self, **kw): + r = {"metadata": {'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}} + return (200, {}, r) + def get_volumes_1234_encryption(self, **kw): r = {'encryption_key_id': 'id'} return (200, {}, r) @@ -411,18 +505,18 @@ def post_volumes_1234_action(self, body, **kw): assert len(list(body)) == 1 action = list(body)[0] if action == 'os-attach': - assert sorted(list(body[action])) == ['instance_uuid', - 'mode', - 'mountpoint'] + keys = sorted(list(body[action])) + assert (keys == ['instance_uuid', 'mode', 'mountpoint'] or + keys == ['host_name', 'mode', 'mountpoint']) elif action == 'os-detach': - assert body[action] is None + assert list(body[action]) == ['attachment_id'] elif action == 'os-reserve': assert body[action] is None elif action == 'os-unreserve': assert body[action] is None elif action == 'os-initialize_connection': assert list(body[action]) == ['connector'] - return (202, {}, {'connection_info': 'foos'}) + return (202, {}, {'connection_info': {'foos': 'bars'}}) elif action == 'os-terminate_connection': assert list(body[action]) == ['connector'] elif action == 'os-begin_detaching': @@ -430,7 +524,8 @@ def post_volumes_1234_action(self, body, **kw): elif action == 'os-roll_detaching': assert body[action] is None elif action == 'os-reset_status': - assert 'status' in body[action] + assert ('status' or 'attach_status' or 'migration_status' + in body[action]) elif action == 'os-extend': assert list(body[action]) == ['new_size'] elif action == 'os-migrate_volume': @@ -444,14 +539,34 @@ def post_volumes_1234_action(self, body, **kw): assert list(body[action]) == ['bootable'] elif action == 'os-unmanage': assert body[action] is None - elif action == 'os-promote-replica': - assert body[action] is None - elif action == 'os-reenable-replica': + elif action == 'os-set_image_metadata': + assert list(body[action]) == ['metadata'] + elif action == 'os-unset_image_metadata': + assert 'key' in body[action] + elif action == 'os-show_image_metadata': assert body[action] is None + elif action == 'os-volume_upload_image': + assert 'image_name' in body[action] + _body = body + elif action == 'revert': + assert 'snapshot_id' in body[action] + elif action == 'os-reimage': + assert 'image_id' in body[action] + elif action == 'os-extend_volume_completion': + assert 'error' in body[action] else: raise AssertionError("Unexpected action: %s" % action) return (resp, {}, _body) + def get_volumes_fake(self, **kw): + r = {'volume': self.get_volumes_detail(id='fake')[2]['volumes'][0]} + return (200, {}, r) + + def post_volumes_fake_action(self, body, **kw): + _body = None + resp = 202 + return (resp, {}, _body) + def post_volumes_5678_action(self, body, **kw): return self.post_volumes_1234_action(body, **kw) @@ -538,7 +653,7 @@ def get_os_quota_sets_test(self, **kw): 'gigabytes': 1, 'backups': 1, 'backup_gigabytes': 1, - 'consistencygroups': 1}}) + 'per_volume_gigabytes': 1, }}) def get_os_quota_sets_test_defaults(self): return (200, {}, {'quota_set': { @@ -549,7 +664,7 @@ def get_os_quota_sets_test_defaults(self): 'gigabytes': 1, 'backups': 1, 'backup_gigabytes': 1, - 'consistencygroups': 1}}) + 'per_volume_gigabytes': 1, }}) def put_os_quota_sets_test(self, body, **kw): assert list(body) == ['quota_set'] @@ -563,7 +678,7 @@ def put_os_quota_sets_test(self, body, **kw): 'gigabytes': 1, 'backups': 1, 'backup_gigabytes': 1, - 'consistencygroups': 2}}) + 'per_volume_gigabytes': 1, }}) def delete_os_quota_sets_1234(self, **kw): return (200, {}, {}) @@ -578,27 +693,23 @@ def delete_os_quota_sets_test(self, **kw): def get_os_quota_class_sets_test(self, **kw): return (200, {}, {'quota_class_set': { 'class_name': 'test', - 'metadata_items': [], 'volumes': 1, 'snapshots': 1, 'gigabytes': 1, 'backups': 1, 'backup_gigabytes': 1, - 'consistencygroups': 1}}) + 'per_volume_gigabytes': 1, }}) def put_os_quota_class_sets_test(self, body, **kw): assert list(body) == ['quota_class_set'] - fakes.assert_has_keys(body['quota_class_set'], - required=['class_name']) + fakes.assert_has_keys(body['quota_class_set']) return (200, {}, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], 'volumes': 2, 'snapshots': 2, 'gigabytes': 1, 'backups': 1, 'backup_gigabytes': 1, - 'consistencygroups': 2}}) + 'per_volume_gigabytes': 1}}) # # VolumeTypes @@ -619,7 +730,7 @@ def get_types_1(self, **kw): return (200, {}, {'volume_type': {'id': 1, 'name': 'test-type-1', 'description': 'test_type-1-desc', - 'extra_specs': {}}}) + 'extra_specs': {'key': 'value'}}}) def get_types_2(self, **kw): return (200, {}, {'volume_type': {'id': 2, @@ -661,14 +772,30 @@ def post_types_1_extra_specs(self, body, **kw): return (200, {}, {'extra_specs': {'k': 'v'}}) def delete_types_1_extra_specs_k(self, **kw): - return(204, {}, None) + return (204, {}, None) + + def delete_types_1_extra_specs_m(self, **kw): + return (204, {}, None) def delete_types_1(self, **kw): return (202, {}, None) + def delete_types_3_extra_specs_k(self, **kw): + return (204, {}, None) + + def delete_types_3(self, **kw): + return (202, {}, None) + def put_types_1(self, **kw): return self.get_types_1() + def put_types_3(self, **kw): + return (200, {}, {'volume_type': {'id': 3, + 'name': 'test-type-2', + 'description': 'test_type-3-desc', + 'is_public': True, + 'extra_specs': {}}}) + # # VolumeAccess # @@ -692,8 +819,12 @@ def get_types_2_encryption(self, **kw): def post_types_2_encryption(self, body, **kw): return (200, {}, {'encryption': body}) - def put_types_1_encryption_1(self, body, **kw): - return (200, {}, {}) + def put_types_1_encryption_provider(self, body, **kw): + get_body = self.get_types_1_encryption()[2] + for k, v in body.items(): + if k in get_body.keys(): + get_body.update([(k, v)]) + return (200, {}, get_body) def delete_types_1_encryption_provider(self, **kw): return (202, {}, None) @@ -750,11 +881,31 @@ def get_backups_76a17945_3c6f_435c_975b_b5685db10b62(self, **kw): return (200, {}, {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) + def get_backups_1234(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '1234' + return (200, {}, + {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) + + def get_backups_5678(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '5678' + return (200, {}, + {'backup': _stub_backup_full(backup1, base_uri, tenant_id)}) + def get_backups_detail(self, **kw): base_uri = 'http://localhost:8776' tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' backup2 = 'd09534c6-08b8-4441-9e87-8976f3a8f699' + if kw.get('with_count', False): + return (200, {}, + {'backups': [ + _stub_backup_full(backup1, base_uri, tenant_id), + _stub_backup_full(backup2, base_uri, tenant_id)], + 'count': 2}) return (200, {}, {'backups': [ _stub_backup_full(backup1, base_uri, tenant_id), @@ -763,6 +914,12 @@ def get_backups_detail(self, **kw): def delete_backups_76a17945_3c6f_435c_975b_b5685db10b62(self, **kw): return (202, {}, None) + def delete_backups_1234(self, **kw): + return (202, {}, None) + + def delete_backups_5678(self, **kw): + return (202, {}, None) + def post_backups(self, **kw): base_uri = 'http://localhost:8776' tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' @@ -778,6 +935,15 @@ def post_backups_1234_restore(self, **kw): return (200, {}, {'restore': _stub_restore()}) + def post_backups_76a17945_3c6f_435c_975b_b5685db10b62_action(self, **kw): + return (200, {}, None) + + def post_backups_1234_action(self, **kw): + return (200, {}, None) + + def post_backups_5678_action(self, **kw): + return (200, {}, None) + def get_backups_76a17945_3c6f_435c_975b_b5685db10b62_export_record(self, **kw): return (200, @@ -866,6 +1032,14 @@ def get_qos_specs_1B6B6A04_A927_4AEB_810B_B7BAAD49F57C_disassociate_all( # VolumeTransfers # + def get_os_volume_transfer_1234(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + transfer1 = '1234' + return (200, {}, + {'transfer': + _stub_transfer_full(transfer1, base_uri, tenant_id)}) + def get_os_volume_transfer_5678(self, **kw): base_uri = 'http://localhost:8776' tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' @@ -884,6 +1058,9 @@ def get_os_volume_transfer_detail(self, **kw): _stub_transfer_full(transfer1, base_uri, tenant_id), _stub_transfer_full(transfer2, base_uri, tenant_id)]}) + def delete_os_volume_transfer_1234(self, **kw): + return (202, {}, None) + def delete_os_volume_transfer_5678(self, **kw): return (202, {}, None) @@ -901,6 +1078,36 @@ def post_os_volume_transfer_5678_accept(self, **kw): return (200, {}, {'transfer': _stub_transfer(transfer1, base_uri, tenant_id)}) + def get_with_base_url(self, url, **kw): + if 'default-types' in url: + return self._cs_request(url, 'GET', **kw) + server_versions = _stub_server_versions() + return (200, {'versions': server_versions}) + + def create_update_with_base_url(self, url, **kwargs): + return self._cs_request(url, 'PUT', **kwargs) + + def put_v3_default_types_629632e7_99d2_4c40_9ae3_106fa3b1c9b7( + self, **kwargs): + default_type = stub_default_type() + return (200, {}, default_type) + + def get_v3_default_types_629632e7_99d2_4c40_9ae3_106fa3b1c9b7( + self, **kw): + default_types = stub_default_type() + return (200, {}, default_types) + + def get_v3_default_types(self, **kw): + default_types = stub_default_types() + return (200, {}, default_types) + + def delete_with_base_url(self, url, **kwargs): + return self._cs_request(url, 'DELETE', **kwargs) + + def delete_v3_default_types_629632e7_99d2_4c40_9ae3_106fa3b1c9b7( + self, **kwargs): + return (204, {}, {}) + # # Services # @@ -934,9 +1141,9 @@ def get_os_services(self, **kw): }, ] if host: - services = filter(lambda i: i['host'] == host, services) + services = [i for i in services if i['host'] == host] if binary: - services = filter(lambda i: i['binary'] == binary, services) + services = [i for i in services if i['binary'] == binary] return (200, {}, {'services': services}) def put_os_services_enable(self, body, **kw): @@ -1022,16 +1229,62 @@ def put_volumes_1234_metadata(self, **kw): def put_snapshots_1234_metadata(self, **kw): return (200, {}, {"metadata": {"key1": "val1", "key2": "val2"}}) + def get_os_volume_manage(self, **kw): + vol_id = "volume-ffffffff-0000-ffff-0000-ffffffffffff" + vols = [{"size": 4, "safe_to_manage": False, "actual_size": 4.0, + "reference": {"source-name": vol_id}}, + {"size": 5, "safe_to_manage": True, "actual_size": 4.3, + "reference": {"source-name": "myvol"}}] + return (200, {}, {"manageable-volumes": vols}) + + def get_os_volume_manage_detail(self, **kw): + vol_id = "volume-ffffffff-0000-ffff-0000-ffffffffffff" + vols = [{"size": 4, "reason_not_safe": "volume in use", + "safe_to_manage": False, "extra_info": "qos_setting:high", + "reference": {"source-name": vol_id}, + "actual_size": 4.0}, + {"size": 5, "reason_not_safe": None, "safe_to_manage": True, + "extra_info": "qos_setting:low", "actual_size": 4.3, + "reference": {"source-name": "myvol"}}] + return (200, {}, {"manageable-volumes": vols}) + def post_os_volume_manage(self, **kw): volume = _stub_volume(id='1234') volume.update(kw['body']['volume']) return (202, {}, {'volume': volume}) - def post_os_promote_replica_1234(self, **kw): - return (202, {}, {}) - - def post_os_reenable_replica_1234(self, **kw): - return (202, {}, {}) + def get_os_snapshot_manage(self, **kw): + snap_id = "snapshot-ffffffff-0000-ffff-0000-ffffffffffff" + snaps = [{"actual_size": 4.0, "size": 4, + "safe_to_manage": False, "source_id_type": "source-name", + "source_cinder_id": "00000000-ffff-0000-ffff-00000000", + "reference": {"source-name": snap_id}, + "source_identifier": "volume-00000000-ffff-0000-ffff-000000"}, + {"actual_size": 4.3, "reference": {"source-name": "mysnap"}, + "source_id_type": "source-name", "source_identifier": "myvol", + "safe_to_manage": True, "source_cinder_id": None, "size": 5}] + return (200, {}, {"manageable-snapshots": snaps}) + + def get_os_snapshot_manage_detail(self, **kw): + snap_id = "snapshot-ffffffff-0000-ffff-0000-ffffffffffff" + snaps = [{"actual_size": 4.0, "size": 4, + "safe_to_manage": False, "source_id_type": "source-name", + "source_cinder_id": "00000000-ffff-0000-ffff-00000000", + "reference": {"source-name": snap_id}, + "source_identifier": "volume-00000000-ffff-0000-ffff-000000", + "extra_info": "qos_setting:high", + "reason_not_safe": "snapshot in use"}, + {"actual_size": 4.3, "reference": {"source-name": "mysnap"}, + "safe_to_manage": True, "source_cinder_id": None, + "source_id_type": "source-name", "identifier": "mysnap", + "source_identifier": "myvol", "size": 5, + "extra_info": "qos_setting:low", "reason_not_safe": None}] + return (200, {}, {"manageable-snapshots": snaps}) + + def post_os_snapshot_manage(self, **kw): + snapshot = _stub_snapshot(id='1234', volume_id='volume_id1') + snapshot.update(kw['body']['snapshot']) + return (202, {}, {'snapshot': snapshot}) def get_scheduler_stats_get_pools(self, **kw): stats = [ @@ -1053,3 +1306,20 @@ def get_scheduler_stats_get_pools(self, **kw): }, ] return (200, {}, {"pools": stats}) + + def get_capabilities_host(self, **kw): + return (200, {}, + { + 'namespace': 'OS::Storage::Capabilities::fake', + 'vendor_name': 'OpenStack', + 'volume_backend_name': 'lvm', + 'pool_name': 'pool', + 'storage_protocol': 'iSCSI', + 'properties': { + 'compression': { + 'title': 'Compression', + 'description': 'Enables compression.', + 'type': 'boolean'}, + } + } + ) diff --git a/cinderclient/tests/unit/v3/test_attachments.py b/cinderclient/tests/unit/v3/test_attachments.py new file mode 100644 index 000000000..acf064639 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_attachments.py @@ -0,0 +1,48 @@ +# Copyright (C) 2016 EMC Corporation. +# +# 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 import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + + +class AttachmentsTest(utils.TestCase): + + def test_create_attachment(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.27')) + att = cs.attachments.create( + 'e84fda45-4de4-4ce4-8f39-fc9d3b0aa05e', + {}, + '557ad76c-ce54-40a3-9e91-c40d21665cc3', + 'null') + cs.assert_called('POST', '/attachments') + self.assertEqual(fakes.fake_attachment['attachment'], att) + + def test_create_attachment_without_instance_uuid(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.27')) + att = cs.attachments.create( + 'e84fda45-4de4-4ce4-8f39-fc9d3b0aa05e', + {}, + None, + 'null') + cs.assert_called('POST', '/attachments') + self.assertEqual( + fakes.fake_attachment_without_instance_id['attachment'], att) + + def test_complete_attachment(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.44')) + att = cs.attachments.complete('a232e9ae') + self.assertTrue(att.ok) diff --git a/cinderclient/tests/unit/v2/test_auth.py b/cinderclient/tests/unit/v3/test_auth.py similarity index 98% rename from cinderclient/tests/unit/v2/test_auth.py rename to cinderclient/tests/unit/v3/test_auth.py index 9e9784696..3e5e70890 100644 --- a/cinderclient/tests/unit/v2/test_auth.py +++ b/cinderclient/tests/unit/v3/test_auth.py @@ -15,13 +15,13 @@ # under the License. import json +from unittest import mock -import mock import requests from cinderclient import exceptions -from cinderclient.v2 import client from cinderclient.tests.unit import utils +from cinderclient.v3 import client class AuthenticateAgainstKeystoneTests(utils.TestCase): @@ -325,8 +325,8 @@ def test_auth_automatic(self): @mock.patch.object(http_client, 'authenticate') def test_auth_call(m): http_client.get('/') - m.assert_called() - mock_request.assert_called() + self.assertTrue(m.called) + self.assertTrue(mock_request.called) test_auth_call() @@ -336,6 +336,6 @@ def test_auth_manual(self): @mock.patch.object(cs.client, 'authenticate') def test_auth_call(m): cs.authenticate() - m.assert_called() + self.assertTrue(m.called) test_auth_call() diff --git a/cinderclient/tests/unit/v2/test_availability_zone.py b/cinderclient/tests/unit/v3/test_availability_zone.py similarity index 70% rename from cinderclient/tests/unit/v2/test_availability_zone.py rename to cinderclient/tests/unit/v3/test_availability_zone.py index cc6da9bb8..ebacf83ae 100644 --- a/cinderclient/tests/unit/v2/test_availability_zone.py +++ b/cinderclient/tests/unit/v3/test_availability_zone.py @@ -14,18 +14,17 @@ # License for the specific language governing permissions and limitations # under the License. -import six +from cinderclient.v3 import availability_zones +from cinderclient.v3 import shell -from cinderclient.v2 import availability_zones -from cinderclient.v2 import shell -from cinderclient.tests.unit.fixture_data import client from cinderclient.tests.unit.fixture_data import availability_zones as azfixture # noqa +from cinderclient.tests.unit.fixture_data import client from cinderclient.tests.unit import utils class AvailabilityZoneTest(utils.FixturedTestCase): - client_fixture_class = client.V2 + client_fixture_class = client.V3 data_fixture_class = azfixture.Fixture def _assertZone(self, zone, name, status): @@ -35,6 +34,7 @@ def _assertZone(self, zone, name, status): def test_list_availability_zone(self): zones = self.cs.availability_zones.list(detailed=False) self.assert_called('GET', '/os-availability-zone') + self._assert_request_id(zones) for zone in zones: self.assertIsInstance(zone, @@ -42,11 +42,11 @@ def test_list_availability_zone(self): self.assertEqual(2, len(zones)) - l0 = [six.u('zone-1'), six.u('available')] - l1 = [six.u('zone-2'), six.u('not available')] + l0 = ['zone-1', 'available'] + l1 = ['zone-2', 'not available'] - z0 = shell._treeizeAvailabilityZone(zones[0]) - z1 = shell._treeizeAvailabilityZone(zones[1]) + z0 = shell.treeizeAvailabilityZone(zones[0]) + z1 = shell.treeizeAvailabilityZone(zones[1]) self.assertEqual((1, 1), (len(z0), len(z1))) @@ -56,6 +56,7 @@ def test_list_availability_zone(self): def test_detail_availability_zone(self): zones = self.cs.availability_zones.list(detailed=True) self.assert_called('GET', '/os-availability-zone/detail') + self._assert_request_id(zones) for zone in zones: self.assertIsInstance(zone, @@ -63,19 +64,19 @@ def test_detail_availability_zone(self): self.assertEqual(3, len(zones)) - l0 = [six.u('zone-1'), six.u('available')] - l1 = [six.u('|- fake_host-1'), six.u('')] - l2 = [six.u('| |- cinder-volume'), - six.u('enabled :-) 2012-12-26 14:45:25')] - l3 = [six.u('internal'), six.u('available')] - l4 = [six.u('|- fake_host-1'), six.u('')] - l5 = [six.u('| |- cinder-sched'), - six.u('enabled :-) 2012-12-26 14:45:24')] - l6 = [six.u('zone-2'), six.u('not available')] - - z0 = shell._treeizeAvailabilityZone(zones[0]) - z1 = shell._treeizeAvailabilityZone(zones[1]) - z2 = shell._treeizeAvailabilityZone(zones[2]) + l0 = ['zone-1', 'available'] + l1 = ['|- fake_host-1', ''] + l2 = ['| |- cinder-volume', + 'enabled :-) 2012-12-26 14:45:25'] + l3 = ['internal', 'available'] + l4 = ['|- fake_host-1', ''] + l5 = ['| |- cinder-sched', + 'enabled :-) 2012-12-26 14:45:24'] + l6 = ['zone-2', 'not available'] + + z0 = shell.treeizeAvailabilityZone(zones[0]) + z1 = shell.treeizeAvailabilityZone(zones[1]) + z2 = shell.treeizeAvailabilityZone(zones[2]) self.assertEqual((3, 3, 1), (len(z0), len(z1), len(z2))) diff --git a/cinderclient/tests/unit/v3/test_capabilities.py b/cinderclient/tests/unit/v3/test_capabilities.py new file mode 100644 index 000000000..9f8c4c66f --- /dev/null +++ b/cinderclient/tests/unit/v3/test_capabilities.py @@ -0,0 +1,60 @@ +# Copyright (c) 2015 Hitachi Data Systems, 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 import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3.capabilities import Capabilities + +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + +FAKE_CAPABILITY = { + 'namespace': 'OS::Storage::Capabilities::fake', + 'vendor_name': 'OpenStack', + 'volume_backend_name': 'lvm', + 'pool_name': 'pool', + 'storage_protocol': 'iSCSI', + 'properties': { + 'compression': { + 'title': 'Compression', + 'description': 'Enables compression.', + 'type': 'boolean', + }, + }, +} + + +class CapabilitiesTest(utils.TestCase): + + def test_get_capabilities(self): + capabilities = cs.capabilities.get('host') + cs.assert_called('GET', '/capabilities/host') + self.assertEqual(FAKE_CAPABILITY, capabilities._info) + self._assert_request_id(capabilities) + + def test___repr__(self): + """ + Unit test for Capabilities.__repr__ + + Verify that Capabilities object can be printed. + """ + cap = Capabilities(None, FAKE_CAPABILITY) + self.assertEqual( + "" % FAKE_CAPABILITY['namespace'], repr(cap)) + + def test__repr__when_empty(self): + cap = Capabilities(None, {}) + self.assertEqual( + "", repr(cap)) diff --git a/cinderclient/tests/unit/v2/test_cgsnapshots.py b/cinderclient/tests/unit/v3/test_cgsnapshots.py similarity index 66% rename from cinderclient/tests/unit/v2/test_cgsnapshots.py rename to cinderclient/tests/unit/v3/test_cgsnapshots.py index e4d480612..5f0cec76c 100644 --- a/cinderclient/tests/unit/v2/test_cgsnapshots.py +++ b/cinderclient/tests/unit/v3/test_cgsnapshots.py @@ -14,30 +14,35 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient import api_versions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes +from cinderclient.tests.unit.v3 import fakes -cs = fakes.FakeClient() +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) class cgsnapshotsTest(utils.TestCase): def test_delete_cgsnapshot(self): v = cs.cgsnapshots.list()[0] - v.delete() + vol = v.delete() + self._assert_request_id(vol) cs.assert_called('DELETE', '/cgsnapshots/1234') - cs.cgsnapshots.delete('1234') + vol = cs.cgsnapshots.delete('1234') cs.assert_called('DELETE', '/cgsnapshots/1234') - cs.cgsnapshots.delete(v) + self._assert_request_id(vol) + vol = cs.cgsnapshots.delete(v) cs.assert_called('DELETE', '/cgsnapshots/1234') + self._assert_request_id(vol) def test_create_cgsnapshot(self): - cs.cgsnapshots.create('cgsnap') + vol = cs.cgsnapshots.create('cgsnap') cs.assert_called('POST', '/cgsnapshots') + self._assert_request_id(vol) def test_create_cgsnapshot_with_cg_id(self): - cs.cgsnapshots.create('1234') + vol = cs.cgsnapshots.create('1234') expected = {'cgsnapshot': {'status': 'creating', 'description': None, 'user_id': None, @@ -45,37 +50,46 @@ def test_create_cgsnapshot_with_cg_id(self): 'consistencygroup_id': '1234', 'project_id': None}} cs.assert_called('POST', '/cgsnapshots', body=expected) + self._assert_request_id(vol) def test_update_cgsnapshot(self): v = cs.cgsnapshots.list()[0] expected = {'cgsnapshot': {'name': 'cgs2'}} - v.update(name='cgs2') + vol = v.update(name='cgs2') cs.assert_called('PUT', '/cgsnapshots/1234', body=expected) - cs.cgsnapshots.update('1234', name='cgs2') + self._assert_request_id(vol) + vol = cs.cgsnapshots.update('1234', name='cgs2') cs.assert_called('PUT', '/cgsnapshots/1234', body=expected) - cs.cgsnapshots.update(v, name='cgs2') + self._assert_request_id(vol) + vol = cs.cgsnapshots.update(v, name='cgs2') cs.assert_called('PUT', '/cgsnapshots/1234', body=expected) + self._assert_request_id(vol) def test_update_cgsnapshot_no_props(self): cs.cgsnapshots.update('1234') def test_list_cgsnapshot(self): - cs.cgsnapshots.list() + lst = cs.cgsnapshots.list() cs.assert_called('GET', '/cgsnapshots/detail') + self._assert_request_id(lst) def test_list_cgsnapshot_detailed_false(self): - cs.cgsnapshots.list(detailed=False) + lst = cs.cgsnapshots.list(detailed=False) cs.assert_called('GET', '/cgsnapshots') + self._assert_request_id(lst) def test_list_cgsnapshot_with_search_opts(self): - cs.cgsnapshots.list(search_opts={'foo': 'bar'}) + lst = cs.cgsnapshots.list(search_opts={'foo': 'bar'}) cs.assert_called('GET', '/cgsnapshots/detail?foo=bar') + self._assert_request_id(lst) def test_list_cgsnapshot_with_empty_search_opt(self): - cs.cgsnapshots.list(search_opts={'foo': 'bar', '123': None}) + lst = cs.cgsnapshots.list(search_opts={'foo': 'bar', '123': None}) cs.assert_called('GET', '/cgsnapshots/detail?foo=bar') + self._assert_request_id(lst) def test_get_cgsnapshot(self): cgsnapshot_id = '1234' - cs.cgsnapshots.get(cgsnapshot_id) + vol = cs.cgsnapshots.get(cgsnapshot_id) cs.assert_called('GET', '/cgsnapshots/%s' % cgsnapshot_id) + self._assert_request_id(vol) diff --git a/cinderclient/tests/unit/v3/test_clusters.py b/cinderclient/tests/unit/v3/test_clusters.py new file mode 100644 index 000000000..21b560d5f --- /dev/null +++ b/cinderclient/tests/unit/v3/test_clusters.py @@ -0,0 +1,137 @@ +# Copyright (c) 2016 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. + +import ddt + +from cinderclient import api_versions +from cinderclient import exceptions as exc +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + +cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.7')) + + +@ddt.ddt +class ClusterTest(utils.TestCase): + def _check_fields_present(self, clusters, detailed=False): + expected_keys = {'name', 'binary', 'state', 'status'} + + if detailed: + expected_keys.update(('num_hosts', 'num_down_hosts', + 'last_heartbeat', 'disabled_reason', + 'created_at', 'updated_at')) + + for cluster in clusters: + self.assertEqual(expected_keys, set(cluster.to_dict())) + + def _assert_call(self, base_url, detailed, params=None, method='GET', + body=None): + url = base_url + if detailed: + url += '/detail' + if params: + url += '?' + params + if body: + cs.assert_called(method, url, body) + else: + cs.assert_called(method, url) + + @ddt.data(True, False) + def test_clusters_list(self, detailed): + lst = cs.clusters.list(detailed=detailed) + self._assert_call('/clusters', detailed) + self.assertEqual(3, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_pre_version(self, detailed): + pre_cs = fakes.FakeClient(api_version= + api_versions.APIVersion('3.6')) + self.assertRaises(exc.VersionNotFoundForAPIMethod, + pre_cs.clusters.list, detailed=detailed) + + @ddt.data(True, False) + def test_cluster_list_name(self, detailed): + lst = cs.clusters.list(name='cluster1@lvmdriver-1', + detailed=detailed) + self._assert_call('/clusters', detailed, + 'name=cluster1@lvmdriver-1') + self.assertEqual(1, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_binary(self, detailed): + lst = cs.clusters.list(binary='cinder-volume', detailed=detailed) + self._assert_call('/clusters', detailed, 'binary=cinder-volume') + self.assertEqual(2, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_is_up(self, detailed): + lst = cs.clusters.list(is_up=True, detailed=detailed) + self._assert_call('/clusters', detailed, 'is_up=True') + self.assertEqual(2, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_disabled(self, detailed): + lst = cs.clusters.list(disabled=True, detailed=detailed) + self._assert_call('/clusters', detailed, 'disabled=True') + self.assertEqual(1, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_num_hosts(self, detailed): + lst = cs.clusters.list(num_hosts=1, detailed=detailed) + self._assert_call('/clusters', detailed, 'num_hosts=1') + self.assertEqual(1, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + @ddt.data(True, False) + def test_clusters_list_num_down_hosts(self, detailed): + lst = cs.clusters.list(num_down_hosts=2, detailed=detailed) + self._assert_call('/clusters', detailed, 'num_down_hosts=2') + self.assertEqual(2, len(lst)) + self._assert_request_id(lst) + self._check_fields_present(lst, detailed) + + def test_cluster_show(self): + result = cs.clusters.show('1') + self._assert_call('/clusters/1', False) + self._assert_request_id(result) + self._check_fields_present([result], True) + + def test_cluster_enable(self): + body = {'binary': 'cinder-volume', 'name': 'cluster@lvmdriver-1'} + result = cs.clusters.update(body['name'], body['binary'], False, + disabled_reason='is ignored') + self._assert_call('/clusters/enable', False, method='PUT', body=body) + self._assert_request_id(result) + self._check_fields_present([result], False) + + def test_cluster_disable(self): + body = {'binary': 'cinder-volume', 'name': 'cluster@lvmdriver-1', + 'disabled_reason': 'is passed'} + result = cs.clusters.update(body['name'], body['binary'], True, + body['disabled_reason']) + self._assert_call('/clusters/disable', False, method='PUT', body=body) + self._assert_request_id(result) + self._check_fields_present([result], False) diff --git a/cinderclient/tests/unit/v2/test_consistencygroups.py b/cinderclient/tests/unit/v3/test_consistencygroups.py similarity index 58% rename from cinderclient/tests/unit/v2/test_consistencygroups.py rename to cinderclient/tests/unit/v3/test_consistencygroups.py index 390162e4e..d265aabd3 100644 --- a/cinderclient/tests/unit/v2/test_consistencygroups.py +++ b/cinderclient/tests/unit/v3/test_consistencygroups.py @@ -14,29 +14,34 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient import api_versions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes +from cinderclient.tests.unit.v3 import fakes -cs = fakes.FakeClient() +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) class ConsistencygroupsTest(utils.TestCase): def test_delete_consistencygroup(self): v = cs.consistencygroups.list()[0] - v.delete(force='True') + vol = v.delete(force='True') + self._assert_request_id(vol) cs.assert_called('POST', '/consistencygroups/1234/delete') - cs.consistencygroups.delete('1234', force=True) + vol = cs.consistencygroups.delete('1234', force=True) + self._assert_request_id(vol) cs.assert_called('POST', '/consistencygroups/1234/delete') - cs.consistencygroups.delete(v, force=True) + vol = cs.consistencygroups.delete(v, force=True) + self._assert_request_id(vol) cs.assert_called('POST', '/consistencygroups/1234/delete') def test_create_consistencygroup(self): - cs.consistencygroups.create('type1,type2', 'cg') + vol = cs.consistencygroups.create('type1,type2', 'cg') cs.assert_called('POST', '/consistencygroups') + self._assert_request_id(vol) def test_create_consistencygroup_with_volume_types(self): - cs.consistencygroups.create('type1,type2', 'cg') + vol = cs.consistencygroups.create('type1,type2', 'cg') expected = {'consistencygroup': {'status': 'creating', 'description': None, 'availability_zone': None, @@ -45,57 +50,70 @@ def test_create_consistencygroup_with_volume_types(self): 'volume_types': 'type1,type2', 'project_id': None}} cs.assert_called('POST', '/consistencygroups', body=expected) + self._assert_request_id(vol) def test_update_consistencygroup_name(self): v = cs.consistencygroups.list()[0] expected = {'consistencygroup': {'name': 'cg2'}} - v.update(name='cg2') + vol = v.update(name='cg2') cs.assert_called('PUT', '/consistencygroups/1234', body=expected) - cs.consistencygroups.update('1234', name='cg2') + self._assert_request_id(vol) + vol = cs.consistencygroups.update('1234', name='cg2') cs.assert_called('PUT', '/consistencygroups/1234', body=expected) - cs.consistencygroups.update(v, name='cg2') + self._assert_request_id(vol) + vol = cs.consistencygroups.update(v, name='cg2') cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + self._assert_request_id(vol) def test_update_consistencygroup_description(self): v = cs.consistencygroups.list()[0] expected = {'consistencygroup': {'description': 'cg2 desc'}} - v.update(description='cg2 desc') + vol = v.update(description='cg2 desc') cs.assert_called('PUT', '/consistencygroups/1234', body=expected) - cs.consistencygroups.update('1234', description='cg2 desc') + self._assert_request_id(vol) + vol = cs.consistencygroups.update('1234', description='cg2 desc') cs.assert_called('PUT', '/consistencygroups/1234', body=expected) - cs.consistencygroups.update(v, description='cg2 desc') + self._assert_request_id(vol) + vol = cs.consistencygroups.update(v, description='cg2 desc') cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + self._assert_request_id(vol) def test_update_consistencygroup_add_volumes(self): v = cs.consistencygroups.list()[0] uuids = 'uuid1,uuid2' expected = {'consistencygroup': {'add_volumes': uuids}} - v.update(add_volumes=uuids) + vol = v.update(add_volumes=uuids) cs.assert_called('PUT', '/consistencygroups/1234', body=expected) - cs.consistencygroups.update('1234', add_volumes=uuids) + self._assert_request_id(vol) + vol = cs.consistencygroups.update('1234', add_volumes=uuids) cs.assert_called('PUT', '/consistencygroups/1234', body=expected) - cs.consistencygroups.update(v, add_volumes=uuids) + self._assert_request_id(vol) + vol = cs.consistencygroups.update(v, add_volumes=uuids) cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + self._assert_request_id(vol) def test_update_consistencygroup_remove_volumes(self): v = cs.consistencygroups.list()[0] uuids = 'uuid3,uuid4' expected = {'consistencygroup': {'remove_volumes': uuids}} - v.update(remove_volumes=uuids) + vol = v.update(remove_volumes=uuids) cs.assert_called('PUT', '/consistencygroups/1234', body=expected) - cs.consistencygroups.update('1234', remove_volumes=uuids) + self._assert_request_id(vol) + vol = cs.consistencygroups.update('1234', remove_volumes=uuids) cs.assert_called('PUT', '/consistencygroups/1234', body=expected) - cs.consistencygroups.update(v, remove_volumes=uuids) + self._assert_request_id(vol) + vol = cs.consistencygroups.update(v, remove_volumes=uuids) cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + self._assert_request_id(vol) def test_update_consistencygroup_none(self): - self.assertEqual(None, cs.consistencygroups.update('1234')) + self.assertIsNone(cs.consistencygroups.update('1234')) def test_update_consistencygroup_no_props(self): cs.consistencygroups.update('1234') - def test_create_consistencygroup_from_src(self): - cs.consistencygroups.create_from_src('5678', name='cg') + def test_create_consistencygroup_from_src_snap(self): + vol = cs.consistencygroups.create_from_src('5678', None, name='cg') expected = { 'consistencygroup-from-src': { 'status': 'creating', @@ -103,29 +121,55 @@ def test_create_consistencygroup_from_src(self): 'user_id': None, 'name': 'cg', 'cgsnapshot_id': '5678', - 'project_id': None + 'project_id': None, + 'source_cgid': None } } cs.assert_called('POST', '/consistencygroups/create_from_src', body=expected) + self._assert_request_id(vol) + + def test_create_consistencygroup_from_src_cg(self): + vol = cs.consistencygroups.create_from_src(None, '5678', name='cg') + expected = { + 'consistencygroup-from-src': { + 'status': 'creating', + 'description': None, + 'user_id': None, + 'name': 'cg', + 'source_cgid': '5678', + 'project_id': None, + 'cgsnapshot_id': None + } + } + cs.assert_called('POST', '/consistencygroups/create_from_src', + body=expected) + self._assert_request_id(vol) def test_list_consistencygroup(self): - cs.consistencygroups.list() + lst = cs.consistencygroups.list() cs.assert_called('GET', '/consistencygroups/detail') + self._assert_request_id(lst) def test_list_consistencygroup_detailed_false(self): - cs.consistencygroups.list(detailed=False) + lst = cs.consistencygroups.list(detailed=False) cs.assert_called('GET', '/consistencygroups') + self._assert_request_id(lst) def test_list_consistencygroup_with_search_opts(self): - cs.consistencygroups.list(search_opts={'foo': 'bar'}) + lst = cs.consistencygroups.list(search_opts={'foo': 'bar'}) cs.assert_called('GET', '/consistencygroups/detail?foo=bar') + self._assert_request_id(lst) def test_list_consistencygroup_with_empty_search_opt(self): - cs.consistencygroups.list(search_opts={'foo': 'bar', 'abc': None}) + lst = cs.consistencygroups.list( + search_opts={'foo': 'bar', 'abc': None} + ) cs.assert_called('GET', '/consistencygroups/detail?foo=bar') + self._assert_request_id(lst) def test_get_consistencygroup(self): consistencygroup_id = '1234' - cs.consistencygroups.get(consistencygroup_id) + vol = cs.consistencygroups.get(consistencygroup_id) cs.assert_called('GET', '/consistencygroups/%s' % consistencygroup_id) + self._assert_request_id(vol) diff --git a/cinderclient/tests/unit/v3/test_default_types.py b/cinderclient/tests/unit/v3/test_default_types.py new file mode 100644 index 000000000..621aeb804 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_default_types.py @@ -0,0 +1,46 @@ +# 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 import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + +defaults = fakes.FakeClient(api_versions.APIVersion('3.62')) + + +class VolumeTypeDefaultTest(utils.TestCase): + + def test_set(self): + defaults.default_types.create('4c298f16-e339-4c80-b934-6cbfcb7525a0', + '629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + defaults.assert_called( + 'PUT', 'v3/default-types/629632e7-99d2-4c40-9ae3-106fa3b1c9b7', + body={'default_type': + {'volume_type': '4c298f16-e339-4c80-b934-6cbfcb7525a0'}} + ) + + def test_get(self): + defaults.default_types.list('629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + defaults.assert_called( + 'GET', 'v3/default-types/629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + + def test_get_all(self): + defaults.default_types.list() + defaults.assert_called( + 'GET', 'v3/default-types') + + def test_unset(self): + defaults.default_types.delete('629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + defaults.assert_called( + 'DELETE', 'v3/default-types/629632e7-99d2-4c40-9ae3-106fa3b1c9b7') diff --git a/cinderclient/tests/unit/v3/test_group_snapshots.py b/cinderclient/tests/unit/v3/test_group_snapshots.py new file mode 100644 index 000000000..fea86167d --- /dev/null +++ b/cinderclient/tests/unit/v3/test_group_snapshots.py @@ -0,0 +1,99 @@ +# Copyright (C) 2016 EMC Corporation. +# +# 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 ddt + +from cinderclient import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + +cs = fakes.FakeClient(api_versions.APIVersion('3.14')) + + +@ddt.ddt +class GroupSnapshotsTest(utils.TestCase): + + def test_delete_group_snapshot(self): + s1 = cs.group_snapshots.list()[0] + snap = s1.delete() + self._assert_request_id(snap) + cs.assert_called('DELETE', '/group_snapshots/1234') + snap = cs.group_snapshots.delete('1234') + cs.assert_called('DELETE', '/group_snapshots/1234') + self._assert_request_id(snap) + snap = cs.group_snapshots.delete(s1) + cs.assert_called('DELETE', '/group_snapshots/1234') + self._assert_request_id(snap) + + def test_create_group_snapshot(self): + snap = cs.group_snapshots.create('group_snap') + cs.assert_called('POST', '/group_snapshots') + self._assert_request_id(snap) + + def test_create_group_snapshot_with_group_id(self): + snap = cs.group_snapshots.create('1234') + expected = {'group_snapshot': {'description': None, + 'name': None, + 'group_id': '1234'}} + cs.assert_called('POST', '/group_snapshots', body=expected) + self._assert_request_id(snap) + + def test_update_group_snapshot(self): + s1 = cs.group_snapshots.list()[0] + expected = {'group_snapshot': {'name': 'grp_snap2'}} + snap = s1.update(name='grp_snap2') + cs.assert_called('PUT', '/group_snapshots/1234', body=expected) + self._assert_request_id(snap) + snap = cs.group_snapshots.update('1234', name='grp_snap2') + cs.assert_called('PUT', '/group_snapshots/1234', body=expected) + self._assert_request_id(snap) + snap = cs.group_snapshots.update(s1, name='grp_snap2') + cs.assert_called('PUT', '/group_snapshots/1234', body=expected) + self._assert_request_id(snap) + + def test_update_group_snapshot_no_props(self): + ret = cs.group_snapshots.update('1234') + self.assertIsNone(ret) + + def test_list_group_snapshot(self): + lst = cs.group_snapshots.list() + cs.assert_called('GET', '/group_snapshots/detail') + self._assert_request_id(lst) + + @ddt.data( + {'detailed': True, 'url': '/group_snapshots/detail'}, + {'detailed': False, 'url': '/group_snapshots'} + ) + @ddt.unpack + def test_list_group_snapshot_detailed(self, detailed, url): + lst = cs.group_snapshots.list(detailed=detailed) + cs.assert_called('GET', url) + self._assert_request_id(lst) + + @ddt.data( + {'foo': 'bar'}, + {'foo': 'bar', '123': None} + ) + def test_list_group_snapshot_with_search_opts(self, opts): + lst = cs.group_snapshots.list(search_opts=opts) + cs.assert_called('GET', '/group_snapshots/detail?foo=bar') + self._assert_request_id(lst) + + def test_get_group_snapshot(self): + group_snapshot_id = '1234' + snap = cs.group_snapshots.get(group_snapshot_id) + cs.assert_called('GET', '/group_snapshots/%s' % group_snapshot_id) + self._assert_request_id(snap) diff --git a/cinderclient/tests/unit/v3/test_group_types.py b/cinderclient/tests/unit/v3/test_group_types.py new file mode 100644 index 000000000..2263d0e8a --- /dev/null +++ b/cinderclient/tests/unit/v3/test_group_types.py @@ -0,0 +1,111 @@ +# Copyright (c) 2016 EMC Corporation +# +# 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 import api_versions +from cinderclient import exceptions as exc +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import group_types + +cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.11')) +pre_cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.10')) + + +class GroupTypesTest(utils.TestCase): + + def test_list_group_types(self): + tl = cs.group_types.list() + cs.assert_called('GET', '/group_types?is_public=None') + self._assert_request_id(tl) + for t in tl: + self.assertIsInstance(t, group_types.GroupType) + + def test_list_group_types_pre_version(self): + self.assertRaises(exc.VersionNotFoundForAPIMethod, + pre_cs.group_types.list) + + def test_list_group_types_not_public(self): + t1 = cs.group_types.list(is_public=None) + cs.assert_called('GET', '/group_types?is_public=None') + self._assert_request_id(t1) + + def test_create(self): + t = cs.group_types.create('test-type-3', 'test-type-3-desc') + cs.assert_called('POST', '/group_types', + {'group_type': { + 'name': 'test-type-3', + 'description': 'test-type-3-desc', + 'is_public': True + }}) + self.assertIsInstance(t, group_types.GroupType) + self._assert_request_id(t) + + def test_create_non_public(self): + t = cs.group_types.create('test-type-3', 'test-type-3-desc', False) + cs.assert_called('POST', '/group_types', + {'group_type': { + 'name': 'test-type-3', + 'description': 'test-type-3-desc', + 'is_public': False + }}) + self.assertIsInstance(t, group_types.GroupType) + self._assert_request_id(t) + + def test_update(self): + t = cs.group_types.update('1', 'test_type_1', 'test_desc_1', False) + cs.assert_called('PUT', + '/group_types/1', + {'group_type': {'name': 'test_type_1', + 'description': 'test_desc_1', + 'is_public': False}}) + self.assertIsInstance(t, group_types.GroupType) + self._assert_request_id(t) + + def test_get(self): + t = cs.group_types.get('1') + cs.assert_called('GET', '/group_types/1') + self.assertIsInstance(t, group_types.GroupType) + self._assert_request_id(t) + + def test_default(self): + t = cs.group_types.default() + cs.assert_called('GET', '/group_types/default') + self.assertIsInstance(t, group_types.GroupType) + self._assert_request_id(t) + + def test_set_key(self): + t = cs.group_types.get(1) + res = t.set_keys({'k': 'v'}) + cs.assert_called('POST', + '/group_types/1/group_specs', + {'group_specs': {'k': 'v'}}) + self._assert_request_id(res) + + def test_set_key_pre_version(self): + t = group_types.GroupType(pre_cs, {'id': 1}) + self.assertRaises(exc.VersionNotFoundForAPIMethod, + t.set_keys, {'k': 'v'}) + + def test_unset_keys(self): + t = cs.group_types.get(1) + res = t.unset_keys(['k']) + cs.assert_called('DELETE', '/group_types/1/group_specs/k') + self._assert_request_id(res) + + def test_delete(self): + t = cs.group_types.delete(1) + cs.assert_called('DELETE', '/group_types/1') + self._assert_request_id(t) diff --git a/cinderclient/tests/unit/v3/test_groups.py b/cinderclient/tests/unit/v3/test_groups.py new file mode 100644 index 000000000..dec17d060 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_groups.py @@ -0,0 +1,210 @@ +# Copyright (C) 2016 EMC Corporation. +# +# 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 ddt + +from cinderclient import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + +cs = fakes.FakeClient(api_versions.APIVersion('3.13')) + + +@ddt.ddt +class GroupsTest(utils.TestCase): + + def test_delete_group(self): + expected = {'delete': {'delete-volumes': True}} + v = cs.groups.list()[0] + grp = v.delete(delete_volumes=True) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.delete('1234', delete_volumes=True) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.delete(v, delete_volumes=True) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + + def test_create_group(self): + grp = cs.groups.create('my_group_type', 'type1,type2', name='group') + cs.assert_called('POST', '/groups') + self._assert_request_id(grp) + + def test_create_group_with_volume_types(self): + grp = cs.groups.create('my_group_type', 'type1,type2', name='group') + expected = {'group': {'description': None, + 'availability_zone': None, + 'name': 'group', + 'group_type': 'my_group_type', + 'volume_types': ['type1', 'type2']}} + cs.assert_called('POST', '/groups', body=expected) + self._assert_request_id(grp) + + @ddt.data( + {'name': 'group2', 'desc': None, 'add': None, 'remove': None}, + {'name': None, 'desc': 'group2 desc', 'add': None, 'remove': None}, + {'name': None, 'desc': None, 'add': 'uuid1,uuid2', 'remove': None}, + {'name': None, 'desc': None, 'add': None, 'remove': 'uuid3,uuid4'}, + ) + @ddt.unpack + def test_update_group_name(self, name, desc, add, remove): + v = cs.groups.list()[0] + expected = {'group': {'name': name, 'description': desc, + 'add_volumes': add, 'remove_volumes': remove}} + grp = v.update(name=name, description=desc, + add_volumes=add, remove_volumes=remove) + cs.assert_called('PUT', '/groups/1234', body=expected) + self._assert_request_id(grp) + grp = cs.groups.update('1234', name=name, description=desc, + add_volumes=add, remove_volumes=remove) + cs.assert_called('PUT', '/groups/1234', body=expected) + self._assert_request_id(grp) + grp = cs.groups.update(v, name=name, description=desc, + add_volumes=add, remove_volumes=remove) + cs.assert_called('PUT', '/groups/1234', body=expected) + self._assert_request_id(grp) + + def test_update_group_none(self): + self.assertIsNone(cs.groups.update('1234')) + + def test_update_group_no_props(self): + cs.groups.update('1234') + + def test_list_group(self): + lst = cs.groups.list() + cs.assert_called('GET', '/groups/detail') + self._assert_request_id(lst) + + def test_list_group_detailed_false(self): + lst = cs.groups.list(detailed=False) + cs.assert_called('GET', '/groups') + self._assert_request_id(lst) + + def test_list_group_with_search_opts(self): + lst = cs.groups.list(search_opts={'foo': 'bar'}) + cs.assert_called('GET', '/groups/detail?foo=bar') + self._assert_request_id(lst) + + def test_list_group_with_volume(self): + lst = cs.groups.list(list_volume=True) + cs.assert_called('GET', '/groups/detail?list_volume=True') + self._assert_request_id(lst) + + def test_list_group_with_empty_search_opt(self): + lst = cs.groups.list( + search_opts={'foo': 'bar', 'abc': None} + ) + cs.assert_called('GET', '/groups/detail?foo=bar') + self._assert_request_id(lst) + + def test_get_group(self): + group_id = '1234' + grp = cs.groups.get(group_id) + cs.assert_called('GET', '/groups/%s' % group_id) + self._assert_request_id(grp) + + def test_get_group_with_list_volume(self): + group_id = '1234' + grp = cs.groups.get(group_id, list_volume=True) + cs.assert_called('GET', '/groups/%s?list_volume=True' % group_id) + self._assert_request_id(grp) + + def test_create_group_from_src_snap(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.14')) + grp = cs.groups.create_from_src('5678', None, name='group') + expected = { + 'create-from-src': { + 'description': None, + 'name': 'group', + 'group_snapshot_id': '5678' + } + } + cs.assert_called('POST', '/groups/action', + body=expected) + self._assert_request_id(grp) + + def test_create_group_from_src_group_(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.14')) + grp = cs.groups.create_from_src(None, '5678', name='group') + expected = { + 'create-from-src': { + 'description': None, + 'name': 'group', + 'source_group_id': '5678' + } + } + cs.assert_called('POST', '/groups/action', + body=expected) + self._assert_request_id(grp) + + def test_enable_replication_group(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.38')) + expected = {'enable_replication': {}} + g0 = cs.groups.list()[0] + grp = g0.enable_replication() + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.enable_replication('1234') + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.enable_replication(g0) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + + def test_disable_replication_group(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.38')) + expected = {'disable_replication': {}} + g0 = cs.groups.list()[0] + grp = g0.disable_replication() + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.disable_replication('1234') + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.disable_replication(g0) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + + def test_failover_replication_group(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.38')) + expected = {'failover_replication': + {'allow_attached_volume': False, + 'secondary_backend_id': None}} + g0 = cs.groups.list()[0] + grp = g0.failover_replication() + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.failover_replication('1234') + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.failover_replication(g0) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + + def test_list_replication_targets(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.38')) + expected = {'list_replication_targets': {}} + g0 = cs.groups.list()[0] + grp = g0.list_replication_targets() + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.list_replication_targets('1234') + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) + grp = cs.groups.list_replication_targets(g0) + self._assert_request_id(grp) + cs.assert_called('POST', '/groups/1234/action', body=expected) diff --git a/cinderclient/tests/unit/v2/test_limits.py b/cinderclient/tests/unit/v3/test_limits.py similarity index 66% rename from cinderclient/tests/unit/v2/test_limits.py rename to cinderclient/tests/unit/v3/test_limits.py index e296541a5..a42f770fe 100644 --- a/cinderclient/tests/unit/v2/test_limits.py +++ b/cinderclient/tests/unit/v3/test_limits.py @@ -13,10 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -import mock +from unittest import mock + +import ddt from cinderclient.tests.unit import utils -from cinderclient.v2 import limits +from cinderclient.v3 import limits + + +REQUEST_ID = 'req-test-request-id' def _get_default_RateLimit(verb="verb1", uri="uri1", regex="regex1", @@ -29,55 +34,61 @@ def _get_default_RateLimit(verb="verb1", uri="uri1", regex="regex1", class TestLimits(utils.TestCase): def test_repr(self): - l = limits.Limits(None, {"foo": "bar"}) - self.assertEqual("", repr(l)) + limit = limits.Limits(None, {"foo": "bar"}, resp=REQUEST_ID) + self.assertEqual("", repr(limit)) + self._assert_request_id(limit) def test_absolute(self): - l = limits.Limits(None, - {"absolute": {"name1": "value1", "name2": "value2"}}) + limit = limits.Limits( + None, + {"absolute": {"name1": "value1", "name2": "value2"}}, + resp=REQUEST_ID) l1 = limits.AbsoluteLimit("name1", "value1") l2 = limits.AbsoluteLimit("name2", "value2") - for item in l.absolute: + for item in limit.absolute: self.assertIn(item, [l1, l2]) + self._assert_request_id(limit) def test_rate(self): - l = limits.Limits(None, - { - "rate": [ - { - "uri": "uri1", - "regex": "regex1", - "limit": [ - { - "verb": "verb1", - "value": "value1", - "remaining": "remain1", - "unit": "unit1", - "next-available": "next1", - }, - ], - }, - { - "uri": "uri2", - "regex": "regex2", - "limit": [ - { - "verb": "verb2", - "value": "value2", - "remaining": "remain2", - "unit": "unit2", - "next-available": "next2", - }, - ], - }, - ], - }) + limit = limits.Limits( + None, + { + "rate": [ + { + "uri": "uri1", + "regex": "regex1", + "limit": [ + { + "verb": "verb1", + "value": "value1", + "remaining": "remain1", + "unit": "unit1", + "next-available": "next1", + }, + ], + }, + { + "uri": "uri2", + "regex": "regex2", + "limit": [ + { + "verb": "verb2", + "value": "value2", + "remaining": "remain2", + "unit": "unit2", + "next-available": "next2", + }, + ], + }, ], + }, + resp=REQUEST_ID) l1 = limits.RateLimit("verb1", "uri1", "regex1", "value1", "remain1", "unit1", "next1") l2 = limits.RateLimit("verb2", "uri2", "regex2", "value2", "remain2", "unit2", "next2") - for item in l.rate: + for item in limit.rate: self.assertIn(item, [l1, l2]) + self._assert_request_id(limit) class TestRateLimit(utils.TestCase): @@ -147,8 +158,10 @@ def test_repr(self): self.assertEqual("", repr(l1)) +@ddt.ddt class TestLimitsManager(utils.TestCase): - def test_get(self): + @ddt.data(None, 'test') + def test_get(self, tenant_id): api = mock.Mock() api.client.get.return_value = ( None, @@ -157,8 +170,12 @@ def test_get(self): l1 = limits.AbsoluteLimit("name1", "value1") limitsManager = limits.LimitsManager(api) - lim = limitsManager.get() + lim = limitsManager.get(tenant_id) + query_str = '' + if tenant_id: + query_str = '?tenant_id=%s' % tenant_id + api.client.get.assert_called_once_with('/limits%s' % query_str) self.assertIsInstance(lim, limits.Limits) - for l in lim.absolute: - self.assertEqual(l1, l) + for limit in lim.absolute: + self.assertEqual(l1, limit) diff --git a/cinderclient/tests/unit/v3/test_messages.py b/cinderclient/tests/unit/v3/test_messages.py new file mode 100644 index 000000000..9f22996ce --- /dev/null +++ b/cinderclient/tests/unit/v3/test_messages.py @@ -0,0 +1,60 @@ +# 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 urllib import parse + +import ddt + +from cinderclient import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + + +@ddt.ddt +class MessagesTest(utils.TestCase): + + def test_list_messages(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.3')) + cs.messages.list() + cs.assert_called('GET', '/messages') + + @ddt.data('id', 'id:asc', 'id:desc', 'resource_type', 'event_id', + 'resource_uuid', 'message_level', 'guaranteed_until', + 'request_id') + def test_list_messages_with_sort(self, sort_string): + cs = fakes.FakeClient(api_versions.APIVersion('3.5')) + cs.messages.list(sort=sort_string) + cs.assert_called('GET', '/messages?sort=%s' % parse.quote(sort_string)) + + @ddt.data('id', 'resource_type', 'event_id', 'resource_uuid', + 'message_level', 'guaranteed_until', 'request_id') + def test_list_messages_with_filters(self, filter_string): + cs = fakes.FakeClient(api_versions.APIVersion('3.5')) + cs.messages.list(search_opts={filter_string: 'value'}) + cs.assert_called('GET', '/messages?%s=value' % parse.quote( + filter_string)) + + @ddt.data('fake', 'fake:asc', 'fake:desc') + def test_list_messages_with_invalid_sort(self, sort_string): + cs = fakes.FakeClient(api_versions.APIVersion('3.5')) + self.assertRaises(ValueError, cs.messages.list, sort=sort_string) + + def test_get_messages(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.3')) + fake_id = '1234' + cs.messages.get(fake_id) + cs.assert_called('GET', '/messages/%s' % fake_id) + + def test_delete_messages(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.3')) + fake_id = '1234' + cs.messages.delete(fake_id) + cs.assert_called('DELETE', '/messages/%s' % fake_id) diff --git a/cinderclient/tests/unit/v2/test_pools.py b/cinderclient/tests/unit/v3/test_pools.py similarity index 87% rename from cinderclient/tests/unit/v2/test_pools.py rename to cinderclient/tests/unit/v3/test_pools.py index 921d58f82..6af90578b 100644 --- a/cinderclient/tests/unit/v2/test_pools.py +++ b/cinderclient/tests/unit/v3/test_pools.py @@ -13,11 +13,13 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.v2.pools import Pool +from cinderclient import api_versions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3.pools import Pool -cs = fakes.FakeClient() + +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) class PoolsTest(utils.TestCase): @@ -25,6 +27,7 @@ class PoolsTest(utils.TestCase): def test_get_pool_stats(self): sl = cs.pools.list() cs.assert_called('GET', '/scheduler-stats/get_pools') + self._assert_request_id(sl) for s in sl: self.assertIsInstance(s, Pool) self.assertTrue(hasattr(s, "name")) @@ -35,6 +38,7 @@ def test_get_pool_stats(self): def test_get_detail_pool_stats(self): sl = cs.pools.list(detailed=True) + self._assert_request_id(sl) cs.assert_called('GET', '/scheduler-stats/get_pools?detail=True') for s in sl: self.assertIsInstance(s, Pool) diff --git a/cinderclient/tests/unit/v1/test_qos.py b/cinderclient/tests/unit/v3/test_qos.py similarity index 70% rename from cinderclient/tests/unit/v1/test_qos.py rename to cinderclient/tests/unit/v3/test_qos.py index cfecbe8d0..f6133900a 100644 --- a/cinderclient/tests/unit/v1/test_qos.py +++ b/cinderclient/tests/unit/v3/test_qos.py @@ -13,67 +13,78 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient import api_versions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v1 import fakes +from cinderclient.tests.unit.v3 import fakes -cs = fakes.FakeClient() +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) class QoSSpecsTest(utils.TestCase): def test_create(self): specs = dict(k1='v1', k2='v2') - cs.qos_specs.create('qos-name', specs) + qos = cs.qos_specs.create('qos-name', specs) cs.assert_called('POST', '/qos-specs') + self._assert_request_id(qos) def test_get(self): qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' - cs.qos_specs.get(qos_id) + qos = cs.qos_specs.get(qos_id) cs.assert_called('GET', '/qos-specs/%s' % qos_id) + self._assert_request_id(qos) def test_list(self): - cs.qos_specs.list() + lst = cs.qos_specs.list() cs.assert_called('GET', '/qos-specs') + self._assert_request_id(lst) def test_delete(self): - cs.qos_specs.delete('1B6B6A04-A927-4AEB-810B-B7BAAD49F57C') + qos = cs.qos_specs.delete('1B6B6A04-A927-4AEB-810B-B7BAAD49F57C') cs.assert_called('DELETE', '/qos-specs/1B6B6A04-A927-4AEB-810B-B7BAAD49F57C?' 'force=False') + self._assert_request_id(qos) 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) + qos = cs.qos_specs.set_keys(qos_id, body) cs.assert_called('PUT', '/qos-specs/%s' % qos_id) + self._assert_request_id(qos) def test_unset_keys(self): qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' body = {'keys': ['k1']} - cs.qos_specs.unset_keys(qos_id, body) + qos = cs.qos_specs.unset_keys(qos_id, body) cs.assert_called('PUT', '/qos-specs/%s/delete_keys' % qos_id) + self._assert_request_id(qos) def test_get_associations(self): qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' - cs.qos_specs.get_associations(qos_id) + qos = cs.qos_specs.get_associations(qos_id) cs.assert_called('GET', '/qos-specs/%s/associations' % qos_id) + self._assert_request_id(qos) 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) + qos = cs.qos_specs.associate(qos_id, type_id) cs.assert_called('GET', '/qos-specs/%s/associate?vol_type_id=%s' % (qos_id, type_id)) + self._assert_request_id(qos) 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) + qos = cs.qos_specs.disassociate(qos_id, type_id) cs.assert_called('GET', '/qos-specs/%s/disassociate?vol_type_id=%s' % (qos_id, type_id)) + self._assert_request_id(qos) def test_disassociate_all(self): qos_id = '1B6B6A04-A927-4AEB-810B-B7BAAD49F57C' - cs.qos_specs.disassociate_all(qos_id) + qos = cs.qos_specs.disassociate_all(qos_id) cs.assert_called('GET', '/qos-specs/%s/disassociate_all' % qos_id) + self._assert_request_id(qos) diff --git a/cinderclient/tests/unit/v2/test_quota_classes.py b/cinderclient/tests/unit/v3/test_quota_classes.py similarity index 77% rename from cinderclient/tests/unit/v2/test_quota_classes.py rename to cinderclient/tests/unit/v3/test_quota_classes.py index 96ca309af..29f4d0c23 100644 --- a/cinderclient/tests/unit/v2/test_quota_classes.py +++ b/cinderclient/tests/unit/v3/test_quota_classes.py @@ -13,26 +13,29 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient import api_versions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes +from cinderclient.tests.unit.v3 import fakes -cs = fakes.FakeClient() +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) class QuotaClassSetsTest(utils.TestCase): def test_class_quotas_get(self): class_name = 'test' - cs.quota_classes.get(class_name) + cls = cs.quota_classes.get(class_name) cs.assert_called('GET', '/os-quota-class-sets/%s' % class_name) + self._assert_request_id(cls) def test_update_quota(self): q = cs.quota_classes.get('test') q.update(volumes=2, snapshots=2, gigabytes=2000, backups=2, backup_gigabytes=2000, - consistencygroups=2) + per_volume_gigabytes=100) cs.assert_called('PUT', '/os-quota-class-sets/test') + self._assert_request_id(q) def test_refresh_quota(self): q = cs.quota_classes.get('test') @@ -42,7 +45,7 @@ def test_refresh_quota(self): self.assertEqual(q.gigabytes, q2.gigabytes) self.assertEqual(q.backups, q2.backups) self.assertEqual(q.backup_gigabytes, q2.backup_gigabytes) - self.assertEqual(q.consistencygroups, q2.consistencygroups) + self.assertEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) q2.volumes = 0 self.assertNotEqual(q.volumes, q2.volumes) q2.snapshots = 0 @@ -53,12 +56,14 @@ def test_refresh_quota(self): self.assertNotEqual(q.backups, q2.backups) q2.backup_gigabytes = 0 self.assertNotEqual(q.backup_gigabytes, q2.backup_gigabytes) - q2.consistencygroups = 0 - self.assertNotEqual(q.consistencygroups, q2.consistencygroups) + q2.per_volume_gigabytes = 0 + self.assertNotEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) q2.get() self.assertEqual(q.volumes, q2.volumes) self.assertEqual(q.snapshots, q2.snapshots) self.assertEqual(q.gigabytes, q2.gigabytes) self.assertEqual(q.backups, q2.backups) self.assertEqual(q.backup_gigabytes, q2.backup_gigabytes) - self.assertEqual(q.consistencygroups, q2.consistencygroups) + self.assertEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) + self._assert_request_id(q) + self._assert_request_id(q2) diff --git a/cinderclient/tests/unit/v2/test_quotas.py b/cinderclient/tests/unit/v3/test_quotas.py similarity index 69% rename from cinderclient/tests/unit/v2/test_quotas.py rename to cinderclient/tests/unit/v3/test_quotas.py index cc70f00de..e67c47764 100644 --- a/cinderclient/tests/unit/v2/test_quotas.py +++ b/cinderclient/tests/unit/v3/test_quotas.py @@ -1,4 +1,4 @@ -# Copyright (c) 2013 OpenStack Foundation +# Copyright (c) 2017 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -13,24 +13,27 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient import api_versions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes +from cinderclient.tests.unit.v3 import fakes -cs = fakes.FakeClient() +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) class QuotaSetsTest(utils.TestCase): def test_tenant_quotas_get(self): tenant_id = 'test' - cs.quotas.get(tenant_id) + quota = cs.quotas.get(tenant_id) cs.assert_called('GET', '/os-quota-sets/%s?usage=False' % tenant_id) + self._assert_request_id(quota) def test_tenant_quotas_defaults(self): tenant_id = 'test' - cs.quotas.defaults(tenant_id) + quota = cs.quotas.defaults(tenant_id) cs.assert_called('GET', '/os-quota-sets/%s/defaults' % tenant_id) + self._assert_request_id(quota) def test_update_quota(self): q = cs.quotas.get('test') @@ -39,8 +42,15 @@ def test_update_quota(self): q.update(gigabytes=2000) q.update(backups=2) q.update(backup_gigabytes=2000) - q.update(consistencygroups=2) + q.update(per_volume_gigabytes=100) cs.assert_called('PUT', '/os-quota-sets/test') + self._assert_request_id(q) + + def test_update_quota_with_skip_(self): + q = cs.quotas.get('test') + q.update(skip_validation=False) + cs.assert_called('PUT', '/os-quota-sets/test?skip_validation=False') + self._assert_request_id(q) def test_refresh_quota(self): q = cs.quotas.get('test') @@ -50,7 +60,7 @@ def test_refresh_quota(self): self.assertEqual(q.gigabytes, q2.gigabytes) self.assertEqual(q.backups, q2.backups) self.assertEqual(q.backup_gigabytes, q2.backup_gigabytes) - self.assertEqual(q.consistencygroups, q2.consistencygroups) + self.assertEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) q2.volumes = 0 self.assertNotEqual(q.volumes, q2.volumes) q2.snapshots = 0 @@ -61,17 +71,20 @@ def test_refresh_quota(self): self.assertNotEqual(q.backups, q2.backups) q2.backup_gigabytes = 0 self.assertNotEqual(q.backup_gigabytes, q2.backup_gigabytes) - q2.consistencygroups = 0 - self.assertNotEqual(q.consistencygroups, q2.consistencygroups) + q2.per_volume_gigabytes = 0 + self.assertNotEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) q2.get() self.assertEqual(q.volumes, q2.volumes) self.assertEqual(q.snapshots, q2.snapshots) self.assertEqual(q.gigabytes, q2.gigabytes) self.assertEqual(q.backups, q2.backups) self.assertEqual(q.backup_gigabytes, q2.backup_gigabytes) - self.assertEqual(q.consistencygroups, q2.consistencygroups) + self.assertEqual(q.per_volume_gigabytes, q2.per_volume_gigabytes) + self._assert_request_id(q) + self._assert_request_id(q2) def test_delete_quota(self): tenant_id = 'test' - cs.quotas.delete(tenant_id) + quota = cs.quotas.delete(tenant_id) cs.assert_called('DELETE', '/os-quota-sets/test') + self._assert_request_id(quota) diff --git a/cinderclient/tests/unit/v3/test_resource_filters.py b/cinderclient/tests/unit/v3/test_resource_filters.py new file mode 100644 index 000000000..0fc1c4246 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_resource_filters.py @@ -0,0 +1,33 @@ +# 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 ddt + +from cinderclient import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + +cs = fakes.FakeClient(api_versions.APIVersion('3.33')) + + +@ddt.ddt +class ResourceFilterTests(utils.TestCase): + @ddt.data({'resource': None, 'query_url': None}, + {'resource': 'volume', 'query_url': '?resource=volume'}, + {'resource': 'group', 'query_url': '?resource=group'}) + @ddt.unpack + def test_list_resource_filters(self, resource, query_url): + cs.resource_filters.list(resource) + url = '/resource_filters' + if resource is not None: + url += query_url + cs.assert_called('GET', url) diff --git a/cinderclient/tests/unit/v3/test_services.py b/cinderclient/tests/unit/v3/test_services.py new file mode 100644 index 000000000..8af368283 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_services.py @@ -0,0 +1,94 @@ +# Copyright (c) 2016 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 import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import services + + +class ServicesTest(utils.TestCase): + + def test_list_services_with_cluster_info(self): + cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.7')) + services_list = cs.services.list() + cs.assert_called('GET', '/os-services') + self.assertEqual(3, len(services_list)) + for service in services_list: + self.assertIsInstance(service, services.Service) + # Make sure cluster fields from v3.7 is present and not None + self.assertIsNotNone(getattr(service, 'cluster')) + self._assert_request_id(services_list) + + def test_api_version(self): + client = fakes.FakeClient(version_header='3.0') + svs = client.services.server_api_version() + [self.assertIsInstance(s, services.Service) for s in svs] + + def test_set_log_levels(self): + expected = {'level': 'debug', 'binary': 'cinder-api', + 'server': 'host1', 'prefix': 'sqlalchemy.'} + + cs = fakes.FakeClient(version_header='3.32') + cs.services.set_log_levels(expected['level'], expected['binary'], + expected['server'], expected['prefix']) + + cs.assert_called('PUT', '/os-services/set-log', body=expected) + + def test_get_log_levels(self): + expected = {'binary': 'cinder-api', 'server': 'host1', + 'prefix': 'sqlalchemy.'} + + cs = fakes.FakeClient(version_header='3.32') + result = cs.services.get_log_levels(expected['binary'], + expected['server'], + expected['prefix']) + + cs.assert_called('PUT', '/os-services/get-log', body=expected) + expected = [services.LogLevel(cs.services, + {'binary': 'cinder-api', 'host': 'host1', + 'prefix': 'prefix1', 'level': 'DEBUG'}, + loaded=True), + services.LogLevel(cs.services, + {'binary': 'cinder-api', 'host': 'host1', + 'prefix': 'prefix2', 'level': 'INFO'}, + loaded=True), + services.LogLevel(cs.services, + {'binary': 'cinder-volume', + 'host': 'host@backend#pool', + 'prefix': 'prefix3', + 'level': 'WARNING'}, + loaded=True), + services.LogLevel(cs.services, + {'binary': 'cinder-volume', + 'host': 'host@backend#pool', + 'prefix': 'prefix4', 'level': 'ERROR'}, + loaded=True)] + # Since it will be sorted by the prefix we can compare them directly + self.assertListEqual(expected, result) + + def test_list_services_with_backend_state(self): + cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.49')) + services_list = cs.services.list() + cs.assert_called('GET', '/os-services') + self.assertEqual(3, len(services_list)) + for service in services_list: + self.assertIsInstance(service, services.Service) + # Make sure backend_state fields from v3.49 is present and not + # None + if service.binary == 'cinder-volume': + self.assertIsNotNone(getattr(service, 'backend_state', + None)) + self._assert_request_id(services_list) diff --git a/cinderclient/tests/unit/v2/test_services.py b/cinderclient/tests/unit/v3/test_services_base.py similarity index 81% rename from cinderclient/tests/unit/v2/test_services.py rename to cinderclient/tests/unit/v3/test_services_base.py index 12da4c38a..711a5361d 100644 --- a/cinderclient/tests/unit/v2/test_services.py +++ b/cinderclient/tests/unit/v3/test_services_base.py @@ -13,21 +13,27 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient import api_versions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes -from cinderclient.v2 import services +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import services -cs = fakes.FakeClient() +cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.0')) class ServicesTest(utils.TestCase): + """Tests for v3.0 behavior""" def test_list_services(self): svs = cs.services.list() cs.assert_called('GET', '/os-services') self.assertEqual(3, len(svs)) - [self.assertIsInstance(s, services.Service) for s in svs] + for service in svs: + self.assertIsInstance(service, services.Service) + # Make sure cluster fields from v3.7 are not there + self.assertFalse(hasattr(service, 'cluster')) + self._assert_request_id(svs) def test_list_services_with_hostname(self): svs = cs.services.list(host='host2') @@ -35,6 +41,7 @@ def test_list_services_with_hostname(self): self.assertEqual(2, len(svs)) [self.assertIsInstance(s, services.Service) for s in svs] [self.assertEqual('host2', s.host) for s in svs] + self._assert_request_id(svs) def test_list_services_with_binary(self): svs = cs.services.list(binary='cinder-volume') @@ -42,6 +49,7 @@ def test_list_services_with_binary(self): self.assertEqual(2, len(svs)) [self.assertIsInstance(s, services.Service) for s in svs] [self.assertEqual('cinder-volume', s.binary) for s in svs] + self._assert_request_id(svs) def test_list_services_with_host_binary(self): svs = cs.services.list('host2', 'cinder-volume') @@ -50,6 +58,7 @@ def test_list_services_with_host_binary(self): [self.assertIsInstance(s, services.Service) for s in svs] [self.assertEqual('host2', s.host) for s in svs] [self.assertEqual('cinder-volume', s.binary) for s in svs] + self._assert_request_id(svs) def test_services_enable(self): s = cs.services.enable('host1', 'cinder-volume') @@ -57,6 +66,7 @@ def test_services_enable(self): cs.assert_called('PUT', '/os-services/enable', values) self.assertIsInstance(s, services.Service) self.assertEqual('enabled', s.status) + self._assert_request_id(s) def test_services_disable(self): s = cs.services.disable('host1', 'cinder-volume') @@ -64,6 +74,7 @@ def test_services_disable(self): cs.assert_called('PUT', '/os-services/disable', values) self.assertIsInstance(s, services.Service) self.assertEqual('disabled', s.status) + self._assert_request_id(s) def test_services_disable_log_reason(self): s = cs.services.disable_log_reason( @@ -73,3 +84,4 @@ def test_services_disable_log_reason(self): cs.assert_called('PUT', '/os-services/disable-log-reason', values) self.assertIsInstance(s, services.Service) self.assertEqual('disabled', s.status) + self._assert_request_id(s) diff --git a/cinderclient/tests/unit/v3/test_shell.py b/cinderclient/tests/unit/v3/test_shell.py new file mode 100644 index 000000000..89e89d842 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_shell.py @@ -0,0 +1,2015 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 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. + + +# NOTE(geguileo): For v3 we cannot mock any of the following methods +# - utils.find_volume +# - shell_utils.find_backup +# - shell_utils.find_volume_snapshot +# - shell_utils.find_group +# - shell_utils.find_group_snapshot +# because we are caching them in cinderclient.v3.shell:RESET_STATE_RESOURCES +# which means that our tests could fail depending on the mocking and loading +# order. +# +# Alternatives are: +# - Mock utils.find_resource when we have only 1 call to that method +# - Use an auxiliary method that will call original method for irrelevant +# calls. Example from test_revert_to_snapshot: +# original = client_utils.find_resource +# +# def find_resource(manager, name_or_id, **kwargs): +# if isinstance(manager, volume_snapshots.SnapshotManager): +# return volume_snapshots.Snapshot(self, +# {'id': '5678', +# 'volume_id': '1234'}) +# return original(manager, name_or_id, **kwargs) + +from unittest import mock +from urllib import parse + +import ddt +import fixtures +from requests_mock.contrib import fixture as requests_mock_fixture + +import cinderclient +from cinderclient import api_versions +from cinderclient import base +from cinderclient import client +from cinderclient import exceptions +from cinderclient import shell +from cinderclient.tests.unit.fixture_data import keystone_client +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient import utils as cinderclient_utils +from cinderclient.v3 import attachments +from cinderclient.v3 import volume_snapshots +from cinderclient.v3 import volumes + + +@ddt.ddt +@mock.patch.object(client, 'Client', fakes.FakeClient) +class ShellTest(utils.TestCase): + + FAKE_ENV = { + 'CINDER_USERNAME': 'username', + 'CINDER_PASSWORD': 'password', + 'CINDER_PROJECT_ID': 'project_id', + 'OS_VOLUME_API_VERSION': '3', + 'CINDER_URL': keystone_client.BASE_URL, + } + + # Patch os.environ to avoid required auth info. + def setUp(self): + """Run before each test.""" + super(ShellTest, self).setUp() + for var in self.FAKE_ENV: + self.useFixture(fixtures.EnvironmentVariable(var, + self.FAKE_ENV[var])) + + self.mock_completion() + + self.shell = shell.OpenStackCinderShell() + + self.requests = self.useFixture(requests_mock_fixture.Fixture()) + self.requests.register_uri( + 'GET', keystone_client.BASE_URL, + text=keystone_client.keystone_request_callback) + + self.cs = mock.Mock() + + def run_command(self, cmd): + # Ensure the version negotiation indicates that + # all versions are supported + with mock.patch('cinderclient.api_versions._get_server_version_range', + return_value=(api_versions.APIVersion('3.0'), + api_versions.APIVersion('3.99'))): + self.shell.main(cmd.split()) + + def run_command_with_server_api_max(self, api_max, cmd): + # version negotiation will use the supplied api_max, which must be + # a string value, as the server's max supported version + with mock.patch('cinderclient.api_versions._get_server_version_range', + return_value=(api_versions.APIVersion('3.0'), + api_versions.APIVersion(api_max))): + self.shell.main(cmd.split()) + + def assert_called(self, method, url, body=None, + partial_body=None, **kwargs): + return self.shell.cs.assert_called(method, url, body, + partial_body, **kwargs) + + def assert_call_contained(self, url_part): + self.shell.cs.assert_in_call(url_part) + + @ddt.data({'resource': None, 'query_url': None}, + {'resource': 'volume', 'query_url': '?resource=volume'}, + {'resource': 'group', 'query_url': '?resource=group'}) + @ddt.unpack + def test_list_filters(self, resource, query_url): + url = '/resource_filters' + if resource is not None: + url += query_url + self.run_command('--os-volume-api-version 3.33 ' + 'list-filters --resource=%s' % resource) + else: + self.run_command('--os-volume-api-version 3.33 list-filters') + + self.assert_called('GET', url) + + @ddt.data( + # testcases for list volume + {'command': + 'list --name=123 --filters name=456', + 'expected': + '/volumes/detail?name=456'}, + {'command': + 'list --filters name=123', + 'expected': + '/volumes/detail?name=123'}, + {'command': + 'list --filters metadata={key1:value1}', + 'expected': + '/volumes/detail?metadata=%7B%27key1%27%3A+%27value1%27%7D'}, + {'command': + 'list --filters name~=456', + 'expected': + '/volumes/detail?name~=456'}, + {'command': + u'list --filters name~=Σ', + 'expected': + '/volumes/detail?name~=%CE%A3'}, + {'command': + u'list --filters name=abc --filters size=1', + 'expected': + '/volumes/detail?name=abc&size=1'}, + {'command': + u'list --filters created_at=lt:2020-01-15T00:00:00', + 'expected': + '/volumes/detail?created_at=lt%3A2020-01-15T00%3A00%3A00'}, + {'command': + u'list --filters updated_at=gte:2020-02-01T00:00:00,' + u'lt:2020-03-01T00:00:00', + 'expected': + '/volumes/detail?updated_at=gte%3A2020-02-01T00%3A00%3A00%2C' + 'lt%3A2020-03-01T00%3A00%3A00'}, + {'command': + u'list --filters updated_at=gte:2020-02-01T00:00:00,' + u'lt:2020-03-01T00:00:00 --filters created_at=' + u'lt:2020-01-15T00:00:00', + 'expected': + '/volumes/detail?created_at=lt%3A2020-01-15T00%3A00%3A00' + '&updated_at=gte%3A2020-02-01T00%3A00%3A00%2C' + 'lt%3A2020-03-01T00%3A00%3A00'}, + # testcases for list group + {'command': + 'group-list --filters name=456', + 'expected': + '/groups/detail?name=456'}, + {'command': + 'group-list --filters status=available', + 'expected': + '/groups/detail?status=available'}, + {'command': + 'group-list --filters name~=456', + 'expected': + '/groups/detail?name~=456'}, + {'command': + 'group-list --filters name=abc --filters status=available', + 'expected': + '/groups/detail?name=abc&status=available'}, + # testcases for list group-snapshot + {'command': + 'group-snapshot-list --status=error --filters status=available', + 'expected': + '/group_snapshots/detail?status=available'}, + {'command': + 'group-snapshot-list --filters availability_zone=123', + 'expected': + '/group_snapshots/detail?availability_zone=123'}, + {'command': + 'group-snapshot-list --filters status~=available', + 'expected': + '/group_snapshots/detail?status~=available'}, + {'command': + 'group-snapshot-list --filters status=available ' + '--filters availability_zone=123', + 'expected': + '/group_snapshots/detail?availability_zone=123&status=available'}, + # testcases for list message + {'command': + 'message-list --event_id=123 --filters event_id=456', + 'expected': + '/messages?event_id=456'}, + {'command': + 'message-list --filters request_id=123', + 'expected': + '/messages?request_id=123'}, + {'command': + 'message-list --filters request_id~=123', + 'expected': + '/messages?request_id~=123'}, + {'command': + 'message-list --filters request_id=123 --filters event_id=456', + 'expected': + '/messages?event_id=456&request_id=123'}, + # testcases for list attachment + {'command': + 'attachment-list --volume-id=123 --filters volume_id=456', + 'expected': + '/attachments?volume_id=456'}, + {'command': + 'attachment-list --filters mountpoint=123', + 'expected': + '/attachments?mountpoint=123'}, + {'command': + 'attachment-list --filters volume_id~=456', + 'expected': + '/attachments?volume_id~=456'}, + {'command': + 'attachment-list --filters volume_id=123 ' + '--filters mountpoint=456', + 'expected': + '/attachments?mountpoint=456&volume_id=123'}, + # testcases for list backup + {'command': + 'backup-list --volume-id=123 --filters volume_id=456', + 'expected': + '/backups/detail?volume_id=456'}, + {'command': + 'backup-list --filters name=123', + 'expected': + '/backups/detail?name=123'}, + {'command': + 'backup-list --filters volume_id~=456', + 'expected': + '/backups/detail?volume_id~=456'}, + {'command': + 'backup-list --filters volume_id=123 --filters name=456', + 'expected': + '/backups/detail?name=456&volume_id=123'}, + # testcases for list snapshot + {'command': + 'snapshot-list --volume-id=123 --filters volume_id=456', + 'expected': + '/snapshots/detail?volume_id=456'}, + {'command': + 'snapshot-list --filters name=123', + 'expected': + '/snapshots/detail?name=123'}, + {'command': + 'snapshot-list --filters volume_id~=456', + 'expected': + '/snapshots/detail?volume_id~=456'}, + {'command': + 'snapshot-list --filters volume_id=123 --filters name=456', + 'expected': + '/snapshots/detail?name=456&volume_id=123'}, + # testcases for get pools + {'command': + 'get-pools --filters name=456 --detail', + 'expected': + '/scheduler-stats/get_pools?detail=True&name=456'}, + {'command': + 'get-pools --filters name=456', + 'expected': + '/scheduler-stats/get_pools?name=456'}, + {'command': + 'get-pools --filters name=456 --filters detail=True', + 'expected': + '/scheduler-stats/get_pools?detail=True&name=456'} + ) + @ddt.unpack + def test_list_with_filters_mixed(self, command, expected): + self.run_command('--os-volume-api-version 3.33 %s' % command) + self.assert_called('GET', expected) + + def test_list(self): + self.run_command('list') + # NOTE(jdg): we default to detail currently + self.assert_called('GET', '/volumes/detail') + + def test_list_with_with_count(self): + self.run_command('--os-volume-api-version 3.45 list --with-count') + self.assert_called('GET', '/volumes/detail?with_count=True') + + def test_summary(self): + self.run_command('--os-volume-api-version 3.12 summary') + self.assert_called('GET', '/volumes/summary') + + def test_list_with_group_id_before_3_10(self): + self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + 'list --group_id fake_id') + + def test_type_list_with_filters_invalid(self): + self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + '--os-volume-api-version 3.51 type-list ' + '--filters key=value') + + def test_type_list_with_filters(self): + self.run_command('--os-volume-api-version 3.52 type-list ' + '--filters extra_specs={key:value}') + self.assert_called('GET', mock.ANY) + self.assert_call_contained( + parse.urlencode( + {'extra_specs': + {'key': 'value'}})) + self.assert_call_contained(parse.urlencode({'is_public': None})) + + def test_type_list_public(self): + self.run_command('--os-volume-api-version 3.52 type-list ' + '--filters is_public=True') + self.assert_called('GET', '/types?is_public=True') + + def test_type_list_private(self): + self.run_command('--os-volume-api-version 3.52 type-list ' + '--filters is_public=False') + self.assert_called('GET', '/types?is_public=False') + + def test_type_list_public_private(self): + self.run_command('--os-volume-api-version 3.52 type-list') + self.assert_called('GET', '/types?is_public=None') + + @ddt.data("3.10", "3.11") + def test_list_with_group_id_after_3_10(self, version): + command = ('--os-volume-api-version %s list --group_id fake_id' % + version) + self.run_command(command) + self.assert_called('GET', '/volumes/detail?group_id=fake_id') + + @mock.patch("cinderclient.shell_utils.print_list") + def test_list_duplicate_fields(self, mock_print): + self.run_command('list --field Status,id,Size,status') + self.assert_called('GET', '/volumes/detail') + key_list = ['ID', 'Status', 'Size'] + mock_print.assert_called_once_with(mock.ANY, key_list, + exclude_unavailable=True, sortby_index=0) + + @mock.patch("cinderclient.shell.OpenStackCinderShell.downgrade_warning") + def test_list_version_downgrade(self, mock_warning): + self.run_command('--os-volume-api-version 3.998 list') + mock_warning.assert_called_once_with( + api_versions.APIVersion('3.998'), + api_versions.APIVersion(api_versions.MAX_VERSION) + ) + + def test_list_availability_zone(self): + self.run_command('availability-zone-list') + self.assert_called('GET', '/os-availability-zone') + + @ddt.data({'cmd': '1234 1233', + 'body': {'instance_uuid': '1233', + 'connector': {}, + 'volume_uuid': '1234'}}, + {'cmd': '1234 1233 ' + '--connect True ' + '--ip 10.23.12.23 --host server01 ' + '--platform x86_xx ' + '--ostype 123 ' + '--multipath true ' + '--mountpoint /123 ' + '--initiator aabbccdd', + 'body': {'instance_uuid': '1233', + 'connector': {'ip': '10.23.12.23', + 'host': 'server01', + 'os_type': '123', + 'multipath': 'true', + 'mountpoint': '/123', + 'initiator': 'aabbccdd', + 'platform': 'x86_xx'}, + 'volume_uuid': '1234'}}, + {'cmd': 'abc 1233', + 'body': {'instance_uuid': '1233', + 'connector': {}, + 'volume_uuid': '1234'}}, + {'cmd': '1234', + 'body': {'connector': {}, + 'volume_uuid': '1234'}}, + {'cmd': '1234 ' + '--connect True ' + '--ip 10.23.12.23 --host server01 ' + '--platform x86_xx ' + '--ostype 123 ' + '--multipath true ' + '--mountpoint /123 ' + '--initiator aabbccdd', + 'body': {'connector': {'ip': '10.23.12.23', + 'host': 'server01', + 'os_type': '123', + 'multipath': 'true', + 'mountpoint': '/123', + 'initiator': 'aabbccdd', + 'platform': 'x86_xx'}, + 'volume_uuid': '1234'}}) + @mock.patch('cinderclient.utils.find_resource') + @ddt.unpack + def test_attachment_create(self, mock_find_volume, cmd, body): + mock_find_volume.return_value = volumes.Volume(self, + {'id': '1234'}, + loaded=True) + command = '--os-volume-api-version 3.27 attachment-create ' + command += cmd + self.run_command(command) + expected = {'attachment': body} + self.assertTrue(mock_find_volume.called) + self.assert_called('POST', '/attachments', body=expected) + + @ddt.data({'cmd': '1234 1233', + 'body': {'instance_uuid': '1233', + 'connector': {}, + 'volume_uuid': '1234', + 'mode': 'ro'}}, + {'cmd': '1234 1233 ' + '--connect True ' + '--ip 10.23.12.23 --host server01 ' + '--platform x86_xx ' + '--ostype 123 ' + '--multipath true ' + '--mountpoint /123 ' + '--initiator aabbccdd', + 'body': {'instance_uuid': '1233', + 'connector': {'ip': '10.23.12.23', + 'host': 'server01', + 'os_type': '123', + 'multipath': 'true', + 'mountpoint': '/123', + 'initiator': 'aabbccdd', + 'platform': 'x86_xx'}, + 'volume_uuid': '1234', + 'mode': 'ro'}}, + {'cmd': 'abc 1233', + 'body': {'instance_uuid': '1233', + 'connector': {}, + 'volume_uuid': '1234', + 'mode': 'ro'}}, + {'cmd': '1234', + 'body': {'connector': {}, + 'volume_uuid': '1234', + 'mode': 'ro'}}, + {'cmd': '1234 ' + '--connect True ' + '--ip 10.23.12.23 --host server01 ' + '--platform x86_xx ' + '--ostype 123 ' + '--multipath true ' + '--mountpoint /123 ' + '--initiator aabbccdd', + 'body': {'connector': {'ip': '10.23.12.23', + 'host': 'server01', + 'os_type': '123', + 'multipath': 'true', + 'mountpoint': '/123', + 'initiator': 'aabbccdd', + 'platform': 'x86_xx'}, + 'volume_uuid': '1234', + 'mode': 'ro'}}) + @mock.patch('cinderclient.utils.find_resource') + @ddt.unpack + def test_attachment_create_with_mode(self, mock_find_volume, cmd, body): + mock_find_volume.return_value = volumes.Volume(self, + {'id': '1234'}, + loaded=True) + command = ('--os-volume-api-version 3.54 ' + 'attachment-create ' + '--mode ro ') + command += cmd + self.run_command(command) + expected = {'attachment': body} + self.assertTrue(mock_find_volume.called) + self.assert_called('POST', '/attachments', body=expected) + + @mock.patch.object(volumes.VolumeManager, 'findall') + def test_attachment_create_duplicate_name_vol(self, mock_findall): + found = [volumes.Volume(self, {'id': '7654', 'name': 'abc'}, + loaded=True), + volumes.Volume(self, {'id': '9876', 'name': 'abc'}, + loaded=True)] + mock_findall.return_value = found + self.assertRaises(exceptions.CommandError, + self.run_command, + '--os-volume-api-version 3.27 ' + 'attachment-create abc 789') + + @ddt.data({'cmd': '', + 'expected': ''}, + {'cmd': '--volume-id 1234', + 'expected': '?volume_id=1234'}, + {'cmd': '--status error', + 'expected': '?status=error'}, + {'cmd': '--all-tenants 1', + 'expected': '?all_tenants=1'}, + {'cmd': '--all-tenants 1 --volume-id 12345', + 'expected': '?all_tenants=1&volume_id=12345'}, + {'cmd': '--all-tenants 1 --tenant 12345', + 'expected': '?all_tenants=1&project_id=12345'}, + {'cmd': '--tenant 12345', + 'expected': '?all_tenants=1&project_id=12345'} + + ) + @ddt.unpack + def test_attachment_list(self, cmd, expected): + command = '--os-volume-api-version 3.27 attachment-list ' + command += cmd + self.run_command(command) + self.assert_called('GET', '/attachments%s' % expected) + + @mock.patch('cinderclient.shell_utils.print_list') + @mock.patch.object(cinderclient.v3.attachments.VolumeAttachmentManager, + 'list') + def test_attachment_list_setattr(self, mock_list, mock_print): + command = '--os-volume-api-version 3.27 attachment-list ' + fake_attachment = [attachments.VolumeAttachment(mock.ANY, attachment) + for attachment in fakes.fake_attachment_list['attachments']] + mock_list.return_value = fake_attachment + self.run_command(command) + for attach in fake_attachment: + setattr(attach, 'server_id', getattr(attach, 'instance')) + columns = ['ID', 'Volume ID', 'Status', 'Server ID'] + mock_print.assert_called_once_with(fake_attachment, columns, + sortby_index=0) + + def test_revert_to_snapshot(self): + original = cinderclient_utils.find_resource + + def find_resource(manager, name_or_id, **kwargs): + if isinstance(manager, volume_snapshots.SnapshotManager): + return volume_snapshots.Snapshot(self, + {'id': '5678', + 'volume_id': '1234'}) + return original(manager, name_or_id, **kwargs) + + with mock.patch('cinderclient.utils.find_resource', + side_effect=find_resource): + self.run_command( + '--os-volume-api-version 3.40 revert-to-snapshot 5678') + + self.assert_called('POST', '/volumes/1234/action', + body={'revert': {'snapshot_id': '5678'}}) + + def test_attachment_show(self): + self.run_command('--os-volume-api-version 3.27 attachment-show 1234') + self.assert_called('GET', '/attachments/1234') + + @ddt.data({'cmd': '1234 ' + '--ip 10.23.12.23 --host server01 ' + '--platform x86_xx ' + '--ostype 123 ' + '--multipath true ' + '--mountpoint /123 ' + '--initiator aabbccdd', + 'body': {'connector': {'ip': '10.23.12.23', + 'host': 'server01', + 'os_type': '123', + 'multipath': 'true', + 'mountpoint': '/123', + 'initiator': 'aabbccdd', + 'platform': 'x86_xx'}}}) + @ddt.unpack + def test_attachment_update(self, cmd, body): + command = '--os-volume-api-version 3.27 attachment-update ' + command += cmd + self.run_command(command) + self.assert_called('PUT', '/attachments/1234', body={'attachment': + body}) + + @ddt.unpack + def test_attachment_complete(self): + command = '--os-volume-api-version 3.44 attachment-complete 1234' + self.run_command(command) + self.assert_called('POST', '/attachments/1234/action', body=None) + + def test_attachment_delete(self): + self.run_command('--os-volume-api-version 3.27 ' + 'attachment-delete 1234') + self.assert_called('DELETE', '/attachments/1234') + + def test_upload_to_image(self): + expected = {'os-volume_upload_image': {'force': False, + 'container_format': 'bare', + 'disk_format': 'raw', + 'image_name': 'test-image'}} + self.run_command('upload-to-image 1234 test-image') + self.assert_called_anytime('GET', '/volumes/1234') + self.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + + def test_upload_to_image_private_not_protected(self): + expected = {'os-volume_upload_image': {'force': False, + 'container_format': 'bare', + 'disk_format': 'raw', + 'image_name': 'test-image', + 'protected': False, + 'visibility': 'private'}} + self.run_command('--os-volume-api-version 3.1 ' + 'upload-to-image 1234 test-image') + self.assert_called_anytime('GET', '/volumes/1234') + self.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + + def test_upload_to_image_public_protected(self): + expected = {'os-volume_upload_image': {'force': False, + 'container_format': 'bare', + 'disk_format': 'raw', + 'image_name': 'test-image', + 'protected': 'True', + 'visibility': 'public'}} + self.run_command('--os-volume-api-version 3.1 ' + 'upload-to-image --visibility=public ' + '--protected=True 1234 test-image') + self.assert_called_anytime('GET', '/volumes/1234') + self.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + + def test_backup_update(self): + self.run_command('--os-volume-api-version 3.9 ' + 'backup-update --name new_name 1234') + expected = {'backup': {'name': 'new_name'}} + self.assert_called('PUT', '/backups/1234', body=expected) + + def test_backup_list_with_with_count(self): + self.run_command( + '--os-volume-api-version 3.45 backup-list --with-count') + self.assert_called('GET', '/backups/detail?with_count=True') + + def test_backup_update_with_description(self): + self.run_command('--os-volume-api-version 3.9 ' + 'backup-update 1234 --description=new-description') + expected = {'backup': {'description': 'new-description'}} + self.assert_called('PUT', '/backups/1234', body=expected) + + def test_backup_update_with_metadata(self): + cmd = '--os-volume-api-version 3.43 ' + cmd += 'backup-update ' + cmd += '--metadata foo=bar ' + cmd += '1234' + self.run_command(cmd) + expected = {'backup': {'metadata': {'foo': 'bar'}}} + self.assert_called('PUT', '/backups/1234', body=expected) + + def test_backup_update_all(self): + # rename and change description + self.run_command('--os-volume-api-version 3.43 ' + 'backup-update --name new-name ' + '--description=new-description ' + '--metadata foo=bar 1234') + expected = {'backup': { + 'name': 'new-name', + 'description': 'new-description', + 'metadata': {'foo': 'bar'} + }} + self.assert_called('PUT', '/backups/1234', body=expected) + + def test_backup_update_without_arguments(self): + # Call rename with no arguments + self.assertRaises(SystemExit, self.run_command, + '--os-volume-api-version 3.9 backup-update') + + def test_backup_update_bad_request(self): + self.assertRaises(exceptions.ClientException, + self.run_command, + '--os-volume-api-version 3.9 backup-update 1234') + + def test_backup_update_wrong_version(self): + self.assertRaises(SystemExit, + self.run_command, + '--os-volume-api-version 3.8 ' + 'backup-update --name new-name 1234') + + def test_group_type_list(self): + self.run_command('--os-volume-api-version 3.11 group-type-list') + self.assert_called_anytime('GET', '/group_types?is_public=None') + + def test_group_type_list_public(self): + self.run_command('--os-volume-api-version 3.52 group-type-list ' + '--filters is_public=True') + self.assert_called('GET', '/group_types?is_public=True') + + def test_group_type_list_private(self): + self.run_command('--os-volume-api-version 3.52 group-type-list ' + '--filters is_public=False') + self.assert_called('GET', '/group_types?is_public=False') + + def test_group_type_list_public_private(self): + self.run_command('--os-volume-api-version 3.52 group-type-list') + self.assert_called('GET', '/group_types?is_public=None') + + def test_group_type_show(self): + self.run_command('--os-volume-api-version 3.11 ' + 'group-type-show 1') + self.assert_called('GET', '/group_types/1') + + def test_group_type_create(self): + self.run_command('--os-volume-api-version 3.11 ' + 'group-type-create test-type-1') + self.assert_called('POST', '/group_types') + + def test_group_type_create_public(self): + expected = {'group_type': {'name': 'test-type-1', + 'description': 'test_type-1-desc', + 'is_public': True}} + self.run_command('--os-volume-api-version 3.11 ' + 'group-type-create test-type-1 ' + '--description=test_type-1-desc ' + '--is-public=True') + self.assert_called('POST', '/group_types', body=expected) + + def test_group_type_create_private(self): + expected = {'group_type': {'name': 'test-type-3', + 'description': 'test_type-3-desc', + 'is_public': False}} + self.run_command('--os-volume-api-version 3.11 ' + 'group-type-create test-type-3 ' + '--description=test_type-3-desc ' + '--is-public=False') + self.assert_called('POST', '/group_types', body=expected) + + def test_group_specs_list(self): + self.run_command('--os-volume-api-version 3.11 group-specs-list') + self.assert_called('GET', '/group_types?is_public=None') + + def test_create_volume_with_group(self): + self.run_command('--os-volume-api-version 3.13 create --group-id 5678 ' + '--volume-type 4321 1') + self.assert_called('GET', '/volumes/1234') + expected = {'volume': {'imageRef': None, + 'size': 1, + 'availability_zone': None, + 'source_volid': None, + 'consistencygroup_id': None, + 'group_id': '5678', + 'name': None, + 'snapshot_id': None, + 'metadata': {}, + 'volume_type': '4321', + 'description': None, + 'backup_id': None}} + self.assert_called_anytime('POST', '/volumes', expected) + + @ddt.data({'cmd': '--os-volume-api-version 3.47 create --backup-id 1234', + 'update': {'backup_id': '1234'}}, + {'cmd': '--os-volume-api-version 3.47 create 2', + 'update': {'size': 2}} + ) + @ddt.unpack + def test_create_volume_with_backup(self, cmd, update): + self.run_command(cmd) + self.assert_called('GET', '/volumes/1234') + expected = {'volume': {'imageRef': None, + 'size': None, + 'availability_zone': None, + 'source_volid': None, + 'consistencygroup_id': None, + 'name': None, + 'snapshot_id': None, + 'metadata': {}, + 'volume_type': None, + 'description': None, + 'backup_id': None}} + expected['volume'].update(update) + self.assert_called_anytime('POST', '/volumes', body=expected) + + def test_group_list(self): + self.run_command('--os-volume-api-version 3.13 group-list') + self.assert_called_anytime('GET', '/groups/detail') + + def test_group_list__with_all_tenant(self): + self.run_command( + '--os-volume-api-version 3.13 group-list --all-tenants') + self.assert_called_anytime('GET', '/groups/detail?all_tenants=1') + + def test_group_show(self): + self.run_command('--os-volume-api-version 3.13 ' + 'group-show 1234') + self.assert_called('GET', '/groups/1234') + + def test_group_show_with_list_volume(self): + self.run_command('--os-volume-api-version 3.25 ' + 'group-show 1234 --list-volume') + self.assert_called('GET', '/groups/1234?list_volume=True') + + @ddt.data(True, False) + def test_group_delete(self, delete_vol): + cmd = '--os-volume-api-version 3.13 group-delete 1234' + if delete_vol: + cmd += ' --delete-volumes' + self.run_command(cmd) + expected = {'delete': {'delete-volumes': delete_vol}} + self.assert_called('POST', '/groups/1234/action', expected) + + def test_group_create(self): + expected = {'group': {'name': 'test-1', + 'description': 'test-1-desc', + 'group_type': 'my_group_type', + 'volume_types': ['type1', 'type2'], + 'availability_zone': 'zone1'}} + self.run_command('--os-volume-api-version 3.13 ' + 'group-create --name test-1 ' + '--description test-1-desc ' + '--availability-zone zone1 ' + 'my_group_type type1,type2') + self.assert_called_anytime('POST', '/groups', body=expected) + + def test_group_update(self): + self.run_command('--os-volume-api-version 3.13 group-update ' + '--name group2 --description desc2 ' + '--add-volumes uuid1,uuid2 ' + '--remove-volumes uuid3,uuid4 ' + '1234') + expected = {'group': {'name': 'group2', + 'description': 'desc2', + 'add_volumes': 'uuid1,uuid2', + 'remove_volumes': 'uuid3,uuid4'}} + self.assert_called('PUT', '/groups/1234', + body=expected) + + def test_group_update_invalid_args(self): + self.assertRaises(exceptions.ClientException, + self.run_command, + '--os-volume-api-version 3.13 group-update 1234') + + def test_group_snapshot_list(self): + self.run_command('--os-volume-api-version 3.14 group-snapshot-list') + self.assert_called_anytime('GET', + '/group_snapshots/detail') + + def test_group_snapshot_show(self): + self.run_command('--os-volume-api-version 3.14 ' + 'group-snapshot-show 1234') + self.assert_called('GET', '/group_snapshots/1234') + + def test_group_snapshot_delete(self): + cmd = '--os-volume-api-version 3.14 group-snapshot-delete 1234' + self.run_command(cmd) + self.assert_called('DELETE', '/group_snapshots/1234') + + def test_group_snapshot_create(self): + expected = {'group_snapshot': {'name': 'test-1', + 'description': 'test-1-desc', + 'group_id': '1234'}} + self.run_command('--os-volume-api-version 3.14 ' + 'group-snapshot-create --name test-1 ' + '--description test-1-desc 1234') + self.assert_called_anytime('POST', '/group_snapshots', body=expected) + + @ddt.data( + {'grp_snap_id': '1234', 'src_grp_id': None, + 'src': '--group-snapshot 1234'}, + {'grp_snap_id': None, 'src_grp_id': '1234', + 'src': '--source-group 1234'}, + ) + @ddt.unpack + def test_group_create_from_src(self, grp_snap_id, src_grp_id, src): + expected = {'create-from-src': {'name': 'test-1', + 'description': 'test-1-desc'}} + if grp_snap_id: + expected['create-from-src']['group_snapshot_id'] = grp_snap_id + elif src_grp_id: + expected['create-from-src']['source_group_id'] = src_grp_id + + cmd = ('--os-volume-api-version 3.14 ' + 'group-create-from-src --name test-1 ' + '--description test-1-desc ') + cmd += src + self.run_command(cmd) + self.assert_called_anytime('POST', '/groups/action', body=expected) + + def test_volume_manageable_list(self): + self.run_command('--os-volume-api-version 3.8 ' + 'manageable-list fakehost') + self.assert_called('GET', '/manageable_volumes/detail?host=fakehost') + + def test_volume_manageable_list_details(self): + self.run_command('--os-volume-api-version 3.8 ' + 'manageable-list fakehost --detailed True') + self.assert_called('GET', '/manageable_volumes/detail?host=fakehost') + + def test_volume_manageable_list_no_details(self): + self.run_command('--os-volume-api-version 3.8 ' + 'manageable-list fakehost --detailed False') + self.assert_called('GET', '/manageable_volumes?host=fakehost') + + def test_volume_manageable_list_cluster(self): + self.run_command('--os-volume-api-version 3.17 ' + 'manageable-list --cluster dest') + self.assert_called('GET', '/manageable_volumes/detail?cluster=dest') + + @ddt.data(True, False, 'Nonboolean') + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_pre_3_66(self, force_value, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + snap_body_3_65 = { + 'snapshot': { + 'volume_id': '123456', + 'force': f'{force_value}', + 'name': None, + 'description': None, + 'metadata': {} + } + } + self.run_command('--os-volume-api-version 3.65 ' + f'snapshot-create --force {force_value} 123456') + self.assert_called_anytime('POST', '/snapshots', body=snap_body_3_65) + + @mock.patch('cinderclient.shell.CinderClientArgumentParser.exit') + def test_snapshot_create_pre_3_66_with_naked_force( + self, mock_exit): + mock_exit.side_effect = Exception("mock exit") + try: + self.run_command('--os-volume-api-version 3.65 ' + 'snapshot-create --force 123456') + except Exception as e: + # ignore the exception (it's raised to simulate an exit), + # but make sure it's the exception we expect + self.assertEqual('mock exit', str(e)) + + exit_code = mock_exit.call_args.args[0] + self.assertEqual(2, exit_code) + + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_pre_3_66_with_force_None( + self, mock_find_vol): + """We will let the API detect the problematic value.""" + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + snap_body_3_65 = { + 'snapshot': { + 'volume_id': '123456', + # note: this is a string, NOT None! + 'force': 'None', + 'name': None, + 'description': None, + 'metadata': {} + } + } + self.run_command('--os-volume-api-version 3.65 ' + 'snapshot-create --force None 123456') + self.assert_called_anytime('POST', '/snapshots', body=snap_body_3_65) + + SNAP_BODY_3_66 = { + 'snapshot': { + 'volume_id': '123456', + 'name': None, + 'description': None, + 'metadata': {} + } + } + + SNAP_BODY_3_66_W_METADATA = { + 'snapshot': { + 'volume_id': '123456', + 'name': None, + 'description': None, + 'metadata': {'a': 'b'} + } + } + + @ddt.data(True, 'true', 'on', '1') + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_3_66_with_force_true(self, f_val, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + mock_find_vol.return_value = volumes.Volume(self, + {'id': '123456'}, + loaded=True) + self.run_command('--os-volume-api-version 3.66 ' + f'snapshot-create --force {f_val} 123456') + self.assert_called_anytime('POST', '/snapshots', + body=self.SNAP_BODY_3_66) + + @ddt.data(False, 'false', 'no', '0', 'whatever') + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_3_66_with_force_not_true( + self, f_val, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + uae = self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + '--os-volume-api-version 3.66 ' + f'snapshot-create --force {f_val} 123456') + self.assertIn('not allowed after microversion 3.65', str(uae)) + + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_3_66_with_force_None( + self, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + uae = self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + '--os-volume-api-version 3.66 ' + 'snapshot-create --force None 123456') + self.assertIn('not allowed after microversion 3.65', str(uae)) + + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_3_66(self, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + self.run_command('--os-volume-api-version 3.66 ' + 'snapshot-create 123456') + self.assert_called_anytime('POST', '/snapshots', + body=self.SNAP_BODY_3_66) + + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_3_66_not_supported(self, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + self.run_command_with_server_api_max( + '3.64', + '--os-volume-api-version 3.66 snapshot-create 123456') + # call should be made, but will use the pre-3.66 request body + # because the client in use has been downgraded to 3.64 + pre_3_66_request_body = { + 'snapshot': { + 'volume_id': '123456', + # default value is False + 'force': False, + 'name': None, + 'description': None, + 'metadata': {} + } + } + self.assert_called_anytime('POST', '/snapshots', + body=pre_3_66_request_body) + + @mock.patch('cinderclient.utils.find_resource') + def test_snapshot_create_w_metadata(self, mock_find_vol): + mock_find_vol.return_value = volumes.Volume( + self, {'id': '123456'}, loaded=True) + self.run_command('--os-volume-api-version 3.66 ' + 'snapshot-create 123456 --metadata a=b') + self.assert_called_anytime('POST', '/snapshots', + body=self.SNAP_BODY_3_66_W_METADATA) + + def test_snapshot_manageable_list(self): + self.run_command('--os-volume-api-version 3.8 ' + 'snapshot-manageable-list fakehost') + self.assert_called('GET', '/manageable_snapshots/detail?host=fakehost') + + def test_snapshot_manageable_list_details(self): + self.run_command('--os-volume-api-version 3.8 ' + 'snapshot-manageable-list fakehost --detailed True') + self.assert_called('GET', '/manageable_snapshots/detail?host=fakehost') + + def test_snapshot_manageable_list_no_details(self): + self.run_command('--os-volume-api-version 3.8 ' + 'snapshot-manageable-list fakehost --detailed False') + self.assert_called('GET', '/manageable_snapshots?host=fakehost') + + def test_snapshot_manageable_list_cluster(self): + self.run_command('--os-volume-api-version 3.17 ' + 'snapshot-manageable-list --cluster dest') + self.assert_called('GET', '/manageable_snapshots/detail?cluster=dest') + + @ddt.data('', 'snapshot-') + def test_manageable_list_cluster_before_3_17(self, prefix): + self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + '--os-volume-api-version 3.16 ' + '%smanageable-list --cluster dest' % prefix) + + @mock.patch('cinderclient.shell.CinderClientArgumentParser.error') + @ddt.data('', 'snapshot-') + def test_manageable_list_mutual_exclusion(self, prefix, error_mock): + error_mock.side_effect = SystemExit + self.assertRaises(SystemExit, + self.run_command, + '--os-volume-api-version 3.17 ' + '%smanageable-list fakehost --cluster dest' % prefix) + + @mock.patch('cinderclient.shell.CinderClientArgumentParser.error') + @ddt.data('', 'snapshot-') + def test_manageable_list_missing_required(self, prefix, error_mock): + error_mock.side_effect = SystemExit + self.assertRaises(SystemExit, + self.run_command, + '--os-volume-api-version 3.17 ' + '%smanageable-list' % prefix) + + def test_list_messages(self): + self.run_command('--os-volume-api-version 3.3 message-list') + self.assert_called('GET', '/messages') + + @ddt.data('volume', 'backup', 'snapshot', None) + def test_reset_state_entity_not_found(self, entity_type): + cmd = 'reset-state 999999' + if entity_type is not None: + cmd += ' --type %s' % entity_type + self.assertRaises(exceptions.CommandError, self.run_command, cmd) + + @ddt.data({'entity_types': [{'name': 'volume', 'version': '3.0', + 'command': 'os-reset_status'}, + {'name': 'backup', 'version': '3.0', + 'command': 'os-reset_status'}, + {'name': 'snapshot', 'version': '3.0', + 'command': 'os-reset_status'}, + {'name': None, 'version': '3.0', + 'command': 'os-reset_status'}, + {'name': 'group', 'version': '3.20', + 'command': 'reset_status'}, + {'name': 'group-snapshot', 'version': '3.19', + 'command': 'reset_status'}], + 'r_id': ['1234'], + 'states': ['available', 'error', None]}, + {'entity_types': [{'name': 'volume', 'version': '3.0', + 'command': 'os-reset_status'}, + {'name': 'backup', 'version': '3.0', + 'command': 'os-reset_status'}, + {'name': 'snapshot', 'version': '3.0', + 'command': 'os-reset_status'}, + {'name': None, 'version': '3.0', + 'command': 'os-reset_status'}, + {'name': 'group', 'version': '3.20', + 'command': 'reset_status'}, + {'name': 'group-snapshot', 'version': '3.19', + 'command': 'reset_status'}], + 'r_id': ['1234', '5678'], + 'states': ['available', 'error', None]}) + @ddt.unpack + def test_reset_state_normal(self, entity_types, r_id, states): + for state in states: + for t in entity_types: + if state is None: + expected = {t['command']: {}} + cmd = ('--os-volume-api-version ' + '%s reset-state %s') % (t['version'], + ' '.join(r_id)) + else: + expected = {t['command']: {'status': state}} + cmd = ('--os-volume-api-version ' + '%s reset-state ' + '--state %s %s') % (t['version'], + state, ' '.join(r_id)) + if t['name'] is not None: + cmd += ' --type %s' % t['name'] + + self.run_command(cmd) + + name = t['name'] if t['name'] else 'volume' + for re in r_id: + self.assert_called_anytime('POST', '/%ss/%s/action' + % (name.replace('-', '_'), re), + body=expected) + + @ddt.data({'command': '--attach-status detached', + 'expected': {'attach_status': 'detached'}}, + {'command': '--state in-use --attach-status attached', + 'expected': {'status': 'in-use', + 'attach_status': 'attached'}}, + {'command': '--reset-migration-status', + 'expected': {'migration_status': 'none'}}) + @ddt.unpack + def test_reset_state_volume_additional_status(self, command, expected): + self.run_command('reset-state %s 1234' % command) + expected = {'os-reset_status': expected} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_snapshot_list_with_with_count(self): + self.run_command( + '--os-volume-api-version 3.45 snapshot-list --with-count') + self.assert_called('GET', '/snapshots/detail?with_count=True') + + def test_snapshot_list_with_metadata(self): + self.run_command('--os-volume-api-version 3.22 ' + 'snapshot-list --metadata key1=val1') + expected = ("/snapshots/detail?metadata=%s" + % parse.quote_plus("{'key1': 'val1'}")) + self.assert_called('GET', expected) + + @ddt.data(('resource_type',), ('event_id',), ('resource_uuid',), + ('level', 'message_level'), ('request_id',)) + def test_list_messages_with_filters(self, filter): + self.run_command('--os-volume-api-version 3.5 message-list --%s=TEST' + % filter[0]) + self.assert_called('GET', '/messages?%s=TEST' % filter[-1]) + + def test_list_messages_with_sort(self): + self.run_command('--os-volume-api-version 3.5 ' + 'message-list --sort=id:asc') + self.assert_called('GET', '/messages?sort=id%3Aasc') + + def test_list_messages_with_limit(self): + self.run_command('--os-volume-api-version 3.5 message-list --limit=1') + self.assert_called('GET', '/messages?limit=1') + + def test_list_messages_with_marker(self): + self.run_command('--os-volume-api-version 3.5 message-list --marker=1') + self.assert_called('GET', '/messages?marker=1') + + def test_list_with_image_metadata_before_3_4(self): + self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + 'list --image_metadata image_name=1234') + + def test_list_filter_image_metadata(self): + self.run_command('--os-volume-api-version 3.4 ' + 'list --image_metadata image_name=1234') + url = ('/volumes/detail?%s' % + parse.urlencode([('glance_metadata', {"image_name": "1234"})])) + self.assert_called('GET', url) + + def test_show_message(self): + self.run_command('--os-volume-api-version 3.5 message-show 1234') + self.assert_called('GET', '/messages/1234') + + def test_delete_message(self): + self.run_command('--os-volume-api-version 3.5 message-delete 1234') + self.assert_called('DELETE', '/messages/1234') + + def test_delete_messages(self): + self.run_command( + '--os-volume-api-version 3.3 message-delete 1234 12345') + self.assert_called_anytime('DELETE', '/messages/1234') + self.assert_called_anytime('DELETE', '/messages/12345') + + @mock.patch('cinderclient.utils.find_resource') + def test_delete_metadata(self, mock_find_volume): + mock_find_volume.return_value = volumes.Volume(self, + {'id': '1234', + 'metadata': + {'k1': 'v1', + 'k2': 'v2', + 'k3': 'v3'}}, + loaded = True) + expected = {'metadata': {'k2': 'v2'}} + self.run_command('--os-volume-api-version 3.15 ' + 'metadata 1234 unset k1 k3') + self.assert_called('PUT', '/volumes/1234/metadata', body=expected) + + @ddt.data(("3.0", None), ("3.6", None), + ("3.7", True), ("3.7", False), ("3.7", "")) + @ddt.unpack + def test_service_list_withreplication(self, version, replication): + command = ('--os-volume-api-version %s service-list' % + version) + if replication is not None: + command += ' --withreplication %s' % replication + self.run_command(command) + self.assert_called('GET', '/os-services') + + def test_group_enable_replication(self): + cmd = '--os-volume-api-version 3.38 group-enable-replication 1234' + self.run_command(cmd) + expected = {'enable_replication': {}} + self.assert_called('POST', '/groups/1234/action', body=expected) + + def test_group_disable_replication(self): + cmd = '--os-volume-api-version 3.38 group-disable-replication 1234' + self.run_command(cmd) + expected = {'disable_replication': {}} + self.assert_called('POST', '/groups/1234/action', body=expected) + + @ddt.data((False, None), (True, None), + (False, "backend1"), (True, "backend1"), + (False, "default"), (True, "default")) + @ddt.unpack + def test_group_failover_replication(self, attach_vol, backend): + attach = '--allow-attached-volume ' if attach_vol else '' + backend_id = ('--secondary-backend-id ' + backend) if backend else '' + cmd = ('--os-volume-api-version 3.38 ' + 'group-failover-replication 1234 ' + attach + backend_id) + self.run_command(cmd) + expected = {'failover_replication': + {'allow_attached_volume': attach_vol, + 'secondary_backend_id': backend if backend else None}} + self.assert_called('POST', '/groups/1234/action', body=expected) + + def test_group_list_replication_targets(self): + cmd = ('--os-volume-api-version 3.38 group-list-replication-targets' + ' 1234') + self.run_command(cmd) + expected = {'list_replication_targets': {}} + self.assert_called('POST', '/groups/1234/action', body=expected) + + @mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels') + def test_service_get_log_before_3_32(self, get_levels_mock): + self.assertRaises(SystemExit, + self.run_command, '--os-volume-api-version 3.28 ' + 'service-get-log') + get_levels_mock.assert_not_called() + + @mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels') + @mock.patch('cinderclient.shell_utils.print_list') + def test_service_get_log_no_params(self, print_mock, get_levels_mock): + self.run_command('--os-volume-api-version 3.32 service-get-log') + get_levels_mock.assert_called_once_with('', '', '') + print_mock.assert_called_once_with(get_levels_mock.return_value, + ('Binary', 'Host', 'Prefix', + 'Level')) + + @ddt.data('*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', + 'cinder-backup') + @mock.patch('cinderclient.v3.services.ServiceManager.get_log_levels') + @mock.patch('cinderclient.shell_utils.print_list') + def test_service_get_log(self, binary, print_mock, get_levels_mock): + server = 'host1' + prefix = 'sqlalchemy' + + self.run_command('--os-volume-api-version 3.32 service-get-log ' + '--binary %s --server %s --prefix %s' % ( + binary, server, prefix)) + get_levels_mock.assert_called_once_with(binary, server, prefix) + print_mock.assert_called_once_with(get_levels_mock.return_value, + ('Binary', 'Host', 'Prefix', + 'Level')) + + @mock.patch('cinderclient.v3.services.ServiceManager.set_log_levels') + def test_service_set_log_before_3_32(self, set_levels_mock): + self.assertRaises(SystemExit, + self.run_command, '--os-volume-api-version 3.28 ' + 'service-set-log debug') + set_levels_mock.assert_not_called() + + @mock.patch('cinderclient.v3.services.ServiceManager.set_log_levels') + @mock.patch('cinderclient.shell.CinderClientArgumentParser.error') + def test_service_set_log_missing_required(self, error_mock, + set_levels_mock): + error_mock.side_effect = SystemExit + self.assertRaises(SystemExit, + self.run_command, '--os-volume-api-version 3.32 ' + 'service-set-log') + set_levels_mock.assert_not_called() + msg = 'the following arguments are required: ' + error_mock.assert_called_once_with(msg) + + @ddt.data('debug', 'DEBUG', 'info', 'INFO', 'warning', 'WARNING', 'error', + 'ERROR') + @mock.patch('cinderclient.v3.services.ServiceManager.set_log_levels') + def test_service_set_log_min_params(self, level, set_levels_mock): + self.run_command('--os-volume-api-version 3.32 ' + 'service-set-log %s' % level) + set_levels_mock.assert_called_once_with(level, '', '', '') + + @ddt.data('*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', + 'cinder-backup') + @mock.patch('cinderclient.v3.services.ServiceManager.set_log_levels') + def test_service_set_log_levels(self, binary, set_levels_mock): + level = 'debug' + server = 'host1' + prefix = 'sqlalchemy.' + self.run_command('--os-volume-api-version 3.32 ' + 'service-set-log %s --binary %s --server %s ' + '--prefix %s' % (level, binary, server, prefix)) + set_levels_mock.assert_called_once_with(level, binary, server, prefix) + + @mock.patch('cinderclient.shell_utils._poll_for_status') + def test_create_with_poll(self, poll_method): + self.run_command('create --poll 1') + self.assert_called_anytime('GET', '/volumes/1234') + volume = self.shell.cs.volumes.get('1234') + info = dict() + info.update(volume._info) + self.assertEqual(1, poll_method.call_count) + timeout_period = 3600 + poll_method.assert_has_calls([mock.call(self.shell.cs.volumes.get, + 1234, info, 'creating', ['available'], timeout_period, + self.shell.cs.client.global_request_id, + self.shell.cs.messages)]) + + @mock.patch('cinderclient.shell_utils.time') + def test_poll_for_status(self, mock_time): + poll_period = 2 + some_id = "some-id" + global_request_id = "req-someid" + action = "some" + updated_objects = ( + base.Resource(None, info={"not_default_field": "creating"}), + base.Resource(None, info={"not_default_field": "available"})) + poll_fn = mock.MagicMock(side_effect=updated_objects) + cinderclient.shell_utils._poll_for_status( + poll_fn = poll_fn, + obj_id = some_id, + global_request_id = global_request_id, + messages = base.Resource(None, {}), + info = {}, + action = action, + status_field = "not_default_field", + final_ok_states = ['available'], + timeout_period=3600) + self.assertEqual([mock.call(poll_period)] * 2, + mock_time.sleep.call_args_list) + self.assertEqual([mock.call(some_id)] * 2, poll_fn.call_args_list) + + @mock.patch('cinderclient.v3.messages.MessageManager.list') + @mock.patch('cinderclient.shell_utils.time') + def test_poll_for_status_error(self, mock_time, mock_message_list): + poll_period = 2 + some_id = "some_id" + global_request_id = "req-someid" + action = "some" + updated_objects = ( + base.Resource(None, info={"not_default_field": "creating"}), + base.Resource(None, info={"not_default_field": "error"})) + poll_fn = mock.MagicMock(side_effect=updated_objects) + msg_object = base.Resource(cinderclient.v3.messages.MessageManager, + info = {"user_message": "ERROR!"}) + mock_message_list.return_value = (msg_object,) + self.assertRaises(exceptions.ResourceInErrorState, + cinderclient.shell_utils._poll_for_status, + poll_fn=poll_fn, + obj_id=some_id, + global_request_id=global_request_id, + messages=cinderclient.v3.messages.MessageManager(api=3.34), + info=dict(), + action=action, + final_ok_states=['available'], + status_field="not_default_field", + timeout_period=3600) + self.assertEqual([mock.call(poll_period)] * 2, + mock_time.sleep.call_args_list) + self.assertEqual([mock.call(some_id)] * 2, poll_fn.call_args_list) + + def test_backup(self): + self.run_command('--os-volume-api-version 3.42 backup-create ' + '--name 1234 1234') + expected = {'backup': {'volume_id': 1234, + 'container': None, + 'name': '1234', + 'description': None, + 'incremental': False, + 'force': False, + 'snapshot_id': None, + }} + self.assert_called('POST', '/backups', body=expected) + + def test_backup_with_metadata(self): + self.run_command('--os-volume-api-version 3.43 backup-create ' + '--metadata foo=bar --name 1234 1234') + expected = {'backup': {'volume_id': 1234, + 'container': None, + 'name': '1234', + 'description': None, + 'incremental': False, + 'force': False, + 'snapshot_id': None, + 'metadata': {'foo': 'bar'}, }} + self.assert_called('POST', '/backups', body=expected) + + def test_backup_with_az(self): + self.run_command('--os-volume-api-version 3.51 backup-create ' + '--availability-zone AZ2 --name 1234 1234') + expected = {'backup': {'volume_id': 1234, + 'container': None, + 'name': '1234', + 'description': None, + 'incremental': False, + 'force': False, + 'snapshot_id': None, + 'availability_zone': 'AZ2'}} + self.assert_called('POST', '/backups', body=expected) + + @mock.patch("cinderclient.shell_utils.print_list") + def test_snapshot_list(self, mock_print_list): + """Ensure we always present all existing fields when listing snaps.""" + self.run_command('--os-volume-api-version 3.65 snapshot-list') + self.assert_called('GET', '/snapshots/detail') + columns = ['ID', 'Volume ID', 'Status', 'Name', 'Size', + 'Consumes Quota', 'User ID'] + mock_print_list.assert_called_once_with(mock.ANY, columns, + exclude_unavailable=True, + sortby_index=0) + + @mock.patch('cinderclient.v3.volumes.Volume.migrate_volume') + def test_migrate_volume_before_3_16(self, v3_migrate_mock): + self.run_command('--os-volume-api-version 3.15 ' + 'migrate 1234 fakehost') + + v3_migrate_mock.assert_called_once_with( + 'fakehost', False, False, None) + + @mock.patch('cinderclient.v3.volumes.Volume.migrate_volume') + def test_migrate_volume_3_16(self, v3_migrate_mock): + self.run_command('--os-volume-api-version 3.16 ' + 'migrate 1234 fakehost') + self.assertEqual(4, len(v3_migrate_mock.call_args[0])) + + def test_migrate_volume_with_cluster_before_3_16(self): + self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + '--os-volume-api-version 3.15 ' + 'migrate 1234 fakehost --cluster fakecluster') + + @mock.patch('cinderclient.shell.CinderClientArgumentParser.error') + def test_migrate_volume_mutual_exclusion(self, error_mock): + error_mock.side_effect = SystemExit + self.assertRaises(SystemExit, + self.run_command, + '--os-volume-api-version 3.16 ' + 'migrate 1234 fakehost --cluster fakecluster') + msg = 'argument --cluster: not allowed with argument ' + error_mock.assert_called_once_with(msg) + + @mock.patch('cinderclient.shell.CinderClientArgumentParser.error') + def test_migrate_volume_missing_required(self, error_mock): + error_mock.side_effect = SystemExit + self.assertRaises(SystemExit, + self.run_command, + '--os-volume-api-version 3.16 ' + 'migrate 1234') + msg = 'one of the arguments --cluster is required' + error_mock.assert_called_once_with(msg) + + def test_migrate_volume_host(self): + self.run_command('--os-volume-api-version 3.16 ' + 'migrate 1234 fakehost') + expected = {'os-migrate_volume': {'force_host_copy': False, + 'lock_volume': False, + 'host': 'fakehost'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_migrate_volume_cluster(self): + self.run_command('--os-volume-api-version 3.16 ' + 'migrate 1234 --cluster mycluster') + expected = {'os-migrate_volume': {'force_host_copy': False, + 'lock_volume': False, + 'cluster': 'mycluster'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_migrate_volume_bool_force(self): + self.run_command('--os-volume-api-version 3.16 ' + 'migrate 1234 fakehost --force-host-copy ' + '--lock-volume') + expected = {'os-migrate_volume': {'force_host_copy': True, + 'lock_volume': True, + 'host': 'fakehost'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + def test_migrate_volume_bool_force_false(self): + # Set both --force-host-copy and --lock-volume to False. + self.run_command('--os-volume-api-version 3.16 ' + 'migrate 1234 fakehost --force-host-copy=False ' + '--lock-volume=False') + expected = {'os-migrate_volume': {'force_host_copy': 'False', + 'lock_volume': 'False', + 'host': 'fakehost'}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + # Do not set the values to --force-host-copy and --lock-volume. + self.run_command('--os-volume-api-version 3.16 ' + 'migrate 1234 fakehost') + expected = {'os-migrate_volume': {'force_host_copy': False, + 'lock_volume': False, + 'host': 'fakehost'}} + self.assert_called('POST', '/volumes/1234/action', + body=expected) + + @ddt.data({'bootable': False, 'by_id': False, 'cluster': None}, + {'bootable': True, 'by_id': False, 'cluster': None}, + {'bootable': False, 'by_id': True, 'cluster': None}, + {'bootable': True, 'by_id': True, 'cluster': None}, + {'bootable': True, 'by_id': True, 'cluster': 'clustername'}) + @ddt.unpack + def test_volume_manage(self, bootable, by_id, cluster): + cmd = ('--os-volume-api-version 3.16 ' + 'manage host1 some_fake_name --name foo --description bar ' + '--volume-type baz --availability-zone az ' + '--metadata k1=v1 k2=v2') + if by_id: + cmd += ' --id-type source-id' + if bootable: + cmd += ' --bootable' + if cluster: + cmd += ' --cluster ' + cluster + + self.run_command(cmd) + ref = 'source-id' if by_id else 'source-name' + expected = {'volume': {'host': 'host1', + 'ref': {ref: 'some_fake_name'}, + 'name': 'foo', + 'description': 'bar', + 'volume_type': 'baz', + 'availability_zone': 'az', + 'metadata': {'k1': 'v1', 'k2': 'v2'}, + 'bootable': bootable}} + if cluster: + expected['volume']['cluster'] = cluster + self.assert_called_anytime('POST', '/os-volume-manage', body=expected) + + def test_volume_manage_before_3_16(self): + """Cluster optional argument was not acceptable.""" + self.assertRaises(exceptions.UnsupportedAttribute, + self.run_command, + 'manage host1 some_fake_name ' + '--cluster clustername' + '--name foo --description bar --bootable ' + '--volume-type baz --availability-zone az ' + '--metadata k1=v1 k2=v2') + + def test_worker_cleanup_before_3_24(self): + self.assertRaises(SystemExit, + self.run_command, + 'work-cleanup fakehost') + + def test_worker_cleanup(self): + self.run_command('--os-volume-api-version 3.24 ' + 'work-cleanup --cluster clustername --host hostname ' + '--binary binaryname --is-up false --disabled true ' + '--resource-id uuid --resource-type Volume ' + '--service-id 1') + expected = {'cluster_name': 'clustername', + 'host': 'hostname', + 'binary': 'binaryname', + 'is_up': 'false', + 'disabled': 'true', + 'resource_id': 'uuid', + 'resource_type': 'Volume', + 'service_id': 1} + + self.assert_called('POST', '/workers/cleanup', body=expected) + + def test_create_transfer(self): + self.run_command('transfer-create 1234') + expected = {'transfer': {'volume_id': 1234, + 'name': None, + }} + self.assert_called('POST', '/os-volume-transfer', body=expected) + + def test_create_transfer_no_snaps(self): + self.run_command('--os-volume-api-version 3.55 transfer-create ' + '--no-snapshots 1234') + expected = {'transfer': {'volume_id': 1234, + 'name': None, + 'no_snapshots': True + }} + self.assert_called('POST', '/volume-transfers', body=expected) + + def test_list_transfer_sorty_not_sorty(self): + self.run_command( + '--os-volume-api-version 3.59 transfer-list') + url = ('/volume-transfers/detail') + self.assert_called('GET', url) + + def test_delete_transfer(self): + self.run_command('transfer-delete 1234') + self.assert_called('DELETE', '/os-volume-transfer/1234') + + def test_delete_transfers(self): + self.run_command('transfer-delete 1234 5678') + self.assert_called_anytime('DELETE', '/os-volume-transfer/1234') + self.assert_called_anytime('DELETE', '/os-volume-transfer/5678') + + def test_subcommand_parser(self): + """Ensure that all the expected commands show up. + + This test ensures that refactoring code does not somehow result in + a command accidentally ceasing to exist. + + TODO: add a similar test for 3.59 or so + """ + p = self.shell.get_subcommand_parser(api_versions.APIVersion("3.0"), + input_args=['help'], do_help=True) + help_text = p.format_help() + + # These are v3.0 commands only + expected_commands = ('absolute-limits', + 'api-version', + 'availability-zone-list', + 'backup-create', + 'backup-delete', + 'backup-export', + 'backup-import', + 'backup-list', + 'backup-reset-state', + 'backup-restore', + 'backup-show', + 'cgsnapshot-create', + 'cgsnapshot-delete', + 'cgsnapshot-list', + 'cgsnapshot-show', + 'consisgroup-create', + 'consisgroup-create-from-src', + 'consisgroup-delete', + 'consisgroup-list', + 'consisgroup-show', + 'consisgroup-update', + 'create', + 'delete', + 'encryption-type-create', + 'encryption-type-delete', + 'encryption-type-list', + 'encryption-type-show', + 'encryption-type-update', + 'extend', + 'extra-specs-list', + 'failover-host', + 'force-delete', + 'freeze-host', + 'get-capabilities', + 'get-pools', + 'image-metadata', + 'image-metadata-show', + 'list', + 'manage', + 'metadata', + 'metadata-show', + 'metadata-update-all', + 'migrate', + 'qos-associate', + 'qos-create', + 'qos-delete', + 'qos-disassociate', + 'qos-disassociate-all', + 'qos-get-association', + 'qos-key', + 'qos-list', + 'qos-show', + 'quota-class-show', + 'quota-class-update', + 'quota-defaults', + 'quota-delete', + 'quota-show', + 'quota-update', + 'quota-usage', + 'rate-limits', + 'readonly-mode-update', + 'rename', + 'reset-state', + 'retype', + 'service-disable', + 'service-enable', + 'service-list', + 'set-bootable', + 'show', + 'snapshot-create', + 'snapshot-delete', + 'snapshot-list', + 'snapshot-manage', + 'snapshot-metadata', + 'snapshot-metadata-show', + 'snapshot-metadata-update-all', + 'snapshot-rename', + 'snapshot-reset-state', + 'snapshot-show', + 'snapshot-unmanage', + 'thaw-host', + 'transfer-accept', + 'transfer-create', + 'transfer-delete', + 'transfer-list', + 'transfer-show', + 'type-access-add', + 'type-access-list', + 'type-access-remove', + 'type-create', + 'type-default', + 'type-delete', + 'type-key', + 'type-list', + 'type-show', + 'type-update', + 'unmanage', + 'upload-to-image', + 'version-list', + 'bash-completion', + 'help',) + + for e in expected_commands: + self.assertIn(' ' + e, help_text) + + @ddt.data( + # testcases for list transfers + {'command': + 'transfer-list --filters volume_id=456', + 'expected': + '/os-volume-transfer/detail?volume_id=456'}, + {'command': + 'transfer-list --filters id=123', + 'expected': + '/os-volume-transfer/detail?id=123'}, + {'command': + 'transfer-list --filters name=abc', + 'expected': + '/os-volume-transfer/detail?name=abc'}, + {'command': + 'transfer-list --filters name=abc --filters volume_id=456', + 'expected': + '/os-volume-transfer/detail?name=abc&volume_id=456'}, + {'command': + 'transfer-list --filters id=123 --filters volume_id=456', + 'expected': + '/os-volume-transfer/detail?id=123&volume_id=456'}, + {'command': + 'transfer-list --filters id=123 --filters name=abc', + 'expected': + '/os-volume-transfer/detail?id=123&name=abc'}, + ) + @ddt.unpack + def test_transfer_list_with_filters(self, command, expected): + self.run_command('--os-volume-api-version 3.52 %s' % command) + self.assert_called('GET', expected) + + def test_default_type_set(self): + self.run_command('--os-volume-api-version 3.62 default-type-set ' + '4c298f16-e339-4c80-b934-6cbfcb7525a0 ' + '629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + body = { + 'default_type': + { + 'volume_type': '4c298f16-e339-4c80-b934-6cbfcb7525a0' + } + } + self.assert_called( + 'PUT', 'v3/default-types/629632e7-99d2-4c40-9ae3-106fa3b1c9b7', + body=body) + + def test_default_type_list_project(self): + self.run_command('--os-volume-api-version 3.62 default-type-list ' + '--project-id 629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + self.assert_called( + 'GET', 'v3/default-types/629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + + def test_default_type_list(self): + self.run_command('--os-volume-api-version 3.62 default-type-list') + self.assert_called('GET', 'v3/default-types') + + def test_default_type_delete(self): + self.run_command('--os-volume-api-version 3.62 default-type-unset ' + '629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + self.assert_called( + 'DELETE', 'v3/default-types/629632e7-99d2-4c40-9ae3-106fa3b1c9b7') + + def test_restore(self): + self.run_command('backup-restore 1234') + self.assert_called('POST', '/backups/1234/restore') + + def test_restore_with_name(self): + self.run_command('backup-restore 1234 --name restore_vol') + expected = {'restore': {'volume_id': None, 'name': 'restore_vol'}} + self.assert_called('POST', '/backups/1234/restore', + body=expected) + + def test_restore_with_name_error(self): + self.assertRaises(exceptions.CommandError, self.run_command, + 'backup-restore 1234 --volume fake_vol --name ' + 'restore_vol') + + def test_restore_with_az(self): + self.run_command('--os-volume-api-version 3.47 backup-restore 1234 ' + '--name restore_vol --availability-zone restore_az') + expected = {'volume': {'size': 10, + 'name': 'restore_vol', + 'availability_zone': 'restore_az', + 'backup_id': '1234', + 'metadata': {}, + 'imageRef': None, + 'source_volid': None, + 'consistencygroup_id': None, + 'snapshot_id': None, + 'volume_type': None, + 'description': None}} + self.assert_called('POST', '/volumes', body=expected) + + def test_restore_with_az_microversion_error(self): + self.assertRaises(exceptions.UnsupportedAttribute, self.run_command, + '--os-volume-api-version 3.46 backup-restore 1234 ' + '--name restore_vol --availability-zone restore_az') + + def test_restore_with_volume_type(self): + self.run_command('--os-volume-api-version 3.47 backup-restore 1234 ' + '--name restore_vol --volume-type restore_type') + expected = {'volume': {'size': 10, + 'name': 'restore_vol', + 'volume_type': 'restore_type', + 'backup_id': '1234', + 'metadata': {}, + 'imageRef': None, + 'source_volid': None, + 'consistencygroup_id': None, + 'snapshot_id': None, + 'availability_zone': None, + 'description': None}} + self.assert_called('POST', '/volumes', body=expected) + + def test_restore_with_volume_type_microversion_error(self): + self.assertRaises(exceptions.UnsupportedAttribute, self.run_command, + '--os-volume-api-version 3.46 backup-restore 1234 ' + '--name restore_vol --volume-type restore_type') + + def test_restore_with_volume_type_and_az_no_name(self): + self.run_command('--os-volume-api-version 3.47 backup-restore 1234 ' + '--volume-type restore_type ' + '--availability-zone restore_az') + expected = {'volume': {'size': 10, + 'name': 'restore_backup_1234', + 'volume_type': 'restore_type', + 'availability_zone': 'restore_az', + 'backup_id': '1234', + 'metadata': {}, + 'imageRef': None, + 'source_volid': None, + 'consistencygroup_id': None, + 'snapshot_id': None, + 'description': None}} + self.assert_called('POST', '/volumes', body=expected) + + @ddt.data( + { + 'volume': '1234', + 'name': None, + 'volume_type': None, + 'availability_zone': None, + }, { + 'volume': '1234', + 'name': 'ignored', + 'volume_type': None, + 'availability_zone': None, + }, { + 'volume': None, + 'name': 'sample-volume', + 'volume_type': 'sample-type', + 'availability_zone': None, + }, { + 'volume': None, + 'name': 'sample-volume', + 'volume_type': None, + 'availability_zone': 'az1', + }, { + 'volume': None, + 'name': 'sample-volume', + 'volume_type': None, + 'availability_zone': 'different-az', + }, { + 'volume': None, + 'name': None, + 'volume_type': None, + 'availability_zone': 'different-az', + }, + ) + @ddt.unpack + @mock.patch('cinderclient.shell_utils.print_dict') + @mock.patch('cinderclient.tests.unit.v3.fakes_base._stub_restore') + def test_do_backup_restore(self, + mock_stub_restore, + mock_print_dict, + volume, + name, + volume_type, + availability_zone): + + # Restore from the fake '1234' backup. + cmd = '--os-volume-api-version 3.47 backup-restore 1234' + + if volume: + cmd += ' --volume %s' % volume + if name: + cmd += ' --name %s' % name + if volume_type: + cmd += ' --volume-type %s' % volume_type + if availability_zone: + cmd += ' --availability-zone %s' % availability_zone + + if name or volume: + volume_name = 'sample-volume' + else: + volume_name = 'restore_backup_1234' + + mock_stub_restore.return_value = {'volume_id': '1234', + 'volume_name': volume_name} + + self.run_command(cmd) + + # Check whether mock_stub_restore was called in order to determine + # whether the restore command invoked the backup-restore API. If + # mock_stub_restore was not called then this indicates the command + # invoked the volume-create API to restore the backup to a new volume + # of a specific volume type, or in a different AZ (the fake '1234' + # backup is in az1). + if volume_type or availability_zone == 'different-az': + mock_stub_restore.assert_not_called() + else: + mock_stub_restore.assert_called_once() + + mock_print_dict.assert_called_once_with({ + 'backup_id': '1234', + 'volume_id': '1234', + 'volume_name': volume_name, + }) + + def test_reimage(self): + self.run_command('--os-volume-api-version 3.68 reimage 1234 1') + expected = {'os-reimage': {'image_id': '1', + 'reimage_reserved': False}} + self.assert_called('POST', '/volumes/1234/action', body=expected) + + @ddt.data('False', 'True') + def test_reimage_reserved(self, reimage_reserved): + self.run_command( + '--os-volume-api-version 3.68 reimage --reimage-reserved %s 1234 1' + % reimage_reserved) + expected = {'os-reimage': {'image_id': '1', + 'reimage_reserved': reimage_reserved}} + self.assert_called('POST', '/volumes/1234/action', body=expected) diff --git a/cinderclient/tests/unit/v1/test_snapshot_actions.py b/cinderclient/tests/unit/v3/test_snapshot_actions.py similarity index 54% rename from cinderclient/tests/unit/v1/test_snapshot_actions.py rename to cinderclient/tests/unit/v3/test_snapshot_actions.py index 46d31b2a4..8d2b23a0d 100644 --- a/cinderclient/tests/unit/v1/test_snapshot_actions.py +++ b/cinderclient/tests/unit/v3/test_snapshot_actions.py @@ -13,24 +13,46 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.tests.unit import utils from cinderclient.tests.unit.fixture_data import client from cinderclient.tests.unit.fixture_data import snapshots +from cinderclient.tests.unit import utils class SnapshotActionsTest(utils.FixturedTestCase): - client_fixture_class = client.V1 + client_fixture_class = client.V3 data_fixture_class = snapshots.Fixture def test_update_snapshot_status(self): - s = self.cs.volume_snapshots.get('1234') + snap = self.cs.volume_snapshots.get('1234') + self._assert_request_id(snap) stat = {'status': 'available'} - self.cs.volume_snapshots.update_snapshot_status(s, stat) + stats = self.cs.volume_snapshots.update_snapshot_status(snap, stat) self.assert_called('POST', '/snapshots/1234/action') + self._assert_request_id(stats) def test_update_snapshot_status_with_progress(self): s = self.cs.volume_snapshots.get('1234') + self._assert_request_id(s) stat = {'status': 'available', 'progress': '73%'} - self.cs.volume_snapshots.update_snapshot_status(s, stat) + stats = self.cs.volume_snapshots.update_snapshot_status(s, stat) self.assert_called('POST', '/snapshots/1234/action') + self._assert_request_id(stats) + + def test_list_snapshots_with_marker_limit(self): + lst = self.cs.volume_snapshots.list(marker=1234, limit=2) + self.assert_called('GET', '/snapshots/detail?limit=2&marker=1234') + self._assert_request_id(lst) + + def test_list_snapshots_with_sort(self): + lst = self.cs.volume_snapshots.list(sort="id") + self.assert_called('GET', '/snapshots/detail?sort=id') + self._assert_request_id(lst) + + def test_snapshot_unmanage(self): + s = self.cs.volume_snapshots.get('1234') + self._assert_request_id(s) + snap = self.cs.volume_snapshots.unmanage(s) + self.assert_called('POST', '/snapshots/1234/action', + {'os-unmanage': None}) + self._assert_request_id(snap) diff --git a/cinderclient/tests/unit/v2/test_type_access.py b/cinderclient/tests/unit/v3/test_type_access.py similarity index 74% rename from cinderclient/tests/unit/v2/test_type_access.py rename to cinderclient/tests/unit/v3/test_type_access.py index 7e41b3ee3..5b2dbb3e4 100644 --- a/cinderclient/tests/unit/v2/test_type_access.py +++ b/cinderclient/tests/unit/v3/test_type_access.py @@ -14,9 +14,9 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.v2 import volume_type_access from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import volume_type_access cs = fakes.FakeClient() @@ -28,15 +28,18 @@ class TypeAccessTest(utils.TestCase): def test_list(self): access = cs.volume_type_access.list(volume_type='3') cs.assert_called('GET', '/types/3/os-volume-type-access') + self._assert_request_id(access) for a in access: - self.assertTrue(isinstance(a, volume_type_access.VolumeTypeAccess)) + self.assertIsInstance(a, volume_type_access.VolumeTypeAccess) def test_add_project_access(self): - cs.volume_type_access.add_project_access('3', PROJECT_UUID) + access = cs.volume_type_access.add_project_access('3', PROJECT_UUID) cs.assert_called('POST', '/types/3/action', {'addProjectAccess': {'project': PROJECT_UUID}}) + self._assert_request_id(access) def test_remove_project_access(self): - cs.volume_type_access.remove_project_access('3', PROJECT_UUID) + access = cs.volume_type_access.remove_project_access('3', PROJECT_UUID) cs.assert_called('POST', '/types/3/action', {'removeProjectAccess': {'project': PROJECT_UUID}}) + self._assert_request_id(access) diff --git a/cinderclient/tests/unit/v2/test_types.py b/cinderclient/tests/unit/v3/test_types.py similarity index 60% rename from cinderclient/tests/unit/v2/test_types.py rename to cinderclient/tests/unit/v3/test_types.py index db4cbc3bc..fdbd32317 100644 --- a/cinderclient/tests/unit/v2/test_types.py +++ b/cinderclient/tests/unit/v3/test_types.py @@ -14,9 +14,9 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.v2 import volume_types from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import volume_types cs = fakes.FakeClient() @@ -25,13 +25,15 @@ class TypesTest(utils.TestCase): def test_list_types(self): tl = cs.volume_types.list() - cs.assert_called('GET', '/types') + cs.assert_called('GET', '/types?is_public=None') + self._assert_request_id(tl) for t in tl: self.assertIsInstance(t, volume_types.VolumeType) def test_list_types_not_public(self): - cs.volume_types.list(is_public=None) + t1 = cs.volume_types.list(is_public=None) cs.assert_called('GET', '/types?is_public=None') + self._assert_request_id(t1) def test_create(self): t = cs.volume_types.create('test-type-3', 'test-type-3-desc') @@ -42,6 +44,7 @@ def test_create(self): 'os-volume-type-access:is_public': True }}) self.assertIsInstance(t, volume_types.VolumeType) + self._assert_request_id(t) def test_create_non_public(self): t = cs.volume_types.create('test-type-3', 'test-type-3-desc', False) @@ -52,37 +55,73 @@ def test_create_non_public(self): 'os-volume-type-access:is_public': False }}) self.assertIsInstance(t, volume_types.VolumeType) + self._assert_request_id(t) def test_update(self): - t = cs.volume_types.update('1', 'test_type_1', 'test_desc_1') + t = cs.volume_types.update('1', 'test_type_1', 'test_desc_1', False) cs.assert_called('PUT', '/types/1', {'volume_type': {'name': 'test_type_1', - 'description': 'test_desc_1'}}) + 'description': 'test_desc_1', + 'is_public': False}}) self.assertIsInstance(t, volume_types.VolumeType) + self._assert_request_id(t) + + def test_update_name(self): + """Test volume_type update shell command + + Verify that only name is updated and the description and + is_public properties remains unchanged. + """ + # create volume_type with is_public True + t = cs.volume_types.create('test-type-3', 'test_type-3-desc', True) + self.assertTrue(t.is_public) + # update name only + t1 = cs.volume_types.update(t.id, 'test-type-2') + cs.assert_called('PUT', + '/types/3', + {'volume_type': {'name': 'test-type-2', + 'description': None}}) + # verify that name is updated and the description + # and is_public are the same. + self.assertEqual('test-type-2', t1.name) + self.assertEqual('test_type-3-desc', t1.description) + self.assertTrue(t1.is_public) def test_get(self): t = cs.volume_types.get('1') cs.assert_called('GET', '/types/1') self.assertIsInstance(t, volume_types.VolumeType) + self._assert_request_id(t) def test_default(self): t = cs.volume_types.default() cs.assert_called('GET', '/types/default') self.assertIsInstance(t, volume_types.VolumeType) + self._assert_request_id(t) def test_set_key(self): t = cs.volume_types.get(1) - t.set_keys({'k': 'v'}) + res = t.set_keys({'k': 'v'}) cs.assert_called('POST', '/types/1/extra_specs', {'extra_specs': {'k': 'v'}}) + self._assert_request_id(res) - def test_unsset_keys(self): + def test_unset_keys(self): t = cs.volume_types.get(1) - t.unset_keys(['k']) + res = t.unset_keys(['k']) cs.assert_called('DELETE', '/types/1/extra_specs/k') + self._assert_request_id(res) + + def test_unset_multiple_keys(self): + t = cs.volume_types.get(1) + res = t.unset_keys(['k', 'm']) + cs.assert_called_anytime('DELETE', '/types/1/extra_specs/k') + cs.assert_called_anytime('DELETE', '/types/1/extra_specs/m') + self._assert_request_id(res, count=2) def test_delete(self): - cs.volume_types.delete(1) + t = cs.volume_types.delete(1) cs.assert_called('DELETE', '/types/1') + self._assert_request_id(t) diff --git a/cinderclient/tests/unit/v3/test_volume_backups.py b/cinderclient/tests/unit/v3/test_volume_backups.py new file mode 100644 index 000000000..3be6328fb --- /dev/null +++ b/cinderclient/tests/unit/v3/test_volume_backups.py @@ -0,0 +1,58 @@ +# Copyright (c) 2016 Intel, 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 import api_versions +from cinderclient import exceptions as exc +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import volume_backups_restore + + +class VolumesTest(utils.TestCase): + + def test_update(self): + cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.9')) + b = cs.backups.get('1234') + backup = b.update(name='new-name') + cs.assert_called( + 'PUT', '/backups/1234', + {'backup': {'name': 'new-name'}}) + self._assert_request_id(backup) + + def test_pre_version(self): + cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.8')) + b = cs.backups.get('1234') + self.assertRaises(exc.VersionNotFoundForAPIMethod, + b.update, name='new-name') + + def test_restore(self): + cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.0')) + backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' + info = cs.restores.restore(backup_id) + cs.assert_called('POST', '/backups/%s/restore' % backup_id) + self.assertIsInstance(info, + volume_backups_restore.VolumeBackupsRestore) + self._assert_request_id(info) + + def test_restore_with_name(self): + cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.0')) + backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' + name = 'restore_vol' + info = cs.restores.restore(backup_id, name=name) + expected_body = {'restore': {'volume_id': None, 'name': name}} + cs.assert_called('POST', '/backups/%s/restore' % backup_id, + body=expected_body) + self.assertIsInstance(info, + volume_backups_restore.VolumeBackupsRestore) diff --git a/cinderclient/tests/unit/v3/test_volume_backups_30.py b/cinderclient/tests/unit/v3/test_volume_backups_30.py new file mode 100644 index 000000000..daf517c9f --- /dev/null +++ b/cinderclient/tests/unit/v3/test_volume_backups_30.py @@ -0,0 +1,148 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# 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.unit import utils +from cinderclient.tests.unit.v3 import fakes + + +cs = fakes.FakeClient() + + +class VolumeBackupsTest(utils.TestCase): + + def test_create(self): + vol = cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4') + cs.assert_called('POST', '/backups') + self._assert_request_id(vol) + + def test_create_full(self): + vol = cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4', + None, None, False) + cs.assert_called('POST', '/backups') + self._assert_request_id(vol) + + def test_create_incremental(self): + vol = cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4', + None, None, True) + cs.assert_called('POST', '/backups') + self._assert_request_id(vol) + + def test_create_force(self): + vol = cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4', + None, None, False, True) + cs.assert_called('POST', '/backups') + self._assert_request_id(vol) + + def test_create_snapshot(self): + cs.backups.create('2b695faf-b963-40c8-8464-274008fbcef4', + None, None, False, False, + '3c706gbg-c074-51d9-9575-385119gcdfg5') + cs.assert_called('POST', '/backups') + + def test_get(self): + backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' + back = cs.backups.get(backup_id) + cs.assert_called('GET', '/backups/%s' % backup_id) + self._assert_request_id(back) + + def test_list(self): + lst = cs.backups.list() + cs.assert_called('GET', '/backups/detail') + self._assert_request_id(lst) + + def test_list_with_pagination(self): + lst = cs.backups.list(limit=2, marker=100) + cs.assert_called('GET', '/backups/detail?limit=2&marker=100') + self._assert_request_id(lst) + + def test_sorted_list(self): + lst = cs.backups.list(sort="id") + cs.assert_called('GET', '/backups/detail?sort=id') + self._assert_request_id(lst) + + def test_sorted_list_by_data_timestamp(self): + cs.backups.list(sort="data_timestamp") + cs.assert_called('GET', '/backups/detail?sort=data_timestamp') + + def test_delete(self): + b = cs.backups.list()[0] + del_back = b.delete() + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + self._assert_request_id(del_back) + del_back = cs.backups.delete('76a17945-3c6f-435c-975b-b5685db10b62') + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + self._assert_request_id(del_back) + del_back = cs.backups.delete(b) + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + self._assert_request_id(del_back) + + def test_force_delete_with_True_force_param_value(self): + """Tests delete backup with force parameter set to True""" + b = cs.backups.list()[0] + del_back = b.delete(force=True) + expected_body = {'os-force_delete': None} + cs.assert_called('POST', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62/action', + expected_body) + self._assert_request_id(del_back) + + def test_force_delete_with_false_force_param_vaule(self): + """To delete backup with force parameter set to False""" + b = cs.backups.list()[0] + del_back = b.delete(force=False) + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + self._assert_request_id(del_back) + del_back = cs.backups.delete('76a17945-3c6f-435c-975b-b5685db10b62') + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + self._assert_request_id(del_back) + del_back = cs.backups.delete(b) + cs.assert_called('DELETE', + '/backups/76a17945-3c6f-435c-975b-b5685db10b62') + self._assert_request_id(del_back) + + def test_reset_state(self): + b = cs.backups.list()[0] + api = '/backups/76a17945-3c6f-435c-975b-b5685db10b62/action' + st = b.reset_state(state='error') + cs.assert_called('POST', api) + self._assert_request_id(st) + st = cs.backups.reset_state('76a17945-3c6f-435c-975b-b5685db10b62', + state='error') + cs.assert_called('POST', api) + self._assert_request_id(st) + st = cs.backups.reset_state(b, state='error') + cs.assert_called('POST', api) + self._assert_request_id(st) + + def test_record_export(self): + backup_id = '76a17945-3c6f-435c-975b-b5685db10b62' + export = cs.backups.export_record(backup_id) + cs.assert_called('GET', + '/backups/%s/export_record' % backup_id) + self._assert_request_id(export) + + def test_record_import(self): + backup_service = 'fake-backup-service' + backup_url = 'fake-backup-url' + expected_body = {'backup-record': {'backup_service': backup_service, + 'backup_url': backup_url}} + impt = cs.backups.import_record(backup_service, backup_url) + cs.assert_called('POST', '/backups/import_record', expected_body) + self._assert_request_id(impt) diff --git a/cinderclient/tests/unit/v2/test_volume_encryption_types.py b/cinderclient/tests/unit/v3/test_volume_encryption_types.py similarity index 67% rename from cinderclient/tests/unit/v2/test_volume_encryption_types.py rename to cinderclient/tests/unit/v3/test_volume_encryption_types.py index 9938dba18..9e38d5165 100644 --- a/cinderclient/tests/unit/v2/test_volume_encryption_types.py +++ b/cinderclient/tests/unit/v3/test_volume_encryption_types.py @@ -13,12 +13,19 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.v2.volume_encryption_types import VolumeEncryptionType from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3.volume_encryption_types import VolumeEncryptionType cs = fakes.FakeClient() +FAKE_ENCRY_TYPE = {'provider': 'Test', + 'key_size': None, + 'cipher': None, + 'control_location': None, + 'volume_type_id': '65922555-7bc0-47e9-8d88-c7fdbcac4781', + 'encryption_id': '62daf814-cf9b-401c-8fc8-f84d7850fb7c'} + class VolumeEncryptionTypesTest(utils.TestCase): """ @@ -36,11 +43,12 @@ def test_list(self): 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?is_public=None') 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) + self._assert_request_id(encryption_type) def test_get(self): """ @@ -53,6 +61,7 @@ def test_get(self): encryption_type = cs.volume_encryption_types.get(1) cs.assert_called('GET', '/types/1/encryption') self.assertIsInstance(encryption_type, VolumeEncryptionType) + self._assert_request_id(encryption_type) def test_get_no_encryption(self): """ @@ -65,6 +74,7 @@ def test_get_no_encryption(self): self.assertIsInstance(encryption_type, VolumeEncryptionType) self.assertFalse(hasattr(encryption_type, 'id'), 'encryption type has an id') + self._assert_request_id(encryption_type) def test_create(self): """ @@ -80,12 +90,24 @@ def test_create(self): None}) cs.assert_called('POST', '/types/2/encryption') self.assertIsInstance(result, VolumeEncryptionType) + self._assert_request_id(result) def test_update(self): """ Unit test for VolumeEncryptionTypesManager.update + + Verify that one PUT request is made for encryption type update + Verify that an empty encryption-type update returns the original + encryption-type information. """ - self.skipTest("Not implemented") + expected = {'id': 1, 'volume_type_id': 1, 'provider': 'test', + 'cipher': 'test', 'key_size': 1, + 'control_location': 'front-end'} + result = cs.volume_encryption_types.update(1, {}) + cs.assert_called('PUT', '/types/1/encryption/provider') + self.assertEqual(expected, result, + "empty update must yield original data") + self._assert_request_id(result) def test_delete(self): """ @@ -96,4 +118,17 @@ def test_delete(self): """ result = cs.volume_encryption_types.delete(1) cs.assert_called('DELETE', '/types/1/encryption/provider') - self.assertIsNone(result, "delete result must be None") + self.assertIsInstance(result, tuple) + self.assertEqual(202, result[0].status_code) + self._assert_request_id(result) + + def test___repr__(self): + """ + Unit test for VolumeEncryptionTypes.__repr__ + + Verify that one encryption type can be printed + """ + encry_type = VolumeEncryptionType(None, FAKE_ENCRY_TYPE) + self.assertEqual( + "" % FAKE_ENCRY_TYPE['encryption_id'], + repr(encry_type)) diff --git a/cinderclient/tests/unit/v3/test_volume_transfers.py b/cinderclient/tests/unit/v3/test_volume_transfers.py new file mode 100644 index 000000000..536e602ad --- /dev/null +++ b/cinderclient/tests/unit/v3/test_volume_transfers.py @@ -0,0 +1,109 @@ +# Copyright 2018 FiberHome Telecommunication Technologies CO.,LTD +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from cinderclient import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + +TRANSFER_URL = 'os-volume-transfer' +TRANSFER_355_URL = 'volume-transfers' + +# Create calls need the right version of faked client +v355cs = fakes.FakeClient(api_versions.APIVersion('3.55')) +# Other calls fall back to API extension behavior +v3cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + + +class VolumeTransfersTest(utils.TestCase): + + def test_create(self): + vol = v3cs.transfers.create('1234') + v3cs.assert_called('POST', '/%s' % TRANSFER_URL, + body={'transfer': {'volume_id': '1234', + 'name': None}}) + self._assert_request_id(vol) + + def test_create_355(self): + vol = v355cs.transfers.create('1234') + v355cs.assert_called('POST', '/%s' % TRANSFER_355_URL, + body={'transfer': {'volume_id': '1234', + 'name': None, + 'no_snapshots': False}}) + self._assert_request_id(vol) + + def test_create_without_snapshots(self): + vol = v355cs.transfers.create('1234', no_snapshots=True) + v355cs.assert_called('POST', '/%s' % TRANSFER_355_URL, + body={'transfer': {'volume_id': '1234', + 'name': None, + 'no_snapshots': True}}) + self._assert_request_id(vol) + + def _test_get(self, client, expected_url): + transfer_id = '5678' + vol = client.transfers.get(transfer_id) + client.assert_called('GET', '/%s/%s' % (expected_url, transfer_id)) + self._assert_request_id(vol) + + def test_get(self): + self._test_get(v3cs, TRANSFER_URL) + + def test_get_355(self): + self._test_get(v355cs, TRANSFER_355_URL) + + def _test_list(self, client, expected_url): + lst = client.transfers.list() + client.assert_called('GET', '/%s/detail' % expected_url) + self._assert_request_id(lst) + + def test_list(self): + self._test_list(v3cs, TRANSFER_URL) + + def test_list_355(self): + self._test_list(v355cs, TRANSFER_355_URL) + + def _test_delete(self, client, expected_url): + url = '/%s/5678' % expected_url + b = client.transfers.list()[0] + vol = b.delete() + client.assert_called('DELETE', url) + self._assert_request_id(vol) + vol = client.transfers.delete('5678') + self._assert_request_id(vol) + client.assert_called('DELETE', url) + vol = client.transfers.delete(b) + client.assert_called('DELETE', url) + self._assert_request_id(vol) + + def test_delete(self): + self._test_delete(v3cs, TRANSFER_URL) + + def test_delete_355(self): + self._test_delete(v355cs, TRANSFER_355_URL) + + def _test_accept(self, client, expected_url): + transfer_id = '5678' + auth_key = '12345' + vol = client.transfers.accept(transfer_id, auth_key) + client.assert_called( + 'POST', + '/%s/%s/accept' % (expected_url, transfer_id)) + self._assert_request_id(vol) + + def test_accept(self): + self._test_accept(v3cs, TRANSFER_URL) + + def test_accept_355(self): + self._test_accept(v355cs, TRANSFER_355_URL) diff --git a/cinderclient/tests/unit/v3/test_volumes.py b/cinderclient/tests/unit/v3/test_volumes.py new file mode 100644 index 000000000..b970d7d28 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_volumes.py @@ -0,0 +1,225 @@ +# Copyright 2016 FUJITSU LIMITED +# Copyright (c) 2016 EMC Corporation +# +# 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 urllib import parse + +import ddt + +from cinderclient import api_versions +from cinderclient import exceptions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import volume_snapshots +from cinderclient.v3 import volumes + + +@ddt.ddt +class VolumesTest(utils.TestCase): + + def test_volume_manager_upload_to_image(self): + expected = {'os-volume_upload_image': + {'force': False, + 'container_format': 'bare', + 'disk_format': 'raw', + 'image_name': 'name', + 'visibility': 'public', + 'protected': True}} + api_version = api_versions.APIVersion('3.1') + cs = fakes.FakeClient(api_version) + manager = volumes.VolumeManager(cs) + fake_volume = volumes.Volume(manager, + {'id': 1234, 'name': 'sample-volume'}, + loaded=True) + fake_volume.upload_to_image(False, 'name', 'bare', 'raw', + visibility='public', protected=True) + cs.assert_called_anytime('POST', '/volumes/1234/action', body=expected) + + @ddt.data('3.39', '3.40') + def test_revert_to_snapshot(self, version): + + api_version = api_versions.APIVersion(version) + cs = fakes.FakeClient(api_version) + manager = volumes.VolumeManager(cs) + fake_snapshot = volume_snapshots.Snapshot( + manager, {'id': 12345, 'name': 'fake-snapshot'}, loaded=True) + fake_volume = volumes.Volume(manager, + {'id': 1234, 'name': 'sample-volume'}, + loaded=True) + expected = {'revert': {'snapshot_id': 12345}} + + if version == '3.40': + fake_volume.revert_to_snapshot(fake_snapshot) + + cs.assert_called_anytime('POST', '/volumes/1234/action', + body=expected) + else: + self.assertRaises(exceptions.VersionNotFoundForAPIMethod, + fake_volume.revert_to_snapshot, fake_snapshot) + + def test_create_volume(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.13')) + vol = cs.volumes.create(1, group_id='1234', volume_type='5678') + expected = {'volume': {'description': None, + 'availability_zone': None, + 'source_volid': None, + 'snapshot_id': None, + 'size': 1, + 'name': None, + 'imageRef': None, + 'volume_type': '5678', + 'metadata': {}, + 'consistencygroup_id': None, + 'group_id': '1234', + 'backup_id': None}} + cs.assert_called('POST', '/volumes', body=expected) + self._assert_request_id(vol) + + def test_create_volume_with_hint(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + vol = cs.volumes.create(1, scheduler_hints='uuid') + expected = {'volume': {'description': None, + 'availability_zone': None, + 'source_volid': None, + 'snapshot_id': None, + 'size': 1, + 'name': None, + 'imageRef': None, + 'volume_type': None, + 'metadata': {}, + 'consistencygroup_id': None, + 'backup_id': None, + }, + 'OS-SCH-HNT:scheduler_hints': 'uuid'} + cs.assert_called('POST', '/volumes', body=expected) + self._assert_request_id(vol) + + @ddt.data((False, '/volumes/summary'), + (True, '/volumes/summary?all_tenants=True')) + def test_volume_summary(self, all_tenants_input): + all_tenants, url = all_tenants_input + cs = fakes.FakeClient(api_versions.APIVersion('3.12')) + cs.volumes.summary(all_tenants=all_tenants) + cs.assert_called('GET', url) + + def test_volume_manage_cluster(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.16')) + vol = cs.volumes.manage(None, {'k': 'v'}, cluster='cluster1') + expected = {'host': None, 'name': None, 'availability_zone': None, + 'description': None, 'metadata': None, 'ref': {'k': 'v'}, + 'volume_type': None, 'bootable': False, + 'cluster': 'cluster1'} + cs.assert_called('POST', '/os-volume-manage', {'volume': expected}) + self._assert_request_id(vol) + + def test_volume_list_manageable(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.8')) + cs.volumes.list_manageable('host1', detailed=False) + cs.assert_called('GET', '/manageable_volumes?host=host1') + + def test_volume_list_manageable_detailed(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.8')) + cs.volumes.list_manageable('host1', detailed=True) + cs.assert_called('GET', '/manageable_volumes/detail?host=host1') + + def test_snapshot_list_manageable(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.8')) + cs.volume_snapshots.list_manageable('host1', detailed=False) + cs.assert_called('GET', '/manageable_snapshots?host=host1') + + def test_snapshot_list_manageable_detailed(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.8')) + cs.volume_snapshots.list_manageable('host1', detailed=True) + cs.assert_called('GET', '/manageable_snapshots/detail?host=host1') + + def test_snapshot_list_with_metadata(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.22')) + cs.volume_snapshots.list(search_opts={'metadata': {'key1': 'val1'}}) + expected = ("/snapshots/detail?metadata=%s" + % parse.quote_plus("{'key1': 'val1'}")) + cs.assert_called('GET', expected) + + def test_list_with_image_metadata(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + cs.volumes.list(search_opts={'glance_metadata': {'key1': 'val1'}}) + expected = ("/volumes/detail?glance_metadata=%s" + % parse.quote_plus("{'key1': 'val1'}")) + cs.assert_called('GET', expected) + + @ddt.data(True, False) + def test_get_pools_filter_by_name(self, detail): + cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.33')) + vol = cs.volumes.get_pools(detail, {'name': 'pool1'}) + request_url = '/scheduler-stats/get_pools?name=pool1' + if detail: + request_url = '/scheduler-stats/get_pools?detail=True&name=pool1' + cs.assert_called('GET', request_url) + self._assert_request_id(vol) + + def test_migrate_host(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.migrate_volume(v, 'host_dest', False, False) + cs.assert_called('POST', '/volumes/1234/action', + {'os-migrate_volume': {'host': 'host_dest', + 'force_host_copy': False, + 'lock_volume': False}}) + self._assert_request_id(vol) + + def test_migrate_with_lock_volume(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.migrate_volume(v, 'dest', False, True) + cs.assert_called('POST', '/volumes/1234/action', + {'os-migrate_volume': {'host': 'dest', + 'force_host_copy': False, + 'lock_volume': True}}) + self._assert_request_id(vol) + + def test_migrate_cluster(self): + cs = fakes.FakeClient(api_versions.APIVersion('3.16')) + v = cs.volumes.get('fake') + self._assert_request_id(v) + vol = cs.volumes.migrate_volume(v, 'host_dest', False, False, + 'cluster_dest') + cs.assert_called('POST', '/volumes/fake/action', + {'os-migrate_volume': {'cluster': 'cluster_dest', + 'force_host_copy': False, + 'lock_volume': False}}) + self._assert_request_id(vol) + + @ddt.data(False, True) + def test_reimage(self, reimage_reserved): + cs = fakes.FakeClient(api_versions.APIVersion('3.68')) + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.reimage(v, '1', reimage_reserved) + cs.assert_called('POST', '/volumes/1234/action', + {'os-reimage': {'image_id': '1', + 'reimage_reserved': + reimage_reserved}}) + self._assert_request_id(vol) + + @ddt.data(False, True) + def test_complete_volume_extend(self, error): + cs = fakes.FakeClient(api_versions.APIVersion('3.71')) + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.extend_volume_completion(v, error) + cs.assert_called('POST', '/volumes/1234/action', + {'os-extend_volume_completion': {'error': error}}) + self._assert_request_id(vol) diff --git a/cinderclient/tests/unit/v2/test_volumes.py b/cinderclient/tests/unit/v3/test_volumes_base.py similarity index 56% rename from cinderclient/tests/unit/v2/test_volumes.py rename to cinderclient/tests/unit/v3/test_volumes_base.py index d94b47187..cca808aac 100644 --- a/cinderclient/tests/unit/v2/test_volumes.py +++ b/cinderclient/tests/unit/v3/test_volumes_base.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. @@ -14,30 +15,21 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient import api_versions from cinderclient.tests.unit import utils -from cinderclient.tests.unit.v2 import fakes -from cinderclient.v2.volumes import Volume +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3.volumes import Volume -cs = fakes.FakeClient() +cs = fakes.FakeClient(api_version=api_versions.APIVersion('3.0')) class VolumesTest(utils.TestCase): + """Block Storage API v3.0""" def test_list_volumes_with_marker_limit(self): - cs.volumes.list(marker=1234, limit=2) + lst = cs.volumes.list(marker=1234, limit=2) cs.assert_called('GET', '/volumes/detail?limit=2&marker=1234') - - def test_list_volumes_with_sort_key_dir(self): - cs.volumes.list(sort_key='id', sort_dir='asc') - cs.assert_called('GET', '/volumes/detail?sort_dir=asc&sort_key=id') - - def test_list_volumes_with_invalid_sort_key(self): - self.assertRaises(ValueError, - cs.volumes.list, sort_key='invalid', sort_dir='asc') - - def test_list_volumes_with_invalid_sort_dir(self): - self.assertRaises(ValueError, - cs.volumes.list, sort_key='id', sort_dir='invalid') + self._assert_request_id(lst) def test__list(self): # There only 2 volumes available for our tests, so we set limit to 2. @@ -54,6 +46,7 @@ def test__list(self): # osapi_max_limit is 1000 by default. If limit is less than # osapi_max_limit, we can get 2 volumes back. volumes = cs.volumes._list(url, response_key, limit=limit) + self._assert_request_id(volumes) cs.assert_called('GET', url) self.assertEqual(fake_volumes, volumes) @@ -64,60 +57,60 @@ def test__list(self): cs.client.osapi_max_limit = 1 volumes = cs.volumes._list(url, response_key, limit=limit) self.assertEqual(fake_volumes, volumes) + self._assert_request_id(volumes) cs.client.osapi_max_limit = 1000 + def test_create_volume(self): + vol = cs.volumes.create(1) + cs.assert_called('POST', '/volumes') + self._assert_request_id(vol) + def test_delete_volume(self): v = cs.volumes.list()[0] - v.delete() + del_v = v.delete() cs.assert_called('DELETE', '/volumes/1234') - cs.volumes.delete('1234') + self._assert_request_id(del_v) + del_v = cs.volumes.delete('1234') cs.assert_called('DELETE', '/volumes/1234') - cs.volumes.delete(v) + self._assert_request_id(del_v) + del_v = cs.volumes.delete(v) cs.assert_called('DELETE', '/volumes/1234') - - def test_create_volume(self): - cs.volumes.create(1) - cs.assert_called('POST', '/volumes') - - def test_create_volume_with_hint(self): - cs.volumes.create(1, scheduler_hints='uuid') - expected = {'volume': {'status': 'creating', - 'description': None, - 'availability_zone': None, - 'source_volid': None, - 'snapshot_id': None, - 'size': 1, - 'user_id': None, - 'name': None, - 'imageRef': None, - 'attach_status': 'detached', - 'volume_type': None, - 'project_id': None, - 'metadata': {}, - 'source_replica': None, - 'consistencygroup_id': None}, - 'OS-SCH-HNT:scheduler_hints': 'uuid'} - cs.assert_called('POST', '/volumes', body=expected) + self._assert_request_id(del_v) def test_attach(self): v = cs.volumes.get('1234') - cs.volumes.attach(v, 1, '/dev/vdc', mode='ro') + self._assert_request_id(v) + vol = cs.volumes.attach(v, 1, '/dev/vdc', mode='ro') cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) + + def test_attach_to_host(self): + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.attach(v, None, None, host_name='test', mode='rw') + cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_detach(self): v = cs.volumes.get('1234') - cs.volumes.detach(v) + self._assert_request_id(v) + vol = cs.volumes.detach(v, 'abc123') cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_reserve(self): v = cs.volumes.get('1234') - cs.volumes.reserve(v) + self._assert_request_id(v) + vol = cs.volumes.reserve(v) cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_unreserve(self): v = cs.volumes.get('1234') - cs.volumes.unreserve(v) + self._assert_request_id(v) + vol = cs.volumes.unreserve(v) cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_begin_detaching(self): v = cs.volumes.get('1234') @@ -126,110 +119,175 @@ def test_begin_detaching(self): def test_roll_detaching(self): v = cs.volumes.get('1234') - cs.volumes.roll_detaching(v) + self._assert_request_id(v) + vol = cs.volumes.roll_detaching(v) cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_initialize_connection(self): v = cs.volumes.get('1234') - cs.volumes.initialize_connection(v, {}) + self._assert_request_id(v) + vol = cs.volumes.initialize_connection(v, {}) cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_terminate_connection(self): v = cs.volumes.get('1234') - cs.volumes.terminate_connection(v, {}) + self._assert_request_id(v) + vol = cs.volumes.terminate_connection(v, {}) cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_set_metadata(self): - cs.volumes.set_metadata(1234, {'k1': 'v2'}) + vol = cs.volumes.set_metadata(1234, {'k1': 'v2', 'тест': 'тест'}) cs.assert_called('POST', '/volumes/1234/metadata', - {'metadata': {'k1': 'v2'}}) + {'metadata': {'k1': 'v2', 'тест': 'тест'}}) + self._assert_request_id(vol) def test_delete_metadata(self): keys = ['key1'] - cs.volumes.delete_metadata(1234, keys) + vol = cs.volumes.delete_metadata(1234, keys) cs.assert_called('DELETE', '/volumes/1234/metadata/key1') + self._assert_request_id(vol) def test_extend(self): v = cs.volumes.get('1234') - cs.volumes.extend(v, 2) + self._assert_request_id(v) + vol = cs.volumes.extend(v, 2) + cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) + + def test_reset_state(self): + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.reset_state(v, 'in-use', attach_status='detached') + cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) + + def test_reset_state_migration_status(self): + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.reset_state(v, 'in-use', attach_status='detached', + migration_status='none') cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_get_encryption_metadata(self): - cs.volumes.get_encryption_metadata('1234') + vol = cs.volumes.get_encryption_metadata('1234') cs.assert_called('GET', '/volumes/1234/encryption') + self._assert_request_id(vol) def test_migrate(self): v = cs.volumes.get('1234') - cs.volumes.migrate_volume(v, 'dest', False) - cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(v) + vol = cs.volumes.migrate_volume(v, 'dest', False, False) + cs.assert_called('POST', '/volumes/1234/action', + {'os-migrate_volume': {'host': 'dest', + 'force_host_copy': False, + 'lock_volume': False}}) + self._assert_request_id(vol) + + def test_migrate_with_lock_volume(self): + v = cs.volumes.get('1234') + self._assert_request_id(v) + vol = cs.volumes.migrate_volume(v, 'dest', False, True) + cs.assert_called('POST', '/volumes/1234/action', + {'os-migrate_volume': {'host': 'dest', + 'force_host_copy': False, + 'lock_volume': True}}) + self._assert_request_id(vol) def test_metadata_update_all(self): - cs.volumes.update_all_metadata(1234, {'k1': 'v1'}) + vol = cs.volumes.update_all_metadata(1234, {'k1': 'v1'}) cs.assert_called('PUT', '/volumes/1234/metadata', {'metadata': {'k1': 'v1'}}) + self._assert_request_id(vol) def test_readonly_mode_update(self): v = cs.volumes.get('1234') - cs.volumes.update_readonly_flag(v, True) + self._assert_request_id(v) + vol = cs.volumes.update_readonly_flag(v, True) cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_retype(self): v = cs.volumes.get('1234') - cs.volumes.retype(v, 'foo', 'on-demand') + self._assert_request_id(v) + vol = cs.volumes.retype(v, 'foo', 'on-demand') cs.assert_called('POST', '/volumes/1234/action', {'os-retype': {'new_type': 'foo', 'migration_policy': 'on-demand'}}) + self._assert_request_id(vol) def test_set_bootable(self): v = cs.volumes.get('1234') - cs.volumes.set_bootable(v, True) + self._assert_request_id(v) + vol = cs.volumes.set_bootable(v, True) cs.assert_called('POST', '/volumes/1234/action') + self._assert_request_id(vol) def test_volume_manage(self): - cs.volumes.manage('host1', {'k': 'v'}) + vol = cs.volumes.manage('host1', {'k': 'v'}) expected = {'host': 'host1', 'name': None, 'availability_zone': None, 'description': None, 'metadata': None, 'ref': {'k': 'v'}, 'volume_type': None, 'bootable': False} cs.assert_called('POST', '/os-volume-manage', {'volume': expected}) + self._assert_request_id(vol) def test_volume_manage_bootable(self): - cs.volumes.manage('host1', {'k': 'v'}, bootable=True) + vol = cs.volumes.manage('host1', {'k': 'v'}, bootable=True) expected = {'host': 'host1', 'name': None, 'availability_zone': None, 'description': None, 'metadata': None, 'ref': {'k': 'v'}, 'volume_type': None, 'bootable': True} cs.assert_called('POST', '/os-volume-manage', {'volume': expected}) + self._assert_request_id(vol) + + def test_volume_list_manageable(self): + cs.volumes.list_manageable('host1', detailed=False) + cs.assert_called('GET', '/os-volume-manage?host=host1') + + def test_volume_list_manageable_detailed(self): + cs.volumes.list_manageable('host1', detailed=True) + cs.assert_called('GET', '/os-volume-manage/detail?host=host1') def test_volume_unmanage(self): v = cs.volumes.get('1234') - cs.volumes.unmanage(v) + self._assert_request_id(v) + vol = cs.volumes.unmanage(v) cs.assert_called('POST', '/volumes/1234/action', {'os-unmanage': None}) + self._assert_request_id(vol) - def test_replication_promote(self): - v = cs.volumes.get('1234') - cs.volumes.promote(v) - cs.assert_called('POST', '/volumes/1234/action', - {'os-promote-replica': None}) + def test_snapshot_manage(self): + vol = cs.volume_snapshots.manage('volume_id1', {'k': 'v'}) + expected = {'volume_id': 'volume_id1', 'name': None, + 'description': None, 'metadata': None, 'ref': {'k': 'v'}} + cs.assert_called('POST', '/os-snapshot-manage', {'snapshot': expected}) + self._assert_request_id(vol) - def test_replication_reenable(self): - v = cs.volumes.get('1234') - cs.volumes.reenable(v) - cs.assert_called('POST', '/volumes/1234/action', - {'os-reenable-replica': None}) + def test_snapshot_list_manageable(self): + cs.volume_snapshots.list_manageable('host1', detailed=False) + cs.assert_called('GET', '/os-snapshot-manage?host=host1') + + def test_snapshot_list_manageable_detailed(self): + cs.volume_snapshots.list_manageable('host1', detailed=True) + cs.assert_called('GET', '/os-snapshot-manage/detail?host=host1') def test_get_pools(self): - cs.volumes.get_pools('') + vol = cs.volumes.get_pools('') cs.assert_called('GET', '/scheduler-stats/get_pools') + self._assert_request_id(vol) def test_get_pools_detail(self): - cs.volumes.get_pools('--detail') + vol = cs.volumes.get_pools('--detail') cs.assert_called('GET', '/scheduler-stats/get_pools?detail=True') + self._assert_request_id(vol) class FormatSortParamTestCase(utils.TestCase): def test_format_sort_empty_input(self): for s in [None, '', []]: - self.assertEqual(None, cs.volumes._format_sort_param(s)) + self.assertIsNone(cs.volumes._format_sort_param(s)) def test_format_sort_string_single_key(self): s = 'id' @@ -259,21 +317,10 @@ def test_format_sort_list_of_strings(self): self.assertEqual('id:asc,status,size:desc', cs.volumes._format_sort_param(s)) - def test_format_sort_list_of_tuples(self): - s = [('id', 'asc'), 'status', ('size', 'desc')] - self.assertEqual('id:asc,status,size:desc', - cs.volumes._format_sort_param(s)) - - def test_format_sort_list_of_strings_and_tuples(self): - s = [('id', 'asc'), 'status', 'size:desc'] - self.assertEqual('id:asc,status,size:desc', - cs.volumes._format_sort_param(s)) - def test_format_sort_invalid_direction(self): for s in ['id:foo', 'id:asc,status,size:foo', - ['id', 'status', 'size:foo'], - ['id', 'status', ('size', 'foo')]]: + ['id', 'status', 'size:foo']]: self.assertRaises(ValueError, cs.volumes._format_sort_param, s) diff --git a/cinderclient/utils.py b/cinderclient/utils.py index cb9125c68..565c61a3f 100644 --- a/cinderclient/utils.py +++ b/cinderclient/utils.py @@ -13,18 +13,14 @@ # License for the specific language governing permissions and limitations # under the License. -from __future__ import print_function - +import collections import os -import pkg_resources -import sys +from urllib import parse import uuid -import six -import prettytable +import stevedore from cinderclient import exceptions -from cinderclient.openstack.common import strutils def arg(*args, **kwargs): @@ -35,6 +31,15 @@ def _decorator(func): return _decorator +def exclusive_arg(group_name, *args, **kwargs): + """Decorator for CLI mutually exclusive args.""" + def _decorator(func): + required = kwargs.pop('required', None) + add_exclusive_arg(func, group_name, required, *args, **kwargs) + return func + return _decorator + + def env(*vars, **kwargs): """ returns the first environment variable set @@ -61,6 +66,24 @@ def add_arg(f, *args, **kwargs): f.arguments.insert(0, (args, kwargs)) +def add_exclusive_arg(f, group_name, required, *args, **kwargs): + """Bind CLI mutally exclusive arguments to a shell.py `do_foo` function.""" + + if not hasattr(f, 'exclusive_args'): + f.exclusive_args = collections.defaultdict(list) + # Default required to False + f.exclusive_args['__required__'] = collections.defaultdict(bool) + + # NOTE(sirp): avoid dups that can occur when the module is shared across + # tests. + if (args, kwargs) not in f.exclusive_args[group_name]: + # Because of the semantics of decorator composition if we just append + # to the options list positional options will appear to be backwards. + f.exclusive_args[group_name].insert(0, (args, kwargs)) + if required is not None: + f.exclusive_args['__required__'][group_name] = required + + def unauthenticated(f): """ Adds 'unauthenticated' attribute to decorated function. @@ -82,87 +105,44 @@ def isunauthenticated(f): return getattr(f, 'unauthenticated', False) -def service_type(stype): - """ - Adds 'service_type' attribute to decorated function. - Usage: - @service_type('volume') - def mymethod(f): - ... - """ - def inner(f): - f.service_type = stype - return f - return inner +def build_query_param(params, sort=False): + """parse list to url query parameters""" + if not params: + return "" -def get_service_type(f): - """ - Retrieves service type from function - """ - return getattr(f, 'service_type', None) + if not sort: + param_list = list(params.items()) + else: + param_list = list(sorted(params.items())) + query_string = parse.urlencode( + [(k, v) for (k, v) in param_list if v not in (None, '')]) -def _print(pt, order): - if sys.version_info >= (3, 0): - print(pt.get_string(sortby=order)) - else: - print(strutils.safe_encode(pt.get_string(sortby=order))) - - -def print_list(objs, fields, formatters=None, sortby_index=0): - '''Prints a list of objects. - - @param objs: Objects to print - @param fields: Fields on each object to be printed - @param formatters: Custom field formatters - @param sortby_index: Results sorted against the key in the fields list at - this index; if None then the object order is not - altered - ''' - formatters = formatters or {} - mixed_case_fields = ['serverId'] - pt = prettytable.PrettyTable([f for f in fields], caching=False) - pt.aligns = ['l' for f in fields] - - for o in objs: - row = [] - for field in fields: - if field in formatters: - row.append(formatters[field](o)) - else: - if field in mixed_case_fields: - field_name = field.replace(' ', '_') - else: - field_name = field.lower().replace(' ', '_') - if type(o) == dict and field in o: - data = o[field] - else: - data = getattr(o, field_name, '') - if data is None: - data = '-' - row.append(data) - pt.add_row(row) - - if sortby_index is None: - order_by = None - else: - order_by = fields[sortby_index] - _print(pt, order_by) + # urllib's parse library used to adhere to RFC 2396 until + # python 3.7. The library moved from RFC 2396 to RFC 3986 + # for quoting URL strings in python 3.7 and '~' is now + # included in the set of reserved characters. [1] + # + # Below ensures "~" is never encoded. See LP 1784728 [2] for more details. + # [1] https://docs.python.org/3/library/urllib.parse.html#url-quoting + # [2] https://bugs.launchpad.net/python-cinderclient/+bug/1784728 + query_string = query_string.replace("%7E=", "~=") + if query_string: + query_string = "?%s" % (query_string,) -def print_dict(d, property="Property"): - pt = prettytable.PrettyTable([property, 'Value'], caching=False) - pt.aligns = ['l', 'l'] - [pt.add_row(list(r)) for r in six.iteritems(d)] - _print(pt, property) + return query_string -def find_resource(manager, name_or_id): +def find_resource(manager, name_or_id, **kwargs): """Helper for the _find_* methods.""" + is_group = kwargs.pop('is_group', False) # first try to get entity as integer id try: if isinstance(name_or_id, int) or name_or_id.isdigit(): + if is_group: + return manager.get(int(name_or_id), **kwargs) return manager.get(int(name_or_id)) except exceptions.NotFound: pass @@ -170,32 +150,34 @@ def find_resource(manager, name_or_id): # now try to get entity as uuid try: uuid.UUID(name_or_id) + if is_group: + return manager.get(name_or_id, **kwargs) return manager.get(name_or_id) except (ValueError, exceptions.NotFound): pass - if sys.version_info <= (3, 0): - name_or_id = strutils.safe_decode(name_or_id) - try: try: - return manager.find(human_id=name_or_id) + resource = getattr(manager, 'resource_class', None) + name_attr = resource.NAME_ATTR if resource else 'name' + if is_group: + kwargs[name_attr] = name_or_id + return manager.find(**kwargs) + return manager.find(**{name_attr: name_or_id}) except exceptions.NotFound: pass - # finally try to find entity by name + # finally try to find entity by human_id try: - return manager.find(name=name_or_id) + if is_group: + kwargs['human_id'] = name_or_id + return manager.find(**kwargs) + return manager.find(human_id=name_or_id) except exceptions.NotFound: - pass - - # Volumes don't have name, but display_name - try: - return manager.find(display_name=name_or_id) - except (UnicodeDecodeError, exceptions.NotFound): msg = "No %s with a name or ID of '%s' exists." % \ (manager.resource_class.__name__.lower(), name_or_id) raise exceptions.CommandError(msg) + except exceptions.NoUniqueMatch: msg = ("Multiple %s matches found for '%s', use an ID to be more" " specific." % (manager.resource_class.__name__.lower(), @@ -208,36 +190,6 @@ def find_volume(cs, volume): return find_resource(cs.volumes, volume) -def _format_servers_list_networks(server): - output = [] - for (network, addresses) in list(server.networks.items()): - if len(addresses) == 0: - continue - addresses_csv = ', '.join(addresses) - group = "%s=%s" % (network, addresses_csv) - output.append(group) - - return '; '.join(output) - - -class HookableMixin(object): - """Mixin so classes can register and run hooks.""" - _hooks_map = {} - - @classmethod - def add_hook(cls, hook_type, hook_func): - 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): - hook_funcs = cls._hooks_map.get(hook_type) or [] - for hook_func in hook_funcs: - hook_func(*args, **kwargs) - - def safe_issubclass(*args): """Like issubclass, but will just return False if not a class.""" @@ -252,8 +204,17 @@ def safe_issubclass(*args): def _load_entry_point(ep_name, name=None): """Try to load the entry point ep_name that matches name.""" - for ep in pkg_resources.iter_entry_points(ep_name, name=name): - try: - return ep.load() - except (ImportError, pkg_resources.UnknownExtra, AttributeError): - continue + mgr = stevedore.NamedExtensionManager( + namespace=ep_name, + names=[name], + # Ignore errors on load + on_load_failure_callback=lambda mgr, entry_point, error: None, + ) + try: + return mgr[name].plugin + except KeyError: + pass + + +def get_function_name(func): + return "%s.%s" % (func.__module__, func.__qualname__) diff --git a/cinderclient/v1/__init__.py b/cinderclient/v1/__init__.py deleted file mode 100644 index 3637ffdda..000000000 --- a/cinderclient/v1/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (c) 2012 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. - -from cinderclient.v1.client import Client # noqa diff --git a/cinderclient/v1/client.py b/cinderclient/v1/client.py deleted file mode 100644 index 19a017e7d..000000000 --- a/cinderclient/v1/client.py +++ /dev/null @@ -1,121 +0,0 @@ -# Copyright (c) 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. - -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 -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 - - -class Client(object): - """ - Top-level object to access the OpenStack Volume API. - - Create an instance with your creds:: - - >>> client = Client(USERNAME, PASSWORD, PROJECT_ID, AUTH_URL) - - Then call methods on its managers:: - - >>> client.volumes.list() - ... - - """ - - def __init__(self, username=None, api_key=None, project_id=None, - auth_url='', 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, - volume_service_name=None, bypass_url=None, - retries=None, http_log_debug=False, - cacert=None, auth_system='keystone', auth_plugin=None, - session=None, **kwargs): - # FIXME(comstud): Rename the api_key argument above when we - # know it's not being used as keyword argument - password = api_key - self.limits = limits.LimitsManager(self) - - # extensions - 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.qos_specs = qos_specs.QoSSpecsManager(self) - self.quota_classes = quota_classes.QuotaClassSetManager(self) - self.quotas = quotas.QuotaSetManager(self) - self.backups = volume_backups.VolumeBackupManager(self) - self.restores = volume_backups_restore.VolumeBackupRestoreManager(self) - self.transfers = volume_transfers.VolumeTransferManager(self) - self.services = services.ServiceManager(self) - self.availability_zones = \ - availability_zones.AvailabilityZoneManager(self) - - # Add in any extensions... - if extensions: - for extension in extensions: - if extension.manager_class: - setattr(self, extension.name, - extension.manager_class(self)) - - self.client = client._construct_http_client( - username=username, - password=password, - project_id=project_id, - auth_url=auth_url, - insecure=insecure, - timeout=timeout, - tenant_id=tenant_id, - proxy_tenant_id=tenant_id, - proxy_token=proxy_token, - region_name=region_name, - endpoint_type=endpoint_type, - service_type=service_type, - service_name=service_name, - volume_service_name=volume_service_name, - bypass_url=bypass_url, - retries=retries, - http_log_debug=http_log_debug, - cacert=cacert, - auth_system=auth_system, - auth_plugin=auth_plugin, - session=session, - **kwargs) - - def authenticate(self): - """ - Authenticate against the server. - - Normally this is called automatically when you first access the API, - but you can call this method to force authentication right now. - - Returns on success; raises :exc:`exceptions.Unauthorized` if the - credentials are wrong. - """ - self.client.authenticate() - - def get_volume_api_version_from_endpoint(self): - return self.client.get_volume_api_version_from_endpoint() diff --git a/cinderclient/v1/contrib/list_extensions.py b/cinderclient/v1/contrib/list_extensions.py deleted file mode 100644 index 308d39683..000000000 --- a/cinderclient/v1/contrib/list_extensions.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright (c) 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. - -from cinderclient import base -from cinderclient import utils - - -class ListExtResource(base.Resource): - @property - def summary(self): - descr = self.description.strip() - if not descr: - return '??' - lines = descr.split("\n") - if len(lines) == 1: - return lines[0] - else: - return lines[0] + "..." - - -class ListExtManager(base.Manager): - resource_class = ListExtResource - - def show_all(self): - return self._list("/extensions", 'extensions') - - -@utils.service_type('volume') -def do_list_extensions(client, _args): - """ - Lists all available os-api extensions. - """ - extensions = client.list_extensions.show_all() - fields = ["Name", "Summary", "Alias", "Updated"] - utils.print_list(extensions, fields) diff --git a/cinderclient/v1/limits.py b/cinderclient/v1/limits.py deleted file mode 100644 index 1ae281524..000000000 --- a/cinderclient/v1/limits.py +++ /dev/null @@ -1,92 +0,0 @@ -# Copyright 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. -# 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 import base - - -class Limits(base.Resource): - """A collection of RateLimit and AbsoluteLimit objects.""" - - def __repr__(self): - return "" - - @property - def absolute(self): - for (name, value) in list(self._info['absolute'].items()): - yield AbsoluteLimit(name, value) - - @property - def rate(self): - for group in self._info['rate']: - uri = group['uri'] - regex = group['regex'] - for rate in group['limit']: - yield RateLimit(rate['verb'], uri, regex, rate['value'], - rate['remaining'], rate['unit'], - rate['next-available']) - - -class RateLimit(object): - """Data model that represents a flattened view of a single rate limit.""" - - def __init__(self, verb, uri, regex, value, remain, - unit, next_available): - self.verb = verb - self.uri = uri - self.regex = regex - self.value = value - self.remain = remain - self.unit = unit - self.next_available = next_available - - def __eq__(self, other): - return self.uri == other.uri \ - and self.regex == other.regex \ - and self.value == other.value \ - and self.verb == other.verb \ - and self.remain == other.remain \ - and self.unit == other.unit \ - and self.next_available == other.next_available - - def __repr__(self): - return "" % (self.verb, self.uri) - - -class AbsoluteLimit(object): - """Data model that represents a single absolute limit.""" - - def __init__(self, name, value): - self.name = name - self.value = value - - def __eq__(self, other): - return self.value == other.value and self.name == other.name - - def __repr__(self): - return "" % (self.name) - - -class LimitsManager(base.Manager): - """Manager object used to interact with limits resource.""" - - resource_class = Limits - - def get(self): - """ - Get a specific extension. - - :rtype: :class:`Limits` - """ - return self._get("/limits", "limits") diff --git a/cinderclient/v1/qos_specs.py b/cinderclient/v1/qos_specs.py deleted file mode 100644 index b4e4272ae..000000000 --- a/cinderclient/v1/qos_specs.py +++ /dev/null @@ -1,149 +0,0 @@ -# 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/quota_classes.py b/cinderclient/v1/quota_classes.py deleted file mode 100644 index 9e81e2cca..000000000 --- a/cinderclient/v1/quota_classes.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (c) 2012 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. - -from cinderclient import base - - -class QuotaClassSet(base.Resource): - - @property - def id(self): - """QuotaClassSet does not have a 'id' attribute but base.Resource - needs it to self-refresh and QuotaSet is indexed by class_name. - """ - return self.class_name - - def update(self, *args, **kwargs): - self.manager.update(self.class_name, *args, **kwargs) - - -class QuotaClassSetManager(base.Manager): - resource_class = QuotaClassSet - - def get(self, class_name): - return self._get("/os-quota-class-sets/%s" % (class_name), - "quota_class_set") - - def update(self, class_name, **updates): - body = {'quota_class_set': {'class_name': class_name}} - - for update in updates: - body['quota_class_set'][update] = updates[update] - - self._update('/os-quota-class-sets/%s' % (class_name), body) diff --git a/cinderclient/v1/quotas.py b/cinderclient/v1/quotas.py deleted file mode 100644 index 7453cb7fe..000000000 --- a/cinderclient/v1/quotas.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (c) 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. - -from cinderclient import base - - -class QuotaSet(base.Resource): - - @property - def id(self): - """QuotaSet does not have a 'id' attribute but base. Resource needs it - to self-refresh and QuotaSet is indexed by tenant_id. - """ - return self.tenant_id - - def update(self, *args, **kwargs): - return self.manager.update(self.tenant_id, *args, **kwargs) - - -class QuotaSetManager(base.Manager): - resource_class = QuotaSet - - def get(self, tenant_id, usage=False): - if hasattr(tenant_id, 'tenant_id'): - tenant_id = tenant_id.tenant_id - return self._get("/os-quota-sets/%s?usage=%s" % (tenant_id, usage), - "quota_set") - - def update(self, tenant_id, **updates): - body = {'quota_set': {'tenant_id': tenant_id}} - - for update in updates: - body['quota_set'][update] = updates[update] - - result = self._update('/os-quota-sets/%s' % (tenant_id), body) - return self.resource_class(self, result['quota_set'], loaded=True) - - def defaults(self, tenant_id): - return self._get('/os-quota-sets/%s/defaults' % tenant_id, - 'quota_set') - - def delete(self, tenant_id): - if hasattr(tenant_id, 'tenant_id'): - tenant_id = tenant_id.tenant_id - return self._delete("/os-quota-sets/%s" % tenant_id) diff --git a/cinderclient/v1/services.py b/cinderclient/v1/services.py deleted file mode 100644 index 3bc4b3b43..000000000 --- a/cinderclient/v1/services.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) 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. - -""" -service interface -""" -from cinderclient import base - - -class Service(base.Resource): - - def __repr__(self): - return "" % self.service - - -class ServiceManager(base.ManagerWithFind): - resource_class = Service - - def list(self, host=None, binary=None): - """ - Describes service list for host. - - :param host: destination host name. - :param binary: service binary. - """ - url = "/os-services" - filters = [] - if host: - filters.append("host=%s" % host) - if binary: - filters.append("binary=%s" % binary) - if filters: - url = "%s?%s" % (url, "&".join(filters)) - return self._list(url, "services") - - def enable(self, host, binary): - """Enable the service specified by hostname and binary.""" - body = {"host": host, "binary": binary} - result = self._update("/os-services/enable", body) - return self.resource_class(self, result) - - def disable(self, host, binary): - """Disable the service specified by hostname and binary.""" - body = {"host": host, "binary": binary} - result = self._update("/os-services/disable", body) - return self.resource_class(self, result) - - def disable_log_reason(self, host, binary, reason): - """Disable the service with reason.""" - body = {"host": host, "binary": binary, "disabled_reason": reason} - result = self._update("/os-services/disable-log-reason", body) - return self.resource_class(self, result) diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py deleted file mode 100644 index 63aa19eb2..000000000 --- a/cinderclient/v1/shell.py +++ /dev/null @@ -1,1491 +0,0 @@ -# Copyright 2010 Jacob Kaplan-Moss -# -# Copyright (c) 2011-2014 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. - -from __future__ import print_function - -import argparse -import copy -import os -import sys -import time - -from cinderclient import exceptions -from cinderclient.openstack.common import strutils -from cinderclient import utils -from cinderclient.v1 import availability_zones - - -def _poll_for_status(poll_fn, obj_id, action, final_ok_states, - poll_period=5, show_progress=True): - """Blocks while an action occurs. Periodically shows progress.""" - def print_progress(progress): - if show_progress: - msg = ('\rInstance %(action)s... %(progress)s%% complete' - % dict(action=action, progress=progress)) - else: - msg = '\rInstance %(action)s...' % dict(action=action) - - sys.stdout.write(msg) - sys.stdout.flush() - - print() - while True: - obj = poll_fn(obj_id) - status = obj.status.lower() - progress = getattr(obj, 'progress', None) or 0 - if status in final_ok_states: - print_progress(100) - print("\nFinished") - break - elif status == "error": - print("\nError %(action)s instance" % {'action': action}) - break - else: - print_progress(progress) - time.sleep(poll_period) - - -def _find_volume_snapshot(cs, snapshot): - """Gets a volume snapshot by name or ID.""" - return utils.find_resource(cs.volume_snapshots, snapshot) - - -def _find_backup(cs, backup): - """Gets a backup by name or ID.""" - return utils.find_resource(cs.backups, backup) - - -def _find_transfer(cs, transfer): - """Gets a transfer by name or ID.""" - return utils.find_resource(cs.transfers, transfer) - - -def _find_qos_specs(cs, qos_specs): - """Gets a qos specs by ID.""" - return utils.find_resource(cs.qos_specs, qos_specs) - - -def _print_volume(volume): - utils.print_dict(volume._info) - - -def _print_volume_snapshot(snapshot): - utils.print_dict(snapshot._info) - - -def _print_volume_image(image): - utils.print_dict(image[1]['os-volume_upload_image']) - - -def _translate_keys(collection, convert): - for item in collection: - keys = item.__dict__ - for from_key, to_key in convert: - if from_key in keys and to_key not in keys: - setattr(item, to_key, item._info[from_key]) - - -def _translate_volume_keys(collection): - convert = [('displayName', 'display_name'), ('volumeType', 'volume_type'), - ('os-vol-tenant-attr:tenant_id', 'tenant_id')] - _translate_keys(collection, convert) - - -def _translate_volume_snapshot_keys(collection): - convert = [('displayName', 'display_name'), ('volumeId', 'volume_id')] - _translate_keys(collection, convert) - - -def _translate_availability_zone_keys(collection): - convert = [('zoneName', 'name'), ('zoneState', 'status')] - _translate_keys(collection, convert) - - -def _extract_metadata(args): - metadata = {} - 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) - else: - key = metadatum - value = None - - metadata[key] = value - return metadata - - -@utils.arg( - '--all-tenants', - dest='all_tenants', - metavar='<0|1>', - nargs='?', - type=int, - const=1, - default=0, - help='Shows details for all tenants. Admin only.') -@utils.arg( - '--all_tenants', - nargs='?', - type=int, - const=1, - help=argparse.SUPPRESS) -@utils.arg( - '--display-name', - metavar='', - default=None, - help='Filters list by a volume display name. Default=None.') -@utils.arg( - '--status', - metavar='', - default=None, - help='Filters list by a status. Default=None.') -@utils.arg( - '--metadata', - type=str, - nargs='*', - metavar='', - default=None, - help='Filters list by metadata key and value pair. ' - 'Default=None.') -@utils.arg( - '--tenant', - type=str, - dest='tenant', - nargs='?', - metavar='', - help='Display information from single tenant (Admin only).') -@utils.arg( - '--limit', - metavar='', - default=None, - help='Maximum number of volumes to return. OPTIONAL: Default=None.') -@utils.service_type('volume') -def do_list(cs, args): - """Lists all volumes.""" - all_tenants = 1 if args.tenant else \ - int(os.environ.get("ALL_TENANTS", args.all_tenants)) - search_opts = { - 'all_tenants': all_tenants, - 'project_id': args.tenant, - '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, limit=args.limit) - _translate_volume_keys(volumes) - - # Create a list of servers to which the volume is attached - for vol in volumes: - servers = [s.get('server_id') for s in vol.attachments] - setattr(vol, 'attached_to', ','.join(map(str, servers))) - if all_tenants: - key_list = ['ID', 'Tenant ID', 'Status', 'Display Name', - 'Size', 'Volume Type', 'Bootable', 'Attached to'] - else: - key_list = ['ID', 'Status', 'Display Name', - 'Size', 'Volume Type', 'Bootable', 'Attached to'] - utils.print_list(volumes, key_list) - - -@utils.arg('volume', metavar='', help='Volume name or ID.') -@utils.service_type('volume') -def do_show(cs, args): - """Shows volume details.""" - volume = utils.find_volume(cs, args.volume) - _print_volume(volume) - - -@utils.arg('size', - metavar='', - type=int, - help='Volume size, in GBs.') -@utils.arg( - '--snapshot-id', - metavar='', - default=None, - help='Creates volume from snapshot ID. ' - 'Default=None.') -@utils.arg( - '--snapshot_id', - help=argparse.SUPPRESS) -@utils.arg( - '--source-volid', - metavar='', - default=None, - help='Creates volume from volume ID. ' - 'Default=None.') -@utils.arg( - '--source_volid', - help=argparse.SUPPRESS) -@utils.arg( - '--image-id', - metavar='', - default=None, - help='Creates volume from image ID. ' - 'Default=None.') -@utils.arg( - '--image_id', - help=argparse.SUPPRESS) -@utils.arg( - '--display-name', - metavar='', - default=None, - help='Volume name. ' - 'Default=None.') -@utils.arg( - '--display_name', - help=argparse.SUPPRESS) -@utils.arg( - '--display-description', - metavar='', - default=None, - help='Volume description. ' - 'Default=None.') -@utils.arg( - '--display_description', - help=argparse.SUPPRESS) -@utils.arg( - '--volume-type', - metavar='', - default=None, - help='Volume type. ' - 'Default=None.') -@utils.arg( - '--volume_type', - help=argparse.SUPPRESS) -@utils.arg( - '--availability-zone', - metavar='', - default=None, - help='Availability zone for volume. ' - 'Default=None.') -@utils.arg( - '--availability_zone', - help=argparse.SUPPRESS) -@utils.arg('--metadata', - type=str, - nargs='*', - metavar='', - default=None, - help='Metadata key and value pairs. ' - 'Default=None.') -@utils.service_type('volume') -def do_create(cs, args): - """Creates a volume.""" - - volume_metadata = None - if args.metadata is not None: - volume_metadata = _extract_metadata(args) - - volume = cs.volumes.create(args.size, - args.snapshot_id, - args.source_volid, - args.display_name, - args.display_description, - args.volume_type, - availability_zone=args.availability_zone, - imageRef=args.image_id, - metadata=volume_metadata) - _print_volume(volume) - - -@utils.arg('volume', metavar='', nargs='+', - help='Name or ID of volume to delete. ' - 'Separate multiple volumes with a space.') -@utils.service_type('volume') -def do_delete(cs, args): - """Removes one or more volumes.""" - failure_count = 0 - for volume in args.volume: - try: - utils.find_volume(cs, volume).delete() - except Exception as e: - failure_count += 1 - print("Delete for volume %s failed: %s" % (volume, e)) - if failure_count == len(args.volume): - raise exceptions.CommandError("Unable to delete any of the specified " - "volumes.") - - -@utils.arg('volume', metavar='', nargs='+', - help='Name or ID of volume to delete. ' - 'Separate multiple volumes with a space.') -@utils.service_type('volume') -def do_force_delete(cs, args): - """Attempts force-delete of volume, regardless of state.""" - failure_count = 0 - for volume in args.volume: - try: - utils.find_volume(cs, volume).force_delete() - except Exception as e: - failure_count += 1 - print("Delete for volume %s failed: %s" % (volume, e)) - if failure_count == len(args.volume): - raise exceptions.CommandError("Unable to force delete any of the " - "specified volumes.") - - -@utils.arg('volume', metavar='', nargs='+', - help='Name or ID of volume to modify. ' - 'Separate multiple volumes with a space.') -@utils.arg('--state', metavar='', default='available', - help=('The state to assign to the volume. Valid values are ' - '"available," "error," "creating," "deleting," "in-use," ' - '"attaching," "detaching" and "error_deleting." ' - 'NOTE: This command simply changes the state of the ' - 'Volume in the DataBase with no regard to actual status, ' - 'exercise caution when using. Default=available.')) -@utils.service_type('volume') -def do_reset_state(cs, args): - """Explicitly updates the volume state.""" - failure_flag = False - - for volume in args.volume: - try: - utils.find_volume(cs, volume).reset_state(args.state) - except Exception as e: - failure_flag = True - msg = "Reset state for volume %s failed: %s" % (volume, e) - print(msg) - - if failure_flag: - msg = "Unable to reset the state for the specified volume(s)." - raise exceptions.CommandError(msg) - - -@utils.arg('volume', metavar='', - help='Name or ID of volume to rename.') -@utils.arg('display_name', nargs='?', metavar='', - help='New display name for volume.') -@utils.arg('--display-description', metavar='', - default=None, help='Volume description. Default=None.') -@utils.service_type('volume') -def do_rename(cs, args): - """Renames a volume.""" - kwargs = {} - if args.display_name is not None: - 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) - - -@utils.arg('volume', - metavar='', - help='Name or ID of volume for which to update metadata.') -@utils.arg('action', - metavar='', - choices=['set', 'unset'], - help='The action. Valid values are "set" or "unset."') -@utils.arg('metadata', - metavar='', - nargs='+', - default=[], - help='The metadata key and pair to set or unset. ' - 'For unset, specify only the key. ' - 'Default=[].') -@utils.service_type('volume') -def do_metadata(cs, args): - """Sets or deletes volume metadata.""" - volume = utils.find_volume(cs, args.volume) - metadata = _extract_metadata(args) - - if args.action == 'set': - cs.volumes.set_metadata(volume, metadata) - elif args.action == 'unset': - # NOTE(zul): Make sure py2/py3 sorting is the same - cs.volumes.delete_metadata(volume, sorted(metadata.keys(), - reverse=True)) - - -@utils.arg( - '--all-tenants', - dest='all_tenants', - metavar='<0|1>', - nargs='?', - type=int, - const=1, - default=0, - help='Shows details for all tenants. Admin only.') -@utils.arg( - '--all_tenants', - nargs='?', - type=int, - const=1, - help=argparse.SUPPRESS) -@utils.arg( - '--display-name', - metavar='', - default=None, - help='Filters list by a display name. Default=None.') -@utils.arg( - '--status', - metavar='', - default=None, - help='Filters list by a status. Default=None.') -@utils.arg( - '--volume-id', - metavar='', - default=None, - help='Filters list by a volume ID. Default=None.') -@utils.service_type('volume') -def do_snapshot_list(cs, args): - """Lists all snapshots.""" - all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) - search_opts = { - 'all_tenants': all_tenants, - 'display_name': args.display_name, - 'status': args.status, - 'volume_id': args.volume_id, - } - - snapshots = cs.volume_snapshots.list(search_opts=search_opts) - _translate_volume_snapshot_keys(snapshots) - utils.print_list(snapshots, - ['ID', 'Volume ID', 'Status', 'Display Name', 'Size']) - - -@utils.arg('snapshot', metavar='', - help='Name or ID of snapshot.') -@utils.service_type('volume') -def do_snapshot_show(cs, args): - """Shows snapshot details.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - _print_volume_snapshot(snapshot) - - -@utils.arg('volume', - metavar='', - help='Name or ID of volume to snapshot.') -@utils.arg('--force', - metavar='', - default=False, - help='Allows or disallows snapshot of ' - 'a volume when the volume is attached to an instance. ' - 'If set to True, ignores the current status of the ' - 'volume when attempting to snapshot it rather ' - 'than forcing it to be available. ' - 'Default=False.') -@utils.arg( - '--display-name', - metavar='', - default=None, - help='The snapshot name. Default=None.') -@utils.arg( - '--display_name', - help=argparse.SUPPRESS) -@utils.arg( - '--display-description', - metavar='', - default=None, - help='The snapshot description. Default=None.') -@utils.arg( - '--display_description', - help=argparse.SUPPRESS) -@utils.service_type('volume') -def do_snapshot_create(cs, args): - """Creates a snapshot.""" - volume = utils.find_volume(cs, args.volume) - snapshot = cs.volume_snapshots.create(volume.id, - args.force, - args.display_name, - args.display_description) - _print_volume_snapshot(snapshot) - - -@utils.arg('snapshot', - metavar='', nargs='+', - help='Name or ID of the snapshot(s) to delete.') -@utils.service_type('volume') -def do_snapshot_delete(cs, args): - """Remove one or more snapshots.""" - failure_count = 0 - for snapshot in args.snapshot: - try: - _find_volume_snapshot(cs, snapshot).delete() - except Exception as e: - failure_count += 1 - print("Delete for snapshot %s failed: %s" % (snapshot, e)) - if failure_count == len(args.snapshot): - raise exceptions.CommandError("Unable to delete any of the specified " - "snapshots.") - - -@utils.arg('snapshot', metavar='', - help='Name or ID of snapshot.') -@utils.arg('display_name', nargs='?', metavar='', - help='New display name for snapshot.') -@utils.arg('--display-description', metavar='', - default=None, help='Snapshot description. Default=None.') -@utils.service_type('volume') -def do_snapshot_rename(cs, args): - """Renames a snapshot.""" - kwargs = {} - if args.display_name is not None: - 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) - - -@utils.arg('snapshot', metavar='', nargs='+', - help='Name or ID of snapshot to modify.') -@utils.arg('--state', metavar='', default='available', - help=('The state to assign to the snapshot. Valid values are ' - '"available," "error," "creating," "deleting," and ' - '"error_deleting." NOTE: This command simply changes ' - 'the state of the Snapshot in the DataBase with no regard ' - 'to actual status, exercise caution when using. ' - 'Default=available.')) -@utils.service_type('volume') -def do_snapshot_reset_state(cs, args): - """Explicitly updates the snapshot state.""" - failure_count = 0 - - single = (len(args.snapshot) == 1) - - for snapshot in args.snapshot: - try: - _find_volume_snapshot(cs, snapshot).reset_state(args.state) - except Exception as e: - failure_count += 1 - msg = "Reset state for snapshot %s failed: %s" % (snapshot, e) - if not single: - print(msg) - - if failure_count == len(args.snapshot): - if not single: - msg = ("Unable to reset the state for any of the specified " - "snapshots.") - raise exceptions.CommandError(msg) - - -def _print_volume_type_list(vtypes): - utils.print_list(vtypes, ['ID', 'Name']) - - -@utils.service_type('volume') -def do_type_list(cs, args): - """Lists available 'volume types'.""" - vtypes = cs.volume_types.list() - _print_volume_type_list(vtypes) - - -@utils.service_type('volume') -def do_extra_specs_list(cs, args): - """Lists current volume types and extra specs.""" - vtypes = cs.volume_types.list() - utils.print_list(vtypes, ['ID', 'Name', 'extra_specs']) - - -@utils.arg('name', - metavar='', - help='Name for the volume type.') -@utils.service_type('volume') -def do_type_create(cs, args): - """Creates a volume type.""" - vtype = cs.volume_types.create(args.name) - _print_volume_type_list([vtype]) - - -@utils.arg('id', - metavar='', - help='ID of volume type to delete.') -@utils.service_type('volume') -def do_type_delete(cs, args): - """Deletes a specified volume type.""" - volume_type = _find_volume_type(cs, args.id) - cs.volume_types.delete(volume_type) - - -@utils.arg('vtype', - metavar='', - help='Name or ID of volume type.') -@utils.arg('action', - metavar='', - choices=['set', 'unset'], - help='The action. Valid values are "set" or "unset."') -@utils.arg('metadata', - metavar='', - nargs='*', - default=None, - help='The extra specs key and value pair to set or unset. ' - 'For unset, specify only the key. Default=None.') -@utils.service_type('volume') -def do_type_key(cs, args): - """Sets or unsets extra_spec for a volume type.""" - vtype = _find_volume_type(cs, args.vtype) - - if args.metadata is not None: - keypair = _extract_metadata(args) - - if args.action == 'set': - vtype.set_keys(keypair) - elif args.action == 'unset': - vtype.unset_keys(list(keypair)) - - -def do_endpoints(cs, args): - """Discovers endpoints registered by authentication service.""" - catalog = cs.client.service_catalog.catalog - for e in catalog['serviceCatalog']: - utils.print_dict(e['endpoints'][0], e['name']) - - -def do_credentials(cs, args): - """Shows user credentials returned from auth.""" - catalog = cs.client.service_catalog.catalog - utils.print_dict(catalog['user'], "User Credentials") - utils.print_dict(catalog['token'], "Token") - - -_quota_resources = ['volumes', 'snapshots', 'gigabytes', - 'backups', 'backup_gigabytes'] -_quota_infos = ['Type', 'In_use', 'Reserved', 'Limit'] - - -def _quota_show(quotas): - quota_dict = {} - for resource in quotas._info: - good_name = False - for name in _quota_resources: - if resource.startswith(name): - good_name = True - if not good_name: - continue - quota_dict[resource] = getattr(quotas, resource, None) - utils.print_dict(quota_dict) - - -def _quota_usage_show(quotas): - quota_list = [] - for resource in quotas._info.keys(): - good_name = False - for name in _quota_resources: - if resource.startswith(name): - good_name = True - if not good_name: - continue - quota_info = getattr(quotas, resource, None) - quota_info['Type'] = resource - quota_info = dict((k.capitalize(), v) for k, v in quota_info.items()) - quota_list.append(quota_info) - utils.print_list(quota_list, _quota_infos) - - -def _quota_update(manager, identifier, args): - updates = {} - for resource in _quota_resources: - val = getattr(args, resource, None) - if val is not None: - if args.volume_type: - resource = resource + '_%s' % args.volume_type - updates[resource] = val - - if updates: - _quota_show(manager.update(identifier, **updates)) - - -@utils.arg('tenant', metavar='', - help='ID of the tenant for which to list quotas.') -@utils.service_type('volume') -def do_quota_show(cs, args): - """Lists quotas for a tenant.""" - - _quota_show(cs.quotas.get(args.tenant)) - - -@utils.arg('tenant', metavar='', - help='ID of the tenant for which to list quota usage.') -@utils.service_type('volume') -def do_quota_usage(cs, args): - """Lists quota usage for a tenant.""" - - _quota_usage_show(cs.quotas.get(args.tenant, usage=True)) - - -@utils.arg('tenant', metavar='', - help='ID of the tenant for which to list default quotas.') -@utils.service_type('volume') -def do_quota_defaults(cs, args): - """Lists default quotas for a tenant.""" - - _quota_show(cs.quotas.defaults(args.tenant)) - - -@utils.arg('tenant', metavar='', - help='ID of the tenant for which to set quotas.') -@utils.arg('--volumes', - metavar='', - type=int, default=None, - help='The new "volumes" quota value. Default=None.') -@utils.arg('--snapshots', - metavar='', - type=int, default=None, - help='The new "snapshots" quota value. Default=None.') -@utils.arg('--gigabytes', - metavar='', - type=int, default=None, - help='The new "gigabytes" quota value. Default=None.') -@utils.arg('--backups', - metavar='', - type=int, default=None, - help='The new "backups" quota value. Default=None.') -@utils.arg('--backup-gigabytes', - metavar='', - type=int, default=None, - help='The new "backup_gigabytes" quota value. Default=None.') -@utils.arg('--volume-type', - metavar='', - default=None, - help='Volume type. Default=None.') -@utils.service_type('volume') -def do_quota_update(cs, args): - """Updates quotas for a tenant.""" - - _quota_update(cs.quotas, args.tenant, args) - - -@utils.arg('tenant', metavar='', - help='UUID of tenant to delete the quotas for.') -@utils.service_type('volume') -def do_quota_delete(cs, args): - """Delete the quotas for a tenant.""" - - cs.quotas.delete(args.tenant) - - -@utils.arg('class_name', metavar='', - help='Name of quota class for which to list quotas.') -@utils.service_type('volume') -def do_quota_class_show(cs, args): - """Lists quotas for a quota class.""" - - _quota_show(cs.quota_classes.get(args.class_name)) - - -@utils.arg('class_name', metavar='', - help='Name of quota class for which to set quotas.') -@utils.arg('--volumes', - metavar='', - type=int, default=None, - help='The new "volumes" quota value. Default=None.') -@utils.arg('--snapshots', - metavar='', - type=int, default=None, - help='The new "snapshots" quota value. Default=None.') -@utils.arg('--gigabytes', - metavar='', - type=int, default=None, - help='The new "gigabytes" quota value. Default=None.') -@utils.arg('--volume-type', - metavar='', - default=None, - help='Volume type. Default=None.') -@utils.service_type('volume') -def do_quota_class_update(cs, args): - """Updates quotas for a quota class.""" - - _quota_update(cs.quota_classes, args.class_name, args) - - -@utils.service_type('volume') -def do_absolute_limits(cs, args): - """Lists absolute limits for a user.""" - limits = cs.limits.get().absolute - columns = ['Name', 'Value'] - utils.print_list(limits, columns) - - -@utils.service_type('volume') -def do_rate_limits(cs, args): - """Lists rate limits for a user.""" - limits = cs.limits.get().rate - columns = ['Verb', 'URI', 'Value', 'Remain', 'Unit', 'Next_Available'] - utils.print_list(limits, columns) - - -def _find_volume_type(cs, vtype): - """Gets a volume type by name or ID.""" - return utils.find_resource(cs.volume_types, vtype) - - -@utils.arg('volume', - metavar='', - help='Name or ID of volume to upload to an image.') -@utils.arg('--force', - metavar='', - default=False, - help='Enables or disables upload of ' - 'a volume that is attached to an instance. ' - 'Default=False.') -@utils.arg('--container-format', - metavar='', - default='bare', - help='Container format type. ' - 'Default is bare.') -@utils.arg('--disk-format', - metavar='', - default='raw', - help='Disk format type. ' - 'Default is raw.') -@utils.arg('image_name', - metavar='', - help='The new image name.') -@utils.service_type('volume') -def do_upload_to_image(cs, args): - """Uploads volume to Image Service as an image.""" - volume = utils.find_volume(cs, args.volume) - _print_volume_image(volume.upload_to_image(args.force, - args.image_name, - args.container_format, - args.disk_format)) - - -@utils.arg('volume', metavar='', - help='Name or ID of volume to back up.') -@utils.arg('--container', metavar='', - default=None, - help='Backup container name. Default=None.') -@utils.arg('--display-name', metavar='', - default=None, - help='Backup name. Default=None.') -@utils.arg('--display-description', metavar='', - default=None, - help='Backup description. Default=None.') -@utils.service_type('volume') -def do_backup_create(cs, args): - """Creates a volume backup.""" - volume = utils.find_volume(cs, args.volume) - backup = cs.backups.create(volume.id, - args.container, - args.display_name, - args.display_description) - - info = {"volume_id": volume.id} - info.update(backup._info) - - if 'links' in info: - info.pop('links') - - utils.print_dict(info) - - -@utils.arg('backup', metavar='', help='Name or ID of backup.') -@utils.service_type('volume') -def do_backup_show(cs, args): - """Show backup details.""" - backup = _find_backup(cs, args.backup) - info = dict() - info.update(backup._info) - - if 'links' in info: - info.pop('links') - - utils.print_dict(info) - - -@utils.service_type('volume') -def do_backup_list(cs, args): - """Lists all backups.""" - backups = cs.backups.list() - columns = ['ID', 'Volume ID', 'Status', 'Name', 'Size', 'Object Count', - 'Container'] - utils.print_list(backups, columns) - - -@utils.arg('backup', metavar='', - help='Name or ID of backup to delete.') -@utils.service_type('volume') -def do_backup_delete(cs, args): - """Removes a backup.""" - backup = _find_backup(cs, args.backup) - backup.delete() - - -@utils.arg('backup', metavar='', - help='ID of backup to restore.') -@utils.arg('--volume-id', metavar='', - default=None, - help='ID or name of backup volume to ' - 'which to restore. Default=None.') -@utils.service_type('volume') -def do_backup_restore(cs, args): - """Restores a backup.""" - if args.volume_id: - volume_id = utils.find_volume(cs, args.volume_id).id - else: - volume_id = None - cs.restores.restore(args.backup, volume_id) - - -@utils.arg('volume', metavar='', - help='Name or ID of volume to transfer.') -@utils.arg('--display-name', metavar='', - default=None, - help='Transfer name. Default=None.') -@utils.service_type('volume') -def do_transfer_create(cs, args): - """Creates a volume transfer.""" - volume = utils.find_volume(cs, args.volume) - transfer = cs.transfers.create(volume.id, - args.display_name) - info = dict() - info.update(transfer._info) - - if 'links' in info: - info.pop('links') - - utils.print_dict(info) - - -@utils.arg('transfer', metavar='', - help='Name or ID of transfer to delete.') -@utils.service_type('volume') -def do_transfer_delete(cs, args): - """Undoes a transfer.""" - transfer = _find_transfer(cs, args.transfer) - transfer.delete() - - -@utils.arg('transfer', metavar='', - help='ID of transfer to accept.') -@utils.arg('auth_key', metavar='', - help='Authentication key of transfer to accept.') -@utils.service_type('volume') -def do_transfer_accept(cs, args): - """Accepts a volume transfer.""" - transfer = cs.transfers.accept(args.transfer, args.auth_key) - info = dict() - info.update(transfer._info) - - if 'links' in info: - info.pop('links') - - utils.print_dict(info) - - -@utils.arg( - '--all-tenants', - dest='all_tenants', - metavar='<0|1>', - nargs='?', - type=int, - const=1, - default=0, - help='Shows details for all tenants. Admin only.') -@utils.arg( - '--all_tenants', - nargs='?', - type=int, - const=1, - help=argparse.SUPPRESS) -@utils.service_type('volume') -def do_transfer_list(cs, args): - """Lists all transfers.""" - all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) - search_opts = { - 'all_tenants': all_tenants, - } - transfers = cs.transfers.list(search_opts=search_opts) - columns = ['ID', 'Volume ID', 'Name'] - utils.print_list(transfers, columns) - - -@utils.arg('transfer', metavar='', - help='Name or ID of transfer to accept.') -@utils.service_type('volume') -def do_transfer_show(cs, args): - """Show transfer details.""" - transfer = _find_transfer(cs, args.transfer) - info = dict() - info.update(transfer._info) - - if 'links' in info: - info.pop('links') - - utils.print_dict(info) - - -@utils.arg('volume', metavar='', - help='Name or ID of volume to extend.') -@utils.arg('new_size', - metavar='', - type=int, - help='Size of volume, in GBs.') -@utils.service_type('volume') -def do_extend(cs, args): - """Attempts to extend size of an existing volume.""" - volume = utils.find_volume(cs, args.volume) - cs.volumes.extend(volume, args.new_size) - - -@utils.arg('--host', metavar='', default=None, - help='Host name. Default=None.') -@utils.arg('--binary', metavar='', default=None, - help='Service binary. Default=None.') -@utils.service_type('volume') -def do_service_list(cs, args): - """Lists all services. Filter by host and service binary.""" - result = cs.services.list(host=args.host, binary=args.binary) - columns = ["Binary", "Host", "Zone", "Status", "State", "Updated_at"] - # NOTE(jay-lau-513): we check if the response has disabled_reason - # so as not to add the column when the extended ext is not enabled. - if result and hasattr(result[0], 'disabled_reason'): - columns.append("Disabled Reason") - utils.print_list(result, columns) - - -@utils.arg('host', metavar='', help='Host name.') -@utils.arg('binary', metavar='', help='Service binary.') -@utils.service_type('volume') -def do_service_enable(cs, args): - """Enables the service.""" - result = cs.services.enable(args.host, args.binary) - columns = ["Host", "Binary", "Status"] - utils.print_list([result], columns) - - -@utils.arg('host', metavar='', help='Host name.') -@utils.arg('binary', metavar='', help='Service binary.') -@utils.arg('--reason', metavar='', - help='Reason for disabling service.') -@utils.service_type('volume') -def do_service_disable(cs, args): - """Disables the service.""" - columns = ["Host", "Binary", "Status"] - if args.reason: - columns.append('Disabled Reason') - result = cs.services.disable_log_reason(args.host, args.binary, - args.reason) - else: - result = cs.services.disable(args.host, args.binary) - utils.print_list([result], columns) - - -def _treeizeAvailabilityZone(zone): - """Builds a tree view for availability zones.""" - AvailabilityZone = availability_zones.AvailabilityZone - - az = AvailabilityZone(zone.manager, - copy.deepcopy(zone._info), zone._loaded) - result = [] - - # Zone tree view item - az.zoneName = zone.zoneName - az.zoneState = ('available' - if zone.zoneState['available'] else 'not available') - az._info['zoneName'] = az.zoneName - az._info['zoneState'] = az.zoneState - result.append(az) - - if getattr(zone, "hosts", None) and zone.hosts is not None: - for (host, services) in zone.hosts.items(): - # Host tree view item - az = AvailabilityZone(zone.manager, - copy.deepcopy(zone._info), zone._loaded) - az.zoneName = '|- %s' % host - az.zoneState = '' - az._info['zoneName'] = az.zoneName - az._info['zoneState'] = az.zoneState - result.append(az) - - for (svc, state) in services.items(): - # Service tree view item - az = AvailabilityZone(zone.manager, - copy.deepcopy(zone._info), zone._loaded) - az.zoneName = '| |- %s' % svc - az.zoneState = '%s %s %s' % ( - 'enabled' if state['active'] else 'disabled', - ':-)' if state['available'] else 'XXX', - state['updated_at']) - az._info['zoneName'] = az.zoneName - az._info['zoneState'] = az.zoneState - result.append(az) - return result - - -@utils.service_type('volume') -def do_availability_zone_list(cs, _args): - """Lists all availability zones.""" - try: - availability_zones = cs.availability_zones.list() - except exceptions.Forbidden as e: # policy doesn't allow probably - try: - availability_zones = cs.availability_zones.list(detailed=False) - except Exception: - raise e - - result = [] - for zone in availability_zones: - result += _treeizeAvailabilityZone(zone) - _translate_availability_zone_keys(result) - utils.print_list(result, ['Name', 'Status']) - - -def _print_volume_encryption_type_list(encryption_types): - """ - Lists 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): - """Shows encryption type details for 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 volume type.') -@utils.service_type('volume') -def do_encryption_type_show(cs, args): - """Shows encryption type details for 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 volume type.') -@utils.arg('provider', - metavar='', - type=str, - help='The class that provides encryption support. ' - 'For example, a volume driver class path.') -@utils.arg('--cipher', - metavar='', - type=str, - required=False, - default=None, - help='The encryption algorithm and mode. ' - 'For example, aes-xts-plain64. Default=None.') -@utils.arg('--key_size', - metavar='', - type=int, - required=False, - default=None, - help='Size of encryption key, in bits. ' - 'For example, 128 or 256. Default=None.') -@utils.arg('--control_location', - metavar='', - choices=['front-end', 'back-end'], - type=str, - required=False, - default='front-end', - help='Notional service where encryption is performed. ' - 'Valid values are "front-end" or "back-end." ' - 'For example, front-end=Nova. ' - 'Default is "front-end."') -@utils.service_type('volume') -def do_encryption_type_create(cs, args): - """Creates 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]) - - -@utils.arg('volume_type', - metavar='', - type=str, - help='Name or ID of volume type.') -@utils.service_type('volume') -def do_encryption_type_delete(cs, args): - """Deletes encryption type for a volume type. Admin only.""" - volume_type = _find_volume_type(cs, args.volume_type) - cs.volume_encryption_types.delete(volume_type) - - -@utils.arg('volume', metavar='', help='ID of volume to migrate.') -@utils.arg('host', metavar='', help='Destination host.') -@utils.arg('--force-host-copy', metavar='', - choices=['True', 'False'], required=False, - default=False, - help='Enables or disables generic host-based ' - 'force-migration, which bypasses driver ' - 'optimizations. Default=False.') -@utils.service_type('volume') -def do_migrate(cs, args): - """Migrates volume to a new host.""" - volume = utils.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 new QoS specifications.') -@utils.arg('metadata', - metavar='', - nargs='+', - default=[], - help='Specifications for QoS.') -@utils.service_type('volume') -def do_qos_create(cs, args): - """Creates a 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): - """Lists qos specs.""" - qos_specs = cs.qos_specs.list() - _print_qos_specs_list(qos_specs) - - -@utils.arg('qos_specs', metavar='', - help='ID of QoS specifications.') -@utils.service_type('volume') -def do_qos_show(cs, args): - """Shows a specified qos specs.""" - qos_specs = _find_qos_specs(cs, args.qos_specs) - _print_qos_specs(qos_specs) - - -@utils.arg('qos_specs', metavar='', - help='ID of QoS specifications.') -@utils.arg('--force', - metavar='', - default=False, - help='Enables or disables deletion of in-use ' - 'QoS specifications. Default=False.') -@utils.service_type('volume') -def do_qos_delete(cs, args): - """Deletes a specified 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 specifications.') -@utils.arg('vol_type_id', metavar='', - help='ID of volume type.') -@utils.service_type('volume') -def do_qos_associate(cs, args): - """Associates qos specs with specified volume type.""" - cs.qos_specs.associate(args.qos_specs, args.vol_type_id) - - -@utils.arg('qos_specs', metavar='', - help='ID of QoS specifications.') -@utils.arg('vol_type_id', metavar='', - help='ID of volume type.') -@utils.service_type('volume') -def do_qos_disassociate(cs, args): - """Disassociates qos specs from specified volume type.""" - cs.qos_specs.disassociate(args.qos_specs, args.vol_type_id) - - -@utils.arg('qos_specs', metavar='', - help='ID of QoS specifications.') -@utils.service_type('volume') -def do_qos_disassociate_all(cs, args): - """Disassociates qos specs from all associations.""" - cs.qos_specs.disassociate_all(args.qos_specs) - - -@utils.arg('qos_specs', metavar='', - help='ID of QoS specifications.') -@utils.arg('action', - metavar='', - choices=['set', 'unset'], - help='The action. Valid values are "set" or "unset."') -@utils.arg('metadata', metavar='key=value', - nargs='+', - default=[], - help='Metadata key and value pair to set or unset. ' - 'For unset, specify only the key.') -def do_qos_key(cs, args): - """Sets or unsets 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)) - - -@utils.arg('qos_specs', metavar='', - help='ID of QoS specifications.') -@utils.service_type('volume') -def do_qos_get_association(cs, args): - """Gets all associations for specified qos specs.""" - associations = cs.qos_specs.get_associations(args.qos_specs) - _print_associations_list(associations) - - -@utils.arg('snapshot', - metavar='', - help='ID of snapshot for which to update metadata.') -@utils.arg('action', - metavar='', - choices=['set', 'unset'], - help='The action. Valid values are "set" or "unset."') -@utils.arg('metadata', - metavar='', - nargs='+', - default=[], - help='The metadata key and value pair to set or unset. ' - 'For unset, specify only the key.') -@utils.service_type('volume') -def do_snapshot_metadata(cs, args): - """Sets or deletes snapshot metadata.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - metadata = _extract_metadata(args) - - if args.action == 'set': - metadata = snapshot.set_metadata(metadata) - utils.print_dict(metadata._info) - elif args.action == 'unset': - snapshot.delete_metadata(list(metadata.keys())) - - -@utils.arg('snapshot', metavar='', - help='ID of snapshot.') -@utils.service_type('volume') -def do_snapshot_metadata_show(cs, args): - """Shows snapshot metadata.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - utils.print_dict(snapshot._info['metadata'], 'Metadata-property') - - -@utils.arg('volume', metavar='', - help='ID of volume.') -@utils.service_type('volume') -def do_metadata_show(cs, args): - """Shows volume metadata.""" - volume = utils.find_volume(cs, args.volume) - utils.print_dict(volume._info['metadata'], 'Metadata-property') - - -@utils.arg('volume', - metavar='', - help='ID of volume for which to update metadata.') -@utils.arg('metadata', - metavar='', - nargs='+', - default=[], - help='Metadata key and value pair or pairs to update. ' - 'Default=[].') -@utils.service_type('volume') -def do_metadata_update_all(cs, args): - """Updates volume metadata.""" - volume = utils.find_volume(cs, args.volume) - metadata = _extract_metadata(args) - metadata = volume.update_all_metadata(metadata) - utils.print_dict(metadata['metadata'], 'Metadata-property') - - -@utils.arg('snapshot', - metavar='', - help='ID of snapshot for which to update metadata.') -@utils.arg('metadata', - metavar='', - nargs='+', - default=[], - help='Metadata key and value pair or pairs to update. ' - 'Default=[].') -@utils.service_type('volume') -def do_snapshot_metadata_update_all(cs, args): - """Updates snapshot metadata.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - metadata = _extract_metadata(args) - metadata = snapshot.update_all_metadata(metadata) - utils.print_dict(metadata) - - -@utils.arg('volume', metavar='', help='ID of volume to update.') -@utils.arg('read_only', - metavar='', - choices=['True', 'true', 'False', 'false'], - help='Enables or disables update of volume to ' - 'read-only access mode.') -@utils.service_type('volume') -def do_readonly_mode_update(cs, args): - """Updates volume read-only access-mode flag.""" - volume = utils.find_volume(cs, args.volume) - cs.volumes.update_readonly_flag(volume, - strutils.bool_from_string(args.read_only)) - - -@utils.arg('volume', metavar='', help='ID of the volume to update.') -@utils.arg('bootable', - metavar='', - choices=['True', 'true', 'False', 'false'], - help='Flag to indicate whether volume is bootable.') -@utils.service_type('volume') -def do_set_bootable(cs, args): - """Update bootable status of a volume.""" - volume = utils.find_volume(cs, args.volume) - cs.volumes.set_bootable(volume, - strutils.bool_from_string(args.bootable)) diff --git a/cinderclient/v1/volume_backups.py b/cinderclient/v1/volume_backups.py deleted file mode 100644 index 6c492e9d9..000000000 --- a/cinderclient/v1/volume_backups.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. -# 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 Backups interface (1.1 extension). -""" - -from cinderclient import base - - -class VolumeBackup(base.Resource): - """A volume backup is a block level backup of a volume.""" - def __repr__(self): - return "" % self.id - - def delete(self): - """Delete this volume backup.""" - return self.manager.delete(self) - - -class VolumeBackupManager(base.ManagerWithFind): - """Manage :class:`VolumeBackup` resources.""" - resource_class = VolumeBackup - - def create(self, volume_id, container=None, - name=None, description=None): - """Creates a volume backup. - - :param volume_id: The ID of the volume to backup. - :param container: The name of the backup service container. - :param name: The name of the backup. - :param description: The description of the backup. - :rtype: :class:`VolumeBackup` - """ - body = {'backup': {'volume_id': volume_id, - 'container': container, - 'name': name, - 'description': description}} - return self._create('/backups', body, 'backup') - - def get(self, backup_id): - """Show details of a volume backup. - - :param backup_id: The ID of the backup to display. - :rtype: :class:`VolumeBackup` - """ - return self._get("/backups/%s" % backup_id, "backup") - - def list(self, detailed=True, search_opts=None): - """Get a list of all volume backups. - - :rtype: list of :class:`VolumeBackup` - """ - if detailed is True: - return self._list("/backups/detail", "backups") - else: - return self._list("/backups", "backups") - - def delete(self, backup): - """Delete a volume backup. - - :param backup: The :class:`VolumeBackup` to delete. - """ - self._delete("/backups/%s" % base.getid(backup)) diff --git a/cinderclient/v1/volume_backups_restore.py b/cinderclient/v1/volume_backups_restore.py deleted file mode 100644 index 0eafa8220..000000000 --- a/cinderclient/v1/volume_backups_restore.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. -# 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 Backups Restore interface (1.1 extension). - -This is part of the Volume Backups interface. -""" - -from cinderclient import base - - -class VolumeBackupsRestore(base.Resource): - """A Volume Backups Restore represents a restore operation.""" - def __repr__(self): - return "" % self.volume_id - - -class VolumeBackupRestoreManager(base.Manager): - """Manage :class:`VolumeBackupsRestore` resources.""" - resource_class = VolumeBackupsRestore - - def restore(self, backup_id, volume_id=None): - """Restore a backup to a volume. - - :param backup_id: The ID of the backup to restore. - :param volume_id: The ID of the volume to restore the backup to. - :rtype: :class:`Restore` - """ - body = {'restore': {'volume_id': volume_id}} - return self._create("/backups/%s/restore" % backup_id, - body, "restore") diff --git a/cinderclient/v1/volume_encryption_types.py b/cinderclient/v1/volume_encryption_types.py deleted file mode 100644 index 1099bc37b..000000000 --- a/cinderclient/v1/volume_encryption_types.py +++ /dev/null @@ -1,97 +0,0 @@ -# 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, search_opts=None): - """ - 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): - """ - Creates encryption type for a volume type. Default: admin only. - - :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 - """ - return self._delete("/types/%s/encryption/provider" % - base.getid(volume_type)) diff --git a/cinderclient/v1/volume_snapshots.py b/cinderclient/v1/volume_snapshots.py deleted file mode 100644 index 690fd0123..000000000 --- a/cinderclient/v1/volume_snapshots.py +++ /dev/null @@ -1,202 +0,0 @@ -# Copyright 2011 Denali Systems, 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. - -""" -Volume snapshot interface (1.1 extension). -""" - -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode - -from cinderclient import base -import six - - -class Snapshot(base.Resource): - """ - A Snapshot is a point-in-time snapshot of an openstack volume. - """ - def __repr__(self): - return "" % self.id - - def delete(self): - """ - Delete this snapshot. - """ - self.manager.delete(self) - - def update(self, **kwargs): - """ - Update the display_name or display_description for this snapshot. - """ - self.manager.update(self, **kwargs) - - @property - def progress(self): - return self._info.get('os-extended-snapshot-attributes:progress') - - @property - def project_id(self): - return self._info.get('os-extended-snapshot-attributes:project_id') - - def reset_state(self, state): - """Update the snapshot with the privided state.""" - self.manager.reset_state(self, state) - - def set_metadata(self, metadata): - """Set metadata of this snapshot.""" - return self.manager.set_metadata(self, metadata) - - def delete_metadata(self, keys): - """Delete metadata of this snapshot.""" - return self.manager.delete_metadata(self, keys) - - def update_all_metadata(self, metadata): - """Update_all metadata of this snapshot.""" - return self.manager.update_all_metadata(self, metadata) - - -class SnapshotManager(base.ManagerWithFind): - """ - Manage :class:`Snapshot` resources. - """ - resource_class = Snapshot - - def create(self, volume_id, force=False, - display_name=None, display_description=None): - - """ - Create a snapshot of the given volume. - - :param volume_id: The ID of the volume to snapshot. - :param force: If force is True, create a snapshot even if the volume is - attached to an instance. Default is False. - :param display_name: Name of the snapshot - :param display_description: Description of the snapshot - :rtype: :class:`Snapshot` - """ - body = {'snapshot': {'volume_id': volume_id, - 'force': force, - 'display_name': display_name, - 'display_description': display_description}} - return self._create('/snapshots', body, 'snapshot') - - def get(self, snapshot_id): - """ - Get a snapshot. - - :param snapshot_id: The ID of the snapshot to get. - :rtype: :class:`Snapshot` - """ - return self._get("/snapshots/%s" % snapshot_id, "snapshot") - - def list(self, detailed=True, search_opts=None): - """ - Get a list of all snapshots. - - :rtype: list of :class:`Snapshot` - """ - - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - - # Transform the dict to a sequence of two-element tuples in fixed - # order, then the encoded string will be consistent in Python 2&3. - if qparams: - new_qparams = sorted(qparams.items(), key=lambda x: x[0]) - query_string = "?%s" % urlencode(new_qparams) - else: - query_string = "" - - detail = "" - if detailed: - detail = "/detail" - - return self._list("/snapshots%s%s" % (detail, query_string), - "snapshots") - - def delete(self, snapshot): - """ - Delete a snapshot. - - :param snapshot: The :class:`Snapshot` to delete. - """ - self._delete("/snapshots/%s" % base.getid(snapshot)) - - def update(self, snapshot, **kwargs): - """ - Update the display_name or display_description for a snapshot. - - :param snapshot: The :class:`Snapshot` to update. - """ - if not kwargs: - return - - body = {"snapshot": kwargs} - - self._update("/snapshots/%s" % base.getid(snapshot), body) - - def reset_state(self, snapshot, state): - """Update the specified volume with the provided state.""" - return self._action('os-reset_status', snapshot, {'status': state}) - - def _action(self, action, snapshot, info=None, **kwargs): - """Perform a snapshot action.""" - body = {action: info} - 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) - - def set_metadata(self, snapshot, metadata): - """Update/Set a snapshots metadata. - - :param snapshot: The :class:`Snapshot`. - :param metadata: A list of keys to be set. - """ - body = {'metadata': metadata} - return self._create("/snapshots/%s/metadata" % base.getid(snapshot), - body, "metadata") - - def delete_metadata(self, snapshot, keys): - """Delete specified keys from snapshot metadata. - - :param snapshot: The :class:`Snapshot`. - :param keys: A list of keys to be removed. - """ - snapshot_id = base.getid(snapshot) - for k in keys: - self._delete("/snapshots/%s/metadata/%s" % (snapshot_id, k)) - - def update_all_metadata(self, snapshot, metadata): - """Update_all snapshot metadata. - - :param snapshot: The :class:`Snapshot`. - :param metadata: A list of keys to be updated. - """ - body = {'metadata': metadata} - return self._update("/snapshots/%s/metadata" % base.getid(snapshot), - body) diff --git a/cinderclient/v1/volume_transfers.py b/cinderclient/v1/volume_transfers.py deleted file mode 100644 index 23317d2cd..000000000 --- a/cinderclient/v1/volume_transfers.py +++ /dev/null @@ -1,100 +0,0 @@ -# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. -# 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 transfer interface (1.1 extension). -""" - -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode -import six -from cinderclient import base - - -class VolumeTransfer(base.Resource): - """Transfer a volume from one tenant to another""" - def __repr__(self): - return "" % self.id - - def delete(self): - """Delete this volume transfer.""" - return self.manager.delete(self) - - -class VolumeTransferManager(base.ManagerWithFind): - """Manage :class:`VolumeTransfer` resources.""" - resource_class = VolumeTransfer - - def create(self, volume_id, name=None): - """Creates a volume transfer. - - :param volume_id: The ID of the volume to transfer. - :param name: The name of the transfer. - :rtype: :class:`VolumeTransfer` - """ - body = {'transfer': {'volume_id': volume_id, - 'name': name}} - return self._create('/os-volume-transfer', body, 'transfer') - - def accept(self, transfer_id, auth_key): - """Accept a volume transfer. - - :param transfer_id: The ID of the transfer to accept. - :param auth_key: The auth_key of the transfer. - :rtype: :class:`VolumeTransfer` - """ - body = {'accept': {'auth_key': auth_key}} - return self._create('/os-volume-transfer/%s/accept' % transfer_id, - body, 'transfer') - - def get(self, transfer_id): - """Show details of a volume transfer. - - :param transfer_id: The ID of the volume transfer to display. - :rtype: :class:`VolumeTransfer` - """ - return self._get("/os-volume-transfer/%s" % transfer_id, "transfer") - - def list(self, detailed=True, search_opts=None): - """Get a list of all volume transfer. - - :rtype: list of :class:`VolumeTransfer` - """ - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - - query_string = "?%s" % urlencode(qparams) if qparams else "" - - detail = "" - if detailed: - detail = "/detail" - - return self._list("/os-volume-transfer%s%s" % (detail, query_string), - "transfers") - - def delete(self, transfer_id): - """Delete a volume transfer. - - :param transfer_id: The :class:`VolumeTransfer` to delete. - """ - self._delete("/os-volume-transfer/%s" % base.getid(transfer_id)) diff --git a/cinderclient/v1/volume_types.py b/cinderclient/v1/volume_types.py deleted file mode 100644 index 7e1a779f6..000000000 --- a/cinderclient/v1/volume_types.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright (c) 2011 Rackspace US, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or -# implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -""" -Volume Type interface. -""" - -from cinderclient import base - - -class VolumeType(base.Resource): - """ - A Volume Type is the type of volume to be created - """ - def __repr__(self): - return "" % self.name - - def get_keys(self): - """ - Get extra specs from a volume type. - - :param vol_type: The :class:`VolumeType` to get extra specs from - """ - _resp, body = self.manager.api.client.get( - "/types/%s/extra_specs" % - base.getid(self)) - return body["extra_specs"] - - def set_keys(self, metadata): - """ - Set extra specs on a volume type. - - :param type : The :class:`VolumeType` to set extra spec on - :param metadata: A dict of key/value pairs to be set - """ - body = {'extra_specs': metadata} - return self.manager._create( - "/types/%s/extra_specs" % base.getid(self), - body, - "extra_specs", - return_raw=True) - - def unset_keys(self, keys): - """ - 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 - """ - - # NOTE(jdg): This wasn't actually doing all of the keys before - # the return in the loop resulted in ony ONE key being unset. - # since on success the return was NONE, we'll only interrupt the loop - # and return if there's an error - resp = None - for k in keys: - resp = self.manager._delete( - "/types/%s/extra_specs/%s" % ( - base.getid(self), k)) - if resp is not None: - return resp - - -class VolumeTypeManager(base.ManagerWithFind): - """ - Manage :class:`VolumeType` resources. - """ - resource_class = VolumeType - - def list(self, search_opts=None): - """ - Get a list of all volume types. - - :rtype: list of :class:`VolumeType`. - """ - return self._list("/types", "volume_types") - - def get(self, volume_type): - """ - Get a specific volume type. - - :param volume_type: The ID of the :class:`VolumeType` to get. - :rtype: :class:`VolumeType` - """ - return self._get("/types/%s" % base.getid(volume_type), "volume_type") - - def delete(self, volume_type): - """ - Delete a specific volume_type. - - :param volume_type: The name or ID of the :class:`VolumeType` to get. - """ - self._delete("/types/%s" % base.getid(volume_type)) - - def create(self, name): - """ - Creates a volume type. - - :param name: Descriptive name of the volume type - :rtype: :class:`VolumeType` - """ - - body = { - "volume_type": { - "name": name, - } - } - - return self._create("/types", body, "volume_type") diff --git a/cinderclient/v1/volumes.py b/cinderclient/v1/volumes.py deleted file mode 100644 index 73600d646..000000000 --- a/cinderclient/v1/volumes.py +++ /dev/null @@ -1,436 +0,0 @@ -# Copyright 2011 Denali Systems, 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. - -""" -Volume interface (1.1 extension). -""" - -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode -import six -from cinderclient import base - - -class Volume(base.Resource): - """A volume is an extra block level storage to the OpenStack instances.""" - def __repr__(self): - return "" % self.id - - def delete(self): - """Delete this volume.""" - self.manager.delete(self) - - def update(self, **kwargs): - """Update the display_name or display_description for this volume.""" - self.manager.update(self, **kwargs) - - def attach(self, instance_uuid, mountpoint, mode='rw'): - """Set attachment metadata. - - :param instance_uuid: uuid of the attaching instance. - :param mountpoint: mountpoint on the attaching instance. - :param mode: the access mode - """ - return self.manager.attach(self, instance_uuid, mountpoint, mode) - - def detach(self): - """Clear attachment metadata.""" - return self.manager.detach(self) - - def reserve(self, volume): - """Reserve this volume.""" - return self.manager.reserve(self) - - def unreserve(self, volume): - """Unreserve this volume.""" - return self.manager.unreserve(self) - - def begin_detaching(self, volume): - """Begin detaching volume.""" - return self.manager.begin_detaching(self) - - def roll_detaching(self, volume): - """Roll detaching volume.""" - return self.manager.roll_detaching(self) - - def initialize_connection(self, volume, connector): - """Initialize a volume connection. - - :param connector: connector dict from nova. - """ - return self.manager.initialize_connection(self, connector) - - def terminate_connection(self, volume, connector): - """Terminate a volume connection. - - :param connector: connector dict from nova. - """ - return self.manager.terminate_connection(self, connector) - - def set_metadata(self, volume, metadata): - """Set or Append metadata to a volume. - - :param volume : The :class: `Volume` to set metadata on - :param metadata: A dict of key/value pairs to set - """ - return self.manager.set_metadata(self, metadata) - - def upload_to_image(self, force, image_name, container_format, - disk_format): - """Upload a volume to image service as an image.""" - return self.manager.upload_to_image(self, force, image_name, - container_format, disk_format) - - def force_delete(self): - """Delete the specified volume ignoring its current state. - - :param volume: The UUID of the volume to force-delete. - """ - self.manager.force_delete(self) - - def reset_state(self, state): - """Update the volume with the provided state.""" - self.manager.reset_state(self, state) - - def extend(self, volume, new_size): - """Extend the size of the specified volume. - - :param volume: The UUID of the volume to extend. - :param new_size: The desired size to extend volume to. - """ - self.manager.extend(self, 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 update_all_metadata(self, metadata): - """Update all metadata of this volume.""" - return self.manager.update_all_metadata(self, metadata) - - def update_readonly_flag(self, volume, read_only): - """Update the read-only access mode flag of the specified volume. - - :param volume: The UUID of the volume to update. - :param read_only: The value to indicate whether to update volume to - read-only access mode. - """ - self.manager.update_readonly_flag(self, read_only) - - -class VolumeManager(base.ManagerWithFind): - """ - Manage :class:`Volume` resources. - """ - resource_class = Volume - - def create(self, size, snapshot_id=None, source_volid=None, - display_name=None, display_description=None, - volume_type=None, user_id=None, - project_id=None, availability_zone=None, - metadata=None, imageRef=None): - """ - Creates a volume. - - :param size: Size of volume in GB - :param snapshot_id: ID of the snapshot - :param display_name: Name of the volume - :param display_description: Description of the volume - :param volume_type: Type of 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: - volume_metadata = {} - else: - volume_metadata = metadata - - body = {'volume': {'size': size, - 'snapshot_id': snapshot_id, - 'display_name': display_name, - 'display_description': display_description, - 'volume_type': volume_type, - 'user_id': user_id, - 'project_id': project_id, - 'availability_zone': availability_zone, - 'status': "creating", - 'attach_status': "detached", - 'metadata': volume_metadata, - 'imageRef': imageRef, - 'source_volid': source_volid, - }} - return self._create('/volumes', body, 'volume') - - def get(self, volume_id): - """ - Get a volume. - - :param volume_id: The ID of the volume to get. - :rtype: :class:`Volume` - """ - return self._get("/volumes/%s" % volume_id, "volume") - - def list(self, detailed=True, search_opts=None, limit=None): - """ - Get a list of all volumes. - - :rtype: list of :class:`Volume` - """ - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - - if limit: - qparams['limit'] = limit - - # Transform the dict to a sequence of two-element tuples in fixed - # order, then the encoded string will be consistent in Python 2&3. - if qparams: - new_qparams = sorted(qparams.items(), key=lambda x: x[0]) - query_string = "?%s" % urlencode(new_qparams) - else: - query_string = "" - - detail = "" - if detailed: - detail = "/detail" - - return self._list("/volumes%s%s" % (detail, query_string), - "volumes") - - def delete(self, volume): - """ - Delete a volume. - - :param volume: The :class:`Volume` to delete. - """ - self._delete("/volumes/%s" % base.getid(volume)) - - def update(self, volume, **kwargs): - """ - Update the display_name or display_description for a volume. - - :param volume: The :class:`Volume` to update. - """ - if not kwargs: - return - - body = {"volume": kwargs} - - self._update("/volumes/%s" % base.getid(volume), body) - - def _action(self, action, volume, info=None, **kwargs): - """ - Perform a volume "action." - """ - body = {action: info} - self.run_hooks('modify_body_for_action', body, **kwargs) - url = '/volumes/%s/action' % base.getid(volume) - return self.api.client.post(url, body=body) - - def attach(self, volume, instance_uuid, mountpoint, mode='rw'): - """ - Set attachment metadata. - - :param volume: The :class:`Volume` (or its ID) - you would like to attach. - :param instance_uuid: uuid of the attaching instance. - :param mountpoint: mountpoint on the attaching instance. - :param mode: the access mode. - """ - return self._action('os-attach', - volume, - {'instance_uuid': instance_uuid, - 'mountpoint': mountpoint, - 'mode': mode}) - - def detach(self, volume): - """ - Clear attachment metadata. - - :param volume: The :class:`Volume` (or its ID) - you would like to detach. - """ - return self._action('os-detach', volume) - - def reserve(self, volume): - """ - Reserve this volume. - - :param volume: The :class:`Volume` (or its ID) - you would like to reserve. - """ - return self._action('os-reserve', volume) - - def unreserve(self, volume): - """ - Unreserve this volume. - - :param volume: The :class:`Volume` (or its ID) - you would like to unreserve. - """ - return self._action('os-unreserve', volume) - - def begin_detaching(self, volume): - """ - Begin detaching this volume. - - :param volume: The :class:`Volume` (or its ID) - you would like to detach. - """ - return self._action('os-begin_detaching', volume) - - def roll_detaching(self, volume): - """ - Roll detaching this volume. - - :param volume: The :class:`Volume` (or its ID) - you would like to roll detaching. - """ - return self._action('os-roll_detaching', volume) - - def initialize_connection(self, volume, connector): - """ - Initialize a volume connection. - - :param volume: The :class:`Volume` (or its ID). - :param connector: connector dict from nova. - """ - return self._action('os-initialize_connection', volume, - {'connector': connector})[1]['connection_info'] - - def terminate_connection(self, volume, connector): - """ - Terminate a volume connection. - - :param volume: The :class:`Volume` (or its ID). - :param connector: connector dict from nova. - """ - self._action('os-terminate_connection', volume, - {'connector': connector}) - - def set_metadata(self, volume, metadata): - """ - Update/Set a volumes metadata. - - :param volume: The :class:`Volume`. - :param metadata: A list of keys to be set. - """ - body = {'metadata': metadata} - return self._create("/volumes/%s/metadata" % base.getid(volume), - body, "metadata") - - def delete_metadata(self, volume, keys): - """ - Delete specified keys from volumes metadata. - - :param volume: The :class:`Volume`. - :param keys: A list of keys to be removed. - """ - for k in keys: - self._delete("/volumes/%s/metadata/%s" % (base.getid(volume), k)) - - def upload_to_image(self, volume, force, image_name, container_format, - disk_format): - """ - Upload volume to image service as image. - - :param volume: The :class:`Volume` to upload. - """ - return self._action('os-volume_upload_image', - volume, - {'force': force, - 'image_name': image_name, - 'container_format': container_format, - 'disk_format': disk_format}) - - def force_delete(self, volume): - return self._action('os-force_delete', base.getid(volume)) - - def reset_state(self, volume, state): - """Update the provided volume with the provided state.""" - return self._action('os-reset_status', volume, {'status': state}) - - def extend(self, volume, new_size): - 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 - - 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] - - def update_all_metadata(self, volume, metadata): - """Update all metadata of a volume. - - :param volume: The :class:`Volume`. - :param metadata: A list of keys to be updated. - """ - body = {'metadata': metadata} - return self._update("/volumes/%s/metadata" % base.getid(volume), - body) - - def update_readonly_flag(self, volume, flag): - return self._action('os-update_readonly_flag', - base.getid(volume), - {'readonly': flag}) - - def set_bootable(self, volume, flag): - return self._action('os-set_bootable', - base.getid(volume), - {'bootable': flag}) diff --git a/cinderclient/v2/availability_zones.py b/cinderclient/v2/availability_zones.py deleted file mode 100644 index aec2279ab..000000000 --- a/cinderclient/v2/availability_zones.py +++ /dev/null @@ -1,42 +0,0 @@ -# Copyright 2011-2013 OpenStack Foundation -# 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. - -"""Availability Zone interface (v2 extension)""" - -from cinderclient import base - - -class AvailabilityZone(base.Resource): - NAME_ATTR = 'display_name' - - def __repr__(self): - return "" % self.zoneName - - -class AvailabilityZoneManager(base.ManagerWithFind): - """Manage :class:`AvailabilityZone` resources.""" - resource_class = AvailabilityZone - - def list(self, detailed=False): - """Lists all availability zones. - - :rtype: list of :class:`AvailabilityZone` - """ - if detailed is True: - return self._list("/os-availability-zone/detail", - "availabilityZoneInfo") - else: - return self._list("/os-availability-zone", "availabilityZoneInfo") diff --git a/cinderclient/v2/services.py b/cinderclient/v2/services.py deleted file mode 100644 index 3bc4b3b43..000000000 --- a/cinderclient/v2/services.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (c) 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. - -""" -service interface -""" -from cinderclient import base - - -class Service(base.Resource): - - def __repr__(self): - return "" % self.service - - -class ServiceManager(base.ManagerWithFind): - resource_class = Service - - def list(self, host=None, binary=None): - """ - Describes service list for host. - - :param host: destination host name. - :param binary: service binary. - """ - url = "/os-services" - filters = [] - if host: - filters.append("host=%s" % host) - if binary: - filters.append("binary=%s" % binary) - if filters: - url = "%s?%s" % (url, "&".join(filters)) - return self._list(url, "services") - - def enable(self, host, binary): - """Enable the service specified by hostname and binary.""" - body = {"host": host, "binary": binary} - result = self._update("/os-services/enable", body) - return self.resource_class(self, result) - - def disable(self, host, binary): - """Disable the service specified by hostname and binary.""" - body = {"host": host, "binary": binary} - result = self._update("/os-services/disable", body) - return self.resource_class(self, result) - - def disable_log_reason(self, host, binary, reason): - """Disable the service with reason.""" - body = {"host": host, "binary": binary, "disabled_reason": reason} - result = self._update("/os-services/disable-log-reason", body) - return self.resource_class(self, result) diff --git a/cinderclient/v2/volume_backups.py b/cinderclient/v2/volume_backups.py deleted file mode 100644 index faee6b177..000000000 --- a/cinderclient/v2/volume_backups.py +++ /dev/null @@ -1,102 +0,0 @@ -# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. -# 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 Backups interface (1.1 extension). -""" - -from cinderclient import base - - -class VolumeBackup(base.Resource): - """A volume backup is a block level backup of a volume.""" - def __repr__(self): - return "" % self.id - - def delete(self): - """Delete this volume backup.""" - return self.manager.delete(self) - - -class VolumeBackupManager(base.ManagerWithFind): - """Manage :class:`VolumeBackup` resources.""" - resource_class = VolumeBackup - - def create(self, volume_id, container=None, - name=None, description=None, - incremental=False): - """Creates a volume backup. - - :param volume_id: The ID of the volume to backup. - :param container: The name of the backup service container. - :param name: The name of the backup. - :param description: The description of the backup. - :param incremental: Incremental backup. - :rtype: :class:`VolumeBackup` - """ - body = {'backup': {'volume_id': volume_id, - 'container': container, - 'name': name, - 'description': description, - 'incremental': incremental}} - return self._create('/backups', body, 'backup') - - def get(self, backup_id): - """Show volume backup details. - - :param backup_id: The ID of the backup to display. - :rtype: :class:`VolumeBackup` - """ - return self._get("/backups/%s" % backup_id, "backup") - - def list(self, detailed=True, search_opts=None): - """Get a list of all volume backups. - - :rtype: list of :class:`VolumeBackup` - """ - if detailed is True: - return self._list("/backups/detail", "backups") - else: - return self._list("/backups", "backups") - - def delete(self, backup): - """Delete a volume backup. - - :param backup: The :class:`VolumeBackup` to delete. - """ - self._delete("/backups/%s" % base.getid(backup)) - - def export_record(self, backup_id): - """Export volume backup metadata record. - - :param backup_id: The ID of the backup to export. - :rtype: :class:`VolumeBackup` - """ - resp, body = \ - self.api.client.get("/backups/%s/export_record" % backup_id) - return body['backup-record'] - - def import_record(self, backup_service, backup_url): - """Export volume backup metadata record. - - :param backup_service: Backup service to use for importing the backup - :param backup_urlBackup URL for importing the backup metadata - :rtype: :class:`VolumeBackup` - """ - body = {'backup-record': {'backup_service': backup_service, - 'backup_url': backup_url}} - self.run_hooks('modify_body_for_update', body, 'backup-record') - resp, body = self.api.client.post("/backups/import_record", body=body) - return body['backup'] diff --git a/cinderclient/v2/volume_snapshots.py b/cinderclient/v2/volume_snapshots.py deleted file mode 100644 index 51b611db6..000000000 --- a/cinderclient/v2/volume_snapshots.py +++ /dev/null @@ -1,195 +0,0 @@ -# Copyright (c) 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. - -"""Volume snapshot interface (1.1 extension).""" - -import six -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode - -from cinderclient import base - - -class Snapshot(base.Resource): - """A Snapshot is a point-in-time snapshot of an openstack volume.""" - def __repr__(self): - return "" % self.id - - def delete(self): - """Delete this snapshot.""" - self.manager.delete(self) - - def update(self, **kwargs): - """Update the name or description for this snapshot.""" - self.manager.update(self, **kwargs) - - @property - def progress(self): - return self._info.get('os-extended-snapshot-attributes:progress') - - @property - def project_id(self): - return self._info.get('os-extended-snapshot-attributes:project_id') - - def reset_state(self, state): - """Update the snapshot with the provided state.""" - self.manager.reset_state(self, state) - - def set_metadata(self, metadata): - """Set metadata of this snapshot.""" - return self.manager.set_metadata(self, metadata) - - def delete_metadata(self, keys): - """Delete metadata of this snapshot.""" - return self.manager.delete_metadata(self, keys) - - def update_all_metadata(self, metadata): - """Update_all metadata of this snapshot.""" - return self.manager.update_all_metadata(self, metadata) - - -class SnapshotManager(base.ManagerWithFind): - """Manage :class:`Snapshot` resources.""" - resource_class = Snapshot - - def create(self, volume_id, force=False, - name=None, description=None, metadata=None): - - """Creates a snapshot of the given volume. - - :param volume_id: The ID of the volume to snapshot. - :param force: If force is True, create a snapshot even if the volume is - attached to an instance. Default is False. - :param name: Name of the snapshot - :param description: Description of the snapshot - :param metadata: Metadata of the snapshot - :rtype: :class:`Snapshot` - """ - - if metadata is None: - snapshot_metadata = {} - else: - snapshot_metadata = metadata - - body = {'snapshot': {'volume_id': volume_id, - 'force': force, - 'name': name, - 'description': description, - 'metadata': snapshot_metadata}} - return self._create('/snapshots', body, 'snapshot') - - def get(self, snapshot_id): - """Shows snapshot details. - - :param snapshot_id: The ID of the snapshot to get. - :rtype: :class:`Snapshot` - """ - return self._get("/snapshots/%s" % snapshot_id, "snapshot") - - def list(self, detailed=True, search_opts=None): - """Get a list of all snapshots. - - :rtype: list of :class:`Snapshot` - """ - - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - - # Transform the dict to a sequence of two-element tuples in fixed - # order, then the encoded string will be consistent in Python 2&3. - if qparams: - new_qparams = sorted(qparams.items(), key=lambda x: x[0]) - query_string = "?%s" % urlencode(new_qparams) - else: - query_string = "" - - detail = "" - if detailed: - detail = "/detail" - - return self._list("/snapshots%s%s" % (detail, query_string), - "snapshots") - - def delete(self, snapshot): - """Delete a snapshot. - - :param snapshot: The :class:`Snapshot` to delete. - """ - self._delete("/snapshots/%s" % base.getid(snapshot)) - - def update(self, snapshot, **kwargs): - """Update the name or description for a snapshot. - - :param snapshot: The :class:`Snapshot` to update. - """ - if not kwargs: - return - - body = {"snapshot": kwargs} - - self._update("/snapshots/%s" % base.getid(snapshot), body) - - def reset_state(self, snapshot, state): - """Update the specified snapshot with the provided state.""" - return self._action('os-reset_status', snapshot, {'status': state}) - - def _action(self, action, snapshot, info=None, **kwargs): - """Perform a snapshot action.""" - body = {action: info} - 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) - - def set_metadata(self, snapshot, metadata): - """Update/Set a snapshots metadata. - - :param snapshot: The :class:`Snapshot`. - :param metadata: A list of keys to be set. - """ - body = {'metadata': metadata} - return self._create("/snapshots/%s/metadata" % base.getid(snapshot), - body, "metadata") - - def delete_metadata(self, snapshot, keys): - """Delete specified keys from snapshot metadata. - - :param snapshot: The :class:`Snapshot`. - :param keys: A list of keys to be removed. - """ - snapshot_id = base.getid(snapshot) - for k in keys: - self._delete("/snapshots/%s/metadata/%s" % (snapshot_id, k)) - - def update_all_metadata(self, snapshot, metadata): - """Update_all snapshot metadata. - - :param snapshot: The :class:`Snapshot`. - :param metadata: A list of keys to be updated. - """ - body = {'metadata': metadata} - return self._update("/snapshots/%s/metadata" % base.getid(snapshot), - body) diff --git a/cinderclient/v2/__init__.py b/cinderclient/v3/__init__.py similarity index 92% rename from cinderclient/v2/__init__.py rename to cinderclient/v3/__init__.py index 75afdec8f..714e3f573 100644 --- a/cinderclient/v2/__init__.py +++ b/cinderclient/v3/__init__.py @@ -14,4 +14,4 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.v2.client import Client # noqa +from cinderclient.v3.client import Client # noqa diff --git a/cinderclient/v3/attachments.py b/cinderclient/v3/attachments.py new file mode 100644 index 000000000..506796270 --- /dev/null +++ b/cinderclient/v3/attachments.py @@ -0,0 +1,97 @@ +# 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. + +"""Attachment interface.""" + +from cinderclient import api_versions +from cinderclient import base + + +class VolumeAttachment(base.Resource): + """An attachment is a connected volume.""" + def __repr__(self): + """Obj to Str method.""" + return "" % self.id + + +class VolumeAttachmentManager(base.ManagerWithFind): + resource_class = VolumeAttachment + + @api_versions.wraps('3.27') + def create(self, volume_id, connector, instance_id=None, mode='null'): + """Create a attachment for specified volume.""" + body = {'attachment': {'volume_uuid': volume_id, + 'connector': connector}} + if instance_id: + body['attachment']['instance_uuid'] = instance_id + if self.api_version >= api_versions.APIVersion("3.54"): + if mode and mode != 'null': + body['attachment']['mode'] = mode + retval = self._create('/attachments', body, 'attachment') + return retval.to_dict() + + @api_versions.wraps('3.27') + def delete(self, attachment): + """Delete an attachment by ID.""" + return self._delete("/attachments/%s" % base.getid(attachment)) + + @api_versions.wraps('3.27') + def list(self, detailed=False, search_opts=None, marker=None, limit=None, + sort=None): + """List all attachments.""" + resource_type = "attachments" + url = self._build_list_url(resource_type, + detailed=detailed, + search_opts=search_opts, + marker=marker, + limit=limit, + sort=sort) + return self._list(url, resource_type, limit=limit) + + @api_versions.wraps('3.27') + def show(self, id): + """Attachment show. + + :param id: Attachment ID. + """ + url = '/attachments/%s' % id + resp, body = self.api.client.get(url) + return self.resource_class(self, body['attachment'], loaded=True, + resp=resp) + + @api_versions.wraps('3.27') + def update(self, id, connector): + """Attachment update.""" + body = {'attachment': {'connector': connector}} + resp = self._update('/attachments/%s' % id, body) + # NOTE(jdg): This kinda sucks, + # create returns a dict, but update returns an object :( + return self.resource_class(self, resp['attachment'], loaded=True, + resp=resp) + + @api_versions.wraps('3.44') + def complete(self, attachment): + """Mark the attachment as completed.""" + resp, body = self._action_return_resp_and_body('os-complete', + attachment, + None) + return resp + + def _action_return_resp_and_body(self, action, attachment, info=None, + **kwargs): + """Perform a attachments "action" and return response headers and body. + + """ + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/attachments/%s/action' % base.getid(attachment) + return self.api.client.post(url, body=body) diff --git a/cinderclient/v1/availability_zones.py b/cinderclient/v3/availability_zones.py similarity index 96% rename from cinderclient/v1/availability_zones.py rename to cinderclient/v3/availability_zones.py index b85d6dd3c..db6b8da26 100644 --- a/cinderclient/v1/availability_zones.py +++ b/cinderclient/v3/availability_zones.py @@ -14,7 +14,7 @@ # License for the specific language governing permissions and limitations # under the License. -"""Availability Zone interface (v1 extension)""" +"""Availability Zone interface (v3 extension)""" from cinderclient import base diff --git a/cinderclient/v3/capabilities.py b/cinderclient/v3/capabilities.py new file mode 100644 index 000000000..c837a4009 --- /dev/null +++ b/cinderclient/v3/capabilities.py @@ -0,0 +1,39 @@ +# Copyright (c) 2015 Hitachi Data Systems, 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. + +"""Capabilities interface (v3 extension)""" + + +from cinderclient import base + + +class Capabilities(base.Resource): + NAME_ATTR = 'name' + + def __repr__(self): + return "" % self._info.get('namespace') + + +class CapabilitiesManager(base.Manager): + """Manage :class:`Capabilities` resources.""" + resource_class = Capabilities + + def get(self, host): + """Show backend volume stats and properties. + + :param host: Specified backend to obtain volume stats and properties. + :rtype: :class:`Capabilities` + """ + return self._get('/capabilities/%s' % host, None) diff --git a/cinderclient/v2/cgsnapshots.py b/cinderclient/v3/cgsnapshots.py similarity index 81% rename from cinderclient/v2/cgsnapshots.py rename to cinderclient/v3/cgsnapshots.py index 29513a9ab..1f5abc6e3 100644 --- a/cinderclient/v2/cgsnapshots.py +++ b/cinderclient/v3/cgsnapshots.py @@ -13,15 +13,11 @@ # License for the specific language governing permissions and limitations # under the License. -"""cgsnapshot interface (v2 extension).""" - -import six -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode +"""cgsnapshot interface (v3 extension).""" +from cinderclient.apiclient import base as common_base from cinderclient import base +from cinderclient import utils class Cgsnapshot(base.Resource): @@ -31,11 +27,11 @@ def __repr__(self): def delete(self): """Delete this cgsnapshot.""" - self.manager.delete(self) + return self.manager.delete(self) def update(self, **kwargs): """Update the name or description for this cgsnapshot.""" - self.manager.update(self, **kwargs) + return self.manager.update(self, **kwargs) class CgsnapshotManager(base.ManagerWithFind): @@ -47,7 +43,7 @@ def create(self, consistencygroup_id, name=None, description=None, project_id=None): """Creates a cgsnapshot. - :param consistencygroup: Name or uuid of a consistencygroup + :param consistencygroup: Name or uuid of a consistency group :param name: Name of the cgsnapshot :param description: Description of the cgsnapshot :param user_id: User id derived from context @@ -78,16 +74,7 @@ def list(self, detailed=True, search_opts=None): :rtype: list of :class:`Cgsnapshot` """ - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - - query_string = "?%s" % urlencode(qparams) if qparams else "" + query_string = utils.build_query_param(search_opts) detail = "" if detailed: @@ -101,7 +88,7 @@ def delete(self, cgsnapshot): :param cgsnapshot: The :class:`Cgsnapshot` to delete. """ - self._delete("/cgsnapshots/%s" % base.getid(cgsnapshot)) + return self._delete("/cgsnapshots/%s" % base.getid(cgsnapshot)) def update(self, cgsnapshot, **kwargs): """Update the name or description for a cgsnapshot. @@ -113,7 +100,7 @@ def update(self, cgsnapshot, **kwargs): body = {"cgsnapshot": kwargs} - self._update("/cgsnapshots/%s" % base.getid(cgsnapshot), body) + return self._update("/cgsnapshots/%s" % base.getid(cgsnapshot), body) def _action(self, action, cgsnapshot, info=None, **kwargs): """Perform a cgsnapshot "action." @@ -121,4 +108,5 @@ def _action(self, action, cgsnapshot, info=None, **kwargs): body = {action: info} self.run_hooks('modify_body_for_action', body, **kwargs) url = '/cgsnapshots/%s/action' % base.getid(cgsnapshot) - return self.api.client.post(url, body=body) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v2/client.py b/cinderclient/v3/client.py similarity index 63% rename from cinderclient/v2/client.py rename to cinderclient/v3/client.py index 01c57dea1..8ecaf0069 100644 --- a/cinderclient/v2/client.py +++ b/cinderclient/v3/client.py @@ -13,24 +13,35 @@ # License for the specific language governing permissions and limitations # under the License. +from cinderclient import api_versions from cinderclient import client -from cinderclient.v2 import availability_zones -from cinderclient.v2 import cgsnapshots -from cinderclient.v2 import consistencygroups -from cinderclient.v2 import limits -from cinderclient.v2 import pools -from cinderclient.v2 import qos_specs -from cinderclient.v2 import quota_classes -from cinderclient.v2 import quotas -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_type_access -from cinderclient.v2 import volume_encryption_types -from cinderclient.v2 import volume_backups -from cinderclient.v2 import volume_backups_restore -from cinderclient.v2 import volume_transfers +from cinderclient.v3 import attachments +from cinderclient.v3 import availability_zones +from cinderclient.v3 import capabilities +from cinderclient.v3 import cgsnapshots +from cinderclient.v3 import clusters +from cinderclient.v3 import consistencygroups +from cinderclient.v3 import default_types +from cinderclient.v3 import group_snapshots +from cinderclient.v3 import group_types +from cinderclient.v3 import groups +from cinderclient.v3 import limits +from cinderclient.v3 import messages +from cinderclient.v3 import pools +from cinderclient.v3 import qos_specs +from cinderclient.v3 import quota_classes +from cinderclient.v3 import quotas +from cinderclient.v3 import resource_filters +from cinderclient.v3 import services +from cinderclient.v3 import volume_backups +from cinderclient.v3 import volume_backups_restore +from cinderclient.v3 import volume_encryption_types +from cinderclient.v3 import volume_snapshots +from cinderclient.v3 import volume_transfers +from cinderclient.v3 import volume_type_access +from cinderclient.v3 import volume_types +from cinderclient.v3 import volumes +from cinderclient.v3 import workers class Client(object): @@ -50,36 +61,49 @@ def __init__(self, username=None, api_key=None, project_id=None, auth_url='', insecure=False, timeout=None, tenant_id=None, proxy_tenant_id=None, proxy_token=None, region_name=None, endpoint_type='publicURL', extensions=None, - service_type='volumev2', service_name=None, - volume_service_name=None, bypass_url=None, retries=None, - http_log_debug=False, cacert=None, auth_system='keystone', - auth_plugin=None, session=None, **kwargs): + service_type='volumev3', service_name=None, + volume_service_name=None, os_endpoint=None, retries=0, + http_log_debug=False, cacert=None, cert=None, + auth_system='keystone', auth_plugin=None, session=None, + api_version=None, logger=None, **kwargs): # FIXME(comstud): Rename the api_key argument above when we # know it's not being used as keyword argument password = api_key + self.version = '3.0' self.limits = limits.LimitsManager(self) + self.api_version = api_version or api_versions.APIVersion(self.version) - # extensions self.volumes = volumes.VolumeManager(self) self.volume_snapshots = volume_snapshots.SnapshotManager(self) self.volume_types = volume_types.VolumeTypeManager(self) + self.group_types = group_types.GroupTypeManager(self) self.volume_type_access = \ volume_type_access.VolumeTypeAccessManager(self) self.volume_encryption_types = \ volume_encryption_types.VolumeEncryptionTypeManager(self) + self.default_types = default_types.DefaultVolumeTypeManager(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) + self.messages = messages.MessageManager(self) + self.resource_filters = resource_filters.ResourceFilterManager(self) self.restores = volume_backups_restore.VolumeBackupRestoreManager(self) self.transfers = volume_transfers.VolumeTransferManager(self) self.services = services.ServiceManager(self) + self.clusters = clusters.ClusterManager(self) + self.workers = workers.WorkerManager(self) self.consistencygroups = consistencygroups.\ ConsistencygroupManager(self) + self.groups = groups.GroupManager(self) self.cgsnapshots = cgsnapshots.CgsnapshotManager(self) + self.group_snapshots = group_snapshots.GroupSnapshotManager(self) self.availability_zones = \ availability_zones.AvailabilityZoneManager(self) self.pools = pools.PoolManager(self) + self.capabilities = capabilities.CapabilitiesManager(self) + self.attachments = \ + attachments.VolumeAttachmentManager(self) # Add in any extensions... if extensions: @@ -103,13 +127,16 @@ def __init__(self, username=None, api_key=None, project_id=None, service_type=service_type, service_name=service_name, volume_service_name=volume_service_name, - bypass_url=bypass_url, + os_endpoint=os_endpoint, retries=retries, http_log_debug=http_log_debug, cacert=cacert, + cert=cert, auth_system=auth_system, auth_plugin=auth_plugin, session=session, + api_version=self.api_version, + logger=logger, **kwargs) def authenticate(self): diff --git a/cinderclient/v3/clusters.py b/cinderclient/v3/clusters.py new file mode 100644 index 000000000..bc500106d --- /dev/null +++ b/cinderclient/v3/clusters.py @@ -0,0 +1,87 @@ +# Copyright (c) 2016 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. + +""" +Interface to clusters API +""" +from cinderclient import api_versions +from cinderclient import base + + +class Cluster(base.Resource): + def __repr__(self): + return "" % (self.name, self.id) + + +class ClusterManager(base.ManagerWithFind): + resource_class = Cluster + base_url = '/clusters' + + def _build_url(self, url_path=None, **kwargs): + url = self.base_url + ('/' + url_path if url_path else '') + filters = {'%s=%s' % (k, v) for k, v in kwargs.items() if v} + if filters: + url = "%s?%s" % (url, "&".join(filters)) + return url + + @api_versions.wraps("3.7") + def list(self, name=None, binary=None, is_up=None, disabled=None, + num_hosts=None, num_down_hosts=None, detailed=False): + """Clustered Service list. + + :param name: filter by cluster name. + :param binary: filter by cluster binary. + :param is_up: filtering by up/down status. + :param disabled: filtering by disabled status. + :param num_hosts: filtering by number of hosts. + :param num_down_hosts: filtering by number of hosts that are down. + :param detailed: retrieve simple or detailed list. + """ + url_path = 'detail' if detailed else None + url = self._build_url(url_path, name=name, binary=binary, is_up=is_up, + disabled=disabled, num_hosts=num_hosts, + num_down_hosts=num_down_hosts) + return self._list(url, 'clusters') + + @api_versions.wraps("3.7") + def show(self, name, binary=None): + """Clustered Service show. + + :param name: Cluster name. + :param binary: Clustered service binary. + """ + url = self._build_url(name, binary=binary) + resp, body = self.api.client.get(url) + return self.resource_class(self, body['cluster'], loaded=True, + resp=resp) + + @api_versions.wraps("3.7") + def update(self, name, binary, disabled, disabled_reason=None): + """Enable or disable a clustered service. + + :param name: Cluster name. + :param binary: Clustered service binary. + :param disabled: Boolean determining desired disabled status. + :param disabled_reason: Value to pass as disabled reason. + """ + url_path = 'disable' if disabled else 'enable' + url = self._build_url(url_path) + + body = {'name': name, 'binary': binary} + if disabled and disabled_reason: + body['disabled_reason'] = disabled_reason + result = self._update(url, body) + return self.resource_class(self, result['cluster'], loaded=True, + resp=result.request_ids) diff --git a/cinderclient/v2/consistencygroups.py b/cinderclient/v3/consistencygroups.py similarity index 75% rename from cinderclient/v2/consistencygroups.py rename to cinderclient/v3/consistencygroups.py index c833e96f8..13bc2eefb 100644 --- a/cinderclient/v2/consistencygroups.py +++ b/cinderclient/v3/consistencygroups.py @@ -13,15 +13,11 @@ # License for the specific language governing permissions and limitations # under the License. -"""Consistencygroup interface (v2 extension).""" - -import six -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode +"""Consistencygroup interface (v3 extension).""" +from cinderclient.apiclient import base as common_base from cinderclient import base +from cinderclient import utils class Consistencygroup(base.Resource): @@ -30,12 +26,12 @@ def __repr__(self): return "" % self.id def delete(self, force='False'): - """Delete this consistencygroup.""" - self.manager.delete(self, force) + """Delete this consistency group.""" + return self.manager.delete(self, force) def update(self, **kwargs): - """Update the name or description for this consistencygroup.""" - self.manager.update(self, **kwargs) + """Update the name or description for this consistency group.""" + return self.manager.update(self, **kwargs) class ConsistencygroupManager(base.ManagerWithFind): @@ -45,7 +41,7 @@ class ConsistencygroupManager(base.ManagerWithFind): def create(self, volume_types, name=None, description=None, user_id=None, project_id=None, availability_zone=None): - """Creates a consistencygroup. + """Creates a consistency group. :param name: Name of the ConsistencyGroup :param description: Description of the ConsistencyGroup @@ -67,21 +63,23 @@ def create(self, volume_types, name=None, return self._create('/consistencygroups', body, 'consistencygroup') - def create_from_src(self, cgsnapshot_id, name=None, + def create_from_src(self, cgsnapshot_id, source_cgid, name=None, description=None, user_id=None, project_id=None): - """Creates a consistencygroup from a cgsnapshot. + """Creates a consistency group from a cgsnapshot or a source CG. :param cgsnapshot_id: UUID of a CGSnapshot + :param source_cgid: UUID of a source CG :param name: Name of the ConsistencyGroup :param description: Description of the ConsistencyGroup :param user_id: User id derived from context :param project_id: Project id derived from context - :rtype: :class:`Consistencygroup` + :rtype: A dictionary containing Consistencygroup metadata """ body = {'consistencygroup-from-src': {'name': name, 'description': description, 'cgsnapshot_id': cgsnapshot_id, + 'source_cgid': source_cgid, 'user_id': user_id, 'project_id': project_id, 'status': "creating", @@ -91,32 +89,24 @@ def create_from_src(self, cgsnapshot_id, name=None, 'consistencygroup-from-src') resp, body = self.api.client.post( "/consistencygroups/create_from_src", body=body) - return body['consistencygroup'] + return common_base.DictWithMeta(body['consistencygroup'], resp) def get(self, group_id): - """Get a consistencygroup. + """Get a consistency group. - :param group_id: The ID of the consistencygroup to get. + :param group_id: The ID of the consistency group to get. :rtype: :class:`Consistencygroup` """ return self._get("/consistencygroups/%s" % group_id, "consistencygroup") def list(self, detailed=True, search_opts=None): - """Lists all consistencygroups. + """Lists all consistency groups. :rtype: list of :class:`Consistencygroup` """ - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - query_string = "?%s" % urlencode(qparams) if qparams else "" + query_string = utils.build_query_param(search_opts) detail = "" if detailed: @@ -126,17 +116,18 @@ def list(self, detailed=True, search_opts=None): "consistencygroups") def delete(self, consistencygroup, force=False): - """Delete a consistencygroup. + """Delete a consistency group. :param Consistencygroup: The :class:`Consistencygroup` to delete. """ body = {'consistencygroup': {'force': force}} self.run_hooks('modify_body_for_action', body, 'consistencygroup') url = '/consistencygroups/%s/delete' % base.getid(consistencygroup) - return self.api.client.post(url, body=body) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) def update(self, consistencygroup, **kwargs): - """Update the name or description for a consistencygroup. + """Update the name or description for a consistency group. :param Consistencygroup: The :class:`Consistencygroup` to update. """ @@ -145,13 +136,14 @@ def update(self, consistencygroup, **kwargs): body = {"consistencygroup": kwargs} - self._update("/consistencygroups/%s" % base.getid(consistencygroup), - body) + return self._update("/consistencygroups/%s" % + base.getid(consistencygroup), body) def _action(self, action, consistencygroup, info=None, **kwargs): - """Perform a consistencygroup "action." + """Perform a consistency group "action." """ body = {action: info} self.run_hooks('modify_body_for_action', body, **kwargs) url = '/consistencygroups/%s/action' % base.getid(consistencygroup) - return self.api.client.post(url, body=body) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/tests/unit/v2/__init__.py b/cinderclient/v3/contrib/__init__.py similarity index 100% rename from cinderclient/tests/unit/v2/__init__.py rename to cinderclient/v3/contrib/__init__.py diff --git a/cinderclient/v2/contrib/list_extensions.py b/cinderclient/v3/contrib/list_extensions.py similarity index 88% rename from cinderclient/v2/contrib/list_extensions.py rename to cinderclient/v3/contrib/list_extensions.py index e457e8bb9..548cbec46 100644 --- a/cinderclient/v2/contrib/list_extensions.py +++ b/cinderclient/v3/contrib/list_extensions.py @@ -14,7 +14,7 @@ # under the License. from cinderclient import base -from cinderclient import utils +from cinderclient import shell_utils class ListExtResource(base.Resource): @@ -37,11 +37,8 @@ def show_all(self): return self._list("/extensions", 'extensions') -@utils.service_type('volumev2') def do_list_extensions(client, _args): - """ - Lists all available os-api extensions. - """ + """Lists all available os-api extensions.""" extensions = client.list_extensions.show_all() fields = ["Name", "Summary", "Alias", "Updated"] - utils.print_list(extensions, fields) + shell_utils.print_list(extensions, fields) diff --git a/cinderclient/v3/default_types.py b/cinderclient/v3/default_types.py new file mode 100644 index 000000000..58e04ccb3 --- /dev/null +++ b/cinderclient/v3/default_types.py @@ -0,0 +1,65 @@ +# 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. + + +"""Default Volume Type interface.""" + +from cinderclient import base + + +class DefaultVolumeType(base.Resource): + """Default volume types for projects.""" + def __repr__(self): + return "" % self.project_id + + +class DefaultVolumeTypeManager(base.ManagerWithFind): + """Manage :class:`DefaultVolumeType` resources.""" + resource_class = DefaultVolumeType + + def create(self, volume_type, project_id): + """Creates a default volume type for a project + + :param volume_type: Name or ID of the volume type + :param project_id: Project to set default type for + """ + + body = { + "default_type": { + "volume_type": volume_type + } + } + + return self._create_update_with_base_url( + 'v3/default-types/%s' % project_id, body, + response_key='default_type') + + def list(self, project_id=None): + """List the default types.""" + + url = 'v3/default-types' + response_key = "default_types" + + if project_id: + url += '/' + project_id + response_key = "default_type" + + return self._get_all_with_base_url(url, response_key) + + def delete(self, project_id): + """Removes the default volume type for a project + + :param project_id: The ID of the project to unset default for. + """ + + return self._delete_with_base_url('v3/default-types/%s' % project_id) diff --git a/cinderclient/v3/group_snapshots.py b/cinderclient/v3/group_snapshots.py new file mode 100644 index 000000000..9225995ce --- /dev/null +++ b/cinderclient/v3/group_snapshots.py @@ -0,0 +1,136 @@ +# Copyright (C) 2016 EMC Corporation. +# 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. + +"""group snapshot interface (v3).""" + + +from cinderclient import api_versions +from cinderclient.apiclient import base as common_base +from cinderclient import base +from cinderclient import utils + + +class GroupSnapshot(base.Resource): + """A group snapshot is a snapshot of a group.""" + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this group snapshot.""" + return self.manager.delete(self) + + def update(self, **kwargs): + """Update the name or description for this group snapshot.""" + return self.manager.update(self, **kwargs) + + def reset_state(self, state): + """Reset the group snapshot's state with specified one.""" + return self.manager.reset_state(self, state) + + +class GroupSnapshotManager(base.ManagerWithFind): + """Manage :class:`GroupSnapshot` resources.""" + resource_class = GroupSnapshot + + @api_versions.wraps('3.14') + def create(self, group_id, name=None, description=None, + user_id=None, + project_id=None): + """Creates a group snapshot. + + :param group_id: Name or uuid of a group + :param name: Name of the group snapshot + :param description: Description of the group snapshot + :param user_id: User id derived from context + :param project_id: Project id derived from context + :rtype: :class:`GroupSnapshot` + """ + + body = { + 'group_snapshot': { + 'group_id': group_id, + 'name': name, + 'description': description, + } + } + + return self._create('/group_snapshots', body, 'group_snapshot') + + @api_versions.wraps('3.14') + def get(self, group_snapshot_id): + """Get a group snapshot. + + :param group_snapshot_id: The ID of the group snapshot to get. + :rtype: :class:`GroupSnapshot` + """ + return self._get("/group_snapshots/%s" % group_snapshot_id, + "group_snapshot") + + @api_versions.wraps('3.19') + def reset_state(self, group_snapshot, state): + """Update the provided group snapshot with the provided state. + + :param group_snapshot: The :class:`GroupSnapshot` to set the state. + :param state: The state of the group snapshot to be set. + """ + body = {'status': state} if state else {} + return self._action('reset_status', group_snapshot, body) + + @api_versions.wraps('3.14') + def list(self, detailed=True, search_opts=None): + """Lists all group snapshots. + + :param detailed: list detailed info or not + :param search_opts: search options + :rtype: list of :class:`GroupSnapshot` + """ + query_string = utils.build_query_param(search_opts, sort=True) + + detail = "" + if detailed: + detail = "/detail" + + return self._list("/group_snapshots%s%s" % (detail, query_string), + "group_snapshots") + + @api_versions.wraps('3.14') + def delete(self, group_snapshot): + """Delete a group_snapshot. + + :param group_snapshot: The :class:`GroupSnapshot` to delete. + """ + return self._delete("/group_snapshots/%s" % base.getid(group_snapshot)) + + @api_versions.wraps('3.14') + def update(self, group_snapshot, **kwargs): + """Update the name or description for a group_snapshot. + + :param group_snapshot: The :class:`GroupSnapshot` to update. + """ + if not kwargs: + return + + body = {"group_snapshot": kwargs} + + return self._update("/group_snapshots/%s" % base.getid(group_snapshot), + body) + + def _action(self, action, group_snapshot, info=None, **kwargs): + """Perform a group_snapshot action.""" + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/group_snapshots/%s/action' % base.getid(group_snapshot) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v3/group_types.py b/cinderclient/v3/group_types.py new file mode 100644 index 000000000..696f02be1 --- /dev/null +++ b/cinderclient/v3/group_types.py @@ -0,0 +1,165 @@ +# Copyright (c) 2016 EMC Corporation +# +# 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. + + +"""Group Type interface.""" + +from urllib import parse + +from cinderclient import api_versions +from cinderclient import base + + +class GroupType(base.Resource): + """A Group Type is the type of group to be created.""" + def __repr__(self): + return "" % self.name + + @property + def is_public(self): + """ + Provide a user-friendly accessor to is_public + """ + return self._info.get("is_public", + self._info.get("is_public", 'N/A')) + + @api_versions.wraps("3.11") + def get_keys(self): + """Get group specs from a group type. + + :param type: The :class:`GroupType` to get specs from + """ + _resp, body = self.manager.api.client.get( + "/group_types/%s/group_specs" % + base.getid(self)) + return body["group_specs"] + + @api_versions.wraps("3.11") + def set_keys(self, metadata): + """Set group specs on a group type. + + :param type : The :class:`GroupType` to set spec on + :param metadata: A dict of key/value pairs to be set + """ + body = {'group_specs': metadata} + return self.manager._create( + "/group_types/%s/group_specs" % base.getid(self), + body, + "group_specs", + return_raw=True) + + @api_versions.wraps("3.11") + def unset_keys(self, keys): + """Unset specs on a group type. + + :param type_id: The :class:`GroupType` to unset spec on + :param keys: A list of keys to be unset + """ + + for k in keys: + resp = self.manager._delete( + "/group_types/%s/group_specs/%s" % ( + base.getid(self), k)) + if resp: + return resp + + +class GroupTypeManager(base.ManagerWithFind): + """Manage :class:`GroupType` resources.""" + resource_class = GroupType + + @api_versions.wraps("3.11") + def list(self, search_opts=None, is_public=None): + """Lists all group types. + + :rtype: list of :class:`GroupType`. + """ + if not search_opts: + search_opts = dict() + + query_string = '' + if 'is_public' not in search_opts: + search_opts['is_public'] = is_public + + query_string = "?%s" % parse.urlencode(search_opts) + return self._list("/group_types%s" % (query_string), "group_types") + + @api_versions.wraps("3.11") + def get(self, group_type): + """Get a specific group type. + + :param group_type: The ID of the :class:`GroupType` to get. + :rtype: :class:`GroupType` + """ + return self._get("/group_types/%s" % base.getid(group_type), + "group_type") + + @api_versions.wraps("3.11") + def default(self): + """Get the default group type. + + :rtype: :class:`GroupType` + """ + return self._get("/group_types/default", "group_type") + + @api_versions.wraps("3.11") + def delete(self, group_type): + """Deletes a specific group_type. + + :param group_type: The name or ID of the :class:`GroupType` to get. + """ + return self._delete("/group_types/%s" % base.getid(group_type)) + + @api_versions.wraps("3.11") + def create(self, name, description=None, is_public=True): + """Creates a group type. + + :param name: Descriptive name of the group type + :param description: Description of the group type + :param is_public: Group type visibility + :rtype: :class:`GroupType` + """ + + body = { + "group_type": { + "name": name, + "description": description, + "is_public": is_public, + } + } + + return self._create("/group_types", body, "group_type") + + @api_versions.wraps("3.11") + def update(self, group_type, name=None, description=None, is_public=None): + """Update the name and/or description for a group type. + + :param group_type: The ID of the :class:`GroupType` to update. + :param name: Descriptive name of the group type. + :param description: Description of the group type. + :rtype: :class:`GroupType` + """ + + body = { + "group_type": { + "name": name, + "description": description + } + } + if is_public is not None: + body["group_type"]["is_public"] = is_public + + return self._update("/group_types/%s" % base.getid(group_type), + body, response_key="group_type") diff --git a/cinderclient/v3/groups.py b/cinderclient/v3/groups.py new file mode 100644 index 000000000..310006c94 --- /dev/null +++ b/cinderclient/v3/groups.py @@ -0,0 +1,261 @@ +# Copyright (C) 2016 EMC Corporation. +# 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. + +"""Group interface (v3 extension).""" +from cinderclient import api_versions +from cinderclient.apiclient import base as common_base +from cinderclient import base +from cinderclient import utils + + +class Group(base.Resource): + """A Group of volumes.""" + def __repr__(self): + return "" % self.id + + def delete(self, delete_volumes=False): + """Delete this group.""" + return self.manager.delete(self, delete_volumes) + + def update(self, **kwargs): + """Update the name or description for this group.""" + return self.manager.update(self, **kwargs) + + def reset_state(self, state): + """Reset the group's state with specified one""" + return self.manager.reset_state(self, state) + + def enable_replication(self): + """Enables replication for this group.""" + return self.manager.enable_replication(self) + + def disable_replication(self): + """Disables replication for this group.""" + return self.manager.disable_replication(self) + + def failover_replication(self, allow_attached_volume=False, + secondary_backend_id=None): + """Fails over replication for this group.""" + return self.manager.failover_replication(self, + allow_attached_volume, + secondary_backend_id) + + def list_replication_targets(self): + """Lists replication targets for this group.""" + return self.manager.list_replication_targets(self) + + +class GroupManager(base.ManagerWithFind): + """Manage :class:`Group` resources.""" + resource_class = Group + + @api_versions.wraps('3.13') + def create(self, group_type, volume_types, name=None, + description=None, user_id=None, + project_id=None, availability_zone=None): + """Creates a group. + + :param group_type: Type of the Group + :param volume_types: Types of volume + :param name: Name of the Group + :param description: Description of the Group + :param user_id: User id derived from context + :param project_id: Project id derived from context + :param availability_zone: Availability Zone to use + :rtype: :class:`Group` + """ + body = {'group': {'name': name, + 'description': description, + 'group_type': group_type, + 'volume_types': volume_types.split(','), + 'availability_zone': availability_zone, + }} + + return self._create('/groups', body, 'group') + + @api_versions.wraps('3.20') + def reset_state(self, group, state): + """Update the provided group with the provided state. + + :param group: The :class:`Group` to set the state. + :param state: The state of the group to be set. + """ + body = {'status': state} if state else {} + return self._action('reset_status', group, body) + + @api_versions.wraps('3.14') + def create_from_src(self, group_snapshot_id, source_group_id, + name=None, description=None, user_id=None, + project_id=None): + """Creates a group from a group snapshot or a source group. + + :param group_snapshot_id: UUID of a GroupSnapshot + :param source_group_id: UUID of a source Group + :param name: Name of the Group + :param description: Description of the Group + :param user_id: User id derived from context + :param project_id: Project id derived from context + :rtype: A dictionary containing Group metadata + """ + + # NOTE(wanghao): According the API schema in cinder side, client + # should NOT specify the group_snapshot_id and source_group_id at + # same time, even one of them is None. + if group_snapshot_id: + create_key = 'group_snapshot_id' + create_value = group_snapshot_id + elif source_group_id: + create_key = 'source_group_id' + create_value = source_group_id + + body = {'create-from-src': {'name': name, + 'description': description, + create_key: create_value}} + + self.run_hooks('modify_body_for_action', body, + 'create-from-src') + resp, body = self.api.client.post( + "/groups/action", body=body) + return common_base.DictWithMeta(body['group'], resp) + + @api_versions.wraps('3.13') + def get(self, group_id, **kwargs): + """Get a group. + + :param group_id: The ID of the group to get. + :rtype: :class:`Group` + """ + query_params = kwargs + query_string = utils.build_query_param(query_params, sort=True) + + return self._get("/groups/%s" % group_id + query_string, + "group") + + @api_versions.wraps('3.13') + def list(self, detailed=True, search_opts=None, list_volume=False): + """Lists all groups. + + :rtype: list of :class:`Group` + """ + if list_volume: + if not search_opts: + search_opts = {} + search_opts['list_volume'] = True + query_string = utils.build_query_param(search_opts, sort=True) + + detail = "" + if detailed: + detail = "/detail" + + return self._list("/groups%s%s" % (detail, query_string), + "groups") + + @api_versions.wraps('3.13') + def delete(self, group, delete_volumes=False): + """Delete a group. + + :param group: the :class:`Group` to delete. + :param delete_volumes: delete volumes in the group. + """ + body = {'delete': {'delete-volumes': delete_volumes}} + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + @api_versions.wraps('3.13') + def update(self, group, **kwargs): + """Update the name or description for a group. + + :param Group: The :class:`Group` to update. + """ + if not kwargs: + return + + body = {"group": kwargs} + + return self._update("/groups/%s" % + base.getid(group), body) + + def _action(self, action, group, info=None, **kwargs): + """Perform a group "action." + + :param action: an action to be performed on the group + :param group: a group to perform the action on + :param info: details of the action + :param **kwargs: other parameters + """ + + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + @api_versions.wraps('3.38') + def enable_replication(self, group): + """Enables replication for a group. + + :param group: the :class:`Group` to enable replication. + """ + body = {'enable_replication': {}} + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + @api_versions.wraps('3.38') + def disable_replication(self, group): + """disables replication for a group. + + :param group: the :class:`Group` to disable replication. + """ + body = {'disable_replication': {}} + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + @api_versions.wraps('3.38') + def failover_replication(self, group, allow_attached_volume=False, + secondary_backend_id=None): + """fails over replication for a group. + + :param group: the :class:`Group` to failover. + :param allow attached volumes: allow attached volumes in the group. + :param secondary_backend_id: secondary backend id. + """ + body = { + 'failover_replication': { + 'allow_attached_volume': allow_attached_volume, + 'secondary_backend_id': secondary_backend_id + } + } + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + @api_versions.wraps('3.38') + def list_replication_targets(self, group): + """List replication targets for a group. + + :param group: the :class:`Group` to list replication targets. + """ + body = {'list_replication_targets': {}} + self.run_hooks('modify_body_for_action', body, 'group') + url = '/groups/%s/action' % base.getid(group) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v2/limits.py b/cinderclient/v3/limits.py similarity index 91% rename from cinderclient/v2/limits.py rename to cinderclient/v3/limits.py index 512a58dec..69f053f11 100644 --- a/cinderclient/v2/limits.py +++ b/cinderclient/v3/limits.py @@ -14,6 +14,7 @@ # limitations under the License. from cinderclient import base +from cinderclient import utils class Limits(base.Resource): @@ -83,9 +84,15 @@ class LimitsManager(base.Manager): resource_class = Limits - def get(self): + def get(self, tenant_id=None): """Get a specific extension. :rtype: :class:`Limits` """ - return self._get("/limits", "limits") + opts = {} + if tenant_id: + opts['tenant_id'] = tenant_id + + query_string = utils.build_query_param(opts) + + return self._get("/limits%s" % query_string, "limits") diff --git a/cinderclient/v3/messages.py b/cinderclient/v3/messages.py new file mode 100644 index 000000000..93aeefa71 --- /dev/null +++ b/cinderclient/v3/messages.py @@ -0,0 +1,78 @@ +# 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. + +"""Message interface (v3 extension).""" + +from cinderclient import api_versions +from cinderclient import base + + +class Message(base.Resource): + NAME_ATTR = 'id' + + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this message.""" + return self.manager.delete(self) + + +class MessageManager(base.ManagerWithFind): + """Manage :class:`Message` resources.""" + resource_class = Message + + @api_versions.wraps('3.3') + def get(self, message_id): + """Get a message. + + :param message_id: The ID of the message to get. + :rtype: :class:`Message` + """ + return self._get("/messages/%s" % message_id, "message") + + @api_versions.wraps('3.3', '3.4') + def list(self, **kwargs): + """Lists all messages. + + :rtype: list of :class:`Message` + """ + + resource_type = "messages" + url = self._build_list_url(resource_type, detailed=False) + return self._list(url, resource_type) + + @api_versions.wraps('3.5') + def list(self, search_opts=None, marker=None, limit=None, # noqa: F811 + sort=None): + """Lists all messages. + + :param search_opts: Search options to filter out volumes. + :param marker: Begin returning volumes that appear later in the volume + list than that represented by this volume id. + :param limit: Maximum number of volumes to return. + :param sort: Sort information + :rtype: list of :class:`Message` + """ + resource_type = "messages" + url = self._build_list_url(resource_type, detailed=False, + search_opts=search_opts, marker=marker, + limit=limit, sort=sort) + return self._list(url, resource_type, limit=limit) + + @api_versions.wraps('3.3') + def delete(self, message): + """Delete a message.""" + + loc = "/messages/%s" % base.getid(message) + + return self._delete(loc) diff --git a/cinderclient/v2/pools.py b/cinderclient/v3/pools.py similarity index 94% rename from cinderclient/v2/pools.py rename to cinderclient/v3/pools.py index 608e057d0..5303f8422 100644 --- a/cinderclient/v2/pools.py +++ b/cinderclient/v3/pools.py @@ -13,9 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -"""Pools interface (v2 extension)""" - -import six +"""Pools interface (v3 extension)""" from cinderclient import base @@ -45,7 +43,7 @@ def list(self, detailed=False): # be attributes of the pool itself. for pool in pools: if hasattr(pool, 'capabilities'): - for k, v in six.iteritems(pool.capabilities): + for k, v in pool.capabilities.items(): setattr(pool, k, v) # Remove the capabilities dictionary since all of its diff --git a/cinderclient/v2/qos_specs.py b/cinderclient/v3/qos_specs.py similarity index 81% rename from cinderclient/v2/qos_specs.py rename to cinderclient/v3/qos_specs.py index b4e4272ae..972316482 100644 --- a/cinderclient/v2/qos_specs.py +++ b/cinderclient/v3/qos_specs.py @@ -1,5 +1,5 @@ # Copyright (c) 2013 eBay Inc. -# Copyright (c) OpenStack LLC. +# Copyright (c) OpenStack Foundation # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ QoS Specs interface. """ +from cinderclient.apiclient import base as common_base from cinderclient import base @@ -43,7 +44,7 @@ class QoSSpecsManager(base.ManagerWithFind): """ resource_class = QoSSpecs - def list(self): + def list(self, search_opts=None): """Get a list of all qos specs. :rtype: list of :class:`QoSSpecs`. @@ -65,8 +66,8 @@ def delete(self, qos_specs, force=False): :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)) + return self._delete("/qos-specs/%s?force=%s" % + (base.getid(qos_specs), force)) def create(self, name, specs): """Create a qos specs. @@ -86,7 +87,7 @@ def create(self, name, specs): return self._create("/qos-specs", body, "qos_specs") def set_keys(self, qos_specs, specs): - """Update a qos specs with new specifications. + """Add/Update keys in qos specs. :param qos_specs: The ID of qos specs :param specs: A dict of key/value pairs to be set @@ -101,7 +102,7 @@ def set_keys(self, qos_specs, specs): return self._update("/qos-specs/%s" % qos_specs, body) def unset_keys(self, qos_specs, specs): - """Update a qos specs with new specifications. + """Remove keys from a qos specs. :param qos_specs: The ID of qos specs :param specs: A list of key to be unset @@ -128,8 +129,10 @@ def associate(self, qos_specs, vol_type_id): :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)) + resp, body = self.api.client.get( + "/qos-specs/%s/associate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + return common_base.TupleWithMeta((resp, body), resp) def disassociate(self, qos_specs, vol_type_id): """Disassociate qos specs from volume type. @@ -137,13 +140,17 @@ def disassociate(self, qos_specs, vol_type_id): :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)) + resp, body = self.api.client.get( + "/qos-specs/%s/disassociate?vol_type_id=%s" % + (base.getid(qos_specs), vol_type_id)) + return common_base.TupleWithMeta((resp, body), resp) 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)) + resp, body = self.api.client.get( + "/qos-specs/%s/disassociate_all" % + base.getid(qos_specs)) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v2/quota_classes.py b/cinderclient/v3/quota_classes.py similarity index 71% rename from cinderclient/v2/quota_classes.py rename to cinderclient/v3/quota_classes.py index bf80db0f6..1958fa133 100644 --- a/cinderclient/v2/quota_classes.py +++ b/cinderclient/v3/quota_classes.py @@ -24,7 +24,7 @@ def id(self): return self.class_name def update(self, *args, **kwargs): - self.manager.update(self.class_name, *args, **kwargs) + return self.manager.update(self.class_name, *args, **kwargs) class QuotaClassSetManager(base.Manager): @@ -35,9 +35,13 @@ def get(self, class_name): "quota_class_set") def update(self, class_name, **updates): - body = {'quota_class_set': {'class_name': class_name}} + quota_class_set = {} for update in updates: - body['quota_class_set'][update] = updates[update] + quota_class_set[update] = updates[update] - self._update('/os-quota-class-sets/%s' % (class_name), body) + result = self._update('/os-quota-class-sets/%s' % (class_name), + {'quota_class_set': quota_class_set}) + return self.resource_class(self, + result['quota_class_set'], loaded=True, + resp=result.request_ids) diff --git a/cinderclient/v2/quotas.py b/cinderclient/v3/quotas.py similarity index 85% rename from cinderclient/v2/quotas.py rename to cinderclient/v3/quotas.py index fbc691450..1295f6064 100644 --- a/cinderclient/v2/quotas.py +++ b/cinderclient/v3/quotas.py @@ -37,13 +37,19 @@ def get(self, tenant_id, usage=False): "quota_set") def update(self, tenant_id, **updates): - body = {'quota_set': {'tenant_id': tenant_id}} + skip_validation = updates.pop('skip_validation', True) + body = {'quota_set': {'tenant_id': tenant_id}} for update in updates: body['quota_set'][update] = updates[update] - result = self._update('/os-quota-sets/%s' % (tenant_id), body) - return self.resource_class(self, result['quota_set'], loaded=True) + request_url = '/os-quota-sets/%s' % tenant_id + if not skip_validation: + request_url += '?skip_validation=False' + + result = self._update(request_url, body) + return self.resource_class(self, result['quota_set'], loaded=True, + resp=result.request_ids) def defaults(self, tenant_id): return self._get('/os-quota-sets/%s/defaults' % tenant_id, diff --git a/cinderclient/v3/resource_filters.py b/cinderclient/v3/resource_filters.py new file mode 100644 index 000000000..e7ca6554d --- /dev/null +++ b/cinderclient/v3/resource_filters.py @@ -0,0 +1,37 @@ +# 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. + +"""Resource filters interface.""" + +from cinderclient import api_versions +from cinderclient import base + + +class ResourceFilter(base.Resource): + NAME_ATTR = 'resource' + + def __repr__(self): + return "" % self.resource + + +class ResourceFilterManager(base.ManagerWithFind): + """Manage :class:`ResourceFilter` resources.""" + + resource_class = ResourceFilter + + @api_versions.wraps('3.33') + def list(self, resource=None): + """List all resource filters.""" + url = '/resource_filters' + if resource is not None: + url += '?resource=%s' % resource + return self._list(url, "resource_filters") diff --git a/cinderclient/v3/services.py b/cinderclient/v3/services.py new file mode 100644 index 000000000..b48691f8e --- /dev/null +++ b/cinderclient/v3/services.py @@ -0,0 +1,124 @@ +# Copyright (c) 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. + +""" +service interface +""" + +from cinderclient import api_versions +from cinderclient import base + + +class Service(base.Resource): + + def __repr__(self): + return "" % (self.binary, self.host) + + +class LogLevel(base.Resource): + def __repr__(self): + return '' % ( + self.binary, self.host, self.prefix, self.level) + + +class ServiceManagerBase(base.ManagerWithFind): + resource_class = Service + + def list(self, host=None, binary=None): + """ + Describes service list for host. + + :param host: destination host name. + :param binary: service binary. + """ + url = "/os-services" + filters = [] + if host: + filters.append("host=%s" % host) + if binary: + filters.append("binary=%s" % binary) + if filters: + url = "%s?%s" % (url, "&".join(filters)) + return self._list(url, "services") + + def enable(self, host, binary): + """Enable the service specified by hostname and binary.""" + body = {"host": host, "binary": binary} + result = self._update("/os-services/enable", body) + return self.resource_class(self, result, resp=result.request_ids) + + def disable(self, host, binary): + """Disable the service specified by hostname and binary.""" + body = {"host": host, "binary": binary} + result = self._update("/os-services/disable", body) + return self.resource_class(self, result, resp=result.request_ids) + + def disable_log_reason(self, host, binary, reason): + """Disable the service with reason.""" + body = {"host": host, "binary": binary, "disabled_reason": reason} + result = self._update("/os-services/disable-log-reason", body) + return self.resource_class(self, result, resp=result.request_ids) + + def freeze_host(self, host): + """Freeze the service specified by hostname.""" + body = {"host": host} + return self._update("/os-services/freeze", body) + + def thaw_host(self, host): + """Thaw the service specified by hostname.""" + body = {"host": host} + return self._update("/os-services/thaw", body) + + def failover_host(self, host, backend_id): + """Failover a replicated backend by hostname.""" + body = {"host": host, "backend_id": backend_id} + return self._update("/os-services/failover_host", body) + + +class ServiceManager(ServiceManagerBase): + @api_versions.wraps("3.0") + def server_api_version(self): + """Returns the API Version supported by the server. + + :return: Returns response obj for a server that supports microversions. + Returns an empty list for Liberty and prior Cinder servers. + """ + + try: + return self._get_with_base_url("", response_key='versions') + except LookupError: + return [] + + @api_versions.wraps("3.32") + def set_log_levels(self, level, binary, server, prefix): + """Set log level for services.""" + body = {'level': level, 'binary': binary, 'server': server, + 'prefix': prefix} + return self._update("/os-services/set-log", body) + + @api_versions.wraps("3.32") + def get_log_levels(self, binary, server, prefix): + """Get log levels for services.""" + body = {'binary': binary, 'server': server, 'prefix': prefix} + response = self._update("/os-services/get-log", body) + + log_levels = [] + for entry in response['log_levels']: + entry_levels = sorted(entry['levels'].items(), key=lambda x: x[0]) + for prefix, level in entry_levels: + log_dict = {'binary': entry['binary'], 'host': entry['host'], + 'prefix': prefix, 'level': level} + log_levels.append(LogLevel(self, log_dict, loaded=True)) + return log_levels diff --git a/cinderclient/v3/shell.py b/cinderclient/v3/shell.py new file mode 100644 index 000000000..2542530c8 --- /dev/null +++ b/cinderclient/v3/shell.py @@ -0,0 +1,2883 @@ +# Copyright (c) 2013-2014 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 argparse +import collections +import os + +from oslo_utils import strutils + +import cinderclient +from cinderclient import api_versions +from cinderclient import base +from cinderclient import exceptions +from cinderclient import shell_utils +from cinderclient import utils + +from cinderclient.v3.shell_base import * # noqa +from cinderclient.v3.shell_base import CheckSizeArgForCreate + +FILTER_DEPRECATED = ("This option is deprecated and will be removed in " + "newer release. Please use '--filters' option which " + "is introduced since 3.33 instead.") + + +class AppendFilters(argparse.Action): + + filters = [] + + def __call__(self, parser, namespace, values, option_string): + AppendFilters.filters.append(values[0]) + + +@api_versions.wraps('3.33') +@utils.arg('--resource', + metavar='', + default=None, + help='Show enabled filters for specified resource. Default=None.') +def do_list_filters(cs, args): + """List enabled filters. + + Symbol '~' after filter key means it supports inexact filtering. + """ + filters = cs.resource_filters.list(resource=args.resource) + shell_utils.print_resource_filter_list(filters) + + +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.52', + metavar='', + default=None, + help="Filter key and value pairs. Admin only.") +def do_type_list(cs, args): + """Lists available 'volume types'. + + (Only admin and tenant users will see private types) + """ + # pylint: disable=function-redefined + search_opts = {} + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + vtypes = cs.volume_types.list(search_opts=search_opts) + shell_utils.print_volume_type_list(vtypes) + + with cs.volume_types.completion_cache( + 'uuid', + cinderclient.v3.volume_types.VolumeType, + mode="w"): + for vtype in vtypes: + cs.volume_types.write_to_completion_cache('uuid', vtype.id) + with cs.volume_types.completion_cache( + 'name', + cinderclient.v3.volume_types.VolumeType, + mode="w"): + for vtype in vtypes: + cs.volume_types.write_to_completion_cache('name', vtype.name) + AppendFilters.filters = [] + + +@utils.arg('--all-tenants', + metavar='', + nargs='?', + type=int, + const=1, + default=0, + help='Shows details for all tenants. Admin only.') +@utils.arg('--all_tenants', + nargs='?', + type=int, + const=1, + help=argparse.SUPPRESS) +@utils.arg('--name', + metavar='', + default=None, + help="Filters results by a name. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--status', + metavar='', + default=None, + help="Filters results by a status. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--volume-id', + metavar='', + default=None, + help="Filters results by a volume ID. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--volume_id', + help=argparse.SUPPRESS) +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning backups that appear later in the backup ' + 'list than that represented by this id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of backups to return. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.33', + metavar='', + default=None, + help="Filter key and value pairs. Please use 'cinder list-filters' " + "to check enabled filters from server. Use 'key~=value' for " + "inexact filtering if the key supports. Default=None.") +@utils.arg('--with-count', + type=bool, + default=False, + const=True, + nargs='?', + start_version='3.45', + metavar='', + help="Show total number of backup entities. This is useful when " + "pagination is applied in the request.") +def do_backup_list(cs, args): + """Lists all backups.""" + # pylint: disable=function-redefined + + show_count = True if hasattr( + args, 'with_count') and args.with_count else False + search_opts = { + 'all_tenants': args.all_tenants, + 'name': args.name, + 'status': args.status, + 'volume_id': args.volume_id, + } + + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + + total_count = 0 + if show_count: + search_opts['with_count'] = args.with_count + backups, total_count = cs.backups.list(search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) + else: + backups = cs.backups.list(search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) + shell_utils.translate_volume_snapshot_keys(backups) + columns = ['ID', 'Volume ID', 'Status', 'Name', 'Size', 'Object Count', + 'Container'] + if cs.api_version >= api_versions.APIVersion('3.56'): + columns.append('User ID') + + if args.sort: + sortby_index = None + else: + sortby_index = 0 + shell_utils.print_list(backups, columns, sortby_index=sortby_index) + if show_count: + print("Backup in total: %s" % total_count) + + with cs.backups.completion_cache( + 'uuid', + cinderclient.v3.volume_backups.VolumeBackup, + mode="w"): + for backup in backups: + cs.backups.write_to_completion_cache('uuid', backup.id) + with cs.backups.completion_cache( + 'name', + cinderclient.v3.volume_backups.VolumeBackup, + mode='w'): + for backup in backups: + if backup.name is not None: + cs.backups.write_to_completion_cache('name', backup.name) + AppendFilters.filters = [] + + +@utils.arg('backup', metavar='', + help='Name or ID of backup to restore.') +@utils.arg('--volume', metavar='', + default=None, + help='Name or ID of existing volume to which to restore. ' + 'This is mutually exclusive with --name and takes priority. ' + 'Default=None.') +@utils.arg('--name', metavar='', + default=None, + help='Use the name for new volume creation to restore. ' + 'This is mutually exclusive with --volume and --volume ' + 'takes priority. ' + 'Default=None.') +@utils.arg('--volume-type', + metavar='', + default=None, + start_version='3.47', + help='Volume type for the new volume creation to restore. This ' + 'option is not valid when used with the "volume" option. ' + 'Default=None.') +@utils.arg('--availability-zone', metavar='', + default=None, + start_version='3.47', + help='AZ for the new volume creation to restore. By default it ' + 'will be the same as backup AZ. This option is not valid when ' + 'used with the "volume" option. Default=None.') +def do_backup_restore(cs, args): + """Restores a backup.""" + if args.volume: + volume_id = utils.find_volume(cs, args.volume).id + if args.name: + args.name = None + print('Mutually exclusive options are specified simultaneously: ' + '"volume" and "name". The volume option takes priority.') + else: + volume_id = None + + volume_type = getattr(args, 'volume_type', None) + az = getattr(args, 'availability_zone', None) + if (volume_type or az) and args.volume: + msg = ('The "volume-type" and "availability-zone" options are not ' + 'valid when used with the "volume" option.') + raise exceptions.ClientException(code=1, message=msg) + + backup = shell_utils.find_backup(cs, args.backup) + info = {"backup_id": backup.id} + + if volume_type or (az and az != backup.availability_zone): + # Implement restoring a backup to a newly created volume of a + # specific volume type or in a different AZ by using the + # volume-create API. The default volume name matches the pattern + # cinder uses (see I23730834058d88e30be62624ada3b24cdaeaa6f3). + volume_name = args.name or 'restore_backup_%s' % backup.id + volume = cs.volumes.create(size=backup.size, + name=volume_name, + volume_type=volume_type, + availability_zone=az, + backup_id=backup.id) + info['volume_id'] = volume._info['id'] + info['volume_name'] = volume_name + else: + restore = cs.restores.restore(backup.id, volume_id, args.name) + info.update(restore._info) + info.pop('links', None) + + shell_utils.print_dict(info) + + +@utils.arg('--detail', + action='store_true', + help='Show detailed information about pools.') +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.33', + metavar='', + default=None, + help="Filter key and value pairs. Please use 'cinder list-filters' " + "to check enabled filters from server, Default=None.") +def do_get_pools(cs, args): + """Show pool information for backends. Admin only.""" + # pylint: disable=function-redefined + search_opts = {} + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + if cs.api_version >= api_versions.APIVersion("3.33"): + pools = cs.volumes.get_pools(args.detail, search_opts) + else: + pools = cs.volumes.get_pools(args.detail) + infos = dict() + infos.update(pools._info) + + for info in infos['pools']: + backend = dict() + backend['name'] = info['name'] + if args.detail: + backend.update(info['capabilities']) + shell_utils.print_dict(backend) + AppendFilters.filters = [] + + +RESET_STATE_RESOURCES = {'volume': utils.find_volume, + 'backup': shell_utils.find_backup, + 'snapshot': shell_utils.find_volume_snapshot, + 'group': shell_utils.find_group, + 'group-snapshot': shell_utils.find_group_snapshot} + + +@utils.arg('--group_id', + metavar='', + default=None, + help="Filters results by a group_id. Default=None." + "%s" % FILTER_DEPRECATED, + start_version='3.10') +@utils.arg('--all-tenants', + dest='all_tenants', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=0, + help='Shows details for all tenants. Admin only.') +@utils.arg('--all_tenants', + nargs='?', + type=int, + const=1, + help=argparse.SUPPRESS) +@utils.arg('--name', + metavar='', + default=None, + help="Filters results by a name. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--status', + metavar='', + default=None, + help="Filters results by a status. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--bootable', + metavar='', + const=True, + nargs='?', + choices=['True', 'true', 'False', 'false'], + help="Filters results by bootable status. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--migration_status', + metavar='', + default=None, + help="Filters results by a migration status. Default=None. " + "Admin only. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--metadata', + nargs='*', + metavar='', + default=None, + help="Filters results by a metadata key and value pair. " + "Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--image_metadata', + nargs='*', + metavar='', + default=None, + start_version='3.4', + help="Filters results by a image metadata key and value pair. " + "Require volume api version >=3.4. Default=None." + "%s" % FILTER_DEPRECATED) +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning volumes that appear later in the volume ' + 'list than that represented by this volume id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of volumes to return. Default=None.') +@utils.arg('--fields', + default=None, + metavar='', + help='Comma-separated list of fields to display. ' + 'Use the show command to see which fields are available. ' + 'Unavailable/non-existent fields will be ignored. ' + 'Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) +@utils.arg('--tenant', + type=str, + dest='tenant', + nargs='?', + metavar='', + help='Display information from single tenant (Admin only).') +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.33', + metavar='', + default=None, + help="Filter key and value pairs. Please use 'cinder list-filters' " + "to check enabled filters from server. Use 'key~=value' " + "for inexact filtering if the key supports. Default=None.") +@utils.arg('--with-count', + type=bool, + default=False, + const=True, + nargs='?', + start_version='3.45', + metavar='', + help="Show total number of volume entities. This is useful when " + "pagination is applied in the request.") +def do_list(cs, args): + """Lists all volumes.""" + # pylint: disable=function-redefined + # NOTE(thingee): Backwards-compatibility with v1 args + if args.display_name is not None: + args.name = args.display_name + show_count = True if hasattr( + args, 'with_count') and args.with_count else False + all_tenants = 1 if args.tenant else \ + int(os.environ.get("ALL_TENANTS", args.all_tenants)) + search_opts = { + 'all_tenants': all_tenants, + 'project_id': args.tenant, + 'name': args.name, + 'status': args.status, + 'bootable': args.bootable, + 'migration_status': args.migration_status, + 'metadata': shell_utils.extract_metadata(args) + if args.metadata else None, + 'glance_metadata': shell_utils.extract_metadata(args, + type='image_metadata') + if hasattr(args, 'image_metadata') and args.image_metadata else None, + 'group_id': getattr(args, 'group_id', None), + } + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + + # If unavailable/non-existent fields are specified, these fields will + # be removed from key_list at the print_list() during key validation. + field_titles = [] + if args.fields: + for field_title in args.fields.split(','): + field_titles.append(field_title) + + total_count = 0 + if show_count: + search_opts['with_count'] = args.with_count + volumes, total_count = cs.volumes.list( + search_opts=search_opts, marker=args.marker, + limit=args.limit, sort=args.sort) + else: + volumes = cs.volumes.list(search_opts=search_opts, marker=args.marker, + limit=args.limit, sort=args.sort) + shell_utils.translate_volume_keys(volumes) + + # Create a list of servers to which the volume is attached + for vol in volumes: + servers = [s.get('server_id') for s in vol.attachments] + setattr(vol, 'attached_to', ','.join(map(str, servers))) + + with cs.volumes.completion_cache('uuid', + cinderclient.v3.volumes.Volume, + mode="w"): + for vol in volumes: + cs.volumes.write_to_completion_cache('uuid', vol.id) + + with cs.volumes.completion_cache('name', + cinderclient.v3.volumes.Volume, + mode="w"): + for vol in volumes: + if vol.name is None: + continue + cs.volumes.write_to_completion_cache('name', vol.name) + + if field_titles: + # Remove duplicate fields + key_list = ['ID'] + unique_titles = [k for k in collections.OrderedDict.fromkeys( + [x.title().strip() for x in field_titles]) if k != 'Id'] + key_list.extend(unique_titles) + else: + key_list = ['ID', 'Status', 'Name', 'Size', 'Consumes Quota', + 'Volume Type', 'Bootable', 'Attached to'] + # If all_tenants is specified, print + # Tenant ID as well. + if search_opts['all_tenants']: + key_list.insert(1, 'Tenant ID') + + if args.sort: + sortby_index = None + else: + sortby_index = 0 + shell_utils.print_list(volumes, key_list, exclude_unavailable=True, + sortby_index=sortby_index) + if show_count: + print("Volume in total: %s" % total_count) + AppendFilters.filters = [] + + +@utils.arg('entity', metavar='', nargs='+', + help='Name or ID of entity to update.') +@utils.arg('--type', metavar='', default='volume', + choices=RESET_STATE_RESOURCES.keys(), + help="Type of entity to update. Available resources " + "are: 'volume', 'snapshot', 'backup', " + "'group' (since 3.20) and " + "'group-snapshot' (since 3.19), Default=volume.") +@utils.arg('--state', metavar='', default=None, + help=("The state to assign to the entity. " + "NOTE: This command simply changes the state of the " + "entity in the database with no regard to actual status, " + "exercise caution when using. Default=None, that means the " + "state is unchanged.")) +@utils.arg('--attach-status', metavar='', default=None, + help=('This is only used for a volume entity. The attach status ' + 'to assign to the volume in the database, with no regard ' + 'to the actual status. Valid values are "attached" and ' + '"detached". Default=None, that means the status ' + 'is unchanged.')) +@utils.arg('--reset-migration-status', + action='store_true', + help=('This is only used for a volume entity. Clears the migration ' + 'status of the volume in the DataBase that indicates the ' + 'volume is source or destination of volume migration, ' + 'with no regard to the actual status.')) +def do_reset_state(cs, args): + """Explicitly updates the entity state in the Cinder database. + + Being a database change only, this has no impact on the true state of the + entity and may not match the actual state. This can render a entity + unusable in the case of changing to the 'available' state. + """ + # pylint: disable=function-redefined + failure_count = 0 + single = (len(args.entity) == 1) + + migration_status = 'none' if args.reset_migration_status else None + collector = RESET_STATE_RESOURCES[args.type] + argument = (args.state,) + if args.type == 'volume': + argument += (args.attach_status, migration_status) + + for entity in args.entity: + try: + collector(cs, entity).reset_state(*argument) + except Exception as e: + print(e) + failure_count += 1 + msg = "Reset state for entity %s failed: %s" % (entity, e) + if not single: + print(msg) + + if failure_count == len(args.entity): + msg = "Unable to reset the state for the specified entity(s)." + raise exceptions.CommandError(msg) + + +@utils.arg('size', + metavar='', + nargs='?', + type=int, + action=CheckSizeArgForCreate, + help='Size of volume, in GiBs. (Required unless ' + 'snapshot-id/source-volid/backup-id is specified).') +@utils.arg('--consisgroup-id', + metavar='', + default=None, + help='ID of a consistency group where the new volume belongs to. ' + 'Default=None.') +@utils.arg('--group-id', + metavar='', + default=None, + help='ID of a group where the new volume belongs to. ' + 'Default=None.', + start_version='3.13') +@utils.arg('--snapshot-id', + metavar='', + default=None, + help='Creates volume from snapshot ID. Default=None.') +@utils.arg('--snapshot_id', + help=argparse.SUPPRESS) +@utils.arg('--source-volid', + metavar='', + default=None, + help='Creates volume from volume ID. Default=None.') +@utils.arg('--source_volid', + help=argparse.SUPPRESS) +@utils.arg('--image-id', + metavar='', + default=None, + help='Creates volume from image ID. Default=None.') +@utils.arg('--image_id', + help=argparse.SUPPRESS) +@utils.arg('--image', + metavar='', + default=None, + help='Creates a volume from image (ID or name). Default=None.') +@utils.arg('--backup-id', + metavar='', + default=None, + start_version='3.47', + help='Creates a volume from backup ID. Default=None.') +@utils.arg('--image_ref', + help=argparse.SUPPRESS) +@utils.arg('--name', + metavar='', + default=None, + help='Volume name. Default=None.') +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--display_name', + help=argparse.SUPPRESS) +@utils.arg('--description', + metavar='', + default=None, + help='Volume description. Default=None.') +@utils.arg('--display-description', + help=argparse.SUPPRESS) +@utils.arg('--display_description', + help=argparse.SUPPRESS) +@utils.arg('--volume-type', + metavar='', + default=None, + help='Volume type. Default=None, that is, use the default ' + 'volume type configured for the Block Storage API. You ' + "can see what type this is by using the 'cinder type-default'" + ' command.') +@utils.arg('--volume_type', + help=argparse.SUPPRESS) +@utils.arg('--availability-zone', + metavar='', + default=None, + help='Availability zone for volume. Default=None.') +@utils.arg('--availability_zone', + help=argparse.SUPPRESS) +@utils.arg('--metadata', + nargs='*', + metavar='', + default=None, + help='Metadata key and value pairs. Default=None.') +@utils.arg('--hint', + metavar='', + dest='scheduler_hints', + action='append', + default=[], + help='Scheduler hint, similar to nova. Repeat option to set ' + 'multiple hints. Values with the same key will be stored ' + 'as a list.') +@utils.arg('--poll', + action="store_true", + help=('Wait for volume creation until it completes.')) +def do_create(cs, args): + """Creates a volume.""" + + # NOTE(thingee): Backwards-compatibility with v1 args + if args.display_name is not None: + args.name = args.display_name + + if args.display_description is not None: + args.description = args.display_description + + volume_metadata = None + if args.metadata is not None: + volume_metadata = shell_utils.extract_metadata(args) + + # NOTE(N.S.): take this piece from novaclient + hints = {} + if args.scheduler_hints: + for hint in args.scheduler_hints: + key, _sep, value = hint.partition('=') + # NOTE(vish): multiple copies of same hint will + # result in a list of values + if key in hints: + if isinstance(hints[key], str): + hints[key] = [hints[key]] + hints[key] += [value] + else: + hints[key] = value + # NOTE(N.S.): end of taken piece + + # Keep backward compatibility with image_id, favoring explicit ID + image_ref = args.image_id or args.image or args.image_ref + + try: + group_id = args.group_id + except AttributeError: + group_id = None + + backup_id = args.backup_id if hasattr(args, 'backup_id') else None + + volume = cs.volumes.create(args.size, + args.consisgroup_id, + group_id, + args.snapshot_id, + args.source_volid, + args.name, + args.description, + args.volume_type, + availability_zone=args.availability_zone, + imageRef=image_ref, + metadata=volume_metadata, + scheduler_hints=hints, + backup_id=backup_id) + + info = dict() + volume = cs.volumes.get(volume.id) + info.update(volume._info) + + if 'readonly' in info['metadata']: + info['readonly'] = info['metadata']['readonly'] + + info.pop('links', None) + + if args.poll: + timeout_period = os.environ.get("POLL_TIMEOUT_PERIOD", 3600) + shell_utils._poll_for_status( + cs.volumes.get, volume.id, info, 'creating', ['available'], + timeout_period, cs.client.global_request_id, cs.messages) + volume = cs.volumes.get(volume.id) + info.update(volume._info) + + shell_utils.print_dict(info) + + with cs.volumes.completion_cache('uuid', + cinderclient.v3.volumes.Volume, + mode="a"): + cs.volumes.write_to_completion_cache('uuid', volume.id) + if volume.name is not None: + with cs.volumes.completion_cache('name', + cinderclient.v3.volumes.Volume, + mode="a"): + cs.volumes.write_to_completion_cache('name', volume.name) + + +@utils.arg('volume', + metavar='', + help='Name or ID of volume for which to update metadata.') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help='The action. Valid values are "set" or "unset."') +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + end_version='3.14', + help='Metadata key and value pair to set or unset. ' + 'For unset, specify only the key.') +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + start_version='3.15', + help='Metadata key and value pair to set or unset. ' + 'For unset, specify only the key(s): ') +def do_metadata(cs, args): + """Sets or deletes volume metadata.""" + volume = utils.find_volume(cs, args.volume) + metadata = shell_utils.extract_metadata(args) + + if args.action == 'set': + cs.volumes.set_metadata(volume, metadata) + elif args.action == 'unset': + # NOTE(zul): Make sure py2/py3 sorting is the same + cs.volumes.delete_metadata(volume, sorted(metadata.keys(), + reverse=True)) + + +@api_versions.wraps('3.12') +@utils.arg('--all-tenants', + dest='all_tenants', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=utils.env('ALL_TENANTS', default=0), + help='Shows details for all tenants. Admin only.') +def do_summary(cs, args): + """Get volumes summary.""" + all_tenants = args.all_tenants + info = cs.volumes.summary(all_tenants) + + formatters = ['total_size', 'total_count'] + if cs.api_version >= api_versions.APIVersion("3.36"): + formatters.append('metadata') + + shell_utils.print_dict(info['volume-summary'], formatters=formatters) + + +@api_versions.wraps('3.11') +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.52', + metavar='', + default=None, + help="Filter key and value pairs. Admin only.") +def do_group_type_list(cs, args): + """Lists available 'group types'. (Admin only will see private types)""" + search_opts = {} + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + gtypes = cs.group_types.list(search_opts=search_opts) + shell_utils.print_group_type_list(gtypes) + AppendFilters.filters = [] + + +@api_versions.wraps('3.11') +def do_group_type_default(cs, args): + """List the default group type.""" + gtype = cs.group_types.default() + shell_utils.print_group_type_list([gtype]) + + +@api_versions.wraps('3.11') +@utils.arg('group_type', + metavar='', + help='Name or ID of the group type.') +def do_group_type_show(cs, args): + """Show group type details.""" + gtype = shell_utils.find_gtype(cs, args.group_type) + info = dict() + info.update(gtype._info) + + info.pop('links', None) + shell_utils.print_dict(info, formatters=['group_specs']) + + +@api_versions.wraps('3.11') +@utils.arg('id', + metavar='', + help='ID of the group type.') +@utils.arg('--name', + metavar='', + help='Name of the group type.') +@utils.arg('--description', + metavar='', + help='Description of the group type.') +@utils.arg('--is-public', + metavar='', + help='Make type accessible to the public or not.') +def do_group_type_update(cs, args): + """Updates group type name, description, and/or is_public.""" + is_public = strutils.bool_from_string(args.is_public) + gtype = cs.group_types.update(args.id, args.name, args.description, + is_public) + shell_utils.print_group_type_list([gtype]) + + +@api_versions.wraps('3.11') +def do_group_specs_list(cs, args): + """Lists current group types and specs.""" + gtypes = cs.group_types.list() + shell_utils.print_list(gtypes, ['ID', 'Name', 'group_specs']) + + +@api_versions.wraps('3.11') +@utils.arg('name', + metavar='', + help='Name of new group type.') +@utils.arg('--description', + metavar='', + help='Description of new group type.') +@utils.arg('--is-public', + metavar='', + default=True, + help='Make type accessible to the public (default true).') +def do_group_type_create(cs, args): + """Creates a group type.""" + is_public = strutils.bool_from_string(args.is_public) + gtype = cs.group_types.create(args.name, args.description, is_public) + shell_utils.print_group_type_list([gtype]) + + +@api_versions.wraps('3.11') +@utils.arg('group_type', + metavar='', nargs='+', + help='Name or ID of group type or types to delete.') +def do_group_type_delete(cs, args): + """Deletes group type or types.""" + failure_count = 0 + for group_type in args.group_type: + try: + gtype = shell_utils.find_group_type(cs, group_type) + cs.group_types.delete(gtype) + print("Request to delete group type %s has been accepted." + % group_type) + except Exception as e: + failure_count += 1 + print("Delete for group type %s failed: %s" % (group_type, e)) + if failure_count == len(args.group_type): + raise exceptions.CommandError("Unable to delete any of the " + "specified types.") + + +@api_versions.wraps('3.11') +@utils.arg('gtype', + metavar='', + help='Name or ID of group type.') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help='The action. Valid values are "set" or "unset."') +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='The group specs key and value pair to set or unset. ' + 'For unset, specify only the key.') +def do_group_type_key(cs, args): + """Sets or unsets group_spec for a group type.""" + gtype = shell_utils.find_group_type(cs, args.gtype) + keypair = shell_utils.extract_metadata(args) + + if args.action == 'set': + gtype.set_keys(keypair) + elif args.action == 'unset': + gtype.unset_keys(list(keypair)) + + +@utils.arg('tenant', + metavar='', + help='ID of tenant for which to set quotas.') +@utils.arg('--volumes', + metavar='', + type=int, default=None, + help='The new "volumes" quota value. Default=None.') +@utils.arg('--snapshots', + metavar='', + type=int, default=None, + help='The new "snapshots" quota value. Default=None.') +@utils.arg('--gigabytes', + metavar='', + type=int, default=None, + help='The new "gigabytes" quota value. Default=None.') +@utils.arg('--backups', + metavar='', + type=int, default=None, + help='The new "backups" quota value. Default=None.') +@utils.arg('--backup-gigabytes', + metavar='', + type=int, default=None, + help='The new "backup_gigabytes" quota value. Default=None.') +@utils.arg('--groups', + metavar='', + type=int, default=None, + help='The new "groups" quota value. Default=None.', + start_version='3.13') +@utils.arg('--volume-type', + metavar='', + default=None, + help='Volume type. Default=None.') +@utils.arg('--per-volume-gigabytes', + metavar='', + type=int, default=None, + help='Set max volume size limit. Default=None.') +@utils.arg('--skip-validation', + metavar='', + default=False, + help='Skip validate the existing resource quota. Default=False.') +def do_quota_update(cs, args): + """Updates quotas for a tenant.""" + + shell_utils.quota_update(cs.quotas, args.tenant, args) + + +@utils.arg('volume', + metavar='', + help='Name or ID of volume to snapshot.') +@utils.arg('--force', + metavar='', + const=True, + nargs='?', + default=False, + help='Enables or disables upload of ' + 'a volume that is attached to an instance. ' + 'Default=False. ' + 'This option may not be supported by your cloud.') +@utils.arg('--container-format', + metavar='', + default='bare', + help='Container format type. ' + 'Default is bare.') +@utils.arg('--container_format', + help=argparse.SUPPRESS) +@utils.arg('--disk-format', + metavar='', + default='raw', + help='Disk format type. ' + 'Default is raw.') +@utils.arg('--disk_format', + help=argparse.SUPPRESS) +@utils.arg('image_name', + metavar='', + help='The new image name.') +@utils.arg('--image_name', + help=argparse.SUPPRESS) +@utils.arg('--visibility', + metavar='', + help='Set image visibility to public, private, community or ' + 'shared. Default=private.', + default='private', + start_version='3.1') +@utils.arg('--protected', + metavar='', + help='Prevents image from being deleted. Default=False.', + default=False, + start_version='3.1') +def do_upload_to_image(cs, args): + """Uploads volume to Image Service as an image.""" + volume = utils.find_volume(cs, args.volume) + if cs.api_version >= api_versions.APIVersion("3.1"): + (resp, body) = volume.upload_to_image(args.force, + args.image_name, + args.container_format, + args.disk_format, + args.visibility, + args.protected) + + shell_utils.print_volume_image((resp, body)) + else: + (resp, body) = volume.upload_to_image(args.force, + args.image_name, + args.container_format, + args.disk_format) + shell_utils.print_volume_image((resp, body)) + + +@utils.arg('volume', metavar='', help='ID of volume to migrate.') +# NOTE(geguileo): host is positional but optional in order to maintain backward +# compatibility even with mutually exclusive arguments. If version is < 3.16 +# then only host positional argument will be possible, and since the +# exclusive_arg group has required=True it will be required even if it's +# optional. +@utils.exclusive_arg('destination', 'host', required=True, nargs='?', + metavar='', help='Destination host. Takes the ' + 'form: host@backend-name#pool') +@utils.exclusive_arg('destination', '--cluster', required=True, + help='Destination cluster. Takes the form: ' + 'cluster@backend-name#pool', + start_version='3.16') +@utils.arg('--force-host-copy', metavar='', + choices=['True', 'False'], + required=False, + const=True, + nargs='?', + default=False, + help='Enables or disables generic host-based ' + 'force-migration, which bypasses driver ' + 'optimizations. Default=False.') +@utils.arg('--lock-volume', metavar='', + choices=['True', 'False'], + required=False, + const=True, + nargs='?', + default=False, + help='Enables or disables the termination of volume migration ' + 'caused by other commands. This option applies to the ' + 'available volume. True means it locks the volume ' + 'state and does not allow the migration to be aborted. The ' + 'volume status will be in maintenance during the ' + 'migration. False means it allows the volume migration ' + 'to be aborted. The volume status is still in the original ' + 'status. Default=False.') +def do_migrate(cs, args): + """Migrates volume to a new host.""" + volume = utils.find_volume(cs, args.volume) + try: + volume.migrate_volume(args.host, args.force_host_copy, + args.lock_volume, getattr(args, 'cluster', None)) + print("Request to migrate volume %s has been accepted." % (volume.id)) + except Exception as e: + print("Migration for volume %s failed: %s." % (volume.id, + str(e))) + + +@api_versions.wraps('3.9') +@utils.arg('backup', metavar='', + help='Name or ID of backup to rename.') +@utils.arg('--name', nargs='?', metavar='', + help='New name for backup.') +@utils.arg('--description', metavar='', + help='Backup description. Default=None.') +@utils.arg('--metadata', + nargs='*', + metavar='', + default=None, + help='Metadata key and value pairs. Default=None.', + start_version='3.43') +def do_backup_update(cs, args): + """Updates a backup.""" + kwargs = {} + + if args.name is not None: + kwargs['name'] = args.name + + if args.description is not None: + kwargs['description'] = args.description + + if cs.api_version >= api_versions.APIVersion("3.43"): + if args.metadata is not None: + kwargs['metadata'] = shell_utils.extract_metadata(args) + + if not kwargs: + msg = 'Must supply at least one: name, description or metadata.' + raise exceptions.ClientException(code=1, message=msg) + + shell_utils.find_backup(cs, args.backup).update(**kwargs) + print("Request to update backup '%s' has been accepted." % args.backup) + + +@api_versions.wraps('3.7') +@utils.arg('--name', metavar='', default=None, + help='Filter by cluster name, without backend will list all ' + 'clustered services from the same cluster. Default=None.') +@utils.arg('--binary', metavar='', default=None, + help='Cluster binary. Default=None.') +@utils.arg('--is-up', metavar='', default=None, + choices=('True', 'true', 'False', 'false'), + help='Filter by up/down status. Default=None.') +@utils.arg('--disabled', metavar='', default=None, + choices=('True', 'true', 'False', 'false'), + help='Filter by disabled status. Default=None.') +@utils.arg('--num-hosts', metavar='', default=None, + help='Filter by number of hosts in the cluster.') +@utils.arg('--num-down-hosts', metavar='', default=None, + help='Filter by number of hosts that are down.') +@utils.arg('--detailed', dest='detailed', default=False, + help='Get detailed clustered service information (Default=False).', + action='store_true') +def do_cluster_list(cs, args): + """Lists clustered services with optional filtering.""" + clusters = cs.clusters.list(name=args.name, binary=args.binary, + is_up=args.is_up, disabled=args.disabled, + num_hosts=args.num_hosts, + num_down_hosts=args.num_down_hosts, + detailed=args.detailed) + + columns = ['Name', 'Binary', 'State', 'Status'] + if args.detailed: + columns.extend(('Num Hosts', 'Num Down Hosts', 'Last Heartbeat', + 'Disabled Reason', 'Created At', 'Updated at')) + shell_utils.print_list(clusters, columns) + + +@api_versions.wraps('3.7') +@utils.arg('binary', metavar='', nargs='?', default='cinder-volume', + help='Binary to filter by. Default: cinder-volume.') +@utils.arg('name', metavar='', + help='Name of the clustered service to show.') +def do_cluster_show(cs, args): + """Show detailed information on a clustered service.""" + cluster = cs.clusters.show(args.name, args.binary) + shell_utils.print_dict(cluster.to_dict()) + + +@api_versions.wraps('3.7') +@utils.arg('binary', metavar='', nargs='?', default='cinder-volume', + help='Binary to filter by. Default: cinder-volume.') +@utils.arg('name', metavar='', + help='Name of the clustered services to update.') +def do_cluster_enable(cs, args): + """Enables clustered services.""" + cluster = cs.clusters.update(args.name, args.binary, disabled=False) + shell_utils.print_dict(cluster.to_dict()) + + +@api_versions.wraps('3.7') +@utils.arg('binary', metavar='', nargs='?', default='cinder-volume', + help='Binary to filter by. Default: cinder-volume.') +@utils.arg('name', metavar='', + help='Name of the clustered services to update.') +@utils.arg('--reason', metavar='', default=None, + help='Reason for disabling clustered service.') +def do_cluster_disable(cs, args): + """Disables clustered services.""" + cluster = cs.clusters.update(args.name, args.binary, disabled=True, + disabled_reason=args.reason) + shell_utils.print_dict(cluster.to_dict()) + + +@api_versions.wraps('3.24') +@utils.arg('--cluster', metavar='', default=None, + help='Cluster name. Default=None.') +@utils.arg('--host', metavar='', default=None, + help='Service host name. Default=None.') +@utils.arg('--binary', metavar='', default=None, + help='Service binary. Default=None.') +@utils.arg('--is-up', metavar='', dest='is_up', + default=None, choices=('True', 'true', 'False', 'false'), + help='Filter by up/down status, if set to true services need to be' + ' up, if set to false services need to be down. Default is ' + 'None, which means up/down status is ignored.') +@utils.arg('--disabled', metavar='', default=None, + choices=('True', 'true', 'False', 'false'), + help='Filter by disabled status. Default=None.') +@utils.arg('--resource-id', metavar='', default=None, + help='UUID of a resource to cleanup. Default=None.') +@utils.arg('--resource-type', metavar='', default=None, + choices=('Volume', 'Snapshot'), + help='Type of resource to cleanup.') +@utils.arg('--service-id', + metavar='', + type=int, + default=None, + help='The service id field from the DB, not the uuid of the' + ' service. Default=None.') +def do_work_cleanup(cs, args): + """Request cleanup of services with optional filtering.""" + filters = dict(cluster_name=args.cluster, host=args.host, + binary=args.binary, is_up=args.is_up, + disabled=args.disabled, resource_id=args.resource_id, + resource_type=args.resource_type, + service_id=args.service_id) + + filters = {k: v for k, v in filters.items() if v is not None} + + cleaning, unavailable = cs.workers.clean(**filters) + + columns = ('ID', 'Cluster Name', 'Host', 'Binary') + + if cleaning: + print('Following services will be cleaned:') + shell_utils.print_list(cleaning, columns) + + if unavailable: + print('There are no alternative nodes to do cleanup for the following ' + 'services:') + shell_utils.print_list(unavailable, columns) + + if not (cleaning or unavailable): + print('No cleanable services matched cleanup criteria.') + + +@utils.arg('host', + metavar='', + help='Cinder host on which the existing volume resides; ' + 'takes the form: host@backend-name#pool') +@utils.arg('--cluster', + help='Cinder cluster on which the existing volume resides; ' + 'takes the form: cluster@backend-name#pool', + start_version='3.16') +@utils.arg('identifier', + metavar='', + help='Name or other Identifier for existing volume') +@utils.arg('--id-type', + metavar='', + default='source-name', + help='Type of backend device identifier provided, ' + 'typically source-name or source-id (Default=source-name)') +@utils.arg('--name', + metavar='', + help='Volume name (Default=None)') +@utils.arg('--description', + metavar='', + help='Volume description (Default=None)') +@utils.arg('--volume-type', + metavar='', + help='Volume type (Default=None)') +@utils.arg('--availability-zone', + metavar='', + help='Availability zone for volume (Default=None)') +@utils.arg('--metadata', + type=str, + nargs='*', + metavar='', + help='Metadata key=value pairs (Default=None)') +@utils.arg('--bootable', + action='store_true', + help='Specifies that the newly created volume should be' + ' marked as bootable') +def do_manage(cs, args): + """Manage an existing volume.""" + volume_metadata = None + if args.metadata is not None: + volume_metadata = shell_utils.extract_metadata(args) + + # Build a dictionary of key/value pairs to pass to the API. + ref_dict = {args.id_type: args.identifier} + + # The recommended way to specify an existing volume is by ID or name, and + # have the Cinder driver look for 'source-name' or 'source-id' elements in + # the ref structure. To make things easier for the user, we have special + # --source-name and --source-id CLI options that add the appropriate + # element to the ref structure. + # + # Note how argparse converts hyphens to underscores. We use hyphens in the + # dictionary so that it is consistent with what the user specified on the + # CLI. + + if hasattr(args, 'source_name') and args.source_name is not None: + ref_dict['source-name'] = args.source_name + if hasattr(args, 'source_id') and args.source_id is not None: + ref_dict['source-id'] = args.source_id + + volume = cs.volumes.manage(host=args.host, + ref=ref_dict, + name=args.name, + description=args.description, + volume_type=args.volume_type, + availability_zone=args.availability_zone, + metadata=volume_metadata, + bootable=args.bootable, + cluster=getattr(args, 'cluster', None)) + + info = {} + volume = cs.volumes.get(volume.id) + info.update(volume._info) + info.pop('links', None) + shell_utils.print_dict(info) + + +@api_versions.wraps('3.8') +# NOTE(geguileo): host is positional but optional in order to maintain backward +# compatibility even with mutually exclusive arguments. If version is < 3.16 +# then only host positional argument will be possible, and since the +# exclusive_arg group has required=True it will be required even if it's +# optional. +@utils.exclusive_arg('source', 'host', required=True, nargs='?', + metavar='', + help='Cinder host on which to list manageable volumes; ' + 'takes the form: host@backend-name#pool') +@utils.exclusive_arg('source', '--cluster', required=True, + metavar='CLUSTER', + help='Cinder cluster on which to list manageable ' + 'volumes; takes the form: cluster@backend-name#pool', + start_version='3.17') +@utils.arg('--detailed', + metavar='', + default=True, + help='Returned detailed information (default true).') +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning volumes that appear later in the volume ' + 'list than that represented by this reference. This reference ' + 'should be json like. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of volumes to return. Default=None.') +@utils.arg('--offset', + metavar='', + default=None, + help='Number of volumes to skip after marker. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.' + ) % ', '.join(base.SORT_MANAGEABLE_KEY_VALUES))) +def do_manageable_list(cs, args): + """Lists all manageable volumes.""" + # pylint: disable=function-redefined + detailed = strutils.bool_from_string(args.detailed) + cluster = getattr(args, 'cluster', None) + volumes = cs.volumes.list_manageable(host=args.host, detailed=detailed, + marker=args.marker, limit=args.limit, + offset=args.offset, sort=args.sort, + cluster=cluster) + columns = ['reference', 'size', 'safe_to_manage'] + if detailed: + columns.extend(['reason_not_safe', 'cinder_id', 'extra_info']) + shell_utils.print_list(volumes, columns, sortby_index=None) + + +@api_versions.wraps('3.13') +@utils.arg('--all-tenants', + dest='all_tenants', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=utils.env('ALL_TENANTS', default=None), + help='Shows details for all tenants. Admin only.') +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.33', + metavar='', + default=None, + help="Filter key and value pairs. Please use 'cinder list-filters' " + "to check enabled filters from server. Use 'key~=value' " + "for inexact filtering if the key supports. Default=None.") +def do_group_list(cs, args): + """Lists all groups.""" + search_opts = {'all_tenants': args.all_tenants} + + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + + groups = cs.groups.list(search_opts=search_opts) + + columns = ['ID', 'Status', 'Name'] + shell_utils.print_list(groups, columns) + + with cs.groups.completion_cache( + 'uuid', + cinderclient.v3.groups.Group, + mode='w'): + for group in groups: + cs.groups.write_to_completion_cache('uuid', group.id) + with cs.groups.completion_cache('name', + cinderclient.v3.groups.Group, + mode='w'): + for group in groups: + if group.name is None: + continue + cs.groups.write_to_completion_cache('name', group.name) + AppendFilters.filters = [] + + +@api_versions.wraps('3.13') +@utils.arg('--list-volume', + dest='list_volume', + metavar='', + nargs='?', + type=bool, + const=True, + default=False, + help='Shows volumes included in the group.', + start_version='3.25') +@utils.arg('group', + metavar='', + help='Name or ID of a group.') +def do_group_show(cs, args): + """Shows details of a group.""" + info = dict() + if getattr(args, 'list_volume', None): + group = shell_utils.find_group(cs, args.group, + list_volume=args.list_volume) + else: + group = shell_utils.find_group(cs, args.group) + info.update(group._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@api_versions.wraps('3.13') +@utils.arg('grouptype', + metavar='', + help='Group type.') +@utils.arg('volumetypes', + metavar='', + help='Comma-separated list of volume types.') +@utils.arg('--name', + metavar='', + help='Name of a group.') +@utils.arg('--description', + metavar='', + default=None, + help='Description of a group. Default=None.') +@utils.arg('--availability-zone', + metavar='', + default=None, + help='Availability zone for group. Default=None.') +def do_group_create(cs, args): + """Creates a group.""" + + group = cs.groups.create( + args.grouptype, + args.volumetypes, + args.name, + args.description, + availability_zone=args.availability_zone) + + info = dict() + group = cs.groups.get(group.id) + info.update(group._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + with cs.groups.completion_cache('uuid', + cinderclient.v3.groups.Group, + mode='a'): + cs.groups.write_to_completion_cache('uuid', group.id) + + if group.name is not None: + with cs.groups.completion_cache('name', + cinderclient.v3.groups.Group, + mode='a'): + cs.groups.write_to_completion_cache('name', group.name) + + +@api_versions.wraps('3.14') +@utils.arg('--group-snapshot', + metavar='', + help='Name or ID of a group snapshot. Default=None.') +@utils.arg('--source-group', + metavar='', + help='Name or ID of a source group. Default=None.') +@utils.arg('--name', + metavar='', + help='Name of a group. Default=None.') +@utils.arg('--description', + metavar='', + help='Description of a group. Default=None.') +def do_group_create_from_src(cs, args): + """Creates a group from a group snapshot or a source group.""" + if not args.group_snapshot and not args.source_group: + msg = ('Cannot create group because neither ' + 'group snapshot nor source group is provided.') + raise exceptions.ClientException(code=1, message=msg) + if args.group_snapshot and args.source_group: + msg = ('Cannot create group because both ' + 'group snapshot and source group are provided.') + raise exceptions.ClientException(code=1, message=msg) + group_snapshot = None + if args.group_snapshot: + group_snapshot = shell_utils.find_group_snapshot(cs, + args.group_snapshot) + source_group = None + if args.source_group: + source_group = shell_utils.find_group(cs, args.source_group) + info = cs.groups.create_from_src( + group_snapshot.id if group_snapshot else None, + source_group.id if source_group else None, + args.name, + args.description) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@api_versions.wraps('3.13') +@utils.arg('group', + metavar='', nargs='+', + help='Name or ID of one or more groups ' + 'to be deleted.') +@utils.arg('--delete-volumes', + action='store_true', + default=False, + help='Allows or disallows groups to be deleted ' + 'if they are not empty. If the group is empty, ' + 'it can be deleted without the delete-volumes flag. ' + 'If the group is not empty, the delete-volumes ' + 'flag is required for it to be deleted. If True, ' + 'all volumes in the group will also be deleted.') +def do_group_delete(cs, args): + """Removes one or more groups.""" + failure_count = 0 + for group in args.group: + try: + shell_utils.find_group(cs, group).delete(args.delete_volumes) + except Exception as e: + failure_count += 1 + print("Delete for group %s failed: %s" % + (group, e)) + if failure_count == len(args.group): + raise exceptions.CommandError("Unable to delete any of the specified " + "groups.") + + +@api_versions.wraps('3.13') +@utils.arg('group', + metavar='', + help='Name or ID of a group.') +@utils.arg('--name', metavar='', + help='New name for group. Default=None.') +@utils.arg('--description', metavar='', + help='New description for group. Default=None.') +@utils.arg('--add-volumes', + metavar='', + help='UUID of one or more volumes ' + 'to be added to the group, ' + 'separated by commas. Default=None.') +@utils.arg('--remove-volumes', + metavar='', + help='UUID of one or more volumes ' + 'to be removed from the group, ' + 'separated by commas. Default=None.') +def do_group_update(cs, args): + """Updates a group.""" + kwargs = {} + + if args.name is not None: + kwargs['name'] = args.name + + if args.description is not None: + kwargs['description'] = args.description + + if args.add_volumes is not None: + kwargs['add_volumes'] = args.add_volumes + + if args.remove_volumes is not None: + kwargs['remove_volumes'] = args.remove_volumes + + if not kwargs: + msg = ('At least one of the following args must be supplied: ' + 'name, description, add-volumes, remove-volumes.') + raise exceptions.ClientException(code=1, message=msg) + + shell_utils.find_group(cs, args.group).update(**kwargs) + print("Request to update group '%s' has been accepted." % args.group) + + +@api_versions.wraps('3.38') +@utils.arg('group', + metavar='', + help='Name or ID of the group.') +def do_group_enable_replication(cs, args): + """Enables replication for group.""" + + shell_utils.find_group(cs, args.group).enable_replication() + + +@api_versions.wraps('3.38') +@utils.arg('group', + metavar='', + help='Name or ID of the group.') +def do_group_disable_replication(cs, args): + """Disables replication for group.""" + + shell_utils.find_group(cs, args.group).disable_replication() + + +@api_versions.wraps('3.38') +@utils.arg('group', + metavar='', + help='Name or ID of the group.') +@utils.arg('--allow-attached-volume', + action='store_true', + default=False, + help='Allows or disallows group with ' + 'attached volumes to be failed over.') +@utils.arg('--secondary-backend-id', + metavar='', + help='Secondary backend id. Default=None.') +def do_group_failover_replication(cs, args): + """Fails over replication for group.""" + + shell_utils.find_group(cs, args.group).failover_replication( + allow_attached_volume=args.allow_attached_volume, + secondary_backend_id=args.secondary_backend_id) + + +@api_versions.wraps('3.38') +@utils.arg('group', + metavar='', + help='Name or ID of the group.') +def do_group_list_replication_targets(cs, args): + """Lists replication targets for group. + + Example value for replication_targets: + + .. code-block: json + + { + 'replication_targets': [{'backend_id': 'vendor-id-1', + 'unique_key': 'val1', + ......}, + {'backend_id': 'vendor-id-2', + 'unique_key': 'val2', + ......}] + } + """ + + rc, replication_targets = shell_utils.find_group( + cs, args.group).list_replication_targets() + rep_targets = replication_targets.get('replication_targets') + if rep_targets and len(rep_targets) > 0: + shell_utils.print_list(rep_targets, + [key for key in rep_targets[0].keys()]) + + +@api_versions.wraps('3.14') +@utils.arg('--all-tenants', + dest='all_tenants', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=utils.env('ALL_TENANTS', default=None), + help='Shows details for all tenants. Admin only.') +@utils.arg('--status', + metavar='', + default=None, + help="Filters results by a status. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--group-id', + metavar='', + default=None, + help="Filters results by a group ID. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.33', + metavar='', + default=None, + help="Filter key and value pairs. Please use 'cinder list-filters' " + "to check enabled filters from server. Use 'key~=value' " + "for inexact filtering if the key supports. Default=None.") +def do_group_snapshot_list(cs, args): + """Lists all group snapshots.""" + + search_opts = { + 'all_tenants': args.all_tenants, + 'status': args.status, + 'group_id': args.group_id, + } + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + + group_snapshots = cs.group_snapshots.list(search_opts=search_opts) + + columns = ['ID', 'Status', 'Name'] + shell_utils.print_list(group_snapshots, columns) + AppendFilters.filters = [] + + +@api_versions.wraps('3.14') +@utils.arg('group_snapshot', + metavar='', + help='Name or ID of group snapshot.') +def do_group_snapshot_show(cs, args): + """Shows group snapshot details.""" + info = dict() + group_snapshot = shell_utils.find_group_snapshot(cs, args.group_snapshot) + info.update(group_snapshot._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@api_versions.wraps('3.14') +@utils.arg('group', + metavar='', + help='Name or ID of a group.') +@utils.arg('--name', + metavar='', + default=None, + help='Group snapshot name. Default=None.') +@utils.arg('--description', + metavar='', + default=None, + help='Group snapshot description. Default=None.') +def do_group_snapshot_create(cs, args): + """Creates a group snapshot.""" + group = shell_utils.find_group(cs, args.group) + group_snapshot = cs.group_snapshots.create( + group.id, + args.name, + args.description) + + info = dict() + group_snapshot = cs.group_snapshots.get(group_snapshot.id) + info.update(group_snapshot._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@api_versions.wraps('3.14') +@utils.arg('group_snapshot', + metavar='', nargs='+', + help='Name or ID of one or more group snapshots to be deleted.') +def do_group_snapshot_delete(cs, args): + """Removes one or more group snapshots.""" + failure_count = 0 + for group_snapshot in args.group_snapshot: + try: + shell_utils.find_group_snapshot(cs, group_snapshot).delete() + except Exception as e: + failure_count += 1 + print("Delete for group snapshot %s failed: %s" % + (group_snapshot, e)) + if failure_count == len(args.group_snapshot): + raise exceptions.CommandError("Unable to delete any of the specified " + "group snapshots.") + + +@api_versions.wraps('3.0') +@utils.arg('--host', metavar='', default=None, + help='Host name. Default=None.') +@utils.arg('--binary', metavar='', default=None, + help='Service binary. Default=None.') +@utils.arg('--withreplication', + metavar='', + const=True, + nargs='?', + default=False, + start_version='3.7', + help='Enables or disables display of ' + 'Replication info for c-vol services. Default=False.') +def do_service_list(cs, args): + """Lists all services. Filter by host and service binary.""" + if hasattr(args, 'withreplication'): + replication = strutils.bool_from_string(args.withreplication, + strict=True) + else: + replication = False + + result = cs.services.list(host=args.host, binary=args.binary) + columns = ["Binary", "Host", "Zone", "Status", "State", "Updated_at"] + if cs.api_version.matches('3.7'): + columns.append('Cluster') + if replication: + columns.extend(["Replication Status", "Active Backend ID", "Frozen"]) + # NOTE(jay-lau-513): we check if the response has disabled_reason + # so as not to add the column when the extended ext is not enabled. + if result and hasattr(result[0], 'disabled_reason'): + columns.append("Disabled Reason") + if cs.api_version.matches('3.49'): + columns.extend(["Backend State"]) + shell_utils.print_list(result, columns) + + +@api_versions.wraps('3.8') +# NOTE(geguileo): host is positional but optional in order to maintain backward +# compatibility even with mutually exclusive arguments. If version is < 3.16 +# then only host positional argument will be possible, and since the +# exclusive_arg group has required=True it will be required even if it's +# optional. +@utils.exclusive_arg('source', 'host', required=True, nargs='?', + metavar='', + help='Cinder host on which to list manageable snapshots; ' + 'takes the form: host@backend-name#pool') +@utils.exclusive_arg('source', '--cluster', required=True, + help='Cinder cluster on which to list manageable ' + 'snapshots; takes the form: cluster@backend-name#pool', + start_version='3.17') +@utils.arg('--detailed', + metavar='', + default=True, + help='Returned detailed information (default true).') +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning volumes that appear later in the volume ' + 'list than that represented by this reference. This reference ' + 'should be json like. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of volumes to return. Default=None.') +@utils.arg('--offset', + metavar='', + default=None, + help='Number of volumes to skip after marker. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.' + ) % ', '.join(base.SORT_MANAGEABLE_KEY_VALUES))) +def do_snapshot_manageable_list(cs, args): + """Lists all manageable snapshots.""" + # pylint: disable=function-redefined + detailed = strutils.bool_from_string(args.detailed) + cluster = getattr(args, 'cluster', None) + snapshots = cs.volume_snapshots.list_manageable(host=args.host, + detailed=detailed, + marker=args.marker, + limit=args.limit, + offset=args.offset, + sort=args.sort, + cluster=cluster) + columns = ['reference', 'size', 'safe_to_manage', 'source_reference'] + if detailed: + columns.extend(['reason_not_safe', 'cinder_id', 'extra_info']) + shell_utils.print_list(snapshots, columns, sortby_index=None) + + +@api_versions.wraps("3.0") +def do_api_version(cs, args): + """Display the server API version information.""" + columns = ['ID', 'Status', 'Version', 'Min_version'] + response = cs.services.server_api_version() + shell_utils.print_list(response, columns) + + +@api_versions.wraps("3.40") +@utils.arg( + 'snapshot', + metavar='', + help='Name or ID of the snapshot to restore. The snapshot must be the ' + 'most recent one known to cinder.') +def do_revert_to_snapshot(cs, args): + """Revert a volume to the specified snapshot.""" + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + volume = utils.find_volume(cs, snapshot.volume_id) + volume.revert_to_snapshot(snapshot) + + +@api_versions.wraps("3.3") +@utils.arg('--marker', + metavar='', + default=None, + start_version='3.5', + help='Begin returning message that appear later in the message ' + 'list than that represented by this id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + start_version='3.5', + help='Maximum number of messages to return. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + start_version='3.5', + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) +@utils.arg('--resource_uuid', + metavar='', + default=None, + help="Filters results by a resource uuid. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--resource_type', + metavar='', + default=None, + help="Filters results by a resource type. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--event_id', + metavar='', + default=None, + help="Filters results by event id. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--request_id', + metavar='', + default=None, + help="Filters results by request id. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--level', + metavar='', + default=None, + help="Filters results by the message level. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.33', + metavar='', + default=None, + help="Filter key and value pairs. Please use 'cinder list-filters' " + "to check enabled filters from server. Use 'key~=value' " + "for inexact filtering if the key supports. Default=None.") +def do_message_list(cs, args): + """Lists all messages.""" + search_opts = { + 'resource_uuid': args.resource_uuid, + 'event_id': args.event_id, + 'request_id': args.request_id, + } + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + if args.resource_type: + search_opts['resource_type'] = args.resource_type.upper() + if args.level: + search_opts['message_level'] = args.level.upper() + + marker = args.marker if hasattr(args, 'marker') else None + limit = args.limit if hasattr(args, 'limit') else None + sort = args.sort if hasattr(args, 'sort') else None + + messages = cs.messages.list(search_opts=search_opts, + marker=marker, + limit=limit, + sort=sort) + + columns = ['ID', 'Resource Type', 'Resource UUID', 'Event ID', + 'User Message'] + if sort: + sortby_index = None + else: + sortby_index = 0 + shell_utils.print_list(messages, columns, sortby_index=sortby_index) + AppendFilters.filters = [] + + +@api_versions.wraps("3.3") +@utils.arg('message', + metavar='', + help='ID of message.') +def do_message_show(cs, args): + """Shows message details.""" + info = dict() + message = shell_utils.find_message(cs, args.message) + info.update(message._info) + info.pop('links', None) + shell_utils.print_dict(info) + + +@api_versions.wraps("3.3") +@utils.arg('message', + metavar='', nargs='+', + help='ID of one or more message to be deleted.') +def do_message_delete(cs, args): + """Removes one or more messages.""" + failure_count = 0 + for message in args.message: + try: + shell_utils.find_message(cs, message).delete() + except Exception as e: + failure_count += 1 + print("Delete for message %s failed: %s" % (message, e)) + if failure_count == len(args.message): + raise exceptions.CommandError("Unable to delete any of the specified " + "messages.") + + +@utils.arg('--all-tenants', + dest='all_tenants', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=0, + help='Shows details for all tenants. Admin only.') +@utils.arg('--all_tenants', + nargs='?', + type=int, + const=1, + help=argparse.SUPPRESS) +@utils.arg('--name', + metavar='', + default=None, + help="Filters results by a name. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--display_name', + help=argparse.SUPPRESS) +@utils.arg('--status', + metavar='', + default=None, + help="Filters results by a status. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--volume-id', + metavar='', + default=None, + help="Filters results by a volume ID. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--volume_id', + help=argparse.SUPPRESS) +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning snapshots that appear later in the snapshot ' + 'list than that represented by this id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of snapshots to return. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) +@utils.arg('--tenant', + type=str, + dest='tenant', + nargs='?', + metavar='', + help='Display information from single tenant (Admin only).') +@utils.arg('--metadata', + nargs='*', + metavar='', + default=None, + start_version='3.22', + help="Filters results by a metadata key and value pair. Require " + "volume api version >=3.22. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.33', + metavar='', + default=None, + help="Filter key and value pairs. Please use 'cinder list-filters' " + "to check enabled filters from server. Use 'key~=value' " + "for inexact filtering if the key supports. Default=None.") +@utils.arg('--with-count', + type=bool, + default=False, + const=True, + nargs='?', + start_version='3.45', + metavar='', + help="Show total number of snapshot entities. This is useful when " + "pagination is applied in the request.") +def do_snapshot_list(cs, args): + """Lists all snapshots.""" + # pylint: disable=function-redefined + show_count = True if hasattr( + args, 'with_count') and args.with_count else False + all_tenants = (1 if args.tenant else + int(os.environ.get("ALL_TENANTS", args.all_tenants))) + + if args.display_name is not None: + args.name = args.display_name + + metadata = None + try: + if args.metadata: + metadata = shell_utils.extract_metadata(args) + except AttributeError: + pass + + search_opts = { + 'all_tenants': all_tenants, + 'name': args.name, + 'status': args.status, + 'volume_id': args.volume_id, + 'project_id': args.tenant, + 'metadata': metadata + } + + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + + total_count = 0 + if show_count: + search_opts['with_count'] = args.with_count + snapshots, total_count = cs.volume_snapshots.list( + search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) + else: + snapshots = cs.volume_snapshots.list(search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) + + shell_utils.translate_volume_snapshot_keys(snapshots) + sortby_index = None if args.sort else 0 + # It's the server's responsibility to return the appropriate fields for the + # requested microversion, we present all known fields and skip those that + # are missing. + shell_utils.print_list(snapshots, + ['ID', 'Volume ID', 'Status', 'Name', 'Size', + 'Consumes Quota', 'User ID'], + exclude_unavailable=True, + sortby_index=sortby_index) + if show_count: + print("Snapshot in total: %s" % total_count) + + with cs.volume_snapshots.completion_cache( + 'uuid', + cinderclient.v3.volume_snapshots.Snapshot, + mode='w'): + for snapshot in snapshots: + cs.volume_snapshots.write_to_completion_cache('uuid', snapshot.id) + AppendFilters.filters = [] + + +@api_versions.wraps("3.0", "3.65") +@utils.arg('volume', + metavar='', + help='Name or ID of volume to snapshot.') +@utils.arg('--force', + metavar='', + const=True, + nargs='?', + default=False, + end_version='3.65', + help='Allows or disallows snapshot of ' + 'a volume when the volume is attached to an instance. ' + 'If set to True, ignores the current status of the ' + 'volume when attempting to snapshot it rather ' + 'than forcing it to be available. From microversion 3.66, ' + 'all snapshots are "forced" and this option is invalid. ' + 'Default=False.') +# FIXME: is this second declaration of --force really necessary? +@utils.arg('--force', + metavar='', + nargs='?', + default=None, + start_version='3.66', + help=argparse.SUPPRESS) +@utils.arg('--name', + metavar='', + default=None, + help='Snapshot name. Default=None.') +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--display_name', + help=argparse.SUPPRESS) +@utils.arg('--description', + metavar='', + default=None, + help='Snapshot description. Default=None.') +@utils.arg('--display-description', + help=argparse.SUPPRESS) +@utils.arg('--display_description', + help=argparse.SUPPRESS) +@utils.arg('--metadata', + nargs='*', + metavar='', + default=None, + help='Snapshot metadata key and value pairs. Default=None.') +def do_snapshot_create(cs, args): + """Creates a snapshot.""" + if args.display_name is not None: + args.name = args.display_name + + if args.display_description is not None: + args.description = args.display_description + + snapshot_metadata = None + if args.metadata is not None: + snapshot_metadata = shell_utils.extract_metadata(args) + + volume = utils.find_volume(cs, args.volume) + + snapshot = cs.volume_snapshots.create(volume.id, + args.force, + args.name, + args.description, + metadata=snapshot_metadata) + shell_utils.print_volume_snapshot(snapshot) + + +@api_versions.wraps("3.66") +@utils.arg('volume', + metavar='', + help='Name or ID of volume to snapshot.') +@utils.arg('--force', + nargs='?', + help=argparse.SUPPRESS) +@utils.arg('--name', + metavar='', + default=None, + help='Snapshot name. Default=None.') +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--display_name', + help=argparse.SUPPRESS) +@utils.arg('--description', + metavar='', + default=None, + help='Snapshot description. Default=None.') +@utils.arg('--display-description', + help=argparse.SUPPRESS) +@utils.arg('--display_description', + help=argparse.SUPPRESS) +@utils.arg('--metadata', + nargs='*', + metavar='', + default=None, + help='Snapshot metadata key and value pairs. Default=None.') +def do_snapshot_create(cs, args): # noqa: F811 + """Creates a snapshot.""" + + # TODO(rosmaita): we really need to look into removing this v1 + # compatibility code and the v1 options entirely. Note that if you + # include the --name and also --display_name, the latter will be used. + # Not sure that's desirable, but it is consistent with all the other + # functions in this file, so we'll do it here too. + if args.display_name is not None: + args.name = args.display_name + if args.display_description is not None: + args.description = args.display_description + + snapshot_metadata = None + if args.metadata is not None: + snapshot_metadata = shell_utils.extract_metadata(args) + + force = getattr(args, 'force', None) + + volume = utils.find_volume(cs, args.volume) + + # this is a little weird, but for consistency with the API we + # will silently ignore the --force option when it's passed with + # a value that evaluates to True; otherwise, we report that the + # --force option is illegal for this call + try: + snapshot = cs.volume_snapshots.create(volume.id, + force=force, + name=args.name, + description=args.description, + metadata=snapshot_metadata) + except ValueError as ve: + # make sure it's the exception we expect + em = cinderclient.v3.volume_snapshots.MV_3_66_FORCE_FLAG_ERROR + if em == str(ve): + raise exceptions.UnsupportedAttribute( + 'force', + start_version=None, + end_version=api_versions.APIVersion('3.65')) + else: + raise + + shell_utils.print_volume_snapshot(snapshot) + + +@api_versions.wraps('3.27') +@utils.arg('--all-tenants', + dest='all_tenants', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=utils.env('ALL_TENANTS', default=0), + help='Shows details for all tenants. Admin only.') +@utils.arg('--volume-id', + metavar='', + default=None, + help="Filters results by a volume ID. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--status', + metavar='', + default=None, + help="Filters results by a status. Default=None. " + "%s" % FILTER_DEPRECATED) +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning attachments that appear later in ' + 'attachment list than that represented by this id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of attachments to return. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) +@utils.arg('--tenant', + type=str, + dest='tenant', + nargs='?', + metavar='', + help='Display information from single tenant (Admin only).') +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.33', + metavar='', + default=None, + help="Filter key and value pairs. Please use 'cinder list-filters' " + "to check enabled filters from server. Use 'key~=value' " + "for inexact filtering if the key supports. Default=None.") +def do_attachment_list(cs, args): + """Lists all attachments.""" + search_opts = { + 'all_tenants': 1 if args.tenant else args.all_tenants, + 'project_id': args.tenant, + 'status': args.status, + 'volume_id': args.volume_id, + } + # Update search option with `filters` + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + + attachments = cs.attachments.list(search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) + for attachment in attachments: + setattr(attachment, 'server_id', getattr(attachment, 'instance', None)) + columns = ['ID', 'Volume ID', 'Status', 'Server ID'] + if args.sort: + sortby_index = None + else: + sortby_index = 0 + shell_utils.print_list(attachments, columns, sortby_index=sortby_index) + AppendFilters.filters = [] + + +@api_versions.wraps('3.27') +@utils.arg('attachment', + metavar='', + help='ID of attachment.') +def do_attachment_show(cs, args): + """Show detailed information for attachment.""" + attachment = cs.attachments.show(args.attachment) + attachment_dict = attachment.to_dict() + connection_dict = attachment_dict.pop('connection_info', {}) + shell_utils.print_dict(attachment_dict) + + # TODO(jdg): Need to add checks here like admin/policy for displaying the + # connection_info, this is still experimental so we'll leave it enabled for + # now + if connection_dict: + shell_utils.print_dict(connection_dict) + + +@api_versions.wraps('3.27') +@utils.arg('volume', + metavar='', + help='Name or ID of volume or volumes to attach.') +@utils.arg('server_id', + metavar='', + nargs='?', + default=None, + help='ID of server attaching to.') +@utils.arg('--connect', + metavar='', + default=False, + help='Make an active connection using provided connector info ' + '(True or False).') +@utils.arg('--initiator', + metavar='', + default=None, + help='iqn of the initiator attaching to. Default=None.') +@utils.arg('--ip', + metavar='', + default=None, + help='ip of the system attaching to. Default=None.') +@utils.arg('--host', + metavar='', + default=None, + help='Name of the host attaching to. Default=None.') +@utils.arg('--platform', + metavar='', + default='x86_64', + help='Platform type. Default=x86_64.') +@utils.arg('--ostype', + metavar='', + default='linux2', + help='OS type. Default=linux2.') +@utils.arg('--multipath', + metavar='', + default=False, + help='Use multipath. Default=False.') +@utils.arg('--mountpoint', + metavar='', + default=None, + help='Mountpoint volume will be attached at. Default=None.') +@utils.arg('--mode', + metavar='', + default='null', + start_version='3.54', + help='Mode of attachment, rw, ro and null, where null ' + 'indicates we want to honor any existing ' + 'admin-metadata settings. Default=null.') +def do_attachment_create(cs, args): + """Create an attachment for a cinder volume.""" + + connector = {} + if strutils.bool_from_string(args.connect, strict=True): + # FIXME(jdg): Add in all the options when they're finalized + connector = {'initiator': args.initiator, + 'ip': args.ip, + 'platform': args.platform, + 'host': args.host, + 'os_type': args.ostype, + 'multipath': args.multipath, + 'mountpoint': args.mountpoint} + volume = utils.find_volume(cs, args.volume) + mode = getattr(args, 'mode', 'null') + attachment = cs.attachments.create(volume.id, + connector, + args.server_id, + mode) + + connector_dict = attachment.pop('connection_info', None) + shell_utils.print_dict(attachment) + if connector_dict: + shell_utils.print_dict(connector_dict) + + +@api_versions.wraps('3.27') +@utils.arg('attachment', + metavar='', + help='ID of attachment.') +@utils.arg('--initiator', + metavar='', + default=None, + help='iqn of the initiator attaching to. Default=None.') +@utils.arg('--ip', + metavar='', + default=None, + help='ip of the system attaching to. Default=None.') +@utils.arg('--host', + metavar='', + default=None, + help='Name of the host attaching to. Default=None.') +@utils.arg('--platform', + metavar='', + default='x86_64', + help='Platform type. Default=x86_64.') +@utils.arg('--ostype', + metavar='', + default='linux2', + help='OS type. Default=linux2.') +@utils.arg('--multipath', + metavar='', + default=False, + help='Use multipath. Default=False.') +@utils.arg('--mountpoint', + metavar='', + default=None, + help='Mountpoint volume will be attached at. Default=None.') +def do_attachment_update(cs, args): + """Update an attachment for a cinder volume. + This call is designed to be more of an attachment completion than anything + else. It expects the value of a connector object to notify the driver that + the volume is going to be connected and where it's being connected to. + """ + connector = {'initiator': args.initiator, + 'ip': args.ip, + 'platform': args.platform, + 'host': args.host, + 'os_type': args.ostype, + 'multipath': args.multipath, + 'mountpoint': args.mountpoint} + attachment = cs.attachments.update(args.attachment, + connector) + attachment_dict = attachment.to_dict() + connector_dict = attachment_dict.pop('connection_info', None) + shell_utils.print_dict(attachment_dict) + if connector_dict: + shell_utils.print_dict(connector_dict) + + +@api_versions.wraps('3.27') +@utils.arg('attachment', + metavar='', nargs='+', + help='ID of attachment or attachments to delete.') +def do_attachment_delete(cs, args): + """Delete an attachment for a cinder volume.""" + for attachment in args.attachment: + cs.attachments.delete(attachment) + + +@api_versions.wraps('3.44') +@utils.arg('attachment', + metavar='', nargs='+', + help='ID of attachment or attachments to delete.') +def do_attachment_complete(cs, args): + """Complete an attachment for a cinder volume.""" + for attachment in args.attachment: + cs.attachments.complete(attachment) + + +@api_versions.wraps('3.0') +def do_version_list(cs, args): + """List all API versions.""" + result = cs.services.server_api_version() + if 'min_version' in dir(result[0]): + columns = ["Id", "Status", "Updated", "Min Version", "Version"] + else: + columns = ["Id", "Status", "Updated"] + + print("Client supported API versions:") + print("Minimum version %(v)s" % + {'v': api_versions.MIN_VERSION}) + print("Maximum version %(v)s" % + {'v': api_versions.MAX_VERSION}) + + print("\nServer supported API versions:") + shell_utils.print_list(result, columns) + + +@api_versions.wraps('3.32') +@utils.arg('level', + metavar='', + choices=('INFO', 'WARNING', 'ERROR', 'DEBUG', + 'info', 'warning', 'error', 'debug'), + help='Desired log level.') +@utils.arg('--binary', + choices=('', '*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', + 'cinder-backup'), + default='', + help='Binary to change.') +@utils.arg('--server', + default='', + help='Host or cluster value for service.') +@utils.arg('--prefix', + default='', + help='Prefix for the log. ie: "cinder.volume.drivers.".') +def do_service_set_log(cs, args): + """Sets the service log level.""" + cs.services.set_log_levels(args.level, args.binary, args.server, + args.prefix) + + +@api_versions.wraps('3.32') +@utils.arg('--binary', + choices=('', '*', 'cinder-api', 'cinder-volume', 'cinder-scheduler', + 'cinder-backup'), + default='', + help='Binary to query.') +@utils.arg('--server', + default='', + help='Host or cluster value for service.') +@utils.arg('--prefix', + default='', + help='Prefix for the log. ie: "sqlalchemy.".') +def do_service_get_log(cs, args): + """Gets the service log level.""" + log_levels = cs.services.get_log_levels(args.binary, args.server, + args.prefix) + columns = ('Binary', 'Host', 'Prefix', 'Level') + shell_utils.print_list(log_levels, columns) + + +@utils.arg('volume', metavar='', + help='Name or ID of volume to backup.') +@utils.arg('--container', metavar='', + default=None, + help='Backup container name. Default=None.') +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--name', metavar='', + default=None, + help='Backup name. Default=None.') +@utils.arg('--display-description', + help=argparse.SUPPRESS) +@utils.arg('--description', + metavar='', + default=None, + help='Backup description. Default=None.') +@utils.arg('--incremental', + action='store_true', + help='Incremental backup. Default=False.', + default=False) +@utils.arg('--force', + action='store_true', + help='Allows or disallows backup of a volume ' + 'when the volume is attached to an instance. ' + 'If set to True, backs up the volume whether ' + 'its status is "available" or "in-use". The backup ' + 'of an "in-use" volume means your data is crash ' + 'consistent. Default=False.', + default=False) +@utils.arg('--snapshot-id', + metavar='', + default=None, + help='ID of snapshot to backup. Default=None.') +@utils.arg('--metadata', + nargs='*', + metavar='', + default=None, + start_version='3.43', + help='Metadata key and value pairs. Default=None.') +@utils.arg('--availability-zone', + default=None, + start_version='3.51', + help='AZ where the backup should be stored, by default it will be ' + 'the same as the source.') +def do_backup_create(cs, args): + """Creates a volume backup.""" + if args.display_name is not None: + args.name = args.display_name + + if args.display_description is not None: + args.description = args.display_description + + kwargs = {} + if getattr(args, 'metadata', None): + kwargs['metadata'] = shell_utils.extract_metadata(args) + az = getattr(args, 'availability_zone', None) + if az: + kwargs['availability_zone'] = az + + volume = utils.find_volume(cs, args.volume) + backup = cs.backups.create(volume.id, + args.container, + args.name, + args.description, + args.incremental, + args.force, + args.snapshot_id, + **kwargs) + info = {"volume_id": volume.id} + info.update(backup._info) + + if 'links' in info: + info.pop('links') + + shell_utils.print_dict(info) + + with cs.backups.completion_cache( + 'uuid', + cinderclient.v3.volume_backups.VolumeBackup, + mode="a"): + cs.backups.write_to_completion_cache('uuid', backup.id) + + +@utils.arg('volume', metavar='', + help='Name or ID of volume to transfer.') +@utils.arg('--name', + metavar='', + default=None, + help='Transfer name. Default=None.') +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--no-snapshots', + action='store_true', + help='Allows or disallows transfer volumes without snapshots. ' + 'Default=False.', + start_version='3.55', + default=False) +def do_transfer_create(cs, args): + """Creates a volume transfer.""" + if args.display_name is not None: + args.name = args.display_name + + kwargs = {} + no_snapshots = getattr(args, 'no_snapshots', None) + if no_snapshots is not None: + kwargs['no_snapshots'] = no_snapshots + + volume = utils.find_volume(cs, args.volume) + transfer = cs.transfers.create(volume.id, + args.name, + **kwargs) + info = dict() + info.update(transfer._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('--all-tenants', + dest='all_tenants', + metavar='<0|1>', + nargs='?', + type=int, + const=1, + default=0, + help='Shows details for all tenants. Admin only.') +@utils.arg('--all_tenants', + nargs='?', + type=int, + const=1, + help=argparse.SUPPRESS) +@utils.arg('--sort', + metavar='[:]', + default=None, + help='Sort keys and directions in the form of [:].', + start_version='3.59') +@utils.arg('--filters', + action=AppendFilters, + type=str, + nargs='*', + start_version='3.52', + metavar='', + default=None, + help="Filter key and value pairs.") +def do_transfer_list(cs, args): + """Lists all transfers.""" + all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) + search_opts = { + 'all_tenants': all_tenants, + } + if AppendFilters.filters: + search_opts.update(shell_utils.extract_filters(AppendFilters.filters)) + + sort = getattr(args, 'sort', None) + if sort: + sort_args = sort.split(':') + if len(sort_args) > 2: + raise exceptions.CommandError( + 'Invalid sort parameter provided. Argument must be in the ' + 'form "key[:]".') + + transfers = cs.transfers.list(search_opts=search_opts, sort=sort) + columns = ['ID', 'Volume ID', 'Name'] + shell_utils.print_list(transfers, columns) + AppendFilters.filters = [] + + +@api_versions.wraps('3.62') +@utils.arg('volume_type', + metavar='', + help='Name or ID of the volume type.') +@utils.arg('project', + metavar='', + help='ID of project for which to set default type.') +def do_default_type_set(cs, args): + """Sets a default volume type for a project.""" + volume_type = args.volume_type + project = args.project + + default_type = cs.default_types.create(volume_type, project) + shell_utils.print_dict(default_type._info) + + +@api_versions.wraps('3.62') +@utils.arg('--project-id', + metavar='', + default=None, + help='ID of project for which to show the default type.') +def do_default_type_list(cs, args): + """Lists all default volume types.""" + + project_id = args.project_id + default_types = cs.default_types.list(project_id) + columns = ['Volume Type ID', 'Project ID'] + if project_id: + shell_utils.print_dict(default_types._info) + else: + shell_utils.print_list(default_types, columns) + + +@api_versions.wraps('3.62') +@utils.arg('project_id', + metavar='', + nargs='+', + help='ID of project for which to unset default type.') +def do_default_type_unset(cs, args): + """Unset default volume types.""" + + for project_id in args.project_id: + try: + cs.default_types.delete(project_id) + print("Default volume type for project %s has been unset " + "successfully." % (project_id)) + except Exception as e: + print("Unset for default volume type for project %s failed: %s" + % (project_id, e)) + + +@api_versions.wraps('3.68') +@utils.arg('volume', + metavar='', + help='Name or ID of volume to reimage') +@utils.arg('image_id', + metavar='', + help='The image id of the image that will be used to reimage ' + 'the volume.') +@utils.arg('--reimage-reserved', + metavar='', + default=False, + help='Enables or disables reimage for a volume that is in ' + 'reserved state otherwise only volumes in "available" ' + ' or "error" status may be re-imaged. Default=False.') +def do_reimage(cs, args): + """Rebuilds a volume, overwriting all content with the specified image""" + volume = utils.find_volume(cs, args.volume) + volume.reimage(args.image_id, args.reimage_reserved) diff --git a/cinderclient/v2/shell.py b/cinderclient/v3/shell_base.py similarity index 60% rename from cinderclient/v2/shell.py rename to cinderclient/v3/shell_base.py index 3c13636b3..25d99bb0f 100644 --- a/cinderclient/v2/shell.py +++ b/cinderclient/v3/shell_base.py @@ -14,127 +14,30 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import print_function - import argparse +import collections import copy import os -import sys -import time -import six +from oslo_utils import strutils +from cinderclient import base from cinderclient import exceptions +from cinderclient import shell_utils from cinderclient import utils -from cinderclient.openstack.common import strutils -from cinderclient.v2 import availability_zones -from cinderclient.v2 import volumes - - -def _poll_for_status(poll_fn, obj_id, action, final_ok_states, - poll_period=5, show_progress=True): - """Blocks while an action occurs. Periodically shows progress.""" - def print_progress(progress): - if show_progress: - msg = ('\rInstance %(action)s... %(progress)s%% complete' - % dict(action=action, progress=progress)) - else: - msg = '\rInstance %(action)s...' % dict(action=action) - - sys.stdout.write(msg) - sys.stdout.flush() - - print() - while True: - obj = poll_fn(obj_id) - status = obj.status.lower() - progress = getattr(obj, 'progress', None) or 0 - if status in final_ok_states: - print_progress(100) - print("\nFinished") - break - elif status == "error": - print("\nError %(action)s instance" % {'action': action}) - break - else: - print_progress(progress) - time.sleep(poll_period) - - -def _find_volume_snapshot(cs, snapshot): - """Gets a volume snapshot by name or ID.""" - return utils.find_resource(cs.volume_snapshots, snapshot) - - -def _find_backup(cs, backup): - """Gets a backup by name or ID.""" - return utils.find_resource(cs.backups, backup) - - -def _find_consistencygroup(cs, consistencygroup): - """Gets a consistencygroup by name or ID.""" - return utils.find_resource(cs.consistencygroups, consistencygroup) - - -def _find_cgsnapshot(cs, cgsnapshot): - """Gets a cgsnapshot by name or ID.""" - return utils.find_resource(cs.cgsnapshots, cgsnapshot) - - -def _find_transfer(cs, transfer): - """Gets a transfer by name or ID.""" - return utils.find_resource(cs.transfers, transfer) - - -def _find_qos_specs(cs, qos_specs): - """Gets a qos specs by ID.""" - return utils.find_resource(cs.qos_specs, qos_specs) - - -def _print_volume_snapshot(snapshot): - utils.print_dict(snapshot._info) - - -def _print_volume_image(image): - utils.print_dict(image[1]['os-volume_upload_image']) - - -def _translate_keys(collection, convert): - for item in collection: - keys = item.__dict__ - for from_key, to_key in convert: - if from_key in keys and to_key not in keys: - setattr(item, to_key, item._info[from_key]) - - -def _translate_volume_keys(collection): - convert = [('volumeType', 'volume_type'), - ('os-vol-tenant-attr:tenant_id', 'tenant_id')] - _translate_keys(collection, convert) - - -def _translate_volume_snapshot_keys(collection): - convert = [('volumeId', 'volume_id')] - _translate_keys(collection, convert) - +from cinderclient.v3 import availability_zones -def _translate_availability_zone_keys(collection): - convert = [('zoneName', 'name'), ('zoneState', 'status')] - _translate_keys(collection, convert) - -def _extract_metadata(args): - metadata = {} - 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) - else: - key = metadatum - value = None - - metadata[key] = value - return metadata +def _translate_attachments(info): + attachments = [] + attached_servers = [] + for attachment in info['attachments']: + attachments.append(attachment['attachment_id']) + attached_servers.append(attachment['server_id']) + info.pop('attachments', None) + info['attachment_ids'] = attachments + info['attached_servers'] = attached_servers + return info @utils.arg('--all-tenants', @@ -160,12 +63,22 @@ def _extract_metadata(args): metavar='', default=None, help='Filters results by a status. Default=None.') +@utils.arg('--bootable', + metavar='', + const=True, + nargs='?', + choices=['True', 'true', 'False', 'false'], + help='Filters results by bootable status. Default=None.') +@utils.arg('--migration_status', + metavar='', + default=None, + help='Filters results by a migration status. Default=None. ' + 'Admin only.') @utils.arg('--metadata', - type=str, nargs='*', metavar='', default=None, - help='Filters results by a metadata key and value pair. ' + help='Filters results by a image metadata key and value pair. ' 'Default=None.') @utils.arg('--marker', metavar='', @@ -177,28 +90,26 @@ def _extract_metadata(args): metavar='', default=None, help='Maximum number of volumes to return. Default=None.') -@utils.arg('--sort_key', - metavar='', - default=None, - help=argparse.SUPPRESS) -@utils.arg('--sort_dir', - metavar='', +@utils.arg('--fields', default=None, - help=argparse.SUPPRESS) + metavar='', + help='Comma-separated list of fields to display. ' + 'Use the show command to see which fields are available. ' + 'Unavailable/non-existent fields will be ignored. ' + 'Default=None.') @utils.arg('--sort', metavar='[:]', default=None, help=(('Comma-separated list of sort keys and directions in the ' 'form of [:]. ' 'Valid keys: %s. ' - 'Default=None.') % ', '.join(volumes.SORT_KEY_VALUES))) + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) @utils.arg('--tenant', type=str, dest='tenant', nargs='?', metavar='', help='Display information from single tenant (Admin only).') -@utils.service_type('volumev2') def do_list(cs, args): """Lists all volumes.""" # NOTE(thingee): Backwards-compatibility with v1 args @@ -212,59 +123,76 @@ def do_list(cs, args): 'project_id': args.tenant, 'name': args.name, 'status': args.status, - 'metadata': _extract_metadata(args) if args.metadata else None, + 'bootable': args.bootable, + 'migration_status': args.migration_status, + 'metadata': (shell_utils.extract_metadata(args) if args.metadata + else None), } - # --sort_key and --sort_dir deprecated in kilo and is not supported - # with --sort - if args.sort and (args.sort_key or args.sort_dir): - raise exceptions.CommandError( - 'The --sort_key and --sort_dir arguments are deprecated and are ' - 'not supported with --sort.') + # If unavailable/non-existent fields are specified, these fields will + # be removed from key_list at the print_list() during key validation. + field_titles = [] + if args.fields: + for field_title in args.fields.split(','): + field_titles.append(field_title) volumes = cs.volumes.list(search_opts=search_opts, marker=args.marker, - limit=args.limit, sort_key=args.sort_key, - sort_dir=args.sort_dir, sort=args.sort) - _translate_volume_keys(volumes) + limit=args.limit, sort=args.sort) + shell_utils.translate_volume_keys(volumes) # Create a list of servers to which the volume is attached for vol in volumes: servers = [s.get('server_id') for s in vol.attachments] setattr(vol, 'attached_to', ','.join(map(str, servers))) - if all_tenants: - key_list = ['ID', 'Tenant ID', 'Status', 'Name', - 'Size', 'Volume Type', 'Bootable', 'Attached to'] + if field_titles: + # Remove duplicate fields + key_list = ['ID'] + unique_titles = [k for k in collections.OrderedDict.fromkeys( + [x.title().strip() for x in field_titles]) if k != 'Id'] + key_list.extend(unique_titles) else: - key_list = ['ID', 'Status', 'Name', - 'Size', 'Volume Type', 'Bootable', 'Attached to'] - if args.sort_key or args.sort_dir or args.sort: + key_list = ['ID', 'Status', 'Name', 'Size', 'Volume Type', + 'Bootable', 'Attached to'] + # If all_tenants is specified, print + # Tenant ID as well. + if search_opts['all_tenants']: + key_list.insert(1, 'Tenant ID') + + if args.sort: sortby_index = None else: sortby_index = 0 - utils.print_list(volumes, key_list, sortby_index=sortby_index) + shell_utils.print_list(volumes, key_list, exclude_unavailable=True, + sortby_index=sortby_index) @utils.arg('volume', metavar='', help='Name or ID of volume.') -@utils.service_type('volumev2') def do_show(cs, args): """Shows volume details.""" info = dict() volume = utils.find_volume(cs, args.volume) info.update(volume._info) + if 'readonly' in info['metadata']: + info['readonly'] = info['metadata']['readonly'] + info.pop('links', None) - utils.print_dict(info) + info = _translate_attachments(info) + shell_utils.print_dict(info, + formatters=['metadata', 'volume_image_metadata', + 'attachment_ids', 'attached_servers']) class CheckSizeArgForCreate(argparse.Action): def __call__(self, parser, args, values, option_string=None): - if (values or args.snapshot_id or args.source_volid - or args.source_replica) is None: - parser.error('Size is a required parameter if snapshot ' - 'or source volume is not specified.') + if ((args.snapshot_id or args.source_volid) + is None and values is None): + if not hasattr(args, 'backup_id') or args.backup_id is None: + parser.error('Size is a required parameter if snapshot ' + 'or source volume or backup is not specified.') setattr(args, self.dest, values) @@ -273,7 +201,7 @@ def __call__(self, parser, args, values, option_string=None): nargs='?', type=int, action=CheckSizeArgForCreate, - help='Size of volume, in GBs. (Required unless ' + help='Size of volume, in GiBs. (Required unless ' 'snapshot-id/source-volid is specified).') @utils.arg('--consisgroup-id', metavar='', @@ -292,10 +220,6 @@ def __call__(self, parser, args, values, option_string=None): help='Creates volume from volume ID. Default=None.') @utils.arg('--source_volid', help=argparse.SUPPRESS) -@utils.arg('--source-replica', - metavar='', - default=None, - help='Creates volume from replicated volume ID. Default=None.') @utils.arg('--image-id', metavar='', default=None, @@ -337,7 +261,6 @@ def __call__(self, parser, args, values, option_string=None): @utils.arg('--availability_zone', help=argparse.SUPPRESS) @utils.arg('--metadata', - type=str, nargs='*', metavar='', default=None, @@ -347,8 +270,9 @@ def __call__(self, parser, args, values, option_string=None): dest='scheduler_hints', action='append', default=[], - help='Scheduler hint, like in nova.') -@utils.service_type('volumev2') + help='Scheduler hint, similar to nova. Repeat option to set ' + 'multiple hints. Values with the same key will be stored ' + 'as a list.') def do_create(cs, args): """Creates a volume.""" # NOTE(thingee): Backwards-compatibility with v1 args @@ -360,7 +284,7 @@ def do_create(cs, args): volume_metadata = None if args.metadata is not None: - volume_metadata = _extract_metadata(args) + volume_metadata = shell_utils.extract_metadata(args) # NOTE(N.S.): take this piece from novaclient hints = {} @@ -370,7 +294,7 @@ def do_create(cs, args): # NOTE(vish): multiple copies of same hint will # result in a list of values if key in hints: - if isinstance(hints[key], six.string_types): + if isinstance(hints[key], str): hints[key] = [hints[key]] hints[key] += [value] else: @@ -390,27 +314,34 @@ def do_create(cs, args): availability_zone=args.availability_zone, imageRef=image_ref, metadata=volume_metadata, - scheduler_hints=hints, - source_replica=args.source_replica) + scheduler_hints=hints) info = dict() volume = cs.volumes.get(volume.id) info.update(volume._info) + if 'readonly' in info['metadata']: + info['readonly'] = info['metadata']['readonly'] + info.pop('links', None) - utils.print_dict(info) + info = _translate_attachments(info) + shell_utils.print_dict(info) +@utils.arg('--cascade', + action='store_true', + default=False, + help='Remove any snapshots along with volume. Default=False.') @utils.arg('volume', metavar='', nargs='+', help='Name or ID of volume or volumes to delete.') -@utils.service_type('volumev2') def do_delete(cs, args): """Removes one or more volumes.""" failure_count = 0 for volume in args.volume: try: - utils.find_volume(cs, volume).delete() + utils.find_volume(cs, volume).delete(cascade=args.cascade) + print("Request to delete volume %s has been accepted." % (volume)) except Exception as e: failure_count += 1 print("Delete for volume %s failed: %s" % (volume, e)) @@ -422,7 +353,6 @@ def do_delete(cs, args): @utils.arg('volume', metavar='', nargs='+', help='Name or ID of volume or volumes to delete.') -@utils.service_type('volumev2') def do_force_delete(cs, args): """Attempts force-delete of volume, regardless of state.""" failure_count = 0 @@ -439,14 +369,25 @@ def do_force_delete(cs, args): @utils.arg('volume', metavar='', nargs='+', help='Name or ID of volume to modify.') -@utils.arg('--state', metavar='', default='available', +@utils.arg('--state', metavar='', default=None, help=('The state to assign to the volume. Valid values are ' - '"available," "error," "creating," "deleting," "in-use," ' - '"attaching," "detaching" and "error_deleting." ' + '"available", "error", "creating", "deleting", "in-use", ' + '"attaching", "detaching", "error_deleting" and ' + '"maintenance". ' 'NOTE: This command simply changes the state of the ' 'Volume in the DataBase with no regard to actual status, ' - 'exercise caution when using. Default=available.')) -@utils.service_type('volumev2') + 'exercise caution when using. Default=None, that means the ' + 'state is unchanged.')) +@utils.arg('--attach-status', metavar='', default=None, + help=('The attach status to assign to the volume in the DataBase, ' + 'with no regard to the actual status. Valid values are ' + '"attached" and "detached". Default=None, that means the ' + 'status is unchanged.')) +@utils.arg('--reset-migration-status', + action='store_true', + help=('Clears the migration status of the volume in the DataBase ' + 'that indicates the volume is source or destination of ' + 'volume migration, with no regard to the actual status.')) def do_reset_state(cs, args): """Explicitly updates the volume state in the Cinder database. @@ -457,10 +398,16 @@ def do_reset_state(cs, args): unusable in the case of change to the 'available' state. """ failure_flag = False + migration_status = 'none' if args.reset_migration_status else None + if not (args.state or args.attach_status or migration_status): + # Nothing specified, default to resetting state + args.state = 'available' for volume in args.volume: try: - utils.find_volume(cs, volume).reset_state(args.state) + utils.find_volume(cs, volume).reset_state(args.state, + args.attach_status, + migration_status) except Exception as e: failure_flag = True msg = "Reset state for volume %s failed: %s" % (volume, e) @@ -485,7 +432,6 @@ def do_reset_state(cs, args): help=argparse.SUPPRESS) @utils.arg('--display_description', help=argparse.SUPPRESS) -@utils.service_type('volumev2') def do_rename(cs, args): """Renames a volume.""" kwargs = {} @@ -517,11 +463,10 @@ def do_rename(cs, args): default=[], help='Metadata key and value pair to set or unset. ' 'For unset, specify only the key.') -@utils.service_type('volumev2') def do_metadata(cs, args): """Sets or deletes volume metadata.""" volume = utils.find_volume(cs, args.volume) - metadata = _extract_metadata(args) + metadata = shell_utils.extract_metadata(args) if args.action == 'set': cs.volumes.set_metadata(volume, metadata) @@ -531,6 +476,31 @@ def do_metadata(cs, args): reverse=True)) +@utils.arg('volume', + metavar='', + help='Name or ID of volume for which to update metadata.') +@utils.arg('action', + metavar='', + choices=['set', 'unset'], + help="The action. Valid values are 'set' or 'unset.'") +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='Metadata key and value pair to set or unset. ' + 'For unset, specify only the key.') +def do_image_metadata(cs, args): + """Sets or deletes volume image metadata.""" + volume = utils.find_volume(cs, args.volume) + metadata = shell_utils.extract_metadata(args) + + if args.action == 'set': + cs.volumes.set_image_metadata(volume, metadata) + elif args.action == 'unset': + cs.volumes.delete_image_metadata(volume, sorted(metadata.keys(), + reverse=True)) + + @utils.arg('--all-tenants', dest='all_tenants', metavar='<0|1>', @@ -562,105 +532,84 @@ def do_metadata(cs, args): help='Filters results by a volume ID. Default=None.') @utils.arg('--volume_id', help=argparse.SUPPRESS) -@utils.service_type('volumev2') +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning snapshots that appear later in the snapshot ' + 'list than that represented by this id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of snapshots to return. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) +@utils.arg('--tenant', + type=str, + dest='tenant', + nargs='?', + metavar='', + help='Display information from single tenant (Admin only).') def do_snapshot_list(cs, args): """Lists all snapshots.""" - all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) + all_tenants = (1 if args.tenant else + int(os.environ.get("ALL_TENANTS", args.all_tenants))) if args.display_name is not None: args.name = args.display_name search_opts = { 'all_tenants': all_tenants, - 'display_name': args.name, + 'name': args.name, 'status': args.status, 'volume_id': args.volume_id, + 'project_id': args.tenant, } - snapshots = cs.volume_snapshots.list(search_opts=search_opts) - _translate_volume_snapshot_keys(snapshots) - utils.print_list(snapshots, - ['ID', 'Volume ID', 'Status', 'Name', 'Size']) + snapshots = cs.volume_snapshots.list(search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) + shell_utils.translate_volume_snapshot_keys(snapshots) + if args.sort: + sortby_index = None + else: + sortby_index = 0 + + shell_utils.print_list(snapshots, + ['ID', 'Volume ID', 'Status', 'Name', 'Size'], + sortby_index=sortby_index) @utils.arg('snapshot', metavar='', help='Name or ID of snapshot.') -@utils.service_type('volumev2') def do_snapshot_show(cs, args): """Shows snapshot details.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - _print_volume_snapshot(snapshot) - - -@utils.arg('volume', - metavar='', - help='Name or ID of volume to snapshot.') -@utils.arg('--force', - metavar='', - const=True, - nargs='?', - default=False, - help='Allows or disallows snapshot of ' - 'a volume when the volume is attached to an instance. ' - 'If set to True, ignores the current status of the ' - 'volume when attempting to snapshot it rather ' - 'than forcing it to be available. ' - 'Default=False.') -@utils.arg('--name', - metavar='', - default=None, - help='Snapshot name. Default=None.') -@utils.arg('--display-name', - help=argparse.SUPPRESS) -@utils.arg('--display_name', - help=argparse.SUPPRESS) -@utils.arg('--description', - metavar='', - default=None, - help='Snapshot description. Default=None.') -@utils.arg('--display-description', - help=argparse.SUPPRESS) -@utils.arg('--display_description', - help=argparse.SUPPRESS) -@utils.arg('--metadata', - type=str, - nargs='*', - metavar='', - default=None, - help='Snapshot metadata key and value pairs. Default=None.') -@utils.service_type('volumev2') -def do_snapshot_create(cs, args): - """Creates a snapshot.""" - if args.display_name is not None: - args.name = args.display_name - - if args.display_description is not None: - args.description = args.display_description - - snapshot_metadata = None - if args.metadata is not None: - snapshot_metadata = _extract_metadata(args) - - volume = utils.find_volume(cs, args.volume) - snapshot = cs.volume_snapshots.create(volume.id, - args.force, - args.name, - args.description, - metadata=snapshot_metadata) - _print_volume_snapshot(snapshot) + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + shell_utils.print_volume_snapshot(snapshot) @utils.arg('snapshot', metavar='', nargs='+', help='Name or ID of the snapshot(s) to delete.') -@utils.service_type('volumev2') +@utils.arg('--force', + action="store_true", + help='Allows deleting snapshot of a volume ' + 'when its status is other than "available" or "error". ' + 'Default=False.') def do_snapshot_delete(cs, args): """Removes one or more snapshots.""" failure_count = 0 + for snapshot in args.snapshot: try: - _find_volume_snapshot(cs, snapshot).delete() + shell_utils.find_volume_snapshot(cs, snapshot).delete(args.force) except Exception as e: failure_count += 1 print("Delete for snapshot %s failed: %s" % (snapshot, e)) @@ -680,7 +629,6 @@ def do_snapshot_delete(cs, args): help=argparse.SUPPRESS) @utils.arg('--display_description', help=argparse.SUPPRESS) -@utils.service_type('volumev2') def do_snapshot_rename(cs, args): """Renames a snapshot.""" kwargs = {} @@ -697,7 +645,9 @@ def do_snapshot_rename(cs, args): msg = 'Must supply either name or description.' raise exceptions.ClientException(code=1, message=msg) - _find_volume_snapshot(cs, args.snapshot).update(**kwargs) + shell_utils.find_volume_snapshot(cs, args.snapshot).update(**kwargs) + print("Request to rename snapshot '%s' has been accepted." % ( + args.snapshot)) @utils.arg('snapshot', metavar='', nargs='+', @@ -705,12 +655,11 @@ def do_snapshot_rename(cs, args): @utils.arg('--state', metavar='', default='available', help=('The state to assign to the snapshot. Valid values are ' - '"available," "error," "creating," "deleting," and ' - '"error_deleting." NOTE: This command simply changes ' + '"available", "error", "creating", "deleting", and ' + '"error_deleting". NOTE: This command simply changes ' 'the state of the Snapshot in the DataBase with no regard ' 'to actual status, exercise caution when using. ' 'Default=available.')) -@utils.service_type('volumev2') def do_snapshot_reset_state(cs, args): """Explicitly updates the snapshot state.""" failure_count = 0 @@ -719,7 +668,8 @@ def do_snapshot_reset_state(cs, args): for snapshot in args.snapshot: try: - _find_volume_snapshot(cs, snapshot).reset_state(args.state) + shell_utils.find_volume_snapshot( + cs, snapshot).reset_state(args.state) except Exception as e: failure_count += 1 msg = "Reset state for snapshot %s failed: %s" % (snapshot, e) @@ -733,30 +683,38 @@ def do_snapshot_reset_state(cs, args): raise exceptions.CommandError(msg) -def _print_volume_type_list(vtypes): - utils.print_list(vtypes, ['ID', 'Name', 'Description', 'Is_Public']) - - -@utils.service_type('volumev2') -@utils.arg('--all', - dest='all', - action='store_true', - default=False, - help='Display all volume types (Admin only).') def do_type_list(cs, args): - """Lists available 'volume types'.""" - if args.all: - vtypes = cs.volume_types.list(is_public=None) - else: - vtypes = cs.volume_types.list() - _print_volume_type_list(vtypes) + """Lists available 'volume types'. + + (Only admin and tenant users will see private types) + """ + vtypes = cs.volume_types.list() + shell_utils.print_volume_type_list(vtypes) -@utils.service_type('volumev2') def do_type_default(cs, args): - """List the default volume type.""" + """List the default volume type. + + The Block Storage service allows configuration of a default + type for each project, as well as the system default, so use + this command to determine what your effective default volume + type is. + """ vtype = cs.volume_types.default() - _print_volume_type_list([vtype]) + shell_utils.print_volume_type_list([vtype]) + + +@utils.arg('volume_type', + metavar='', + help='Name or ID of the volume type.') +def do_type_show(cs, args): + """Show volume type details.""" + vtype = shell_utils.find_vtype(cs, args.volume_type) + info = dict() + info.update(vtype._info) + + info.pop('links', None) + shell_utils.print_dict(info, formatters=['extra_specs']) @utils.arg('id', @@ -768,18 +726,27 @@ def do_type_default(cs, args): @utils.arg('--description', metavar='', help='Description of the volume type.') -@utils.service_type('volumev2') +@utils.arg('--is-public', + metavar='', + help='Make type accessible to the public or not.') def do_type_update(cs, args): - """Updates volume type name and/or description.""" - vtype = cs.volume_types.update(args.id, args.name, args.description) - _print_volume_type_list([vtype]) + """Updates volume type name, description, and/or is_public.""" + is_public = args.is_public + if args.name is None and args.description is None and is_public is None: + raise exceptions.CommandError('Specify a new type name, description, ' + 'is_public or a combination thereof.') + + if is_public is not None: + is_public = strutils.bool_from_string(args.is_public, strict=True) + vtype = cs.volume_types.update(args.id, args.name, args.description, + is_public) + shell_utils.print_volume_type_list([vtype]) -@utils.service_type('volumev2') def do_extra_specs_list(cs, args): """Lists current volume types and extra specs.""" vtypes = cs.volume_types.list() - utils.print_list(vtypes, ['ID', 'Name', 'extra_specs']) + shell_utils.print_list(vtypes, ['ID', 'Name', 'extra_specs']) @utils.arg('name', @@ -792,21 +759,31 @@ def do_extra_specs_list(cs, args): metavar='', default=True, help='Make type accessible to the public (default true).') -@utils.service_type('volumev2') def do_type_create(cs, args): """Creates a volume type.""" - is_public = strutils.bool_from_string(args.is_public) + is_public = strutils.bool_from_string(args.is_public, strict=True) vtype = cs.volume_types.create(args.name, args.description, is_public) - _print_volume_type_list([vtype]) + shell_utils.print_volume_type_list([vtype]) -@utils.arg('id', - metavar='', - help='ID of volume type to delete.') -@utils.service_type('volumev2') +@utils.arg('vol_type', + metavar='', nargs='+', + help='Name or ID of volume type or types to delete.') def do_type_delete(cs, args): - """Deletes a volume type.""" - cs.volume_types.delete(args.id) + """Deletes volume type or types.""" + failure_count = 0 + for vol_type in args.vol_type: + try: + vtype = shell_utils.find_volume_type(cs, vol_type) + cs.volume_types.delete(vtype) + print("Request to delete volume type %s has been accepted." + % (vol_type)) + except Exception as e: + failure_count += 1 + print("Delete for volume type %s failed: %s" % (vol_type, e)) + if failure_count == len(args.vol_type): + raise exceptions.CommandError("Unable to delete any of the " + "specified types.") @utils.arg('vtype', @@ -822,11 +799,10 @@ def do_type_delete(cs, args): default=[], help='The extra specs key and value pair to set or unset. ' 'For unset, specify only the key.') -@utils.service_type('volumev2') def do_type_key(cs, args): """Sets or unsets extra_spec for a volume type.""" - vtype = _find_volume_type(cs, args.vtype) - keypair = _extract_metadata(args) + vtype = shell_utils.find_volume_type(cs, args.vtype) + keypair = shell_utils.extract_metadata(args) if args.action == 'set': vtype.set_keys(keypair) @@ -836,27 +812,25 @@ def do_type_key(cs, args): @utils.arg('--volume-type', metavar='', required=True, help='Filter results by volume type name or ID.') -@utils.service_type('volumev2') def do_type_access_list(cs, args): """Print access information about the given volume type.""" - volume_type = _find_volume_type(cs, args.volume_type) + volume_type = shell_utils.find_volume_type(cs, args.volume_type) if volume_type.is_public: raise exceptions.CommandError("Failed to get access list " "for public volume type.") access_list = cs.volume_type_access.list(volume_type) columns = ['Volume_type_ID', 'Project_ID'] - utils.print_list(access_list, columns) + shell_utils.print_list(access_list, columns) @utils.arg('--volume-type', metavar='', required=True, help='Volume type name or ID to add access for the given project.') @utils.arg('--project-id', metavar='', required=True, help='Project ID to add volume type access for.') -@utils.service_type('volumev2') def do_type_access_add(cs, args): """Adds volume type access for the given project.""" - vtype = _find_volume_type(cs, args.volume_type) + vtype = shell_utils.find_volume_type(cs, args.volume_type) cs.volume_type_access.add_project_access(vtype, args.project_id) @@ -865,105 +839,37 @@ def do_type_access_add(cs, args): 'for the given project.')) @utils.arg('--project-id', metavar='', required=True, help='Project ID to remove volume type access for.') -@utils.service_type('volumev2') def do_type_access_remove(cs, args): """Removes volume type access for the given project.""" - vtype = _find_volume_type(cs, args.volume_type) + vtype = shell_utils.find_volume_type(cs, args.volume_type) cs.volume_type_access.remove_project_access( vtype, args.project_id) -@utils.service_type('volumev2') -def do_endpoints(cs, args): - """Discovers endpoints registered by authentication service.""" - catalog = cs.client.service_catalog.catalog - for e in catalog['serviceCatalog']: - utils.print_dict(e['endpoints'][0], e['name']) - - -@utils.service_type('volumev2') -def do_credentials(cs, args): - """Shows user credentials returned from auth.""" - catalog = cs.client.service_catalog.catalog - utils.print_dict(catalog['user'], "User Credentials") - utils.print_dict(catalog['token'], "Token") - - -_quota_resources = ['volumes', 'snapshots', 'gigabytes', - 'backups', 'backup_gigabytes', - 'consistencygroups'] -_quota_infos = ['Type', 'In_use', 'Reserved', 'Limit'] - - -def _quota_show(quotas): - quota_dict = {} - for resource in quotas._info: - good_name = False - for name in _quota_resources: - if resource.startswith(name): - good_name = True - if not good_name: - continue - quota_dict[resource] = getattr(quotas, resource, None) - utils.print_dict(quota_dict) - - -def _quota_usage_show(quotas): - quota_list = [] - for resource in quotas._info.keys(): - good_name = False - for name in _quota_resources: - if resource.startswith(name): - good_name = True - if not good_name: - continue - quota_info = getattr(quotas, resource, None) - quota_info['Type'] = resource - quota_info = dict((k.capitalize(), v) for k, v in quota_info.items()) - quota_list.append(quota_info) - utils.print_list(quota_list, _quota_infos) - - -def _quota_update(manager, identifier, args): - updates = {} - for resource in _quota_resources: - val = getattr(args, resource, None) - if val is not None: - if args.volume_type: - resource = resource + '_%s' % args.volume_type - updates[resource] = val - - if updates: - _quota_show(manager.update(identifier, **updates)) - - @utils.arg('tenant', metavar='', help='ID of tenant for which to list quotas.') -@utils.service_type('volumev2') def do_quota_show(cs, args): """Lists quotas for a tenant.""" - _quota_show(cs.quotas.get(args.tenant)) + shell_utils.quota_show(cs.quotas.get(args.tenant)) @utils.arg('tenant', metavar='', help='ID of tenant for which to list quota usage.') -@utils.service_type('volumev2') def do_quota_usage(cs, args): """Lists quota usage for a tenant.""" - _quota_usage_show(cs.quotas.get(args.tenant, usage=True)) + shell_utils.quota_usage_show(cs.quotas.get(args.tenant, usage=True)) @utils.arg('tenant', metavar='', help='ID of tenant for which to list quota defaults.') -@utils.service_type('volumev2') def do_quota_defaults(cs, args): """Lists default quotas for a tenant.""" - _quota_show(cs.quotas.defaults(args.tenant)) + shell_utils.quota_show(cs.quotas.defaults(args.tenant)) @utils.arg('tenant', @@ -989,24 +895,22 @@ def do_quota_defaults(cs, args): metavar='', type=int, default=None, help='The new "backup_gigabytes" quota value. Default=None.') -@utils.arg('--consistencygroups', - metavar='', - type=int, default=None, - help='The new "consistencygroups" quota value. Default=None.') @utils.arg('--volume-type', metavar='', default=None, help='Volume type. Default=None.') -@utils.service_type('volumev2') +@utils.arg('--per-volume-gigabytes', + metavar='', + type=int, default=None, + help='Set max volume size limit. Default=None.') def do_quota_update(cs, args): """Updates quotas for a tenant.""" - _quota_update(cs.quotas, args.tenant, args) + shell_utils.quota_update(cs.quotas, args.tenant, args) @utils.arg('tenant', metavar='', help='UUID of tenant to delete the quotas for.') -@utils.service_type('volume') def do_quota_delete(cs, args): """Delete the quotas for a tenant.""" @@ -1016,15 +920,14 @@ def do_quota_delete(cs, args): @utils.arg('class_name', metavar='', help='Name of quota class for which to list quotas.') -@utils.service_type('volumev2') def do_quota_class_show(cs, args): """Lists quotas for a quota class.""" - _quota_show(cs.quota_classes.get(args.class_name)) + shell_utils.quota_show(cs.quota_classes.get(args.class_name)) -@utils.arg('class-name', - metavar='', +@utils.arg('class_name', + metavar='', help='Name of quota class for which to set quotas.') @utils.arg('--volumes', metavar='', @@ -1038,36 +941,50 @@ def do_quota_class_show(cs, args): metavar='', type=int, default=None, help='The new "gigabytes" quota value. Default=None.') +@utils.arg('--backups', + metavar='', + type=int, default=None, + help='The new "backups" quota value. Default=None.') +@utils.arg('--backup-gigabytes', + metavar='', + type=int, default=None, + help='The new "backup_gigabytes" quota value. Default=None.') @utils.arg('--volume-type', metavar='', default=None, help='Volume type. Default=None.') -@utils.service_type('volumev2') +@utils.arg('--per-volume-gigabytes', + metavar='', + type=int, default=None, + help='Set max volume size limit. Default=None.') def do_quota_class_update(cs, args): """Updates quotas for a quota class.""" - _quota_update(cs.quota_classes, args.class_name, args) + shell_utils.quota_update(cs.quota_classes, args.class_name, args) -@utils.service_type('volumev2') +@utils.arg('tenant', + metavar='', + nargs='?', + default=None, + help='Display information for a single tenant (Admin only).') def do_absolute_limits(cs, args): """Lists absolute limits for a user.""" - limits = cs.limits.get().absolute + limits = cs.limits.get(args.tenant).absolute columns = ['Name', 'Value'] - utils.print_list(limits, columns) + shell_utils.print_list(limits, columns) -@utils.service_type('volumev2') +@utils.arg('tenant', + metavar='', + nargs='?', + default=None, + help='Display information for a single tenant (Admin only).') def do_rate_limits(cs, args): """Lists rate limits for a user.""" - limits = cs.limits.get().rate + limits = cs.limits.get(args.tenant).rate columns = ['Verb', 'URI', 'Value', 'Remain', 'Unit', 'Next_Available'] - utils.print_list(limits, columns) - - -def _find_volume_type(cs, vtype): - """Gets a volume type by name or ID.""" - return utils.find_resource(cs.volume_types, vtype) + shell_utils.print_list(limits, columns) @utils.arg('volume', @@ -1080,7 +997,8 @@ def _find_volume_type(cs, vtype): default=False, help='Enables or disables upload of ' 'a volume that is attached to an instance. ' - 'Default=False.') + 'Default=False. ' + 'This option may not be supported by your cloud.') @utils.arg('--container-format', metavar='', default='bare', @@ -1100,18 +1018,19 @@ def _find_volume_type(cs, vtype): help='The new image name.') @utils.arg('--image_name', help=argparse.SUPPRESS) -@utils.service_type('volumev2') def do_upload_to_image(cs, args): """Uploads volume to Image Service as an image.""" volume = utils.find_volume(cs, args.volume) - _print_volume_image(volume.upload_to_image(args.force, - args.image_name, - args.container_format, - args.disk_format)) + shell_utils.print_volume_image( + volume.upload_to_image(args.force, + args.image_name, + args.container_format, + args.disk_format)) @utils.arg('volume', metavar='', help='ID of volume to migrate.') -@utils.arg('host', metavar='', help='Destination host.') +@utils.arg('host', metavar='', help='Destination host. Takes the form: ' + 'host@backend-name#pool') @utils.arg('--force-host-copy', metavar='', choices=['True', 'False'], required=False, @@ -1121,11 +1040,29 @@ def do_upload_to_image(cs, args): help='Enables or disables generic host-based ' 'force-migration, which bypasses driver ' 'optimizations. Default=False.') -@utils.service_type('volumev2') +@utils.arg('--lock-volume', metavar='', + choices=['True', 'False'], + required=False, + const=True, + nargs='?', + default=False, + help='Enables or disables the termination of volume migration ' + 'caused by other commands. This option applies to the ' + 'available volume. True means it locks the volume ' + 'state and does not allow the migration to be aborted. The ' + 'volume status will be in maintenance during the ' + 'migration. False means it allows the volume migration ' + 'to be aborted. The volume status is still in the original ' + 'status. Default=False.') def do_migrate(cs, args): """Migrates volume to a new host.""" volume = utils.find_volume(cs, args.volume) - volume.migrate_volume(args.host, args.force_host_copy) + try: + volume.migrate_volume(args.host, args.force_host_copy, + args.lock_volume) + print("Request to migrate volume %s has been accepted." % (volume.id)) + except Exception as e: + print("Migration for volume %s failed: %s." % (volume.id, e)) @utils.arg('volume', metavar='', @@ -1134,7 +1071,6 @@ def do_migrate(cs, args): @utils.arg('--migration-policy', metavar='', required=False, choices=['never', 'on-demand'], default='never', help='Migration policy during retype of volume.') -@utils.service_type('volumev2') def do_retype(cs, args): """Changes the volume type for a volume.""" volume = utils.find_volume(cs, args.volume) @@ -1161,7 +1097,19 @@ def do_retype(cs, args): action='store_true', help='Incremental backup. Default=False.', default=False) -@utils.service_type('volumev2') +@utils.arg('--force', + action='store_true', + help='Allows or disallows backup of a volume ' + 'when the volume is attached to an instance. ' + 'If set to True, backs up the volume whether ' + 'its status is "available" or "in-use". The backup ' + 'of an "in-use" volume means your data is crash ' + 'consistent. Default=False.', + default=False) +@utils.arg('--snapshot-id', + metavar='', + default=None, + help='ID of snapshot to backup. Default=None.') def do_backup_create(cs, args): """Creates a volume backup.""" if args.display_name is not None: @@ -1175,7 +1123,9 @@ def do_backup_create(cs, args): args.container, args.name, args.description, - args.incremental) + args.incremental, + args.force, + args.snapshot_id) info = {"volume_id": volume.id} info.update(backup._info) @@ -1183,79 +1133,196 @@ def do_backup_create(cs, args): if 'links' in info: info.pop('links') - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('backup', metavar='', help='Name or ID of backup.') -@utils.service_type('volumev2') def do_backup_show(cs, args): """Shows backup details.""" - backup = _find_backup(cs, args.backup) + backup = shell_utils.find_backup(cs, args.backup) info = dict() info.update(backup._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) -@utils.service_type('volumev2') +@utils.arg('--all-tenants', + metavar='', + nargs='?', + type=int, + const=1, + default=0, + help='Shows details for all tenants. Admin only.') +@utils.arg('--all_tenants', + nargs='?', + type=int, + const=1, + help=argparse.SUPPRESS) +@utils.arg('--name', + metavar='', + default=None, + help='Filters results by a name. Default=None.') +@utils.arg('--status', + metavar='', + default=None, + help='Filters results by a status. Default=None.') +@utils.arg('--volume-id', + metavar='', + default=None, + help='Filters results by a volume ID. Default=None.') +@utils.arg('--volume_id', + help=argparse.SUPPRESS) +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning backups that appear later in the backup ' + 'list than that represented by this id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of backups to return. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) def do_backup_list(cs, args): """Lists all backups.""" - backups = cs.backups.list() + + search_opts = { + 'all_tenants': args.all_tenants, + 'name': args.name, + 'status': args.status, + 'volume_id': args.volume_id, + } + + backups = cs.backups.list(search_opts=search_opts, + marker=args.marker, + limit=args.limit, + sort=args.sort) + shell_utils.translate_volume_snapshot_keys(backups) columns = ['ID', 'Volume ID', 'Status', 'Name', 'Size', 'Object Count', 'Container'] - utils.print_list(backups, columns) + if args.sort: + sortby_index = None + else: + sortby_index = 0 + shell_utils.print_list(backups, columns, sortby_index=sortby_index) -@utils.arg('backup', metavar='', - help='Name or ID of backup to delete.') -@utils.service_type('volumev2') +@utils.arg('--force', + action="store_true", + help='Allows deleting backup of a volume ' + 'when its status is other than "available" or "error". ' + 'Default=False.') +@utils.arg('backup', metavar='', nargs='+', + help='Name or ID of backup(s) to delete.') def do_backup_delete(cs, args): - """Removes a backup.""" - backup = _find_backup(cs, args.backup) - backup.delete() + """Removes one or more backups.""" + failure_count = 0 + for backup in args.backup: + try: + shell_utils.find_backup(cs, backup).delete(args.force) + print("Request to delete backup %s has been accepted." % (backup)) + except Exception as e: + failure_count += 1 + print("Delete for backup %s failed: %s" % (backup, e)) + if failure_count == len(args.backup): + raise exceptions.CommandError("Unable to delete any of the specified " + "backups.") @utils.arg('backup', metavar='', - help='ID of backup to restore.') + help='Name or ID of backup to restore.') @utils.arg('--volume-id', metavar='', default=None, help=argparse.SUPPRESS) @utils.arg('--volume', metavar='', default=None, - help='Name or ID of volume to which to restore. ' + help='Name or ID of existing volume to which to restore. ' + 'This is mutually exclusive with --name and takes priority. ' + 'Default=None.') +@utils.arg('--name', metavar='', + default=None, + help='Use the name for new volume creation to restore. ' + 'This is mutually exclusive with --volume (or the deprecated ' + '--volume-id) and --volume (or --volume-id) takes priority. ' 'Default=None.') -@utils.service_type('volumev2') def do_backup_restore(cs, args): """Restores a backup.""" vol = args.volume or args.volume_id if vol: volume_id = utils.find_volume(cs, vol).id + if args.name: + args.name = None + print('Mutually exclusive options are specified simultaneously: ' + '"--volume (or the deprecated --volume-id) and --name". ' + 'The --volume (or --volume-id) option takes priority.') else: volume_id = None - cs.restores.restore(args.backup, volume_id) + + backup = shell_utils.find_backup(cs, args.backup) + restore = cs.restores.restore(backup.id, volume_id, args.name) + + info = {"backup_id": backup.id} + info.update(restore._info) + + info.pop('links', None) + + shell_utils.print_dict(info) @utils.arg('backup', metavar='', help='ID of the backup to export.') -@utils.service_type('volumev2') def do_backup_export(cs, args): """Export backup metadata record.""" info = cs.backups.export_record(args.backup) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('backup_service', metavar='', help='Backup service to use for importing the backup.') @utils.arg('backup_url', metavar='', help='Backup URL for importing the backup metadata.') -@utils.service_type('volumev2') def do_backup_import(cs, args): """Import backup metadata record.""" info = cs.backups.import_record(args.backup_service, args.backup_url) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) + + +@utils.arg('backup', metavar='', nargs='+', + help='Name or ID of the backup to modify.') +@utils.arg('--state', metavar='', + default='available', + help='The state to assign to the backup. Valid values are ' + '"available", "error". Default=available.') +def do_backup_reset_state(cs, args): + """Explicitly updates the backup state.""" + failure_count = 0 + + single = (len(args.backup) == 1) + + for backup in args.backup: + try: + shell_utils.find_backup(cs, backup).reset_state(args.state) + print("Request to update backup '%s' has been accepted." % backup) + except Exception as e: + failure_count += 1 + msg = "Reset state for backup %s failed: %s" % (backup, e) + if not single: + print(msg) + + if failure_count == len(args.backup): + if not single: + msg = ("Unable to reset the state for any of the specified " + "backups.") + raise exceptions.CommandError(msg) @utils.arg('volume', metavar='', @@ -1266,7 +1333,6 @@ def do_backup_import(cs, args): help='Transfer name. Default=None.') @utils.arg('--display-name', help=argparse.SUPPRESS) -@utils.service_type('volumev2') def do_transfer_create(cs, args): """Creates a volume transfer.""" if args.display_name is not None: @@ -1279,23 +1345,30 @@ def do_transfer_create(cs, args): info.update(transfer._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) -@utils.arg('transfer', metavar='', +@utils.arg('transfer', metavar='', nargs='+', help='Name or ID of transfer to delete.') -@utils.service_type('volumev2') def do_transfer_delete(cs, args): """Undoes a transfer.""" - transfer = _find_transfer(cs, args.transfer) - transfer.delete() + failure_count = 0 + for t in args.transfer: + try: + transfer = shell_utils.find_transfer(cs, t) + transfer.delete() + except Exception as e: + failure_count += 1 + print("Delete for volume transfer %s failed: %s" % (t, e)) + if failure_count == len(args.transfer): + raise exceptions.CommandError("Unable to delete any of the specified " + "volume transfers.") @utils.arg('transfer', metavar='', help='ID of transfer to accept.') @utils.arg('auth_key', metavar='', help='Authentication key of transfer to accept.') -@utils.service_type('volumev2') def do_transfer_accept(cs, args): """Accepts a volume transfer.""" transfer = cs.transfers.accept(args.transfer, args.auth_key) @@ -1303,7 +1376,7 @@ def do_transfer_accept(cs, args): info.update(transfer._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('--all-tenants', @@ -1319,7 +1392,6 @@ def do_transfer_accept(cs, args): type=int, const=1, help=argparse.SUPPRESS) -@utils.service_type('volumev2') def do_transfer_list(cs, args): """Lists all transfers.""" all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) @@ -1328,20 +1400,19 @@ def do_transfer_list(cs, args): } transfers = cs.transfers.list(search_opts=search_opts) columns = ['ID', 'Volume ID', 'Name'] - utils.print_list(transfers, columns) + shell_utils.print_list(transfers, columns) @utils.arg('transfer', metavar='', help='Name or ID of transfer to accept.') -@utils.service_type('volumev2') def do_transfer_show(cs, args): """Shows transfer details.""" - transfer = _find_transfer(cs, args.transfer) + transfer = shell_utils.find_transfer(cs, args.transfer) info = dict() info.update(transfer._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('volume', metavar='', @@ -1349,8 +1420,7 @@ def do_transfer_show(cs, args): @utils.arg('new_size', metavar='', type=int, - help='New size of volume, in GBs.') -@utils.service_type('volumev2') + help='New size of volume, in GiBs.') def do_extend(cs, args): """Attempts to extend size of an existing volume.""" volume = utils.find_volume(cs, args.volume) @@ -1361,33 +1431,41 @@ def do_extend(cs, args): help='Host name. Default=None.') @utils.arg('--binary', metavar='', default=None, help='Service binary. Default=None.') -@utils.service_type('volumev2') +@utils.arg('--withreplication', + metavar='', + const=True, + nargs='?', + default=False, + help='Enables or disables display of ' + 'Replication info for c-vol services. Default=False.') def do_service_list(cs, args): """Lists all services. Filter by host and service binary.""" + replication = strutils.bool_from_string(args.withreplication, + strict=True) result = cs.services.list(host=args.host, binary=args.binary) columns = ["Binary", "Host", "Zone", "Status", "State", "Updated_at"] + if replication: + columns.extend(["Replication Status", "Active Backend ID", "Frozen"]) # NOTE(jay-lau-513): we check if the response has disabled_reason # so as not to add the column when the extended ext is not enabled. if result and hasattr(result[0], 'disabled_reason'): columns.append("Disabled Reason") - utils.print_list(result, columns) + shell_utils.print_list(result, columns) @utils.arg('host', metavar='', help='Host name.') @utils.arg('binary', metavar='', help='Service binary.') -@utils.service_type('volumev2') def do_service_enable(cs, args): """Enables the service.""" result = cs.services.enable(args.host, args.binary) columns = ["Host", "Binary", "Status"] - utils.print_list([result], columns) + shell_utils.print_list([result], columns) @utils.arg('host', metavar='', help='Host name.') @utils.arg('binary', metavar='', help='Service binary.') @utils.arg('--reason', metavar='', help='Reason for disabling service.') -@utils.service_type('volumev2') def do_service_disable(cs, args): """Disables the service.""" columns = ["Host", "Binary", "Status"] @@ -1397,10 +1475,10 @@ def do_service_disable(cs, args): args.reason) else: result = cs.services.disable(args.host, args.binary) - utils.print_list([result], columns) + shell_utils.print_list([result], columns) -def _treeizeAvailabilityZone(zone): +def treeizeAvailabilityZone(zone): """Builds a tree view for availability zones.""" AvailabilityZone = availability_zones.AvailabilityZone @@ -1442,40 +1520,27 @@ def _treeizeAvailabilityZone(zone): return result -@utils.service_type('volumev2') def do_availability_zone_list(cs, _args): """Lists all availability zones.""" try: availability_zones = cs.availability_zones.list() - except exceptions.Forbidden as e: # policy doesn't allow probably + except exceptions.Forbidden: # policy doesn't allow probably try: availability_zones = cs.availability_zones.list(detailed=False) except Exception: - raise e + raise result = [] for zone in availability_zones: - result += _treeizeAvailabilityZone(zone) - _translate_availability_zone_keys(result) - utils.print_list(result, ['Name', 'Status']) - - -def _print_volume_encryption_type_list(encryption_types): - """ - Lists 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']) + result += treeizeAvailabilityZone(zone) + shell_utils.translate_availability_zone_keys(result) + shell_utils.print_list(result, ['Name', 'Status']) -@utils.service_type('volumev2') def do_encryption_type_list(cs, args): """Shows encryption type details for volume types. Admin only.""" result = cs.volume_encryption_types.list() - utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher', + shell_utils.print_list(result, ['Volume Type ID', 'Provider', 'Cipher', 'Key Size', 'Control Location']) @@ -1483,18 +1548,17 @@ def do_encryption_type_list(cs, args): metavar='', type=str, help='Name or ID of volume type.') -@utils.service_type('volumev2') def do_encryption_type_show(cs, args): """Shows encryption type details for a volume type. Admin only.""" - volume_type = _find_volume_type(cs, args.volume_type) + volume_type = shell_utils.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]) + shell_utils.print_volume_encryption_type_list([result]) else: - _print_volume_encryption_type_list([]) + shell_utils.print_volume_encryption_type_list([]) @utils.arg('volume_type', @@ -1504,8 +1568,8 @@ def do_encryption_type_show(cs, args): @utils.arg('provider', metavar='', type=str, - help='The class that provides encryption support. ' - 'For example, LuksEncryptor.') + help='The encryption provider format. ' + 'For example, "luks" or "plain".') @utils.arg('--cipher', metavar='', type=str, @@ -1513,64 +1577,110 @@ def do_encryption_type_show(cs, args): default=None, help='The encryption algorithm or mode. ' 'For example, aes-xts-plain64. Default=None.') -@utils.arg('--key_size', +@utils.arg('--key-size', metavar='', type=int, required=False, default=None, help='Size of encryption key, in bits. ' 'For example, 128 or 256. Default=None.') -@utils.arg('--control_location', +@utils.arg('--key_size', + type=int, + required=False, + default=None, + help=argparse.SUPPRESS) +@utils.arg('--control-location', metavar='', choices=['front-end', 'back-end'], type=str, required=False, default='front-end', help='Notional service where encryption is performed. ' - 'Valid values are "front-end" or "back-end." ' - 'For example, front-end=Nova. Default is "front-end."') -@utils.service_type('volumev2') + 'Valid values are "front-end" or "back-end". ' + 'For example, front-end=Nova. Default is "front-end".') +@utils.arg('--control_location', + type=str, + required=False, + default='front-end', + help=argparse.SUPPRESS) def do_encryption_type_create(cs, args): """Creates encryption type for a volume type. Admin only.""" - volume_type = _find_volume_type(cs, args.volume_type) + volume_type = shell_utils.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 + body = { + 'provider': args.provider, + 'cipher': args.cipher, + 'key_size': args.key_size, + 'control_location': args.control_location + } result = cs.volume_encryption_types.create(volume_type, body) - _print_volume_encryption_type_list([result]) + shell_utils.print_volume_encryption_type_list([result]) + + +@utils.arg('volume_type', + metavar='', + type=str, + help="Name or ID of the volume type") +@utils.arg('--provider', + metavar='', + type=str, + required=False, + default=argparse.SUPPRESS, + help="Encryption provider format (e.g. 'luks' or 'plain').") +@utils.arg('--cipher', + metavar='', + type=str, + nargs='?', + required=False, + default=argparse.SUPPRESS, + const=None, + help="Encryption algorithm/mode to use (e.g., aes-xts-plain64). " + "Provide parameter without value to set to provider default.") +@utils.arg('--key-size', + dest='key_size', + metavar='', + type=int, + nargs='?', + required=False, + default=argparse.SUPPRESS, + const=None, + help="Size of the encryption key, in bits (e.g., 128, 256). " + "Provide parameter without value to set to provider default. ") +@utils.arg('--control-location', + dest='control_location', + metavar='', + choices=['front-end', 'back-end'], + type=str, + required=False, + default=argparse.SUPPRESS, + help="Notional service where encryption is performed (e.g., " + "front-end=Nova). Values: 'front-end', 'back-end'") +def do_encryption_type_update(cs, args): + """Update encryption type information for a volume type (Admin Only).""" + volume_type = shell_utils.find_volume_type(cs, args.volume_type) + + # An argument should only be pulled if the user specified the parameter. + body = {} + for attr in ['provider', 'cipher', 'key_size', 'control_location']: + if hasattr(args, attr): + body[attr] = getattr(args, attr) + + cs.volume_encryption_types.update(volume_type, body) + result = cs.volume_encryption_types.get(volume_type) + shell_utils.print_volume_encryption_type_list([result]) @utils.arg('volume_type', metavar='', type=str, help='Name or ID of volume type.') -@utils.service_type('volumev2') def do_encryption_type_delete(cs, args): """Deletes encryption type for a volume type. Admin only.""" - volume_type = _find_volume_type(cs, args.volume_type) + volume_type = shell_utils.find_volume_type(cs, args.volume_type) cs.volume_encryption_types.delete(volume_type) -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 new QoS specifications.') @@ -1579,30 +1689,27 @@ def _print_associations_list(associations): nargs='+', default=[], help='QoS specifications.') -@utils.service_type('volumev2') def do_qos_create(cs, args): """Creates a qos specs.""" keypair = None if args.metadata is not None: - keypair = _extract_metadata(args) + keypair = shell_utils.extract_metadata(args) qos_specs = cs.qos_specs.create(args.name, keypair) - _print_qos_specs(qos_specs) + shell_utils.print_qos_specs(qos_specs) -@utils.service_type('volumev2') def do_qos_list(cs, args): """Lists qos specs.""" qos_specs = cs.qos_specs.list() - _print_qos_specs_list(qos_specs) + shell_utils.print_qos_specs_list(qos_specs) @utils.arg('qos_specs', metavar='', help='ID of QoS specifications to show.') -@utils.service_type('volumev2') def do_qos_show(cs, args): """Shows qos specs details.""" - qos_specs = _find_qos_specs(cs, args.qos_specs) - _print_qos_specs(qos_specs) + qos_specs = shell_utils.find_qos_specs(cs, args.qos_specs) + shell_utils.print_qos_specs(qos_specs) @utils.arg('qos_specs', metavar='', @@ -1614,11 +1721,11 @@ def do_qos_show(cs, args): default=False, help='Enables or disables deletion of in-use ' 'QoS specifications. Default=False.') -@utils.service_type('volumev2') def do_qos_delete(cs, args): """Deletes a specified qos specs.""" - force = strutils.bool_from_string(args.force) - qos_specs = _find_qos_specs(cs, args.qos_specs) + force = strutils.bool_from_string(args.force, + strict=True) + qos_specs = shell_utils.find_qos_specs(cs, args.qos_specs) cs.qos_specs.delete(qos_specs, force) @@ -1627,7 +1734,6 @@ def do_qos_delete(cs, args): @utils.arg('vol_type_id', metavar='', help='ID of volume type with which to associate ' 'QoS specifications.') -@utils.service_type('volumev2') def do_qos_associate(cs, args): """Associates qos specs with specified volume type.""" cs.qos_specs.associate(args.qos_specs, args.vol_type_id) @@ -1638,7 +1744,6 @@ def do_qos_associate(cs, args): @utils.arg('vol_type_id', metavar='', help='ID of volume type with which to associate ' 'QoS specifications.') -@utils.service_type('volumev2') def do_qos_disassociate(cs, args): """Disassociates qos specs from specified volume type.""" cs.qos_specs.disassociate(args.qos_specs, args.vol_type_id) @@ -1646,7 +1751,6 @@ def do_qos_disassociate(cs, args): @utils.arg('qos_specs', metavar='', help='ID of QoS specifications on which to operate.') -@utils.service_type('volumev2') def do_qos_disassociate_all(cs, args): """Disassociates qos specs from all its associations.""" cs.qos_specs.disassociate_all(args.qos_specs) @@ -1665,7 +1769,7 @@ def do_qos_disassociate_all(cs, args): 'For unset, specify only the key.') def do_qos_key(cs, args): """Sets or unsets specifications for a qos spec.""" - keypair = _extract_metadata(args) + keypair = shell_utils.extract_metadata(args) if args.action == 'set': cs.qos_specs.set_keys(args.qos_specs, keypair) @@ -1675,11 +1779,10 @@ def do_qos_key(cs, args): @utils.arg('qos_specs', metavar='', help='ID of QoS specifications.') -@utils.service_type('volumev2') def do_qos_get_association(cs, args): """Lists all associations for specified qos specs.""" associations = cs.qos_specs.get_associations(args.qos_specs) - _print_associations_list(associations) + shell_utils.print_associations_list(associations) @utils.arg('snapshot', @@ -1695,35 +1798,41 @@ def do_qos_get_association(cs, args): default=[], help='Metadata key and value pair to set or unset. ' 'For unset, specify only the key.') -@utils.service_type('volumev2') def do_snapshot_metadata(cs, args): """Sets or deletes snapshot metadata.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - metadata = _extract_metadata(args) + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + metadata = shell_utils.extract_metadata(args) if args.action == 'set': metadata = snapshot.set_metadata(metadata) - utils.print_dict(metadata._info) + shell_utils.print_dict(metadata._info) elif args.action == 'unset': snapshot.delete_metadata(list(metadata.keys())) @utils.arg('snapshot', metavar='', help='ID of snapshot.') -@utils.service_type('volumev2') def do_snapshot_metadata_show(cs, args): """Shows snapshot metadata.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - utils.print_dict(snapshot._info['metadata'], 'Metadata-property') + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + shell_utils.print_dict(snapshot._info['metadata'], 'Metadata-property') @utils.arg('volume', metavar='', help='ID of volume.') -@utils.service_type('volumev2') def do_metadata_show(cs, args): """Shows volume metadata.""" volume = utils.find_volume(cs, args.volume) - utils.print_dict(volume._info['metadata'], 'Metadata-property') + shell_utils.print_dict(volume._info['metadata'], 'Metadata-property') + + +@utils.arg('volume', metavar='', + help='ID of volume.') +def do_image_metadata_show(cs, args): + """Shows volume image metadata.""" + volume = utils.find_volume(cs, args.volume) + resp, body = volume.show_image_metadata(volume) + shell_utils.print_dict(body['metadata'], 'Metadata-property') @utils.arg('volume', @@ -1734,13 +1843,12 @@ def do_metadata_show(cs, args): nargs='+', default=[], help='Metadata key and value pair or pairs to update.') -@utils.service_type('volumev2') def do_metadata_update_all(cs, args): """Updates volume metadata.""" volume = utils.find_volume(cs, args.volume) - metadata = _extract_metadata(args) + metadata = shell_utils.extract_metadata(args) metadata = volume.update_all_metadata(metadata) - utils.print_dict(metadata['metadata'], 'Metadata-property') + shell_utils.print_dict(metadata['metadata'], 'Metadata-property') @utils.arg('snapshot', @@ -1751,13 +1859,12 @@ def do_metadata_update_all(cs, args): nargs='+', default=[], help='Metadata key and value pair to update.') -@utils.service_type('volumev2') def do_snapshot_metadata_update_all(cs, args): """Updates snapshot metadata.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - metadata = _extract_metadata(args) + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + metadata = shell_utils.extract_metadata(args) metadata = snapshot.update_all_metadata(metadata) - utils.print_dict(metadata) + shell_utils.print_dict(metadata) @utils.arg('volume', metavar='', help='ID of volume to update.') @@ -1766,12 +1873,12 @@ def do_snapshot_metadata_update_all(cs, args): choices=['True', 'true', 'False', 'false'], help='Enables or disables update of volume to ' 'read-only access mode.') -@utils.service_type('volumev2') def do_readonly_mode_update(cs, args): """Updates volume read-only access-mode flag.""" volume = utils.find_volume(cs, args.volume) cs.volumes.update_readonly_flag(volume, - strutils.bool_from_string(args.read_only)) + strutils.bool_from_string(args.read_only, + strict=True)) @utils.arg('volume', metavar='', help='ID of the volume to update.') @@ -1779,12 +1886,12 @@ def do_readonly_mode_update(cs, args): metavar='', choices=['True', 'true', 'False', 'false'], help='Flag to indicate whether volume is bootable.') -@utils.service_type('volumev2') def do_set_bootable(cs, args): """Update bootable status of a volume.""" volume = utils.find_volume(cs, args.volume) cs.volumes.set_bootable(volume, - strutils.bool_from_string(args.bootable)) + strutils.bool_from_string(args.bootable, + strict=True)) @utils.arg('host', @@ -1812,7 +1919,6 @@ def do_set_bootable(cs, args): metavar='', help='Availability zone for volume (Default=None)') @utils.arg('--metadata', - type=str, nargs='*', metavar='', help='Metadata key=value pairs (Default=None)') @@ -1820,16 +1926,14 @@ def do_set_bootable(cs, args): action='store_true', help='Specifies that the newly created volume should be' ' marked as bootable') -@utils.service_type('volumev2') def do_manage(cs, args): """Manage an existing volume.""" volume_metadata = None if args.metadata is not None: - volume_metadata = _extract_metadata(args) + volume_metadata = shell_utils.extract_metadata(args) # Build a dictionary of key/value pairs to pass to the API. - ref_dict = {} - ref_dict[args.id_type] = args.identifier + ref_dict = {args.id_type: args.identifier} # The recommended way to specify an existing volume is by ID or name, and # have the Cinder driver look for 'source-name' or 'source-id' elements in @@ -1841,11 +1945,9 @@ def do_manage(cs, args): # dictionary so that it is consistent with what the user specified on the # CLI. - if hasattr(args, 'source_name') and \ - args.source_name is not None: + if hasattr(args, 'source_name') and args.source_name is not None: ref_dict['source-name'] = args.source_name - if hasattr(args, 'source_id') and \ - args.source_id is not None: + if hasattr(args, 'source_id') and args.source_id is not None: ref_dict['source-id'] = args.source_id volume = cs.volumes.manage(host=args.host, @@ -1861,36 +1963,17 @@ def do_manage(cs, args): volume = cs.volumes.get(volume.id) info.update(volume._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('volume', metavar='', help='Name or ID of the volume to unmanage.') -@utils.service_type('volumev2') def do_unmanage(cs, args): """Stop managing a volume.""" volume = utils.find_volume(cs, args.volume) cs.volumes.unmanage(volume.id) -@utils.arg('volume', metavar='', - help='Name or ID of the volume to promote.') -@utils.service_type('volumev2') -def do_replication_promote(cs, args): - """Promote a secondary volume to primary for a relationship.""" - volume = utils.find_volume(cs, args.volume) - cs.volumes.promote(volume.id) - - -@utils.arg('volume', metavar='', - help='Name or ID of the volume to reenable replication.') -@utils.service_type('volumev2') -def do_replication_reenable(cs, args): - """Sync the secondary volume with primary for a relationship.""" - volume = utils.find_volume(cs, args.volume) - cs.volumes.reenable(volume.id) - - @utils.arg('--all-tenants', dest='all_tenants', metavar='<0|1>', @@ -1899,27 +1982,28 @@ def do_replication_reenable(cs, args): const=1, default=0, help='Shows details for all tenants. Admin only.') -@utils.service_type('volumev2') def do_consisgroup_list(cs, args): - """Lists all consistencygroups.""" - consistencygroups = cs.consistencygroups.list() + """Lists all consistency groups.""" + search_opts = {'all_tenants': args.all_tenants} + + consistencygroups = cs.consistencygroups.list(search_opts=search_opts) columns = ['ID', 'Status', 'Name'] - utils.print_list(consistencygroups, columns) + shell_utils.print_list(consistencygroups, columns) @utils.arg('consistencygroup', metavar='', help='Name or ID of a consistency group.') -@utils.service_type('volumev2') def do_consisgroup_show(cs, args): """Shows details of a consistency group.""" info = dict() - consistencygroup = _find_consistencygroup(cs, args.consistencygroup) + consistencygroup = shell_utils.find_consistencygroup(cs, + args.consistencygroup) info.update(consistencygroup._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('volumetypes', @@ -1936,7 +2020,6 @@ def do_consisgroup_show(cs, args): metavar='', default=None, help='Availability zone for volume. Default=None.') -@utils.service_type('volumev2') def do_consisgroup_create(cs, args): """Creates a consistency group.""" @@ -1951,33 +2034,45 @@ def do_consisgroup_create(cs, args): info.update(consistencygroup._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('--cgsnapshot', metavar='', help='Name or ID of a cgsnapshot. Default=None.') +@utils.arg('--source-cg', + metavar='', + help='Name or ID of a source CG. Default=None.') @utils.arg('--name', metavar='', help='Name of a consistency group. Default=None.') @utils.arg('--description', metavar='', help='Description of a consistency group. Default=None.') -@utils.service_type('volumev2') def do_consisgroup_create_from_src(cs, args): - """Creates a consistency group from a cgsnapshot.""" - if not args.cgsnapshot: - msg = ('Cannot create consistency group because the source ' - 'cgsnapshot is not provided.') - raise exceptions.BadRequest(code=400, message=msg) - cgsnapshot = _find_cgsnapshot(cs, args.cgsnapshot) + """Creates a consistency group from a cgsnapshot or a source CG.""" + if not args.cgsnapshot and not args.source_cg: + msg = ('Cannot create consistency group because neither ' + 'cgsnapshot nor source CG is provided.') + raise exceptions.ClientException(code=1, message=msg) + if args.cgsnapshot and args.source_cg: + msg = ('Cannot create consistency group because both ' + 'cgsnapshot and source CG are provided.') + raise exceptions.ClientException(code=1, message=msg) + cgsnapshot = None + if args.cgsnapshot: + cgsnapshot = shell_utils.find_cgsnapshot(cs, args.cgsnapshot) + source_cg = None + if args.source_cg: + source_cg = shell_utils.find_consistencygroup(cs, args.source_cg) info = cs.consistencygroups.create_from_src( - cgsnapshot.id, + cgsnapshot.id if cgsnapshot else None, + source_cg.id if source_cg else None, args.name, args.description) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('consistencygroup', @@ -1992,13 +2087,13 @@ def do_consisgroup_create_from_src(cs, args): 'it can be deleted without the force flag. ' 'If the consistency group is not empty, the force ' 'flag is required for it to be deleted.') -@utils.service_type('volumev2') def do_consisgroup_delete(cs, args): """Removes one or more consistency groups.""" failure_count = 0 for consistencygroup in args.consistencygroup: try: - _find_consistencygroup(cs, consistencygroup).delete(args.force) + shell_utils.find_consistencygroup( + cs, consistencygroup).delete(args.force) except Exception as e: failure_count += 1 print("Delete for consistency group %s failed: %s" % @@ -2025,9 +2120,8 @@ def do_consisgroup_delete(cs, args): help='UUID of one or more volumes ' 'to be removed from the consistency group, ' 'separated by commas. Default=None.') -@utils.service_type('volumev2') def do_consisgroup_update(cs, args): - """Updates a consistencygroup.""" + """Updates a consistency group.""" kwargs = {} if args.name is not None: @@ -2045,9 +2139,12 @@ def do_consisgroup_update(cs, args): if not kwargs: msg = ('At least one of the following args must be supplied: ' 'name, description, add-volumes, remove-volumes.') - raise exceptions.BadRequest(code=400, message=msg) + raise exceptions.ClientException(code=1, message=msg) - _find_consistencygroup(cs, args.consistencygroup).update(**kwargs) + shell_utils.find_consistencygroup( + cs, args.consistencygroup).update(**kwargs) + print("Request to update consistency group '%s' has been accepted." % ( + args.consistencygroup)) @utils.arg('--all-tenants', @@ -2066,10 +2163,8 @@ def do_consisgroup_update(cs, args): metavar='', default=None, help='Filters results by a consistency group ID. Default=None.') -@utils.service_type('volumev2') def do_cgsnapshot_list(cs, args): """Lists all cgsnapshots.""" - cgsnapshots = cs.cgsnapshots.list() all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) @@ -2082,21 +2177,20 @@ def do_cgsnapshot_list(cs, args): cgsnapshots = cs.cgsnapshots.list(search_opts=search_opts) columns = ['ID', 'Status', 'Name'] - utils.print_list(cgsnapshots, columns) + shell_utils.print_list(cgsnapshots, columns) @utils.arg('cgsnapshot', metavar='', help='Name or ID of cgsnapshot.') -@utils.service_type('volumev2') def do_cgsnapshot_show(cs, args): """Shows cgsnapshot details.""" info = dict() - cgsnapshot = _find_cgsnapshot(cs, args.cgsnapshot) + cgsnapshot = shell_utils.find_cgsnapshot(cs, args.cgsnapshot) info.update(cgsnapshot._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('consistencygroup', @@ -2110,10 +2204,10 @@ def do_cgsnapshot_show(cs, args): metavar='', default=None, help='Cgsnapshot description. Default=None.') -@utils.service_type('volumev2') def do_cgsnapshot_create(cs, args): """Creates a cgsnapshot.""" - consistencygroup = _find_consistencygroup(cs, args.consistencygroup) + consistencygroup = shell_utils.find_consistencygroup(cs, + args.consistencygroup) cgsnapshot = cs.cgsnapshots.create( consistencygroup.id, args.name, @@ -2124,19 +2218,18 @@ def do_cgsnapshot_create(cs, args): info.update(cgsnapshot._info) info.pop('links', None) - utils.print_dict(info) + shell_utils.print_dict(info) @utils.arg('cgsnapshot', metavar='', nargs='+', help='Name or ID of one or more cgsnapshots to be deleted.') -@utils.service_type('volumev2') def do_cgsnapshot_delete(cs, args): """Removes one or more cgsnapshots.""" failure_count = 0 for cgsnapshot in args.cgsnapshot: try: - _find_cgsnapshot(cs, cgsnapshot).delete() + shell_utils.find_cgsnapshot(cs, cgsnapshot).delete() except Exception as e: failure_count += 1 print("Delete for cgsnapshot %s failed: %s" % (cgsnapshot, e)) @@ -2148,7 +2241,6 @@ def do_cgsnapshot_delete(cs, args): @utils.arg('--detail', action='store_true', help='Show detailed information about pools.') -@utils.service_type('volumev2') def do_get_pools(cs, args): """Show pool information for backends. Admin only.""" pools = cs.volumes.get_pools(args.detail) @@ -2160,4 +2252,185 @@ def do_get_pools(cs, args): backend['name'] = info['name'] if args.detail: backend.update(info['capabilities']) - utils.print_dict(backend) + shell_utils.print_dict(backend) + + +@utils.arg('host', + metavar='', + help='Cinder host to show backend volume stats and properties; ' + 'takes the form: host@backend-name') +def do_get_capabilities(cs, args): + """Show backend volume stats and properties. Admin only.""" + + capabilities = cs.capabilities.get(args.host) + infos = dict() + infos.update(capabilities._info) + + prop = infos.pop('properties', None) + shell_utils.print_dict(infos, "Volume stats") + shell_utils.print_dict(prop, "Backend properties", + formatters=sorted(prop.keys())) + + +@utils.arg('volume', + metavar='', + help='Cinder volume that already exists in the volume backend.') +@utils.arg('identifier', + metavar='', + help='Name or other identifier for existing snapshot. This is ' + 'backend specific.') +@utils.arg('--id-type', + metavar='', + default='source-name', + help='Type of backend device identifier provided, ' + 'typically source-name or source-id (Default=source-name).') +@utils.arg('--name', + metavar='', + help='Snapshot name (Default=None).') +@utils.arg('--description', + metavar='', + help='Snapshot description (Default=None).') +@utils.arg('--metadata', + nargs='*', + metavar='', + help='Metadata key=value pairs (Default=None).') +def do_snapshot_manage(cs, args): + """Manage an existing snapshot.""" + snapshot_metadata = None + if args.metadata is not None: + snapshot_metadata = shell_utils.extract_metadata(args) + + # Build a dictionary of key/value pairs to pass to the API. + ref_dict = {args.id_type: args.identifier} + + if hasattr(args, 'source_name') and args.source_name is not None: + ref_dict['source-name'] = args.source_name + if hasattr(args, 'source_id') and args.source_id is not None: + ref_dict['source-id'] = args.source_id + + volume = utils.find_volume(cs, args.volume) + snapshot = cs.volume_snapshots.manage(volume_id=volume.id, + ref=ref_dict, + name=args.name, + description=args.description, + metadata=snapshot_metadata) + + info = {} + snapshot = cs.volume_snapshots.get(snapshot.id) + info.update(snapshot._info) + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('snapshot', metavar='', + help='Name or ID of the snapshot to unmanage.') +def do_snapshot_unmanage(cs, args): + """Stop managing a snapshot.""" + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + cs.volume_snapshots.unmanage(snapshot.id) + + +@utils.arg('host', metavar='', help='Host name.') +def do_freeze_host(cs, args): + """Freeze and disable the specified cinder-volume host.""" + cs.services.freeze_host(args.host) + + +@utils.arg('host', metavar='', help='Host name.') +def do_thaw_host(cs, args): + """Thaw and enable the specified cinder-volume host.""" + cs.services.thaw_host(args.host) + + +@utils.arg('host', metavar='', help='Host name.') +@utils.arg('--backend_id', + metavar='', + help='ID of backend to failover to (Default=None)') +def do_failover_host(cs, args): + """Failover a replicating cinder-volume host.""" + cs.services.failover_host(args.host, args.backend_id) + + +@utils.arg('host', + metavar='', + help='Cinder host on which to list manageable volumes; ' + 'takes the form: host@backend-name#pool') +@utils.arg('--detailed', + metavar='', + default=True, + help='Returned detailed information (default true).') +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning volumes that appear later in the volume ' + 'list than that represented by this volume id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of volumes to return. Default=None.') +@utils.arg('--offset', + metavar='', + default=None, + help='Number of volumes to skip after marker. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) +def do_manageable_list(cs, args): + """Lists all manageable volumes.""" + detailed = strutils.bool_from_string(args.detailed) + volumes = cs.volumes.list_manageable(host=args.host, detailed=detailed, + marker=args.marker, limit=args.limit, + offset=args.offset, sort=args.sort) + columns = ['reference', 'size', 'safe_to_manage'] + if detailed: + columns.extend(['reason_not_safe', 'cinder_id', 'extra_info']) + shell_utils.print_list(volumes, columns, sortby_index=None) + + +@utils.arg('host', + metavar='', + help='Cinder host on which to list manageable snapshots; ' + 'takes the form: host@backend-name#pool') +@utils.arg('--detailed', + metavar='', + default=True, + help='Returned detailed information (default true).') +@utils.arg('--marker', + metavar='', + default=None, + help='Begin returning snapshots that appear later in the snapshot ' + 'list than that represented by this snapshot id. ' + 'Default=None.') +@utils.arg('--limit', + metavar='', + default=None, + help='Maximum number of snapshots to return. Default=None.') +@utils.arg('--offset', + metavar='', + default=None, + help='Number of snapshots to skip after marker. Default=None.') +@utils.arg('--sort', + metavar='[:]', + default=None, + help=(('Comma-separated list of sort keys and directions in the ' + 'form of [:]. ' + 'Valid keys: %s. ' + 'Default=None.') % ', '.join(base.SORT_KEY_VALUES))) +def do_snapshot_manageable_list(cs, args): + """Lists all manageable snapshots.""" + detailed = strutils.bool_from_string(args.detailed) + snapshots = cs.volume_snapshots.list_manageable(host=args.host, + detailed=detailed, + marker=args.marker, + limit=args.limit, + offset=args.offset, + sort=args.sort) + columns = ['reference', 'size', 'safe_to_manage', 'source_reference'] + if detailed: + columns.extend(['reason_not_safe', 'cinder_id', 'extra_info']) + shell_utils.print_list(snapshots, columns, sortby_index=None) diff --git a/cinderclient/v3/volume_backups.py b/cinderclient/v3/volume_backups.py new file mode 100644 index 000000000..61069c8e5 --- /dev/null +++ b/cinderclient/v3/volume_backups.py @@ -0,0 +1,211 @@ +# Copyright (C) 2013 Hewlett-Packard Development Company, L.P. +# 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 Backups interface (v3 extension). +""" + +from cinderclient import api_versions +from cinderclient.apiclient import base as common_base +from cinderclient import base + + +class VolumeBackup(base.Resource): + """A volume backup is a block level backup of a volume.""" + + def __repr__(self): + return "" % self.id + + def delete(self, force=False): + """Delete this volume backup.""" + return self.manager.delete(self, force) + + def reset_state(self, state): + return self.manager.reset_state(self, state) + + def update(self, **kwargs): + """Update the name or description for this backup.""" + return self.manager.update(self, **kwargs) + + +class VolumeBackupManager(base.ManagerWithFind): + """Manage :class:`VolumeBackup` resources.""" + resource_class = VolumeBackup + + @api_versions.wraps("3.9") + def update(self, backup, **kwargs): + """Update the name or description for a backup. + + :param backup: The :class:`Backup` to update. + """ + if not kwargs: + return + + body = {"backup": kwargs} + + return self._update("/backups/%s" % base.getid(backup), body) + + @api_versions.wraps("3.0") + def create(self, volume_id, container=None, + name=None, description=None, + incremental=False, force=False, + snapshot_id=None): + """Creates a volume backup. + + :param volume_id: The ID of the volume to backup. + :param container: The name of the backup service container. + :param name: The name of the backup. + :param description: The description of the backup. + :param incremental: Incremental backup. + :param force: If True, allows an in-use volume to be backed up. + :param snapshot_id: The ID of the snapshot to backup. This should + be a snapshot of the src volume, when specified, + the new backup will be based on the snapshot. + :rtype: :class:`VolumeBackup` + """ + return self._create_backup(volume_id, container, name, description, + incremental, force, snapshot_id) + + @api_versions.wraps("3.43") + def create(self, volume_id, container=None, # noqa: F811 + name=None, description=None, + incremental=False, force=False, + snapshot_id=None, + metadata=None): + """Creates a volume backup. + + :param volume_id: The ID of the volume to backup. + :param container: The name of the backup service container. + :param name: The name of the backup. + :param description: The description of the backup. + :param incremental: Incremental backup. + :param force: If True, allows an in-use volume to be backed up. + :param metadata: Key Value pairs + :param snapshot_id: The ID of the snapshot to backup. This should + be a snapshot of the src volume, when specified, + the new backup will be based on the snapshot. + :rtype: :class:`VolumeBackup` + """ + # pylint: disable=function-redefined + return self._create_backup(volume_id, container, name, description, + incremental, force, snapshot_id, metadata) + + @api_versions.wraps("3.51") + def create(self, volume_id, container=None, name=None, # noqa: F811 + description=None, incremental=False, force=False, + snapshot_id=None, metadata=None, availability_zone=None): + return self._create_backup(volume_id, container, name, description, + incremental, force, snapshot_id, metadata, + availability_zone) + + def _create_backup(self, volume_id, container=None, name=None, + description=None, incremental=False, force=False, + snapshot_id=None, metadata=None, + availability_zone=None): + """Creates a volume backup. + + :param volume_id: The ID of the volume to backup. + :param container: The name of the backup service container. + :param name: The name of the backup. + :param description: The description of the backup. + :param incremental: Incremental backup. + :param force: If True, allows an in-use volume to be backed up. + :param metadata: Key Value pairs + :param snapshot_id: The ID of the snapshot to backup. This should + be a snapshot of the src volume, when specified, + the new backup will be based on the snapshot. + :param availability_zone: The AZ where we want the backup stored. + :rtype: :class:`VolumeBackup` + """ + # pylint: disable=function-redefined + body = {'backup': {'volume_id': volume_id, + 'container': container, + 'name': name, + 'description': description, + 'incremental': incremental, + 'force': force, + 'snapshot_id': snapshot_id, }} + if metadata: + body['backup']['metadata'] = metadata + if availability_zone: + body['backup']['availability_zone'] = availability_zone + return self._create('/backups', body, 'backup') + + def get(self, backup_id): + """Show volume backup details. + + :param backup_id: The ID of the backup to display. + :rtype: :class:`VolumeBackup` + """ + return self._get("/backups/%s" % backup_id, "backup") + + def list(self, detailed=True, search_opts=None, marker=None, limit=None, + sort=None): + """Get a list of all volume backups. + + :rtype: list of :class:`VolumeBackup` + """ + resource_type = "backups" + url = self._build_list_url(resource_type, detailed=detailed, + search_opts=search_opts, marker=marker, + limit=limit, sort=sort) + return self._list(url, resource_type, limit=limit) + + def delete(self, backup, force=False): + """Delete a volume backup. + + :param backup: The :class:`VolumeBackup` to delete. + :param force: Allow delete in state other than error or available. + """ + if force: + return self._action('os-force_delete', backup) + else: + return self._delete("/backups/%s" % base.getid(backup)) + + def reset_state(self, backup, state): + """Update the specified volume backup with the provided state.""" + return self._action('os-reset_status', backup, + {'status': state} if state else {}) + + def _action(self, action, backup, info=None, **kwargs): + """Perform a volume backup action.""" + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/backups/%s/action' % base.getid(backup) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + def export_record(self, backup_id): + """Export volume backup metadata record. + + :param backup_id: The ID of the backup to export. + :rtype: A dictionary containing 'backup_url' and 'backup_service'. + """ + resp, body = \ + self.api.client.get("/backups/%s/export_record" % backup_id) + return common_base.DictWithMeta(body['backup-record'], resp) + + def import_record(self, backup_service, backup_url): + """Import volume backup metadata record. + + :param backup_service: Backup service to use for importing the backup + :param backup_url: Backup URL for importing the backup metadata + :rtype: A dictionary containing volume backup metadata. + """ + body = {'backup-record': {'backup_service': backup_service, + 'backup_url': backup_url}} + self.run_hooks('modify_body_for_update', body, 'backup-record') + resp, body = self.api.client.post("/backups/import_record", body=body) + return common_base.DictWithMeta(body['backup'], resp) diff --git a/cinderclient/v2/volume_backups_restore.py b/cinderclient/v3/volume_backups_restore.py similarity index 84% rename from cinderclient/v2/volume_backups_restore.py rename to cinderclient/v3/volume_backups_restore.py index 0eafa8220..8a35ed162 100644 --- a/cinderclient/v2/volume_backups_restore.py +++ b/cinderclient/v3/volume_backups_restore.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -"""Volume Backups Restore interface (1.1 extension). +"""Volume Backups Restore interface (v3 extension). This is part of the Volume Backups interface. """ @@ -31,13 +31,14 @@ class VolumeBackupRestoreManager(base.Manager): """Manage :class:`VolumeBackupsRestore` resources.""" resource_class = VolumeBackupsRestore - def restore(self, backup_id, volume_id=None): + def restore(self, backup_id, volume_id=None, name=None): """Restore a backup to a volume. :param backup_id: The ID of the backup to restore. :param volume_id: The ID of the volume to restore the backup to. + :param name : The name for new volume creation to restore. :rtype: :class:`Restore` """ - body = {'restore': {'volume_id': volume_id}} + body = {'restore': {'volume_id': volume_id, 'name': name}} return self._create("/backups/%s/restore" % backup_id, body, "restore") diff --git a/cinderclient/v2/volume_encryption_types.py b/cinderclient/v3/volume_encryption_types.py similarity index 86% rename from cinderclient/v2/volume_encryption_types.py rename to cinderclient/v3/volume_encryption_types.py index 1099bc37b..531e4d229 100644 --- a/cinderclient/v2/volume_encryption_types.py +++ b/cinderclient/v3/volume_encryption_types.py @@ -18,6 +18,7 @@ Volume Encryption Type interface """ +from cinderclient.apiclient import base as common_base from cinderclient import base @@ -27,7 +28,7 @@ class VolumeEncryptionType(base.Resource): encryption for a specific volume type. """ def __repr__(self): - return "" % self.name + return "" % self.encryption_id class VolumeEncryptionTypeManager(base.ManagerWithFind): @@ -40,19 +41,24 @@ def list(self, search_opts=None): """ List all volume encryption types. - :param volume_types: a list of volume types + :param search_opts: Search options to filter out volume + encryption 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 = [] + list_of_resp = [] 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 + + list_of_resp.extend(encryption_type.request_ids) + + return common_base.ListWithMeta(encryption_types, list_of_resp) def get(self, volume_type): """ @@ -84,7 +90,9 @@ def update(self, volume_type, specs): :param specs: the encryption type specifications to update :return: an instance of :class: VolumeEncryptionType """ - raise NotImplementedError() + body = {'encryption': specs} + return self._update("/types/%s/encryption/provider" % + base.getid(volume_type), body) def delete(self, volume_type): """ diff --git a/cinderclient/v3/volume_snapshots.py b/cinderclient/v3/volume_snapshots.py new file mode 100644 index 000000000..cb1c3baeb --- /dev/null +++ b/cinderclient/v3/volume_snapshots.py @@ -0,0 +1,294 @@ +# Copyright (c) 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. + +"""Volume snapshot interface (v3 extension).""" + +from oslo_utils import strutils + +from cinderclient import api_versions +from cinderclient.apiclient import base as common_base +from cinderclient import base + +MV_3_66_FORCE_FLAG_ERROR = ( + "Since microversion 3.66 of the Block Storage API, the 'force' option is " + "invalid for this request. For backward compatibility, however, when the " + "'force' flag is passed with a value evaluating to True, it is silently " + "ignored.") + + +class Snapshot(base.Resource): + """A Snapshot is a point-in-time snapshot of an openstack volume.""" + + def __repr__(self): + return "" % self.id + + def delete(self, force=False): + """Delete this snapshot.""" + return self.manager.delete(self, force) + + def update(self, **kwargs): + """Update the name or description for this snapshot.""" + return self.manager.update(self, **kwargs) + + @property + def progress(self): + return self._info.get('os-extended-snapshot-attributes:progress') + + @property + def project_id(self): + return self._info.get('os-extended-snapshot-attributes:project_id') + + def reset_state(self, state): + """Update the snapshot with the provided state.""" + return self.manager.reset_state(self, state) + + def set_metadata(self, metadata): + """Set metadata of this snapshot.""" + return self.manager.set_metadata(self, metadata) + + def delete_metadata(self, keys): + """Delete metadata of this snapshot.""" + return self.manager.delete_metadata(self, keys) + + def update_all_metadata(self, metadata): + """Update_all metadata of this snapshot.""" + return self.manager.update_all_metadata(self, metadata) + + def manage(self, volume_id, ref, name=None, description=None, + metadata=None): + """Manage an existing snapshot.""" + self.manager.manage(volume_id=volume_id, ref=ref, name=name, + description=description, metadata=metadata) + + def list_manageable(self, host, detailed=True, marker=None, limit=None, + offset=None, sort=None, cluster=None): + return self.manager.list_manageable(host, detailed=detailed, + marker=marker, limit=limit, + offset=offset, sort=sort, + cluster=cluster) + + def unmanage(self, snapshot): + """Unmanage a snapshot.""" + self.manager.unmanage(snapshot) + + +class SnapshotManager(base.ManagerWithFind): + """Manage :class:`Snapshot` resources.""" + resource_class = Snapshot + + @api_versions.wraps("3.0", "3.65") + def create(self, volume_id, force=False, + name=None, description=None, metadata=None): + + """Creates a snapshot of the given volume. + + :param volume_id: The ID of the volume to snapshot. + :param force: If force is True, create a snapshot even if the volume is + attached to an instance. Default is False. + :param name: Name of the snapshot + :param description: Description of the snapshot + :param metadata: Metadata of the snapshot + :rtype: :class:`Snapshot` + """ + + if metadata is None: + snapshot_metadata = {} + else: + snapshot_metadata = metadata + + # Bug #1995883: it's possible for the shell to use the user- + # specified 3.66 do_snapshot_create function, but if the server + # only supports < 3.66, the client will have been downgraded and + # will use this function. In that case, the 'force' parameter will + # be None, which means that the user didn't specify a value for it, + # so we set it to the pre-3.66 default value of False. + # + # NOTE: we know this isn't a problem for current client consumers + # because a null value for 'force' has never been allowed by the + # Block Storage API v3, so there's no reason for anyone to directly + # call this method passing force=None. + if force is None: + force = False + + body = {'snapshot': {'volume_id': volume_id, + 'force': force, + 'name': name, + 'description': description, + 'metadata': snapshot_metadata}} + return self._create('/snapshots', body, 'snapshot') + + @api_versions.wraps("3.66") + def create(self, volume_id, force=None, # noqa: F811 + name=None, description=None, metadata=None): + + """Creates a snapshot of the given volume. + + :param volume_id: The ID of the volume to snapshot. + :param force: This is technically not valid after mv 3.66, but the + API silently accepts force=True for backward compatibility, so this + function will, too + :param name: Name of the snapshot + :param description: Description of the snapshot + :param metadata: Metadata of the snapshot + :raises: ValueError if 'force' is not passed with a value that + evaluates to true + :rtype: :class:`Snapshot` + """ + + if metadata is None: + snapshot_metadata = {} + else: + snapshot_metadata = metadata + + body = {'snapshot': {'volume_id': volume_id, + 'name': name, + 'description': description, + 'metadata': snapshot_metadata}} + if force is not None: + try: + force = strutils.bool_from_string(force, strict=True) + if not force: + raise ValueError() + except ValueError: + raise ValueError(MV_3_66_FORCE_FLAG_ERROR) + return self._create('/snapshots', body, 'snapshot') + + def get(self, snapshot_id): + """Shows snapshot details. + + :param snapshot_id: The ID of the snapshot to get. + :rtype: :class:`Snapshot` + """ + return self._get("/snapshots/%s" % snapshot_id, "snapshot") + + def list(self, detailed=True, search_opts=None, marker=None, limit=None, + sort=None): + """Get a list of all snapshots. + + :rtype: list of :class:`Snapshot` + """ + resource_type = "snapshots" + url = self._build_list_url(resource_type, detailed=detailed, + search_opts=search_opts, marker=marker, + limit=limit, sort=sort) + return self._list(url, resource_type, limit=limit) + + def delete(self, snapshot, force=False): + """Delete a snapshot. + + :param snapshot: The :class:`Snapshot` to delete. + :param force: Allow delete in state other than error or available. + """ + if force: + return self._action('os-force_delete', snapshot) + else: + return self._delete("/snapshots/%s" % base.getid(snapshot)) + + def update(self, snapshot, **kwargs): + """Update the name or description for a snapshot. + + :param snapshot: The :class:`Snapshot` to update. + """ + if not kwargs: + return + + body = {"snapshot": kwargs} + + return self._update("/snapshots/%s" % base.getid(snapshot), body) + + def reset_state(self, snapshot, state): + """Update the specified snapshot with the provided state.""" + return self._action('os-reset_status', snapshot, + {'status': state} if state else {}) + + def _action(self, action, snapshot, info=None, **kwargs): + """Perform a snapshot action.""" + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/snapshots/%s/action' % base.getid(snapshot) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) + + def update_snapshot_status(self, snapshot, update_dict): + return self._action('os-update_snapshot_status', + base.getid(snapshot), update_dict) + + def set_metadata(self, snapshot, metadata): + """Update/Set a snapshots metadata. + + :param snapshot: The :class:`Snapshot`. + :param metadata: A list of keys to be set. + """ + body = {'metadata': metadata} + return self._create("/snapshots/%s/metadata" % base.getid(snapshot), + body, "metadata") + + def delete_metadata(self, snapshot, keys): + """Delete specified keys from snapshot metadata. + + :param snapshot: The :class:`Snapshot`. + :param keys: A list of keys to be removed. + """ + response_list = [] + snapshot_id = base.getid(snapshot) + for k in keys: + resp, body = self._delete("/snapshots/%s/metadata/%s" % + (snapshot_id, k)) + response_list.append(resp) + + return common_base.ListWithMeta([], response_list) + + def update_all_metadata(self, snapshot, metadata): + """Update_all snapshot metadata. + + :param snapshot: The :class:`Snapshot`. + :param metadata: A list of keys to be updated. + """ + body = {'metadata': metadata} + return self._update("/snapshots/%s/metadata" % base.getid(snapshot), + body) + + def manage(self, volume_id, ref, name=None, description=None, + metadata=None): + """Manage an existing snapshot.""" + body = {'snapshot': {'volume_id': volume_id, + 'ref': ref, + 'name': name, + 'description': description, + 'metadata': metadata + } + } + return self._create('/os-snapshot-manage', body, 'snapshot') + + @api_versions.wraps("3.0") + def list_manageable(self, host, detailed=True, marker=None, + limit=None, offset=None, sort=None): + url = self._build_list_url("os-snapshot-manage", detailed=detailed, + search_opts={'host': host}, marker=marker, + limit=limit, offset=offset, sort=sort) + return self._list(url, "manageable-snapshots") + + @api_versions.wraps('3.8') + def list_manageable(self, host, detailed=True, marker=None, # noqa: F811 + limit=None, offset=None, sort=None, cluster=None): + search_opts = {'cluster': cluster} if cluster else {'host': host} + url = self._build_list_url("manageable_snapshots", detailed=detailed, + search_opts=search_opts, marker=marker, + limit=limit, offset=offset, sort=sort) + return self._list(url, "manageable-snapshots") + + def unmanage(self, snapshot): + """Unmanage a snapshot.""" + return self._action('os-unmanage', snapshot, None) diff --git a/cinderclient/v2/volume_transfers.py b/cinderclient/v3/volume_transfers.py similarity index 62% rename from cinderclient/v2/volume_transfers.py rename to cinderclient/v3/volume_transfers.py index 23317d2cd..bcf0e0cc0 100644 --- a/cinderclient/v2/volume_transfers.py +++ b/cinderclient/v3/volume_transfers.py @@ -13,20 +13,14 @@ # License for the specific language governing permissions and limitations # under the License. -""" -Volume transfer interface (1.1 extension). -""" - -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode -import six +"""Volume transfer interface (v3 extension).""" + from cinderclient import base class VolumeTransfer(base.Resource): """Transfer a volume from one tenant to another""" + def __repr__(self): return "" % self.id @@ -39,15 +33,20 @@ class VolumeTransferManager(base.ManagerWithFind): """Manage :class:`VolumeTransfer` resources.""" resource_class = VolumeTransfer - def create(self, volume_id, name=None): + def create(self, volume_id, name=None, no_snapshots=False): """Creates a volume transfer. :param volume_id: The ID of the volume to transfer. :param name: The name of the transfer. + :param no_snapshots: Transfer volumes without snapshots. :rtype: :class:`VolumeTransfer` """ body = {'transfer': {'volume_id': volume_id, 'name': name}} + if self.api_version.matches('3.55'): + body['transfer']['no_snapshots'] = no_snapshots + return self._create('/volume-transfers', body, 'transfer') + return self._create('/os-volume-transfer', body, 'transfer') def accept(self, transfer_id, auth_key): @@ -58,6 +57,10 @@ def accept(self, transfer_id, auth_key): :rtype: :class:`VolumeTransfer` """ body = {'accept': {'auth_key': auth_key}} + if self.api_version.matches('3.55'): + return self._create('/volume-transfers/%s/accept' % transfer_id, + body, 'transfer') + return self._create('/os-volume-transfer/%s/accept' % transfer_id, body, 'transfer') @@ -67,34 +70,35 @@ def get(self, transfer_id): :param transfer_id: The ID of the volume transfer to display. :rtype: :class:`VolumeTransfer` """ + if self.api_version.matches('3.55'): + return self._get("/volume-transfers/%s" % transfer_id, "transfer") + return self._get("/os-volume-transfer/%s" % transfer_id, "transfer") - def list(self, detailed=True, search_opts=None): + def list(self, detailed=True, search_opts=None, sort=None): """Get a list of all volume transfer. + :param detailed: Get detailed object information. + :param search_opts: Filtering options. + :param sort: Sort information :rtype: list of :class:`VolumeTransfer` """ - if search_opts is None: - search_opts = {} + resource_type = 'os-volume-transfer' + if self.api_version.matches('3.55'): + resource_type = 'volume-transfers' - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - - query_string = "?%s" % urlencode(qparams) if qparams else "" - - detail = "" - if detailed: - detail = "/detail" - - return self._list("/os-volume-transfer%s%s" % (detail, query_string), - "transfers") + url = self._build_list_url(resource_type, detailed=detailed, + search_opts=search_opts, + sort=sort) + return self._list(url, 'transfers') def delete(self, transfer_id): """Delete a volume transfer. :param transfer_id: The :class:`VolumeTransfer` to delete. """ - self._delete("/os-volume-transfer/%s" % base.getid(transfer_id)) + if self.api_version.matches('3.55'): + return self._delete( + "/volume-transfers/%s" % base.getid(transfer_id)) + + return self._delete("/os-volume-transfer/%s" % base.getid(transfer_id)) diff --git a/cinderclient/v2/volume_type_access.py b/cinderclient/v3/volume_type_access.py similarity index 84% rename from cinderclient/v2/volume_type_access.py rename to cinderclient/v3/volume_type_access.py index d604041a5..bdd2e7028 100644 --- a/cinderclient/v2/volume_type_access.py +++ b/cinderclient/v3/volume_type_access.py @@ -14,6 +14,7 @@ """Volume type access interface.""" +from cinderclient.apiclient import base as common_base from cinderclient import base @@ -36,16 +37,17 @@ def list(self, volume_type): def add_project_access(self, volume_type, project): """Add a project to the given volume type access list.""" info = {'project': project} - self._action('addProjectAccess', volume_type, info) + return self._action('addProjectAccess', volume_type, info) def remove_project_access(self, volume_type, project): """Remove a project from the given volume type access list.""" info = {'project': project} - self._action('removeProjectAccess', volume_type, info) + return self._action('removeProjectAccess', volume_type, info) def _action(self, action, volume_type, info, **kwargs): """Perform a volume type action.""" body = {action: info} self.run_hooks('modify_body_for_action', body, **kwargs) url = '/types/%s/action' % base.getid(volume_type) - return self.api.client.post(url, body=body) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v2/volume_types.py b/cinderclient/v3/volume_types.py similarity index 69% rename from cinderclient/v2/volume_types.py rename to cinderclient/v3/volume_types.py index 7eda7355b..e82fbb3ac 100644 --- a/cinderclient/v2/volume_types.py +++ b/cinderclient/v3/volume_types.py @@ -15,7 +15,9 @@ """Volume Type interface.""" +from urllib import parse +from cinderclient.apiclient import base as common_base from cinderclient import base @@ -29,7 +31,8 @@ def is_public(self): """ Provide a user-friendly accessor to os-volume-type-access:is_public """ - return self._info.get("os-volume-type-access:is_public", 'N/A') + return self._info.get("os-volume-type-access:is_public", + self._info.get("is_public", 'N/A')) def get_keys(self): """Get extra specs from a volume type. @@ -62,30 +65,45 @@ def unset_keys(self, keys): """ # NOTE(jdg): This wasn't actually doing all of the keys before - # the return in the loop resulted in ony ONE key being unset. - # since on success the return was NONE, we'll only interrupt the loop - # and return if there's an error + # the return in the loop resulted in only ONE key being unset, + # since on success the return was ListWithMeta class, we'll only + # interrupt the loop and if an exception is raised. + response_list = [] for k in keys: - resp = self.manager._delete( + resp, body = self.manager._delete( "/types/%s/extra_specs/%s" % ( base.getid(self), k)) - if resp is not None: - return resp + response_list.append(resp) + + return common_base.ListWithMeta([], response_list) class VolumeTypeManager(base.ManagerWithFind): """Manage :class:`VolumeType` resources.""" resource_class = VolumeType - def list(self, search_opts=None, is_public=True): + def list(self, search_opts=None, is_public=None): """Lists all volume types. - :rtype: list of :class:`VolumeType`. + :param search_opts: Optional search filters. + :param is_public: Whether to only get public types. + :return: List of :class:`VolumeType`. """ - query_string = '' - if not is_public: - query_string = '?is_public=%s' % is_public - return self._list("/types%s" % (query_string), "volume_types") + if not search_opts: + search_opts = dict() + + # Remove 'all_tenants' option added by ManagerWithFind.findall(), + # as it is not a valid search option for volume_types. + search_opts.pop('all_tenants', None) + + # Need to keep backwards compatibility with is_public usage. If it + # isn't included then cinder will assume you want is_public=True, which + # negatively affects the results. + if 'is_public' not in search_opts: + search_opts['is_public'] = is_public + + query_string = "?%s" % parse.urlencode(search_opts) + return self._list("/types%s" % query_string, "volume_types") def get(self, volume_type): """Get a specific volume type. @@ -107,13 +125,13 @@ def delete(self, volume_type): :param volume_type: The name or ID of the :class:`VolumeType` to get. """ - self._delete("/types/%s" % base.getid(volume_type)) + return self._delete("/types/%s" % base.getid(volume_type)) def create(self, name, description=None, is_public=True): """Creates a volume type. :param name: Descriptive name of the volume type - :param description: Description of the the volume type + :param description: Description of the volume type :param is_public: Volume type visibility :rtype: :class:`VolumeType` """ @@ -128,12 +146,12 @@ def create(self, name, description=None, is_public=True): return self._create("/types", body, "volume_type") - def update(self, volume_type, name=None, description=None): + def update(self, volume_type, name=None, description=None, is_public=None): """Update the name and/or description for a volume type. :param volume_type: The ID of the :class:`VolumeType` to update. :param name: Descriptive name of the volume type. - :param description: Description of the the volume type. + :param description: Description of the volume type. :rtype: :class:`VolumeType` """ @@ -143,6 +161,8 @@ def update(self, volume_type, name=None, description=None): "description": description } } + if is_public is not None: + body["volume_type"]["is_public"] = is_public return self._update("/types/%s" % base.getid(volume_type), body, response_key="volume_type") diff --git a/cinderclient/v3/volumes.py b/cinderclient/v3/volumes.py new file mode 100644 index 000000000..9751fae53 --- /dev/null +++ b/cinderclient/v3/volumes.py @@ -0,0 +1,322 @@ +# Copyright (c) 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. + +"""Volume interface (v3 extension).""" + +from cinderclient import api_versions +from cinderclient.apiclient import base as common_base +from cinderclient import base +from cinderclient.v3 import volumes_base + + +class Volume(volumes_base.Volume): + + def upload_to_image(self, force, image_name, container_format, + disk_format, visibility=None, + protected=None): + """Upload a volume to image service as an image. + :param force: Boolean to enables or disables upload of a volume that + is attached to an instance. + :param image_name: The new image name. + :param container_format: Container format type. + :param disk_format: Disk format type. + :param visibility: The accessibility of image (allowed for + 3.1-latest). + :param protected: Boolean to decide whether prevents image from being + deleted (allowed for 3.1-latest). + :returns: tuple (response, body) + """ + if self.manager.api_version >= api_versions.APIVersion("3.1"): + visibility = 'private' if visibility is None else visibility + protected = False if protected is None else protected + return self.manager.upload_to_image(self, force, image_name, + container_format, disk_format, + visibility, protected) + return self.manager.upload_to_image(self, force, image_name, + container_format, disk_format) + + def revert_to_snapshot(self, snapshot): + """Revert a volume to a snapshot.""" + self.manager.revert_to_snapshot(self, snapshot) + + def migrate_volume(self, host, force_host_copy, lock_volume, cluster=None): + """Migrate the volume to a new host.""" + return self.manager.migrate_volume(self, host, force_host_copy, + lock_volume, cluster) + + def manage(self, host, ref, name=None, description=None, + volume_type=None, availability_zone=None, metadata=None, + bootable=False, cluster=None): + """Manage an existing volume.""" + return self.manager.manage(host=host, ref=ref, name=name, + description=description, + volume_type=volume_type, + availability_zone=availability_zone, + metadata=metadata, bootable=bootable, + cluster=cluster) + + def reimage(self, image_id, reimage_reserved=False): + """Rebuilds the volume with the new specified image""" + self.manager.reimage(self, image_id, reimage_reserved) + + def extend_volume_completion(self, volume, error=False): + """Complete extending an attached volume""" + self.manager.extend_volume_completion(self, volume, error) + + +class VolumeManager(volumes_base.VolumeManager): + resource_class = Volume + + def create(self, size, consistencygroup_id=None, + group_id=None, snapshot_id=None, + source_volid=None, name=None, description=None, + volume_type=None, user_id=None, + project_id=None, availability_zone=None, + metadata=None, imageRef=None, scheduler_hints=None, + backup_id=None): + """Create a volume. + + :param size: Size of volume in GB + :param consistencygroup_id: ID of the consistencygroup + :param group_id: ID of the group + :param snapshot_id: ID of the snapshot + :param name: Name of the volume + :param description: Description of the volume + :param volume_type: Type of volume + :param user_id: User id derived from context (IGNORED) + :param project_id: Project id derived from context (IGNORED) + :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 + :param scheduler_hints: (optional extension) arbitrary key-value pairs + specified by the client to help boot an instance + :param backup_id: ID of the backup + :rtype: :class:`Volume` + """ + if metadata is None: + volume_metadata = {} + else: + volume_metadata = metadata + + body = {'volume': {'size': size, + 'consistencygroup_id': consistencygroup_id, + 'snapshot_id': snapshot_id, + 'name': name, + 'description': description, + 'volume_type': volume_type, + 'availability_zone': availability_zone, + 'metadata': volume_metadata, + 'imageRef': imageRef, + 'source_volid': source_volid, + 'backup_id': backup_id + }} + + if group_id: + body['volume']['group_id'] = group_id + + if scheduler_hints: + body['OS-SCH-HNT:scheduler_hints'] = scheduler_hints + + return self._create('/volumes', body, 'volume') + + @api_versions.wraps('3.40') + def revert_to_snapshot(self, volume, snapshot): + """Revert a volume to a snapshot. + + The snapshot must be the most recent one known to cinder. + :param volume: volume object or volume id. + :param snapshot: snapshot object or snapshot id. + """ + return self._action('revert', volume, + info={'snapshot_id': base.getid(snapshot)}) + + @api_versions.wraps('3.12') + def summary(self, all_tenants): + """Get volumes summary.""" + url = "/volumes/summary" + if all_tenants: + url += "?all_tenants=True" + _, body = self.api.client.get(url) + return body + + @api_versions.wraps("3.0") + def delete_metadata(self, volume, keys): + """Delete specified keys from volumes metadata. + + :param volume: The :class:`Volume`. + :param keys: A list of keys to be removed. + """ + response_list = [] + for k in keys: + resp, body = self._delete("/volumes/%s/metadata/%s" % + (base.getid(volume), k)) + response_list.append(resp) + + return common_base.ListWithMeta([], response_list) + + @api_versions.wraps("3.15") + def delete_metadata(self, volume, keys): # noqa: F811 + """Delete specified keys from volumes metadata. + + :param volume: The :class:`Volume`. + :param keys: A list of keys to be removed. + """ + # pylint: disable=function-redefined + data = self._get("/volumes/%s/metadata" % base.getid(volume)) + metadata = data._info.get("metadata", {}) + if set(keys).issubset(metadata.keys()): + for k in keys: + metadata.pop(k) + body = {'metadata': metadata} + kwargs = {'headers': {'If-Match': data._checksum}} + return self._update("/volumes/%s/metadata" % base.getid(volume), + body, **kwargs) + + @api_versions.wraps("3.0") + def upload_to_image(self, volume, force, image_name, container_format, + disk_format): + """Upload volume to image service as image. + :param volume: The :class:`Volume` to upload. + """ + return self._action('os-volume_upload_image', + volume, + {'force': force, + 'image_name': image_name, + 'container_format': container_format, + 'disk_format': disk_format}) + + @api_versions.wraps("3.1") + def upload_to_image(self, volume, force, image_name, # noqa: F811 + container_format, disk_format, visibility, protected): + """Upload volume to image service as image. + :param volume: The :class:`Volume` to upload. + """ + # pylint: disable=function-redefined + return self._action('os-volume_upload_image', + volume, + {'force': force, + 'image_name': image_name, + 'container_format': container_format, + 'disk_format': disk_format, + 'visibility': visibility, + 'protected': protected}) + + def migrate_volume(self, volume, host, force_host_copy, lock_volume, + cluster=None): + """Migrate volume to new backend. + + The new backend is defined by the host or the cluster (not both). + + :param volume: The :class:`Volume` to migrate + :param host: The destination host + :param force_host_copy: Skip driver optimizations + :param lock_volume: Lock the volume and guarantee the migration + to finish + :param cluster: The cluster + """ + body = {'host': host, 'force_host_copy': force_host_copy, + 'lock_volume': lock_volume} + + if self.api_version.matches('3.16'): + if cluster: + body['cluster'] = cluster + del body['host'] + + return self._action('os-migrate_volume', volume, body) + + def manage(self, host, ref, name=None, description=None, + volume_type=None, availability_zone=None, metadata=None, + bootable=False, cluster=None): + """Manage an existing volume.""" + body = {'volume': {'host': host, + 'ref': ref, + 'name': name, + 'description': description, + 'volume_type': volume_type, + 'availability_zone': availability_zone, + 'metadata': metadata, + 'bootable': bootable + }} + if self.api_version.matches('3.16') and cluster: + body['volume']['cluster'] = cluster + return self._create('/os-volume-manage', body, 'volume') + + @api_versions.wraps('3.0') + def list_manageable(self, host, detailed=True, marker=None, + limit=None, offset=None, sort=None): + url = self._build_list_url("os-volume-manage", detailed=detailed, + search_opts={'host': host}, marker=marker, + limit=limit, offset=offset, sort=sort) + return self._list(url, "manageable-volumes") + + @api_versions.wraps('3.8') + def list_manageable(self, host, detailed=True, marker=None, # noqa: F811 + limit=None, offset=None, sort=None, cluster=None): + search_opts = {'cluster': cluster} if cluster else {'host': host} + url = self._build_list_url("manageable_volumes", detailed=detailed, + search_opts=search_opts, marker=marker, + limit=limit, offset=offset, sort=sort) + return self._list(url, "manageable-volumes") + + @api_versions.wraps("3.0", "3.32") + def get_pools(self, detail): + """Show pool information for backends.""" + query_string = "" + if detail: + query_string = "?detail=True" + + return self._get('/scheduler-stats/get_pools%s' % query_string, None) + + @api_versions.wraps("3.33") + def get_pools(self, detail, search_opts): # noqa: F811 + """Show pool information for backends.""" + # pylint: disable=function-redefined + options = {'detail': detail} + options.update(search_opts) + url = self._build_list_url('scheduler-stats/get_pools', detailed=False, + search_opts=options) + + return self._get(url, None) + + @api_versions.wraps('3.68') + def reimage(self, volume, image_id, reimage_reserved=False): + """Reimage a volume + + .. warning:: This is a destructive action and the contents of the + volume will be lost. + + :param volume: Volume to reimage. + :param reimage_reserved: Boolean to enable or disable reimage + of a volume that is in 'reserved' state otherwise only + volumes in 'available' status may be re-imaged. + :param image_id: The image id. + """ + return self._action('os-reimage', + volume, + {'image_id': image_id, + 'reimage_reserved': reimage_reserved}) + + @api_versions.wraps('3.71') + def extend_volume_completion(self, volume, error=False): + """Complete extending an attached volume. + + :param volume: The UUID of the extended volume + :param error: Used to indicate if an error has occured that requires + Cinder to roll back the extend operation. + """ + return self._action('os-extend_volume_completion', + volume, + {'error': error}) diff --git a/cinderclient/v2/volumes.py b/cinderclient/v3/volumes_base.py similarity index 51% rename from cinderclient/v2/volumes.py rename to cinderclient/v3/volumes_base.py index 28e812954..c41361cdf 100644 --- a/cinderclient/v2/volumes.py +++ b/cinderclient/v3/volumes_base.py @@ -13,49 +13,60 @@ # License for the specific language governing permissions and limitations # under the License. -"""Volume interface (v2 extension).""" - -import six -try: - from urllib import urlencode -except ImportError: - from urllib.parse import urlencode +"""Base Volume interface.""" +from cinderclient.apiclient import base as common_base from cinderclient import base -# Valid sort directions and client sort keys -SORT_DIR_VALUES = ('asc', 'desc') -SORT_KEY_VALUES = ('id', 'status', 'size', 'availability_zone', 'name', - 'bootable', 'created_at') -# Mapping of client keys to actual sort keys -SORT_KEY_MAPPINGS = {'name': 'display_name'} - - class Volume(base.Resource): """A volume is an extra block level storage to the OpenStack instances.""" def __repr__(self): return "" % self.id - def delete(self): + def delete(self, cascade=False): """Delete this volume.""" - self.manager.delete(self) + return self.manager.delete(self, cascade=cascade) def update(self, **kwargs): """Update the name or description for this volume.""" - self.manager.update(self, **kwargs) + return self.manager.update(self, **kwargs) - def attach(self, instance_uuid, mountpoint, mode='rw'): - """Set attachment metadata. + def attach(self, instance_uuid, mountpoint, mode='rw', host_name=None): + """Inform Cinder if the given volume is attached to the given instance. + + Calling this method will not actually ask Cinder to attach + a volume, but to mark it on the DB as attached. If the volume + is not actually attached to the given instance, inconsistent + data will result. + + The right flow of calls is : + 1- call reserve + 2- call initialize_connection + 3- call attach :param instance_uuid: uuid of the attaching instance. - :param mountpoint: mountpoint on the attaching instance. + :param mountpoint: mountpoint on the attaching instance or host. :param mode: the access mode. + :param host_name: name of the attaching host. """ - return self.manager.attach(self, instance_uuid, mountpoint, mode) + return self.manager.attach(self, instance_uuid, mountpoint, mode, + host_name) def detach(self): - """Clear attachment metadata.""" + """Inform Cinder that the given volume is detached. + + This inform Cinder that the given volume is detached from the given + instance. Calling this method will not actually ask Cinder to detach + a volume, but to mark it on the DB as detached. If the volume + is not actually detached from the given instance, inconsistent + data will result. + + The right flow of calls is : + 1- call reserve + 2- call initialize_connection + 3- call detach + """ return self.manager.detach(self) def reserve(self, volume): @@ -96,38 +107,60 @@ def set_metadata(self, volume, metadata): """ return self.manager.set_metadata(self, metadata) - def upload_to_image(self, force, image_name, container_format, - disk_format): - """Upload a volume to image service as an image.""" - return self.manager.upload_to_image(self, force, image_name, - container_format, disk_format) + def set_image_metadata(self, volume, metadata): + """Set a volume's image metadata. + + :param volume : The :class: `Volume` to set metadata on + :param metadata: A dict of key/value pairs to set + """ + return self.manager.set_image_metadata(self, volume, metadata) + + def delete_image_metadata(self, volume, keys): + """Delete specified keys from volume's image metadata. + + :param volume: The :class:`Volume`. + :param keys: A list of keys to be removed. + """ + return self.manager.delete_image_metadata(self, volume, keys) + + def show_image_metadata(self, volume): + """Show a volume's image metadata. + + :param volume : The :class: `Volume` where the image metadata + associated. + """ + return self.manager.show_image_metadata(self) def force_delete(self): """Delete the specified volume ignoring its current state. :param volume: The UUID of the volume to force-delete. """ - self.manager.force_delete(self) + return self.manager.force_delete(self) - def reset_state(self, state): - """Update the volume with the provided state.""" - self.manager.reset_state(self, state) + def reset_state(self, state, attach_status=None, migration_status=None): + """Update the volume with the provided state. + + :param state: The state of the volume to set. + :param attach_status: The attach_status of the volume to be set, + or None to keep the current status. + :param migration_status: The migration_status of the volume to be set, + or None to keep the current status. + """ + return self.manager.reset_state(self, state, attach_status, + migration_status) def extend(self, volume, new_size): """Extend the size of the specified volume. + :param volume: The UUID of the volume to extend :param new_size: The desired size to extend volume to. """ - - self.manager.extend(self, 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) + return self.manager.extend(self, new_size) def retype(self, volume_type, policy): """Change a volume's type.""" - self.manager.retype(self, volume_type, policy) + return self.manager.retype(self, volume_type, policy) def update_all_metadata(self, metadata): """Update all metadata of this volume.""" @@ -140,91 +173,27 @@ def update_readonly_flag(self, volume, read_only): :param read_only: The value to indicate whether to update volume to read-only access mode. """ - self.manager.update_readonly_flag(self, read_only) + return self.manager.update_readonly_flag(self, read_only) - def manage(self, host, ref, name=None, description=None, - volume_type=None, availability_zone=None, metadata=None, - bootable=False): - """Manage an existing volume.""" - self.manager.manage(host=host, ref=ref, name=name, - description=description, volume_type=volume_type, - availability_zone=availability_zone, - metadata=metadata, bootable=bootable) + def list_manageable(self, host, detailed=True, marker=None, limit=None, + offset=None, sort=None): + return self.manager.list_manageable(host, detailed=detailed, + marker=marker, limit=limit, + offset=offset, sort=sort) def unmanage(self, volume): """Unmanage a volume.""" - self.manager.unmanage(volume) - - def promote(self, volume): - """Promote secondary to be primary in relationship.""" - self.manager.promote(volume) - - def reenable(self, volume): - """Sync the secondary volume with primary for a relationship.""" - self.manager.reenable(volume) + return self.manager.unmanage(volume) def get_pools(self, detail): """Show pool information for backends.""" - self.manager.get_pools(detail) + return self.manager.get_pools(detail) class VolumeManager(base.ManagerWithFind): """Manage :class:`Volume` resources.""" resource_class = Volume - def create(self, size, consistencygroup_id=None, snapshot_id=None, - source_volid=None, name=None, description=None, - volume_type=None, user_id=None, - project_id=None, availability_zone=None, - metadata=None, imageRef=None, scheduler_hints=None, - source_replica=None): - """Creates a volume. - - :param size: Size of volume in GB - :param consistencygroup_id: ID of the consistencygroup - :param snapshot_id: ID of the snapshot - :param name: Name of the volume - :param description: Description of the volume - :param volume_type: Type of 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 - :param source_replica: ID of source volume to clone replica - :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: - volume_metadata = {} - else: - volume_metadata = metadata - - body = {'volume': {'size': size, - 'consistencygroup_id': consistencygroup_id, - 'snapshot_id': snapshot_id, - 'name': name, - 'description': description, - 'volume_type': volume_type, - 'user_id': user_id, - 'project_id': project_id, - 'availability_zone': availability_zone, - 'status': "creating", - 'attach_status': "detached", - 'metadata': volume_metadata, - 'imageRef': imageRef, - 'source_volid': source_volid, - 'source_replica': source_replica, - }} - - if scheduler_hints: - body['OS-SCH-HNT:scheduler_hints'] = scheduler_hints - - return self._create('/volumes', body, 'volume') - def get(self, volume_id): """Get a volume. @@ -233,57 +202,8 @@ def get(self, volume_id): """ return self._get("/volumes/%s" % volume_id, "volume") - def _format_sort_param(self, sort): - '''Formats the sort information into the sort query string parameter. - - The input sort information can be any of the following: - - Comma-separated string in the form of - - List of strings in the form of - - List of either string keys, or tuples of (key, dir) - - For example, the following import sort values are valid: - - 'key1:dir1,key2,key3:dir3' - - ['key1:dir1', 'key2', 'key3:dir3'] - - [('key1', 'dir1'), 'key2', ('key3', dir3')] - - :param sort: Input sort information - :returns: Formatted query string parameter or None - :raise ValueError: If an invalid sort direction or invalid sort key is - given - ''' - if not sort: - return None - - if isinstance(sort, six.string_types): - # Convert the string into a list for consistent validation - sort = [s for s in sort.split(',') if s] - - sort_array = [] - for sort_item in sort: - if isinstance(sort_item, tuple): - sort_key = sort_item[0] - sort_dir = sort_item[1] - else: - sort_key, _sep, sort_dir = sort_item.partition(':') - sort_key = sort_key.strip() - if sort_key in SORT_KEY_VALUES: - sort_key = SORT_KEY_MAPPINGS.get(sort_key, sort_key) - else: - raise ValueError('sort_key must be one of the following: %s.' - % ', '.join(SORT_KEY_VALUES)) - if sort_dir: - sort_dir = sort_dir.strip() - if sort_dir not in SORT_DIR_VALUES: - msg = ('sort_dir must be one of the following: %s.' - % ', '.join(SORT_DIR_VALUES)) - raise ValueError(msg) - sort_array.append('%s:%s' % (sort_key, sort_dir)) - else: - sort_array.append(sort_key) - return ','.join(sort_array) - def list(self, detailed=True, search_opts=None, marker=None, limit=None, - sort_key=None, sort_dir=None, sort=None): + sort=None): """Lists all volumes. :param detailed: Whether to return detailed volume info. @@ -291,68 +211,29 @@ def list(self, detailed=True, search_opts=None, marker=None, limit=None, :param marker: Begin returning volumes that appear later in the volume list than that represented by this volume id. :param limit: Maximum number of volumes to return. - :param sort_key: Key to be sorted; deprecated in kilo - :param sort_dir: Sort direction, should be 'desc' or 'asc'; deprecated - in kilo :param sort: Sort information :rtype: list of :class:`Volume` """ - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in six.iteritems(search_opts): - if val: - qparams[opt] = val - - if marker: - qparams['marker'] = marker - - if limit: - qparams['limit'] = limit - - # sort_key and sort_dir deprecated in kilo, prefer sort - if sort: - qparams['sort'] = self._format_sort_param(sort) - else: - if sort_key is not None: - if sort_key in SORT_KEY_VALUES: - qparams['sort_key'] = SORT_KEY_MAPPINGS.get(sort_key, - sort_key) - else: - msg = ('sort_key must be one of the following: %s.' - % ', '.join(SORT_KEY_VALUES)) - raise ValueError(msg) - if sort_dir is not None: - if sort_dir in SORT_DIR_VALUES: - qparams['sort_dir'] = sort_dir - else: - msg = ('sort_dir must be one of the following: %s.' - % ', '.join(SORT_DIR_VALUES)) - raise ValueError(msg) - - # Transform the dict to a sequence of two-element tuples in fixed - # order, then the encoded string will be consistent in Python 2&3. - if qparams: - new_qparams = sorted(qparams.items(), key=lambda x: x[0]) - query_string = "?%s" % urlencode(new_qparams) - else: - query_string = "" - - detail = "" - if detailed: - detail = "/detail" - - return self._list("/volumes%s%s" % (detail, query_string), - "volumes", limit=limit) - - def delete(self, volume): + + resource_type = "volumes" + url = self._build_list_url(resource_type, detailed=detailed, + search_opts=search_opts, marker=marker, + limit=limit, sort=sort) + return self._list(url, resource_type, limit=limit) + + def delete(self, volume, cascade=False): """Delete a volume. :param volume: The :class:`Volume` to delete. + :param cascade: Also delete dependent snapshots. """ - self._delete("/volumes/%s" % base.getid(volume)) + + loc = "/volumes/%s" % base.getid(volume) + + if cascade: + loc += '?cascade=True' + + return self._delete(loc) def update(self, volume, **kwargs): """Update the name or description for a volume. @@ -364,38 +245,46 @@ def update(self, volume, **kwargs): body = {"volume": kwargs} - self._update("/volumes/%s" % base.getid(volume), body) + return self._update("/volumes/%s" % base.getid(volume), body) def _action(self, action, volume, info=None, **kwargs): """Perform a volume "action." + + :returns: tuple (response, body) """ body = {action: info} self.run_hooks('modify_body_for_action', body, **kwargs) url = '/volumes/%s/action' % base.getid(volume) - return self.api.client.post(url, body=body) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) - def attach(self, volume, instance_uuid, mountpoint, mode='rw'): + def attach(self, volume, instance_uuid, mountpoint, mode='rw', + host_name=None): """Set attachment metadata. :param volume: The :class:`Volume` (or its ID) you would like to attach. :param instance_uuid: uuid of the attaching instance. - :param mountpoint: mountpoint on the attaching instance. + :param mountpoint: mountpoint on the attaching instance or host. :param mode: the access mode. + :param host_name: name of the attaching host. """ - return self._action('os-attach', - volume, - {'instance_uuid': instance_uuid, - 'mountpoint': mountpoint, - 'mode': mode}) - - def detach(self, volume): + body = {'mountpoint': mountpoint, 'mode': mode} + if instance_uuid is not None: + body.update({'instance_uuid': instance_uuid}) + if host_name is not None: + body.update({'host_name': host_name}) + return self._action('os-attach', volume, body) + + def detach(self, volume, attachment_uuid=None): """Clear attachment metadata. :param volume: The :class:`Volume` (or its ID) you would like to detach. + :param attachment_uuid: The uuid of the volume attachment. """ - return self._action('os-detach', volume) + return self._action('os-detach', volume, + {'attachment_id': attachment_uuid}) def reserve(self, volume): """Reserve this volume. @@ -435,8 +324,9 @@ def initialize_connection(self, volume, connector): :param volume: The :class:`Volume` (or its ID). :param connector: connector dict from nova. """ - return self._action('os-initialize_connection', volume, - {'connector': connector})[1]['connection_info'] + resp, body = self._action('os-initialize_connection', volume, + {'connector': connector}) + return common_base.DictWithMeta(body['connection_info'], resp) def terminate_connection(self, volume, connector): """Terminate a volume connection. @@ -444,8 +334,8 @@ def terminate_connection(self, volume, connector): :param volume: The :class:`Volume` (or its ID). :param connector: connector dict from nova. """ - self._action('os-terminate_connection', volume, - {'connector': connector}) + return self._action('os-terminate_connection', volume, + {'connector': connector}) def set_metadata(self, volume, metadata): """Update/Set a volumes metadata. @@ -463,8 +353,45 @@ def delete_metadata(self, volume, keys): :param volume: The :class:`Volume`. :param keys: A list of keys to be removed. """ + response_list = [] for k in keys: - self._delete("/volumes/%s/metadata/%s" % (base.getid(volume), k)) + resp, body = self._delete("/volumes/%s/metadata/%s" % + (base.getid(volume), k)) + response_list.append(resp) + + return common_base.ListWithMeta([], response_list) + + def set_image_metadata(self, volume, metadata): + """Set a volume's image metadata. + + :param volume: The :class:`Volume`. + :param metadata: keys and the values to be set with. + :type metadata: dict + """ + return self._action("os-set_image_metadata", volume, + {'metadata': metadata}) + + def delete_image_metadata(self, volume, keys): + """Delete specified keys from volume's image metadata. + + :param volume: The :class:`Volume`. + :param keys: A list of keys to be removed. + """ + response_list = [] + for key in keys: + resp, body = self._action("os-unset_image_metadata", volume, + {'key': key}) + response_list.append(resp) + + return common_base.ListWithMeta([], response_list) + + def show_image_metadata(self, volume): + """Show a volume's image metadata. + + :param volume : The :class: `Volume` where the image metadata + associated. + """ + return self._action("os-show_image_metadata", volume) def upload_to_image(self, volume, force, image_name, container_format, disk_format): @@ -475,18 +402,41 @@ def upload_to_image(self, volume, force, image_name, container_format, return self._action('os-volume_upload_image', volume, {'force': force, - 'image_name': image_name, - 'container_format': container_format, - 'disk_format': disk_format}) + 'image_name': image_name, + 'container_format': container_format, + 'disk_format': disk_format}) def force_delete(self, volume): + """Delete the specified volume ignoring its current state. + + :param volume: The :class:`Volume` to force-delete. + """ return self._action('os-force_delete', base.getid(volume)) - def reset_state(self, volume, state): - """Update the provided volume with the provided state.""" - return self._action('os-reset_status', volume, {'status': state}) + def reset_state(self, volume, state, attach_status=None, + migration_status=None): + """Update the provided volume with the provided state. + + :param volume: The :class:`Volume` to set the state. + :param state: The state of the volume to be set. + :param attach_status: The attach_status of the volume to be set, + or None to keep the current status. + :param migration_status: The migration_status of the volume to be set, + or None to keep the current status. + """ + body = {'status': state} if state else {} + if attach_status: + body.update({'attach_status': attach_status}) + if migration_status: + body.update({'migration_status': migration_status}) + return self._action('os-reset_status', volume, body) def extend(self, volume, new_size): + """Extend the size of the specified volume. + + :param volume: The UUID of the volume to extend. + :param new_size: The requested size to extend volume to. + """ return self._action('os-extend', base.getid(volume), {'new_size': new_size}) @@ -498,19 +448,22 @@ def get_encryption_metadata(self, volume_id): :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 + metadata = self._get("/volumes/%s/encryption" % volume_id) + return common_base.DictWithMeta(metadata._info, metadata.request_ids) - def migrate_volume(self, volume, host, force_host_copy): + def migrate_volume(self, volume, host, force_host_copy, lock_volume): """Migrate volume to new host. :param volume: The :class:`Volume` to migrate :param host: The destination host :param force_host_copy: Skip driver optimizations + :param lock_volume: Lock the volume and guarantee the migration + to finish """ - return self._action('os-migrate_volume', volume, - {'host': host, 'force_host_copy': force_host_copy}) + {'host': host, 'force_host_copy': force_host_copy, + 'lock_volume': lock_volume}) def migrate_volume_completion(self, old_volume, new_volume, error): """Complete the migration from the old volume to the temp new one. @@ -519,11 +472,11 @@ def migrate_volume_completion(self, old_volume, new_volume, error): :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] + resp, body = self._action('os-migrate_volume_completion', old_volume, + {'new_volume': new_volume_id, + 'error': error}) + return common_base.DictWithMeta(body, resp) def update_all_metadata(self, volume, metadata): """Update all metadata of a volume. @@ -576,14 +529,6 @@ def unmanage(self, volume): """Unmanage a volume.""" return self._action('os-unmanage', volume, None) - def promote(self, volume): - """Promote secondary to be primary in relationship.""" - return self._action('os-promote-replica', volume, None) - - def reenable(self, volume): - """Sync the secondary volume with primary for a relationship.""" - return self._action('os-reenable-replica', volume, None) - def get_pools(self, detail): """Show pool information for backends.""" query_string = "" diff --git a/cinderclient/v3/workers.py b/cinderclient/v3/workers.py new file mode 100644 index 000000000..3794ee921 --- /dev/null +++ b/cinderclient/v3/workers.py @@ -0,0 +1,46 @@ +# Copyright (c) 2016 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. + +""" +Interface to workers API +""" +from cinderclient import api_versions +from cinderclient.apiclient import base as common_base +from cinderclient import base + + +class Service(base.Resource): + def __repr__(self): + return "" % (self.id, self.host, + self.cluster_name or '-') + + @classmethod + def list_factory(cls, mngr, elements): + return [cls(mngr, element, loaded=True) for element in elements] + + +class WorkerManager(base.Manager): + base_url = '/workers' + + @api_versions.wraps('3.24') + def clean(self, **filters): + url = self.base_url + '/cleanup' + resp, body = self.api.client.post(url, body=filters) + + cleaning = Service.list_factory(self, body['cleaning']) + unavailable = Service.list_factory(self, body['unavailable']) + + result = common_base.TupleWithMeta((cleaning, unavailable), resp) + return result diff --git a/cinderclient/openstack/common/__init__.py b/cinderclient/version.py similarity index 79% rename from cinderclient/openstack/common/__init__.py rename to cinderclient/version.py index d1223eaf7..b553b44e3 100644 --- a/cinderclient/openstack/common/__init__.py +++ b/cinderclient/version.py @@ -1,3 +1,4 @@ +# 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 @@ -10,8 +11,10 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. +# -import six +import pbr.version -six.add_move(six.MovedModule('mox', 'mox', 'mox3.mox')) +version_info = pbr.version.VersionInfo('python-cinderclient') +__version__ = version_info.version_string() diff --git a/cinderclient/tests/unit/v2/contrib/__init__.py b/doc/ext/__init__.py similarity index 100% rename from cinderclient/tests/unit/v2/contrib/__init__.py rename to doc/ext/__init__.py diff --git a/doc/ext/cli.py b/doc/ext/cli.py new file mode 100644 index 000000000..bf24faab0 --- /dev/null +++ b/doc/ext/cli.py @@ -0,0 +1,187 @@ +# 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. + +"""Sphinx extension to generate CLI documentation.""" + +from docutils import nodes +from docutils.parsers import rst +from docutils.parsers.rst import directives +from docutils import statemachine as sm +from sphinx.util import logging +from sphinx.util import nested_parse_with_titles + +from cinderclient import api_versions +from cinderclient import shell + +LOG = logging.getLogger(__name__) + + +class CLIDocsDirective(rst.Directive): + """Directive to generate CLI details into docs output.""" + + def _get_usage_lines(self, usage, append_value=None): + """Breaks usage output into separate lines.""" + results = [] + lines = usage.split('\n') + + indent = 0 + if '[' in lines[0]: + indent = lines[0].index('[') + + for line in lines: + if line.strip(): + results.append(line) + + if append_value: + results.append(' {}{}'.format(' ' * indent, append_value)) + + return results + + def _format_description_lines(self, description): + """Formats option description into formatted lines.""" + desc = description.split('\n') + return [line.strip() for line in desc if line.strip() != ''] + + def run(self): + """Load and document the current config options.""" + + cindershell = shell.OpenStackCinderShell() + parser = cindershell.get_base_parser() + + api_version = api_versions.APIVersion(api_versions.MAX_VERSION) + LOG.info('Generating CLI docs %s', api_version) + + cindershell.get_subcommand_parser(api_version, False, []) + + result = sm.ViewList() + source = '<{}>'.format(__name__) + + result.append('.. _cinder_command_usage:', source) + result.append('', source) + result.append('cinder usage', source) + result.append('------------', source) + result.append('', source) + result.append('.. code-block:: console', source) + result.append('', source) + result.append('', source) + usage = self._get_usage_lines( + parser.format_usage(), ' ...') + for line in usage: + result.append(' {}'.format(line), source) + result.append('', source) + + result.append('.. _cinder_command_options:', source) + result.append('', source) + result.append('Optional Arguments', source) + result.append('~~~~~~~~~~~~~~~~~~', source) + result.append('', source) + + # This accesses a private variable from argparse. That's a little + # risky, but since this is just for the docs and not "production" code, + # and since this variable hasn't changed in years, it's a calculated + # risk to make this documentation generation easier. But if something + # suddenly breaks, check here first. + actions = sorted(parser._actions, key=lambda x: x.option_strings[0]) + for action in actions: + if action.help == '==SUPPRESS==': + continue + opts = ', '.join(action.option_strings) + result.append('``{}``'.format(opts), source) + result.append(' {}'.format(action.help), source) + result.append('', source) + + result.append('', source) + result.append('.. _cinder_commands:', source) + result.append('', source) + result.append('Commands', source) + result.append('~~~~~~~~', source) + result.append('', source) + + for cmd in cindershell.subcommands: + if 'completion' in cmd: + continue + result.append('``{}``'.format(cmd), source) + subcmd = cindershell.subcommands[cmd] + description = self._format_description_lines(subcmd.description) + result.append(' {}'.format(description[0]), source) + result.append('', source) + + result.append('', source) + result.append('.. _cinder_command_details:', source) + result.append('', source) + result.append('Command Details', source) + result.append('---------------', source) + result.append('', source) + + for cmd in cindershell.subcommands: + if 'completion' in cmd: + continue + subcmd = cindershell.subcommands[cmd] + result.append('.. _cinder{}:'.format(cmd), source) + result.append('', source) + result.append(subcmd.prog, source) + result.append('~' * len(subcmd.prog), source) + result.append('', source) + result.append('.. code-block:: console', source) + result.append('', source) + usage = self._get_usage_lines(subcmd.format_usage()) + for line in usage: + result.append(' {}'.format(line), source) + result.append('', source) + description = self._format_description_lines(subcmd.description) + result.append(description[0], source) + result.append('', source) + + if len(subcmd._actions) == 0: + continue + + positional = [] + optional = [] + for action in subcmd._actions: + if len(action.option_strings): + if (action.option_strings[0] != '-h' and + action.help != '==SUPPRESS=='): + optional.append(action) + else: + positional.append(action) + + if positional: + result.append('**Positional arguments:**', source) + result.append('', source) + for action in positional: + result.append('``{}``'.format(action.metavar), source) + result.append(' {}'.format(action.help), source) + result.append('', source) + + if optional: + result.append('**Optional arguments:**', source) + result.append('', source) + for action in optional: + result.append('``{} {}``'.format( + ', '.join(action.option_strings), action.metavar), + source) + result.append(' {}'.format(action.help), source) + result.append('', source) + + node = nodes.section() + node.document = self.state.document + nested_parse_with_titles(self.state, result, node) + return node.children + + +def setup(app): + app.add_directive('cli-docs', CLIDocsDirective) + return { + 'version': '1.0', + 'parallel_read_safe': True, + 'parallel_write_safe': True, + } diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 000000000..ec6aec6cf --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,4 @@ +# These are needed for docs generation +openstackdocstheme>=2.2.1 # Apache-2.0 +reno>=3.2.0 # Apache-2.0 +sphinx>=2.0.0,!=2.1.0 # BSD diff --git a/doc/source/cli/details.rst b/doc/source/cli/details.rst new file mode 100644 index 000000000..4311ef005 --- /dev/null +++ b/doc/source/cli/details.rst @@ -0,0 +1,14 @@ +================================================== +Block Storage service (cinder) command-line client +================================================== + +The cinder client is the command-line interface (CLI) for +the Block Storage service (cinder) API and its extensions. + +For help on a specific :command:`cinder` command, enter: + +.. code-block:: console + + $ cinder help COMMAND + +.. cli-docs:: diff --git a/doc/source/cli/index.rst b/doc/source/cli/index.rst new file mode 100644 index 000000000..1c877ddeb --- /dev/null +++ b/doc/source/cli/index.rst @@ -0,0 +1,85 @@ +============================== +:program:`cinder` CLI man page +============================== + +.. program:: cinder +.. highlight:: bash + + +SYNOPSIS +======== + +:program:`cinder` [options] [command-options] + +:program:`cinder help` + +:program:`cinder help` + + +DESCRIPTION +=========== + +The :program:`cinder` command line utility interacts with OpenStack Block +Storage Service (Cinder). + +In order to use the CLI, you must provide your OpenStack username, password, +project (historically called tenant), and auth endpoint. You can use +configuration options `--os-username`, `--os-password`, `--os-project-name` or +`--os-project-id`, and `--os-auth-url` or set corresponding environment +variables:: + + export OS_USERNAME=user + export OS_PASSWORD=pass + export OS_PROJECT_NAME=myproject + export OS_AUTH_URL=http://auth.example.com:5000/v3 + +You can select an API version to use by `--os-volume-api-version` option or by +setting corresponding environment variable:: + + export OS_VOLUME_API_VERSION=3 + + +OPTIONS +======= + +To get a list of available commands and options run:: + + cinder help + +To get usage and options of a command:: + + cinder help + +You can see more details about the Cinder Command-Line Client at +:doc:`details`. + +EXAMPLES +======== + +Get information about volume create command:: + + cinder help create + +List all the volumes:: + + cinder list + +Create new volume:: + + cinder create 1 --name volume01 + +Describe a specific volume:: + + cinder show 65d23a41-b13f-4345-ab65-918a4b8a6fe6 + +Create a snapshot:: + + cinder snapshot-create 65d23a41-b13f-4345-ab65-918a4b8a6fe6 \ + --name qt-snap + + +BUGS +==== + +Cinder client is hosted in Launchpad so you can view current bugs at +https://bugs.launchpad.net/python-cinderclient/. diff --git a/doc/source/conf.py b/doc/source/conf.py index d433af022..d00b7659c 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,34 +1,39 @@ -# -*- coding: utf-8 -*- +# 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 # -# python-cinderclient documentation build configuration file, created by -# sphinx-quickstart on Sun Dec 6 14:19:25 2009. +# http://www.apache.org/licenses/LICENSE-2.0 # -# This file is execfile()d with current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +# 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. + +# python-cinderclient documentation build configuration file import os import sys -import pbr.version + +sys.setrecursionlimit(4000) # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.append(os.path.abspath('.')) -BASE_DIR = os.path.dirname(os.path.abspath(__file__)) -ROOT = os.path.abspath(os.path.join(BASE_DIR, "..", "..")) -sys.path.insert(0, ROOT) +# sys.path.append(os.path.abspath('.')) +sys.path.insert(0, os.path.join(os.path.abspath('..'), 'ext')) # -- General configuration ---------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'oslosphinx'] +extensions = [ + 'sphinx.ext.autodoc', + 'openstackdocstheme', + 'reno.sphinxext', + 'cli', +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -36,46 +41,21 @@ # The suffix of source filenames. source_suffix = '.rst' -# The encoding of source files. -#source_encoding = 'utf-8' - # The master toctree document. master_doc = 'index' # General information about the project. -project = 'python-cinderclient' copyright = 'OpenStack Contributors' -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -version_info = pbr.version.VersionInfo('python-cinderclient') -# The short X.Y version. -version = version_info.version_string() -# The full version, including alpha/beta/rc tags. -release = version_info.release_string() - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of documents that shouldn't be included in the build. -#unused_docs = [] +# done by the openstackdocstheme ext +# project = 'python-cinderclient' +# version = version_info.version_string() +# release = version_info.release_string() # List of directories, relative to source directory, that shouldn't be searched # for source files. exclude_trees = [] -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - # If true, '()' will be appended to :func: etc. cross-reference text. add_function_parentheses = True @@ -83,124 +63,77 @@ # unit titles (such as .. function::). add_module_names = True -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +pygments_style = 'native' - -man_pages = [ - ('man/cinder', 'cinder', u'Client for OpenStack Block Storage API', - [u'OpenStack Contributors'], 1), -] # -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. -#html_theme = 'nature' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# html_theme = 'nature' +html_theme = 'openstackdocs' # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_use_modindex = True +# html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%Y-%m-%d %H:%M' -# If false, no index is generated. -#html_use_index = True +# -- Options for manual page output ------------------------------------------ -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = '' +man_pages = [ + ('cli/details', 'cinder', 'Client for OpenStack Block Storage API', + ['OpenStack Contributors'], 1), +] -# Output file base name for HTML help builder. -htmlhelp_basename = 'python-cinderclientdoc' +# -- Options for openstackdocstheme ------------------------------------------- +openstackdocs_repo_name = 'openstack/python-cinderclient' +openstackdocs_bug_project = 'python-cinderclient' +openstackdocs_bug_tag = 'doc' +openstackdocs_pdf_link = True # -- Options for LaTeX output ------------------------------------------------- # The paper size ('letter' or 'a4'). -#latex_paper_size = 'letter' +# latex_paper_size = 'letter' # The font size ('10pt', '11pt' or '12pt'). -#latex_font_size = '10pt' +# latex_font_size = '10pt' # Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]) -# . +# (source start file, target name, title, author, documentclass +# [howto/manual]). latex_documents = [ - ('index', 'python-cinderclient.tex', 'python-cinderclient Documentation', - 'Rackspace - based on work by Jacob Kaplan-Moss', 'manual'), + ('index', 'doc-python-cinderclient.tex', 'Cinder Client Documentation', + 'Cinder Contributors', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # Additional stuff for the LaTeX preamble. -#latex_preamble = '' +# latex_preamble = '' # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_use_modindex = True +# latex_use_modindex = True + +# Disable usage of xindy https://bugzilla.redhat.com/show_bug.cgi?id=1643664 +latex_use_xindy = False + +latex_domain_indices = False + +latex_elements = { + 'makeindex': '', + 'printindex': '', + 'preamble': r'\setcounter{tocdepth}{3}', +} + +latex_additional_files = [] diff --git a/doc/source/contributor/contributing.rst b/doc/source/contributor/contributing.rst new file mode 100644 index 000000000..b43385a74 --- /dev/null +++ b/doc/source/contributor/contributing.rst @@ -0,0 +1,14 @@ +============================ +So You Want to Contribute... +============================ + +For general information on contributing to OpenStack, please check out the +`contributor guide `_ to get started. +It covers all the basics that are common to all OpenStack projects: the +accounts you need, the basics of interacting with our Gerrit review system, how +we communicate as a community, etc. + +The python-cinderclient is maintained by the OpenStack Cinder project. +To understand our development process and how you can contribute to it, please +look at the Cinder project's general contributor's page: +http://docs.openstack.org/cinder/latest/contributor/contributing.html diff --git a/doc/source/contributor/functional_tests.rst b/doc/source/contributor/functional_tests.rst new file mode 100644 index 000000000..1e3f239c4 --- /dev/null +++ b/doc/source/contributor/functional_tests.rst @@ -0,0 +1,48 @@ +================ +Functional Tests +================ + +Cinderclient contains a suite of functional tests, in the cinderclient/ +tests/functional directory. + +These are currently non-voting, meaning that zuul will not reject a +patched based on failure of the functional tests. It is highly recommended, +however, that these tests are investigated in the case of a failure. + +Running the tests +----------------- +Run the tests using tox, via the tox.ini file. To run all +tests simply run:: + + tox -e functional + +This will create a virtual environment, load all the packages from +test-requirements.txt and run all unit tests as well as run flake8 and hacking +checks against the code. + +Note that you can inspect the tox.ini file to get more details on the available +options and what the test run does by default. + +Running a subset of tests using tox +----------------------------------- +One common activity is to just run a single test, you can do this with tox +simply by specifying to just run py27 or py34 tests against a single test:: + + tox -e functional -- -n cinderclient.tests.functional.test_readonly_cli.CinderClientReadOnlyTests.test_list + +Or all tests in the test_readonly_clitest_readonly_cli.py file:: + + tox -e functional -- -n cinderclient.tests.functional.test_readonly_cli + +For more information on these options and how to run tests, please see the +`stestr documentation `_. + +Gotchas +------- + +The cinderclient.tests.functional.test_cli.CinderBackupTests.test_backup_create +and_delete test will fail in Devstack without c-bak service running, which +requires Swift. Make sure Swift is enabled when you stack.sh by putting this in +local.conf:: + + enable_service s-proxy s-object s-container s-account diff --git a/doc/source/contributor/unit_tests.rst b/doc/source/contributor/unit_tests.rst new file mode 100644 index 000000000..248e1beb5 --- /dev/null +++ b/doc/source/contributor/unit_tests.rst @@ -0,0 +1,56 @@ +========== +Unit Tests +========== + +Cinderclient contains a suite of unit tests, in the cinderclient/tests/unit +directory. + +Any proposed code change will be automatically rejected by the OpenStack +Jenkins server if the change causes unit test failures. + +Running the tests +----------------- +There are a number of ways to run unit tests currently, and there's a +combination of frameworks used depending on what commands you use. The +preferred method is to use tox, which calls ostestr via the tox.ini file. +To run all tests simply run:: + + tox + +This will create a virtual environment, load all the packages from +test-requirements.txt and run all unit tests as well as run flake8 and hacking +checks against the code. + +Note that you can inspect the tox.ini file to get more details on the available +options and what the test run does by default. + +Running a subset of tests using tox +----------------------------------- +One common activity is to just run a single test, you can do this with tox +simply by specifying to just run py3 tests against a single test:: + + tox -e py3 -- -n cinderclient.tests.unit.v3.test_volumes.VolumesTest.test_create_volume + +Or all tests in the test_volumes.py file:: + + tox -e py3 -- -n cinderclient.tests.unit.v3.test_volumes + +For more information on these options and how to run tests, please see the +`stestr documentation `_. + +Gotchas +------- + +**Running Tests from Shared Folders** + +If you are running the unit tests from a shared folder, you may see tests start +to fail or stop completely as a result of Python lockfile issues. You +can get around this by manually setting or updating the following line in +``cinder/tests/conf_fixture.py``:: + + CONF['lock_path'].SetDefault('/tmp') + +Note that you may use any location (not just ``/tmp``!) as long as it is not +a shared folder. + +.. rubric:: Footnotes diff --git a/doc/source/index.rst b/doc/source/index.rst index 67e843297..eeb706c7d 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,9 +1,12 @@ Python API ========== -In order to use the python api directly, you must first obtain an auth token and identify which endpoint you wish to speak to. Once you have done so, you can use the API like so:: + +In order to use the Python api directly, you must first obtain an auth token +and identify which endpoint you wish to speak to. Once you have done so, you +can use the API like so:: >>> from cinderclient import client - >>> cinder = client.Client('1', $OS_USER_NAME, $OS_PASSWORD, $OS_TENANT_NAME, $OS_AUTH_URL) + >>> cinder = client.Client('1', $OS_USER_NAME, $OS_PASSWORD, $OS_PROJECT_NAME, $OS_AUTH_URL) >>> cinder.volumes.list() [] >>> myvol = cinder.volumes.create(display_name="test-vol", size=1) @@ -11,31 +14,120 @@ In order to use the python api directly, you must first obtain an auth token and ce06d0a8-5c1b-4e2c-81d2-39eca6bbfb70 >>> cinder.volumes.list() [] - >>>myvol.delete + >>> myvol.delete() + +Alternatively, you can create a client instance using the keystoneauth session +API:: + + >>> from keystoneauth1 import loading + >>> from keystoneauth1 import session + >>> from cinderclient import client + >>> loader = loading.get_plugin_loader('password') + >>> auth = loader.load_from_options(auth_url=AUTH_URL, + ... username=USERNAME, + ... password=PASSWORD, + ... project_id=PROJECT_ID, + ... user_domain_name=USER_DOMAIN_NAME) + >>> sess = session.Session(auth=auth) + >>> cinder = client.Client(VERSION, session=sess) + >>> cinder.volumes.list() + [] -Command-line Tool -================= -In order to use the CLI, you must provide your OpenStack username, password, tenant, and auth endpoint. Use the corresponding configuration options (``--os-username``, ``--os-password``, ``--os-tenant-id``, and ``--os-auth-url``) or set them in environment variables:: +User Guides +~~~~~~~~~~~ - export OS_USERNAME=user - export OS_PASSWORD=pass - export OS_TENANT_ID=b363706f891f48019483f8bd6503c54b - export OS_AUTH_URL=http://auth.example.com:5000/v2.0 +.. toctree:: + :maxdepth: 2 -Once you've configured your authentication parameters, you can run ``cinder help`` to see a complete listing of available commands. + user/shell + user/no_auth -See also :doc:`/man/cinder`. +Command-Line Reference +~~~~~~~~~~~~~~~~~~~~~~ +.. toctree:: + :maxdepth: 2 + + cli/index + cli/details + +Developer Guides +~~~~~~~~~~~~~~~~ + +.. toctree:: + :maxdepth: 2 + + contributor/contributing + contributor/functional_tests + contributor/unit_tests Release Notes -============= +~~~~~~~~~~~~~ + +All python-cinderclient release notes can now be found on the `release notes`_ +page. + +.. _`release notes`: https://docs.openstack.org/releasenotes/python-cinderclient/ + +The following are kept for historical purposes. + +1.4.0 +----- + +* Improved error reporting on reaching quota. +* Volume status management for volume migration. +* Added command to fetch specified backend capabilities. +* Added commands for modifying image metadata. +* Support for non-disruptive backup. +* Support for cloning consistency groups. + +.. _1493612: https://bugs.launchpad.net/python-cinderclient/+bug/1493612 +.. _1482988: https://bugs.launchpad.net/python-cinderclient/+bug/1482988 +.. _1422046: https://bugs.launchpad.net/python-cinderclient/+bug/1422046 +.. _1481478: https://bugs.launchpad.net/python-cinderclient/+bug/1481478 +.. _1475430: https://bugs.launchpad.net/python-cinderclient/+bug/1475430 + +1.3.1 +----- + +* Fixed usage of the --debug option. +* Documentation and API example improvements. +* Set max volume size limit for the project. +* Added encryption-type-update to cinderclient. +* Added volume multi attach support. +* Support host-attach of volumes. + +.. _1467628: https://bugs.launchpad.net/python-cinderclient/+bug/1467628 +.. _1454436: https://bugs.launchpad.net/cinder/+bug/1454436 +.. _1423884: https://bugs.launchpad.net/python-cinderclient/+bug/1423884 + +1.3.0 +----- + +* Revert version discovery support due to this breaking deployments using + proxies. We will revisit this once the Kilo config option 'public_endpoint' + has been available longer to allow these deployments to work again with + version discovery available from the Cinder client. +* Add volume multi-attach support. +* Add encryption-type-update to update volume encryption types. + +.. _1454276: http://bugs.launchpad.net/python-cinderclient/+bug/1454276 +.. _1462104: http://bugs.launchpad.net/python-cinderclient/+bug/1462104 +.. _1418580: http://bugs.launchpad.net/python-cinderclient/+bug/1418580 +.. _1464160: http://bugs.launchpad.net/python-cinderclient/+bug/1464160 -MASTER +1.2.2 ----- +* IMPORTANT: version discovery breaks deployments using proxies and has been + reverted in v1.3.0 . Do not use this version. +* Update requirements to resolve conflicts with other OpenStack projects + 1.2.1 ----- +* IMPORTANT: version discovery breaks deployments using proxies and has been + reverted in v1.3.0 . Do not use this version. * Remove warnings about Keystone unable to contact endpoint for discovery. * backup-create subcommand allows specifying --incremental to do an incremental backup. @@ -45,16 +137,18 @@ MASTER consisgroup-create-from-src subcommand. * --force no longer needs a boolean to be specified. -.. _1341411 http://bugs.launchpad.net/python-cinderclient/+bug/1341411 -.. _1429102 http://bugs.launchpad.net/python-cinderclient/+bug/1429102 -.. _1447589 http://bugs.launchpad.net/python-cinderclient/+bug/1447589 -.. _1447162 http://bugs.launchpad.net/python-cinderclient/+bug/1447162 -.. _1448244 http://bugs.launchpad.net/python-cinderclient/+bug/1448244 -.. _1244453 http://bugs.launchpad.net/python-cinderclient/+bug/1244453 +.. _1341411: http://bugs.launchpad.net/python-cinderclient/+bug/1341411 +.. _1429102: http://bugs.launchpad.net/python-cinderclient/+bug/1429102 +.. _1447589: http://bugs.launchpad.net/python-cinderclient/+bug/1447589 +.. _1447162: http://bugs.launchpad.net/python-cinderclient/+bug/1447162 +.. _1448244: http://bugs.launchpad.net/python-cinderclient/+bug/1448244 +.. _1244453: http://bugs.launchpad.net/python-cinderclient/+bug/1244453 1.2.0 ----- +* IMPORTANT: version discovery breaks deployments using proxies and has been + reverted in v1.3.0 . Do not use this version. * Add metadata during snapshot create. * Add TTY password entry when no password is environment vars or --os-password. * Ability to set backup quota in quota-update subcommand. @@ -72,50 +166,53 @@ MASTER * --sort option available instead of --sort-key and --sort-dir. E.q. --sort [:]. * Volume type name can now be updated via subcommand type-update. -* bash compeletion gives subcommands when using 'cinder help'. +* bash completion gives subcommands when using 'cinder help'. * Version discovery is now available. You no longer need a volumev2 service type in your keystone catalog. * Filter by tenant in list subcommand. -.. _1373662 http://bugs.launchpad.net/python-cinderclient/+bug/1373662 -.. _1376311 http://bugs.launchpad.net/python-cinderclient/+bug/1376311 -.. _1368910 http://bugs.launchpad.net/python-cinderclient/+bug/1368910 -.. _1374211 http://bugs.launchpad.net/python-cinderclient/+bug/1374211 -.. _1379505 http://bugs.launchpad.net/python-cinderclient/+bug/1379505 -.. _1282324 http://bugs.launchpad.net/python-cinderclient/+bug/1282324 -.. _1358926 http://bugs.launchpad.net/python-cinderclient/+bug/1358926 -.. _1342192 http://bugs.launchpad.net/python-cinderclient/+bug/1342192 -.. _1386232 http://bugs.launchpad.net/python-cinderclient/+bug/1386232 -.. _1402846 http://bugs.launchpad.net/python-cinderclient/+bug/1402846 -.. _1373766 http://bugs.launchpad.net/python-cinderclient/+bug/1373766 -.. _1403902 http://bugs.launchpad.net/python-cinderclient/+bug/1403902 -.. _1377823 http://bugs.launchpad.net/python-cinderclient/+bug/1377823 -.. _1350702 http://bugs.launchpad.net/python-cinderclient/+bug/1350702 -.. _1357559 http://bugs.launchpad.net/python-cinderclient/+bug/1357559 -.. _1341424 http://bugs.launchpad.net/python-cinderclient/+bug/1341424 -.. _1365273 http://bugs.launchpad.net/python-cinderclient/+bug/1365273 -.. _1404020 http://bugs.launchpad.net/python-cinderclient/+bug/1404020 -.. _1380729 http://bugs.launchpad.net/python-cinderclient/+bug/1380729 -.. _1417273 http://bugs.launchpad.net/python-cinderclient/+bug/1417273 -.. _1420238 http://bugs.launchpad.net/python-cinderclient/+bug/1420238 -.. _1421210 http://bugs.launchpad.net/python-cinderclient/+bug/1421210 -.. _1351084 http://bugs.launchpad.net/python-cinderclient/+bug/1351084 -.. _1366289 http://bugs.launchpad.net/python-cinderclient/+bug/1366289 -.. _1309086 http://bugs.launchpad.net/python-cinderclient/+bug/1309086 -.. _1379486 http://bugs.launchpad.net/python-cinderclient/+bug/1379486 -.. _1422244 http://bugs.launchpad.net/python-cinderclient/+bug/1422244 -.. _1399747 http://bugs.launchpad.net/python-cinderclient/+bug/1399747 -.. _1431693 http://bugs.launchpad.net/python-cinderclient/+bug/1431693 -.. _1428764 http://bugs.launchpad.net/python-cinderclient/+bug/1428764 +.. _1373662: http://bugs.launchpad.net/python-cinderclient/+bug/1373662 +.. _1376311: http://bugs.launchpad.net/python-cinderclient/+bug/1376311 +.. _1368910: http://bugs.launchpad.net/python-cinderclient/+bug/1368910 +.. _1374211: http://bugs.launchpad.net/python-cinderclient/+bug/1374211 +.. _1379505: http://bugs.launchpad.net/python-cinderclient/+bug/1379505 +.. _1282324: http://bugs.launchpad.net/python-cinderclient/+bug/1282324 +.. _1358926: http://bugs.launchpad.net/python-cinderclient/+bug/1358926 +.. _1342192: http://bugs.launchpad.net/python-cinderclient/+bug/1342192 +.. _1386232: http://bugs.launchpad.net/python-cinderclient/+bug/1386232 +.. _1402846: http://bugs.launchpad.net/python-cinderclient/+bug/1402846 +.. _1373766: http://bugs.launchpad.net/python-cinderclient/+bug/1373766 +.. _1403902: http://bugs.launchpad.net/python-cinderclient/+bug/1403902 +.. _1377823: http://bugs.launchpad.net/python-cinderclient/+bug/1377823 +.. _1350702: http://bugs.launchpad.net/python-cinderclient/+bug/1350702 +.. _1357559: http://bugs.launchpad.net/python-cinderclient/+bug/1357559 +.. _1341424: http://bugs.launchpad.net/python-cinderclient/+bug/1341424 +.. _1365273: http://bugs.launchpad.net/python-cinderclient/+bug/1365273 +.. _1404020: http://bugs.launchpad.net/python-cinderclient/+bug/1404020 +.. _1380729: http://bugs.launchpad.net/python-cinderclient/+bug/1380729 +.. _1417273: http://bugs.launchpad.net/python-cinderclient/+bug/1417273 +.. _1420238: http://bugs.launchpad.net/python-cinderclient/+bug/1420238 +.. _1421210: http://bugs.launchpad.net/python-cinderclient/+bug/1421210 +.. _1351084: http://bugs.launchpad.net/python-cinderclient/+bug/1351084 +.. _1366289: http://bugs.launchpad.net/python-cinderclient/+bug/1366289 +.. _1309086: http://bugs.launchpad.net/python-cinderclient/+bug/1309086 +.. _1379486: http://bugs.launchpad.net/python-cinderclient/+bug/1379486 +.. _1422244: http://bugs.launchpad.net/python-cinderclient/+bug/1422244 +.. _1399747: http://bugs.launchpad.net/python-cinderclient/+bug/1399747 +.. _1431693: http://bugs.launchpad.net/python-cinderclient/+bug/1431693 +.. _1428764: http://bugs.launchpad.net/python-cinderclient/+bug/1428764 ** Python 2.4 support removed. + ** --sort-key and --sort-dir are deprecated. Use --sort instead. + ** A dash will be displayed of None when there is no data to display under a column. 1.1.1 ------ -.. _1370152 http://bugs.launchpad.net/python-cinderclient/+bug/1370152 + +.. _1370152: http://bugs.launchpad.net/python-cinderclient/+bug/1370152 1.1.0 ------ @@ -124,30 +221,34 @@ MASTER * Use Adapter from keystoneclient * Add support for Replication feature * Add pagination for Volume List +* Note Connection refused --> Connection error commit: + c9e7818f3f90ce761ad8ccd09181c705880a4266 +* Note Mask Passwords in log output commit: + 80582f2b860b2dadef7ae07bdbd8395bf03848b1 + -.. _1325773 http://bugs.launchpad.net/python-cinderclient/+bug/1325773 -.. _1333257 http://bugs.launchpad.net/python-cinderclient/+bug/1333257 -.. _1268480 http://bugs.launchpad.net/python-cinderclient/+bug/1268480 -.. _1275025 http://bugs.launchpad.net/python-cinderclient/+bug/1275025 -.. _1258489 http://bugs.launchpad.net/python-cinderclient/+bug/1258489 -.. _1241682 http://bugs.launchpad.net/python-cinderclient/+bug/1241682 -.. _1203471 http://bugs.launchpad.net/python-cinderclient/+bug/1203471 -.. _1210874 http://bugs.launchpad.net/python-cinderclient/+bug/1210874 -.. _1200214 http://bugs.launchpad.net/python-cinderclient/+bug/1200214 -.. _1130572 http://bugs.launchpad.net/python-cinderclient/+bug/1130572 -.. _1156994 http://bugs.launchpad.net/python-cinderclient/+bug/1156994 - -** Note Connection refused --> Connection error commit: c9e7818f3f90ce761ad8ccd09181c705880a4266 -** Note Mask Passwords in log output commit: 80582f2b860b2dadef7ae07bdbd8395bf03848b1 +.. _1325773: http://bugs.launchpad.net/python-cinderclient/+bug/1325773 +.. _1333257: http://bugs.launchpad.net/python-cinderclient/+bug/1333257 +.. _1268480: http://bugs.launchpad.net/python-cinderclient/+bug/1268480 +.. _1275025: http://bugs.launchpad.net/python-cinderclient/+bug/1275025 +.. _1258489: http://bugs.launchpad.net/python-cinderclient/+bug/1258489 +.. _1241682: http://bugs.launchpad.net/python-cinderclient/+bug/1241682 +.. _1203471: http://bugs.launchpad.net/python-cinderclient/+bug/1203471 +.. _1210874: http://bugs.launchpad.net/python-cinderclient/+bug/1210874 +.. _1200214: http://bugs.launchpad.net/python-cinderclient/+bug/1200214 +.. _1130572: http://bugs.launchpad.net/python-cinderclient/+bug/1130572 +.. _1156994: http://bugs.launchpad.net/python-cinderclient/+bug/1156994 1.0.9 ------ + .. _1255905: http://bugs.launchpad.net/python-cinderclient/+bug/1255905 .. _1267168: http://bugs.launchpad.net/python-cinderclient/+bug/1267168 .. _1284540: http://bugs.launchpad.net/python-cinderclient/+bug/1284540 1.0.8 ----- + * Add support for reset-state on multiple volumes or snapshots at once * Add volume retype command @@ -166,6 +267,7 @@ MASTER 1.0.7 ----- + * Add support for read-only volumes * Add support for setting snapshot metadata * Deprecate volume-id arg to backup restore in favor of --volume @@ -183,6 +285,7 @@ MASTER 1.0.6 ----- + * Add support for multiple endpoints * Add response info for backup command * Add metadata option to cinder list command @@ -209,6 +312,7 @@ MASTER 1.0.5 ----- + * Add CLI man page * Add Availability Zone list command * Add support for scheduler-hints @@ -228,7 +332,9 @@ MASTER 1.0.4 ----- + * Added support for backup-service commands + .. _1163546: http://bugs.launchpad.net/python-cinderclient/+bug/1163546 .. _1161857: http://bugs.launchpad.net/python-cinderclient/+bug/1161857 .. _1160898: http://bugs.launchpad.net/python-cinderclient/+bug/1160898 @@ -250,6 +356,7 @@ MASTER * Add retries to cinderclient operations * Add Type/Extra-Specs support * Add volume and snapshot rename commands + .. _1155655: http://bugs.launchpad.net/python-cinderclient/+bug/1155655 .. _1130730: http://bugs.launchpad.net/python-cinderclient/+bug/1130730 .. _1068521: http://bugs.launchpad.net/python-cinderclient/+bug/1068521 diff --git a/doc/source/man/cinder.rst b/doc/source/man/cinder.rst deleted file mode 100644 index 50fb644f0..000000000 --- a/doc/source/man/cinder.rst +++ /dev/null @@ -1,58 +0,0 @@ -============================== -:program:`cinder` CLI man page -============================== - -.. program:: cinder -.. highlight:: bash - - -SYNOPSIS -======== - -:program:`cinder` [options] [command-options] - -:program:`cinder help` - -:program:`cinder help` - - -DESCRIPTION -=========== - -The :program:`cinder` command line utility interacts with OpenStack Block -Storage Service (Cinder). - -In order to use the CLI, you must provide your OpenStack username, password, -project (historically called tenant), and auth endpoint. You can use -configuration options :option:`--os-username`, :option:`--os-password`, -:option:`--os-tenant-name` or :option:`--os-tenant-id`, and -:option:`--os-auth-url` or set corresponding environment variables:: - - export OS_USERNAME=user - export OS_PASSWORD=pass - export OS_TENANT_NAME=myproject - export OS_AUTH_URL=http://auth.example.com:5000/v2.0 - -You can select an API version to use by :option:`--os-volume-api-version` -option or by setting corresponding environment variable:: - - export OS_VOLUME_API_VERSION=2 - - -OPTIONS -======= - -To get a list of available commands and options run:: - - cinder help - -To get usage and options of a command:: - - cinder help - - -BUGS -==== - -Cinder client is hosted in Launchpad so you can view current bugs at -https://bugs.launchpad.net/python-cinderclient/. diff --git a/doc/source/user/no_auth.rst b/doc/source/user/no_auth.rst new file mode 100644 index 000000000..597b69abc --- /dev/null +++ b/doc/source/user/no_auth.rst @@ -0,0 +1,32 @@ +============ +Using noauth +============ + +Cinder Server side API setup +============================ +The changes in the cinder.conf on your cinder-api node +are minimal, just set authstrategy to noauth:: + + [DEFAULT] + auth_strategy = noauth + ... + +Using cinderclient +------------------ +To use the cinderclient you'll need to set the following env variables:: + + OS_AUTH_TYPE=noauth + CINDER_ENDPOINT=http://:8776/v3 + OS_PROJECT_ID=foo + OS_VOLUME_API_VERSION=3.10 + +Note that you can have multiple projects, however we don't currently do +any sort of authentication of ownership because, well that's the whole +point, it's noauth. + +Each of these options can also be specified on the cmd line:: + + cinder --os-auth-type=noauth \ + --os-endpoint=http://:8776/v3 \ + --os-project-id=admin \ + --os-volume-api-version=3.10 list diff --git a/doc/source/shell.rst b/doc/source/user/shell.rst similarity index 57% rename from doc/source/shell.rst rename to doc/source/user/shell.rst index 96b4f4fff..7d478cf4f 100644 --- a/doc/source/shell.rst +++ b/doc/source/user/shell.rst @@ -1,5 +1,5 @@ The :program:`cinder` shell utility -========================================= +=================================== .. program:: cinder .. highlight:: bash @@ -8,9 +8,9 @@ The :program:`cinder` shell utility interacts with the OpenStack Cinder API from the command line. It supports the entirety of the OpenStack Cinder API. You'll need to provide :program:`cinder` with your OpenStack username and -API key. You can do this with the :option:`--os-username`, :option:`--os-password` -and :option:`--os-tenant-name` options, but it's easier to just set them as -environment variables by setting two environment variables: +API key. You can do this with the `--os-username`, `--os-password` and +`--os-tenant-name` options, but it's easier to just set them as environment +variables by setting two environment variables: .. envvar:: OS_USERNAME or CINDER_USERNAME @@ -20,7 +20,7 @@ environment variables by setting two environment variables: Your password. -.. envvar:: OS_TENANT_NAME or CINDER_PROJECT_ID +.. envvar:: OS_PROJECT_NAME or CINDER_PROJECT_ID Project for work. @@ -36,9 +36,17 @@ For example, in Bash you'd use:: export OS_USERNAME=yourname export OS_PASSWORD=yadayadayada - export OS_TENANT_NAME=myproject - export OS_AUTH_URL=http://... - export OS_VOLUME_API_VERSION=1 + export OS_PROJECT_NAME=myproject + export OS_AUTH_URL=http://auth.example.com:5000/v3 + export OS_VOLUME_API_VERSION=3 + +If OS_VOLUME_API_VERSION is not set, the highest version +supported by the server will be used. + +If OS_VOLUME_API_VERSION exceeds the highest version +supported by the server, the highest version supported by +both the client and server will be used. A warning +message is printed when this occurs. From there, all shell commands take the form:: diff --git a/functional_creds.conf.sample b/functional_creds.conf.sample new file mode 100644 index 000000000..081a73681 --- /dev/null +++ b/functional_creds.conf.sample @@ -0,0 +1,8 @@ +# Credentials for functional testing +[auth] +uri = http://10.42.0.50:5000/v2.0 + +[admin] +user = admin +tenant = admin +pass = secrete diff --git a/openstack-common.conf b/openstack-common.conf deleted file mode 100644 index 651178a63..000000000 --- a/openstack-common.conf +++ /dev/null @@ -1,10 +0,0 @@ -[DEFAULT] - -# The list of modules to copy from openstack-common -module=apiclient -module=py3kcompat -module=strutils -module=install_venv_common - -# The base module to hold the copy of openstack.common -base=cinderclient diff --git a/pylintrc b/pylintrc new file mode 100644 index 000000000..f399ffe74 --- /dev/null +++ b/pylintrc @@ -0,0 +1,37 @@ +# The format of this file isn't really documented; just use --generate-rcfile + +[Messages Control] +# C0111: Don't require docstrings on every method +# W0511: TODOs in code comments are fine. +# W0142: *args and **kwargs are fine. +# W0622: Redefining id is fine. +disable=C0111,W0511,W0142,W0622 + +[Basic] +# Variable names can be 1 to 31 characters long, with lowercase and underscores +variable-rgx=[a-z_][a-z0-9_]{0,30}$ + +# Argument names can be 2 to 31 characters long, with lowercase and underscores +argument-rgx=[a-z_][a-z0-9_]{1,30}$ + +# Method names should be at least 3 characters long +# and be lowercased with underscores +method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|tearDown)$ + +# Don't require docstrings on tests. +no-docstring-rgx=((__.*__)|([tT]est.*)|setUp|tearDown)$ + +[Design] +max-public-methods=100 +min-public-methods=0 +max-args=6 + +[Variables] + +dummy-variables-rgx=_ + +[Typecheck] +# Disable warnings on the HTTPSConnection classes because pylint doesn't +# support importing from six.moves yet, see: +# https://bitbucket.org/logilab/pylint/issue/550/ +ignored-classes=HTTPSConnection diff --git a/releasenotes/notes/add-generic-reset-state-command-d83v1f3accbf5807.yaml b/releasenotes/notes/add-generic-reset-state-command-d83v1f3accbf5807.yaml new file mode 100644 index 000000000..0a2b5ba91 --- /dev/null +++ b/releasenotes/notes/add-generic-reset-state-command-d83v1f3accbf5807.yaml @@ -0,0 +1,7 @@ +--- +features: + - Use 'cinder reset-state' as generic resource reset + state command for resource 'volume', 'snapshot', 'backup' + 'group' and 'group-snapshot'. Also change volume's + default status from 'available' to none when no + status is specified. diff --git a/releasenotes/notes/adding-option-is-public-to-type-list-9a16bd9c2b8eb65a.yaml b/releasenotes/notes/adding-option-is-public-to-type-list-9a16bd9c2b8eb65a.yaml new file mode 100644 index 000000000..4f85a315e --- /dev/null +++ b/releasenotes/notes/adding-option-is-public-to-type-list-9a16bd9c2b8eb65a.yaml @@ -0,0 +1,13 @@ +--- +upgrade: + - | + Adding ``is_public`` support in ``--filters`` option for ``type-list`` + and ``group-type-list`` command. + This option is used to filter volume types and group types on the basis + of visibility. + This option has 3 possible values : True, False, None with details as + follows : + + * True: List public types only + * False: List private types only + * None: List both public and private types diff --git a/releasenotes/notes/attachment-create-optional-server-id-9299d9da2b62b263.yaml b/releasenotes/notes/attachment-create-optional-server-id-9299d9da2b62b263.yaml new file mode 100644 index 000000000..11935820b --- /dev/null +++ b/releasenotes/notes/attachment-create-optional-server-id-9299d9da2b62b263.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + When attaching to a host, we don't need a server id + so it shouldn't be mandatory to be supplied with + attachment-create operation. + The server_id parameter is made optional so we can + create attachments without passing it. + The backward compatibility is maintained so we can pass + it like how we currently do if required. \ No newline at end of file diff --git a/releasenotes/notes/attachment-mode-8427aa6a2fa26e70.yaml b/releasenotes/notes/attachment-mode-8427aa6a2fa26e70.yaml new file mode 100644 index 000000000..7e9158fbe --- /dev/null +++ b/releasenotes/notes/attachment-mode-8427aa6a2fa26e70.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added the ability to specify the read-write or read-only mode of an + attachment starting with microversion 3.54. The command line usage is + `cinder attachment-create --mode [rw|ro]`. diff --git a/releasenotes/notes/backup-user-id-059ccea871893a0b.yaml b/releasenotes/notes/backup-user-id-059ccea871893a0b.yaml new file mode 100644 index 000000000..abafaa701 --- /dev/null +++ b/releasenotes/notes/backup-user-id-059ccea871893a0b.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Starting with API microversion 3.56, ``backup-list`` and ``backup-show`` + will include the ``User ID`` denoting the user that created the backup. diff --git a/releasenotes/notes/bug-1608166-ad91a7a9f50e658a.yaml b/releasenotes/notes/bug-1608166-ad91a7a9f50e658a.yaml new file mode 100644 index 000000000..6f92b742b --- /dev/null +++ b/releasenotes/notes/bug-1608166-ad91a7a9f50e658a.yaml @@ -0,0 +1,7 @@ +--- +deprecations: + - | + The ``cinder endpoints`` command has been deprecated. This + command performs an identity operation, and should now be + handled by ``openstack catalog list``. + [Bug `1608166 `_] diff --git a/releasenotes/notes/bug-1675973-ad91a7a9f50e658a.yaml b/releasenotes/notes/bug-1675973-ad91a7a9f50e658a.yaml new file mode 100644 index 000000000..813339c8f --- /dev/null +++ b/releasenotes/notes/bug-1675973-ad91a7a9f50e658a.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - The mountpoint argument was ignored when creating an attachment + and now has been fixed. + [Bug `1675973 `_] diff --git a/releasenotes/notes/bug-1675974-34edd5g9870e65b2.yaml b/releasenotes/notes/bug-1675974-34edd5g9870e65b2.yaml new file mode 100644 index 000000000..14ef33c2a --- /dev/null +++ b/releasenotes/notes/bug-1675974-34edd5g9870e65b2.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - The 'tenant' argument was ignored when listing attachments, + and now has been fixed. + [Bug `1675974 `_] diff --git a/releasenotes/notes/bug-1675975-ad91a7a34e0esywc.yaml b/releasenotes/notes/bug-1675975-ad91a7a34e0esywc.yaml new file mode 100644 index 000000000..71f1cff6e --- /dev/null +++ b/releasenotes/notes/bug-1675975-ad91a7a34e0esywc.yaml @@ -0,0 +1,6 @@ +--- +fixes: +- The 'server_id' is now a required parameter when creating an + attachment, that means we should create an attachment with + a command like, 'cinder attachment-create '. + [Bug `1675975 `_] diff --git a/releasenotes/notes/bug-1705093-9bc782d44018c27d.yaml b/releasenotes/notes/bug-1705093-9bc782d44018c27d.yaml new file mode 100644 index 000000000..ee7ef731d --- /dev/null +++ b/releasenotes/notes/bug-1705093-9bc782d44018c27d.yaml @@ -0,0 +1,10 @@ +--- +fixes: + - | + Fixes `bug 1705093`_ by having the + ``cinderclient.client.get_highest_client_server_version`` method return a + string rather than a float. The problem with returning a float is when a + user of that method would cast the float result to a str which turns 3.40, + for example, into "3.4" which is wrong. + + .. _bug 1705093: https://bugs.launchpad.net/python-cinderclient/+bug/1705093 diff --git a/releasenotes/notes/bug-1713082-fb9276eed70f7e3b.yaml b/releasenotes/notes/bug-1713082-fb9276eed70f7e3b.yaml new file mode 100644 index 000000000..e9527c3dd --- /dev/null +++ b/releasenotes/notes/bug-1713082-fb9276eed70f7e3b.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + The attachment_ids in the volume info returned by show volume were + incorrect. It was showing the volume_id, not the attachment_id. This fix + changes the attachment_ids returned by show volume to correctly reflect + the attachment_id. + [Bug `1713082 `_] diff --git a/releasenotes/notes/bug-1826286-c9b68709a0d63d06.yaml b/releasenotes/notes/bug-1826286-c9b68709a0d63d06.yaml new file mode 100644 index 000000000..dec2def1c --- /dev/null +++ b/releasenotes/notes/bug-1826286-c9b68709a0d63d06.yaml @@ -0,0 +1,9 @@ +--- +fixes: + - | + The ``discover_version`` function in the ``cinderclient.api_versions`` + module was documented to return the most recent API version supported + by both the client and the target Block Storage API endpoint, but it + was not taking into account the highest API version supported by the + client. Its behavior has been corrected in this release. + [Bug `1826286 `_] diff --git a/releasenotes/notes/bug-1867061-fix-py-raw-error-msg-ff3c6da0b01d5d6c.yaml b/releasenotes/notes/bug-1867061-fix-py-raw-error-msg-ff3c6da0b01d5d6c.yaml new file mode 100644 index 000000000..91d026bf1 --- /dev/null +++ b/releasenotes/notes/bug-1867061-fix-py-raw-error-msg-ff3c6da0b01d5d6c.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + `Bug #1867061 `_: + Fixed raw Python error message when using ``cinder`` without + a subcommand while passing an optional argument, such as + ``--os-volume-api-version``. \ No newline at end of file diff --git a/releasenotes/notes/bug-1915996-3aaa5e2548eb7c93.yaml b/releasenotes/notes/bug-1915996-3aaa5e2548eb7c93.yaml new file mode 100644 index 000000000..89f51d87b --- /dev/null +++ b/releasenotes/notes/bug-1915996-3aaa5e2548eb7c93.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + `Bug #1915996 `_: + Passing client certificates for mTLS connections was not supported + and now has been fixed. + diff --git a/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml b/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml new file mode 100644 index 000000000..87964d159 --- /dev/null +++ b/releasenotes/notes/bug-1995883-force-flag-none-3a7bb87f655bcf42.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + `Bug #1995883 + `_: + Fixed bad format request body generated for the snapshot-create + action when the client supports mv 3.66 or greater but the Block + Storage API being contacted supports < 3.66. diff --git a/releasenotes/notes/bug-1998596-5cac70cc68b3d6a5.yaml b/releasenotes/notes/bug-1998596-5cac70cc68b3d6a5.yaml new file mode 100644 index 000000000..781e11907 --- /dev/null +++ b/releasenotes/notes/bug-1998596-5cac70cc68b3d6a5.yaml @@ -0,0 +1,6 @@ +--- +fixes: + - | + `Bug #1998596 `_: + fixed version discovery if the server was older than the maximum supported + version defined in python-cinderclient. diff --git a/releasenotes/notes/cinder-poll-4f92694cc7eb657a.yaml b/releasenotes/notes/cinder-poll-4f92694cc7eb657a.yaml new file mode 100644 index 000000000..5d6a106cf --- /dev/null +++ b/releasenotes/notes/cinder-poll-4f92694cc7eb657a.yaml @@ -0,0 +1,4 @@ +features: + - | + Support to wait for volume creation until it completes. + The command is: ``cinder create --poll `` diff --git a/releasenotes/notes/cinderclient-5-de0508ce5a221d21.yaml b/releasenotes/notes/cinderclient-5-de0508ce5a221d21.yaml new file mode 100644 index 000000000..2be8179ed --- /dev/null +++ b/releasenotes/notes/cinderclient-5-de0508ce5a221d21.yaml @@ -0,0 +1,31 @@ +--- +prelude: > + This is a major version release of python-cinderclient. Backwards + compatibility has been removed for some long standing deprecations and + support for the Cinder v1 API has been removed. Prior to upgrading to this + release, ensure all Cinder services that need to be managed are 13.0.0 + (Rocky) or later. +upgrade: + - | + This version of the python-cinderclient no longer supports the Cinder v1 + API. Ensure all mananaged services have at least the v2 API available prior + to upgrading this client. + - | + The ``cinder endpoints`` command was deprecated and has now been removed. + The command ``openstack catalog list`` should be used instead. + - | + The ``cinder credentials`` command was deprecated and has now been removed. + The command ``openstack token issue`` should be used instead. + - | + The use of ``--os_tenant_name``, ``--os_tenant_id`` and the environment + variables ``OS_TENANT_NAME`` and ``OS_TENANT_ID`` have been deprecated + for several releases and have now been removed. After upgrading, use the + equivalent ``--os_project_name``, ``--os_project_id``, ``OS_PROJECT_NAME`` + and ``OS_PROJECT_ID``. + - | + The deprecated volume create option ``--allow-multiattach`` has now been + removed. Multiattach capability is now controlled using `volume-type extra + specs `_. + - | + Support for the deprecated ``--sort_key`` and ``--sort_dir`` arguments have + now been dropped. Use the supported ``--sort`` argument instead. diff --git a/releasenotes/notes/cli-api-ver-negotiation-9f8fd8b77ae299fd.yaml b/releasenotes/notes/cli-api-ver-negotiation-9f8fd8b77ae299fd.yaml new file mode 100644 index 000000000..4501850d8 --- /dev/null +++ b/releasenotes/notes/cli-api-ver-negotiation-9f8fd8b77ae299fd.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + Automatic version negotiation for the cinderclient CLI. + If an API version is not specified, the CLI will use the newest + supported by the client and the server. + If an API version newer than the server supports is requested, + the CLI will fall back to the newest version supported by the server + and issue a warning message. + This does not affect cinderclient library usage. + + diff --git a/releasenotes/notes/cluster_commands-dca50e89c9d53cd2.yaml b/releasenotes/notes/cluster_commands-dca50e89c9d53cd2.yaml new file mode 100644 index 000000000..ccebb1e14 --- /dev/null +++ b/releasenotes/notes/cluster_commands-dca50e89c9d53cd2.yaml @@ -0,0 +1,9 @@ +--- +features: + - Service listings will display additional "cluster" field when working with + microversion 3.7 or higher. + - Add clustered services commands to list -summary and detailed- + (`cluster-list`), show (`cluster-show`), and update (`cluster-enable`, + `cluster-disable`). Listing supports filtering by name, binary, + disabled status, number of hosts, number of hosts that are down, and + up/down status. These commands require API version 3.7 or higher. diff --git a/releasenotes/notes/cluster_list_manageable-40c02489b2c95d55.yaml b/releasenotes/notes/cluster_list_manageable-40c02489b2c95d55.yaml new file mode 100644 index 000000000..306c0dc46 --- /dev/null +++ b/releasenotes/notes/cluster_list_manageable-40c02489b2c95d55.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Cinder ``manageable-list`` and ``snapshot-manageable-list`` commands now + accept ``--cluster`` argument to specify the backend we want to list for + microversion 3.17 and higher. This argument and the ``host`` positional + argument are mutually exclusive. diff --git a/releasenotes/notes/cluster_migration_manage-31144d67bbfdb739.yaml b/releasenotes/notes/cluster_migration_manage-31144d67bbfdb739.yaml new file mode 100644 index 000000000..546443eac --- /dev/null +++ b/releasenotes/notes/cluster_migration_manage-31144d67bbfdb739.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Cinder migrate and manage commands now accept ``--cluster`` argument to + define the destination for Active-Active deployments on microversion 3.16 + and higher. This argument and the ``host`` positional argument are + mutually exclusive for the migrate command. diff --git a/releasenotes/notes/collect-timing-ce6d521d40d422fb.yaml b/releasenotes/notes/collect-timing-ce6d521d40d422fb.yaml new file mode 100644 index 000000000..aa2251766 --- /dev/null +++ b/releasenotes/notes/collect-timing-ce6d521d40d422fb.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + `Bug #1960337 `_: Added + support for ``collect-timing`` parameter to see the timings of REST API + requests from the client when using Keystone authentication. diff --git a/releasenotes/notes/deprecate-allow-multiattach-2213a100c65a95c1.yaml b/releasenotes/notes/deprecate-allow-multiattach-2213a100c65a95c1.yaml new file mode 100644 index 000000000..7ace9d9da --- /dev/null +++ b/releasenotes/notes/deprecate-allow-multiattach-2213a100c65a95c1.yaml @@ -0,0 +1,5 @@ +--- +deprecations: + - | + The ``--allow-multiattach`` flag on volume creation has now been marked + deprecated and will be removed in a future release. diff --git a/releasenotes/notes/do-not-reset-volume-status-ae8e28132d7bfacd.yaml b/releasenotes/notes/do-not-reset-volume-status-ae8e28132d7bfacd.yaml new file mode 100644 index 000000000..f45bd069f --- /dev/null +++ b/releasenotes/notes/do-not-reset-volume-status-ae8e28132d7bfacd.yaml @@ -0,0 +1,5 @@ +--- +fixes: +- Default value of reset-state ``state`` option is changed + from ``available`` to ``None`` because unexpected ``state`` + reset happens when resetting migration status. diff --git a/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml b/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml new file mode 100644 index 000000000..5915647ac --- /dev/null +++ b/releasenotes/notes/drop-python-3-6-and-3-7-fe2dc753e456b527.yaml @@ -0,0 +1,6 @@ +--- +upgrade: + - | + Python 3.6 & 3.7 support has been dropped. The minimum version of Python now + supported is Python 3.8. + diff --git a/releasenotes/notes/drop-python2-support-d3a1bedc75445edc.yaml b/releasenotes/notes/drop-python2-support-d3a1bedc75445edc.yaml new file mode 100644 index 000000000..23ee4d5ca --- /dev/null +++ b/releasenotes/notes/drop-python2-support-d3a1bedc75445edc.yaml @@ -0,0 +1,7 @@ +--- +upgrade: + - | + Python 2.7 support has been dropped. Beginning with release 6.0.0, + the minimum version of Python supported by python-cinderclient is + Python 3.6. The last version of python-cinderclient to support + Python 2.7 is the 5.x series from the Train release. diff --git a/releasenotes/notes/drop-v2-support-e578ca21c7c6b532.yaml b/releasenotes/notes/drop-v2-support-e578ca21c7c6b532.yaml new file mode 100644 index 000000000..8360a601f --- /dev/null +++ b/releasenotes/notes/drop-v2-support-e578ca21c7c6b532.yaml @@ -0,0 +1,5 @@ +--- +upgrade: + - | + This release drops support of the Block Storage API v2. The last version + of the python-cinderclient supporting that API is the 7.x series. diff --git a/releasenotes/notes/enhance-backup-restore-shell-command-0cf55df6ca4b4c55.yaml b/releasenotes/notes/enhance-backup-restore-shell-command-0cf55df6ca4b4c55.yaml new file mode 100644 index 000000000..ee5d85202 --- /dev/null +++ b/releasenotes/notes/enhance-backup-restore-shell-command-0cf55df6ca4b4c55.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Enhance the ``backup-restore`` shell command to support restoring to a new + volume created with a specific volume type and/or in a different AZ. New + ``--volume-type`` and ``--availability-zone`` arguments are compatible with + cinder API microversion v3.47 onward. diff --git a/releasenotes/notes/feature-cross-az-backups-9d428ad4dfc552e1.yaml b/releasenotes/notes/feature-cross-az-backups-9d428ad4dfc552e1.yaml new file mode 100644 index 000000000..d1bb1a4a2 --- /dev/null +++ b/releasenotes/notes/feature-cross-az-backups-9d428ad4dfc552e1.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Support cross AZ backup creation specifying desired backup service AZ + (added in microversion v3.51) diff --git a/releasenotes/notes/fix-transfer-delete-multiple-transfer-43a76c403e7c7e7c.yaml b/releasenotes/notes/fix-transfer-delete-multiple-transfer-43a76c403e7c7e7c.yaml new file mode 100644 index 000000000..7d34c14d7 --- /dev/null +++ b/releasenotes/notes/fix-transfer-delete-multiple-transfer-43a76c403e7c7e7c.yaml @@ -0,0 +1,5 @@ +--- +fixes: + - | + `Bug #2069992 `_: + Fixed transfer-delete command to accept multiple transfers. diff --git a/releasenotes/notes/http_log_debug-ff023f069afde3fe.yaml b/releasenotes/notes/http_log_debug-ff023f069afde3fe.yaml new file mode 100644 index 000000000..6a72cdc49 --- /dev/null +++ b/releasenotes/notes/http_log_debug-ff023f069afde3fe.yaml @@ -0,0 +1,7 @@ +--- +fixes: + - | + `Bug #2035372 + `_: Fixed + not honoring ``http_log_debug`` parameter in ``cinderclient.client.Client`` + when also providing a session. diff --git a/releasenotes/notes/list-with-count-78gtf45r66bf8912.yaml b/releasenotes/notes/list-with-count-78gtf45r66bf8912.yaml new file mode 100644 index 000000000..edd964cc2 --- /dev/null +++ b/releasenotes/notes/list-with-count-78gtf45r66bf8912.yaml @@ -0,0 +1,3 @@ +--- +features: + - Added ``with_count`` option in volume, snapshot and backup's list commands since 3.45. diff --git a/releasenotes/notes/log-request-id-148c74d308bcaa14.yaml b/releasenotes/notes/log-request-id-148c74d308bcaa14.yaml new file mode 100644 index 000000000..58808d611 --- /dev/null +++ b/releasenotes/notes/log-request-id-148c74d308bcaa14.yaml @@ -0,0 +1,6 @@ +--- +features: + - Added support to log 'x-openstack-request-id' for each api call. + Please refer, + https://blueprints.launchpad.net/python-cinderclient/+spec/log-request-id + for more details. diff --git a/releasenotes/notes/messages-v3-api-3da81f4f66bf5903.yaml b/releasenotes/notes/messages-v3-api-3da81f4f66bf5903.yaml new file mode 100644 index 000000000..1033dcd12 --- /dev/null +++ b/releasenotes/notes/messages-v3-api-3da81f4f66bf5903.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Add support for /messages API + + GET /messages + cinder --os-volume-api-version 3.3 message-list + GET /messages/{id} + cinder --os-volume-api-version 3.3 message-show {id} + DELETE /message/{id} + cinder --os-volume-api-version 3.3 message-delete {id} diff --git a/releasenotes/notes/noauth-7d95e5af31a00e96.yaml b/releasenotes/notes/noauth-7d95e5af31a00e96.yaml new file mode 100644 index 000000000..2d53b417c --- /dev/null +++ b/releasenotes/notes/noauth-7d95e5af31a00e96.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Cinderclient now supports noauth mode using `--os-auth-type noauth` + param. Also python-cinderclient now supports keystoneauth1 plugins. +deprecations: + - | + --bypass-url param is now deprecated. Please use --os-endpoint instead + of it. + --os-auth-system param is now deprecated. Please --os-auth-type instead of + it. diff --git a/releasenotes/notes/profile-as-environment-variable-2a5c666ef759e486.yaml b/releasenotes/notes/profile-as-environment-variable-2a5c666ef759e486.yaml new file mode 100644 index 000000000..76fcd9d3f --- /dev/null +++ b/releasenotes/notes/profile-as-environment-variable-2a5c666ef759e486.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + ``--profile`` argument can be loaded from ``OS_PROFILE`` + environment variable to avoid repeating ``--profile`` + in openstack commands. diff --git a/releasenotes/notes/project-default-types-727156d1db10a24d.yaml b/releasenotes/notes/project-default-types-727156d1db10a24d.yaml new file mode 100644 index 000000000..c4385a595 --- /dev/null +++ b/releasenotes/notes/project-default-types-727156d1db10a24d.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added support to set, get, and unset the default volume type for + projects with Block Storage API version 3.62 and higher. + diff --git a/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml b/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml new file mode 100644 index 000000000..a95fb1fb9 --- /dev/null +++ b/releasenotes/notes/reimage-volume-fea3a1178662e65a.yaml @@ -0,0 +1,10 @@ +--- +features: + - | + A new ``cinder reimage`` command and related python API binding has been + added which allows a user to replace the current content of a specified + volume with the data of a specified image supplied by the Image service + (Glance). (Note that this is a destructive action, that is, all data + currently contained in the volume is destroyed when the volume is + re-imaged.) This feature requires Block Storage API microversion 3.68 + or greater. diff --git a/releasenotes/notes/remove-cg-quota-9d4120b62f09cc5c.yaml b/releasenotes/notes/remove-cg-quota-9d4120b62f09cc5c.yaml new file mode 100644 index 000000000..6f4d4d429 --- /dev/null +++ b/releasenotes/notes/remove-cg-quota-9d4120b62f09cc5c.yaml @@ -0,0 +1,3 @@ +--- +other: + - The useless consistencygroup quota operation has been removed. diff --git a/releasenotes/notes/remove-credentials-e92b68e3bda80057.yaml b/releasenotes/notes/remove-credentials-e92b68e3bda80057.yaml new file mode 100644 index 000000000..78a2b6ec1 --- /dev/null +++ b/releasenotes/notes/remove-credentials-e92b68e3bda80057.yaml @@ -0,0 +1,5 @@ +--- +other: + - The cinder credentials command has not worked for several releases. The + preferred alternative is to us the openstack token issue command, therefore + the cinder credentials command has been removed. diff --git a/releasenotes/notes/remove-deprecations-621919062f867015.yaml b/releasenotes/notes/remove-deprecations-621919062f867015.yaml new file mode 100644 index 000000000..38603de07 --- /dev/null +++ b/releasenotes/notes/remove-deprecations-621919062f867015.yaml @@ -0,0 +1,15 @@ +--- +upgrade: + - | + The following CLI options were deprecated for one or more releases and have + now been removed: + + ``--endpoint-type`` + This option has been replaced by ``--os-endpoint-type``. + + ``--bypass-url`` + This option has been replaced by ``--os-endpoint``. + + ``--os-auth-system`` + This option has been replaced by ``--os-auth-type``. + diff --git a/releasenotes/notes/remove-py38-9ff5e159cfa29d23.yaml b/releasenotes/notes/remove-py38-9ff5e159cfa29d23.yaml new file mode 100644 index 000000000..040316360 --- /dev/null +++ b/releasenotes/notes/remove-py38-9ff5e159cfa29d23.yaml @@ -0,0 +1,5 @@ +--- +upgrade: + - | + Support for Python 3.8 has been removed. Now the minimum python version + supported is 3.9 . diff --git a/releasenotes/notes/remove-py39-88ae5d7e3cf12f7d.yaml b/releasenotes/notes/remove-py39-88ae5d7e3cf12f7d.yaml new file mode 100644 index 000000000..eaf3014b9 --- /dev/null +++ b/releasenotes/notes/remove-py39-88ae5d7e3cf12f7d.yaml @@ -0,0 +1,5 @@ +--- +upgrade: + - | + Support for Python 3.9 has been removed. Now Python 3.10 is the minimum + version supported. diff --git a/releasenotes/notes/remove-replv1-cabf2194edb9d963.yaml b/releasenotes/notes/remove-replv1-cabf2194edb9d963.yaml new file mode 100644 index 000000000..28ad29677 --- /dev/null +++ b/releasenotes/notes/remove-replv1-cabf2194edb9d963.yaml @@ -0,0 +1,7 @@ +--- +upgrade: + - | + The volume creation argument ``--source-replica`` on the command line and + the ``source_replica`` kwarg for the ``create()`` call when using the + cinderclient library were for the replication v1 support that was removed + in the Mitaka release. These options have now been removed. diff --git a/releasenotes/notes/remove-replv1-cli-61d5722438f888b6.yaml b/releasenotes/notes/remove-replv1-cli-61d5722438f888b6.yaml new file mode 100644 index 000000000..9aa9f5c68 --- /dev/null +++ b/releasenotes/notes/remove-replv1-cli-61d5722438f888b6.yaml @@ -0,0 +1,4 @@ +--- +prelude: > + The replication v1 have been removed from cinder, the volume promote/reenable + replication on the command line have now been removed. diff --git a/releasenotes/notes/replication-group-v3-api-022900ce6bf8feba.yaml b/releasenotes/notes/replication-group-v3-api-022900ce6bf8feba.yaml new file mode 100644 index 000000000..43e0cd001 --- /dev/null +++ b/releasenotes/notes/replication-group-v3-api-022900ce6bf8feba.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added support for replication group APIs ``enable_replication``, + ``disable_replication``, ``failover_replication`` and + ``list_replication_targets``. diff --git a/releasenotes/notes/return-request-id-to-caller-78d27f33f0048405.yaml b/releasenotes/notes/return-request-id-to-caller-78d27f33f0048405.yaml new file mode 100644 index 000000000..f4e3751ec --- /dev/null +++ b/releasenotes/notes/return-request-id-to-caller-78d27f33f0048405.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + Added support to return "x-openstack-request-id" header in + request_ids attribute for better tracing. + + For example:: + + >>> from cinderclient import client + >>> cinder = client.Client('2', $OS_USER_NAME, $OS_PASSWORD, $OS_TENANT_NAME, $OS_AUTH_URL) + >>> res = cinder.volumes.list() + >>> res.request_ids \ No newline at end of file diff --git a/releasenotes/notes/revert-to-snapshot-er4598df88aq5918.yaml b/releasenotes/notes/revert-to-snapshot-er4598df88aq5918.yaml new file mode 100644 index 000000000..395fab30f --- /dev/null +++ b/releasenotes/notes/revert-to-snapshot-er4598df88aq5918.yaml @@ -0,0 +1,3 @@ +--- +features: + - Added support for the revert-to-snapshot feature. diff --git a/releasenotes/notes/service_cleanup_cmd-cac85b697bc22af1.yaml b/releasenotes/notes/service_cleanup_cmd-cac85b697bc22af1.yaml new file mode 100644 index 000000000..af5f9303c --- /dev/null +++ b/releasenotes/notes/service_cleanup_cmd-cac85b697bc22af1.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + New ``work-cleanup`` command to trigger server cleanups by other nodes + within a cluster on Active-Active deployments on microversion 3.24 and + higher. diff --git a/releasenotes/notes/service_dynamic_log-bd81d93c73fc1570.yaml b/releasenotes/notes/service_dynamic_log-bd81d93c73fc1570.yaml new file mode 100644 index 000000000..e71c7db0a --- /dev/null +++ b/releasenotes/notes/service_dynamic_log-bd81d93c73fc1570.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Support microversion 3.32 that allows dynamically changing and querying + Cinder services' log levels with ``service-set-log`` and + ``service-get-log`` commands. diff --git a/releasenotes/notes/start-using-reno-18001103a6719c13.yaml b/releasenotes/notes/start-using-reno-18001103a6719c13.yaml new file mode 100644 index 000000000..873a30fe6 --- /dev/null +++ b/releasenotes/notes/start-using-reno-18001103a6719c13.yaml @@ -0,0 +1,3 @@ +--- +other: + - Start using reno to manage release notes. diff --git a/releasenotes/notes/support---os-key-option-72ba2fd4880736ac.yaml b/releasenotes/notes/support---os-key-option-72ba2fd4880736ac.yaml new file mode 100644 index 000000000..6d882d0b1 --- /dev/null +++ b/releasenotes/notes/support---os-key-option-72ba2fd4880736ac.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Support --os-key option and OS_KEY environment variable which allows to + provide client cert and its private key separately. diff --git a/releasenotes/notes/support-bs-mv-3.60-a65f1919b5068d17.yaml b/releasenotes/notes/support-bs-mv-3.60-a65f1919b5068d17.yaml new file mode 100644 index 000000000..3813767c3 --- /dev/null +++ b/releasenotes/notes/support-bs-mv-3.60-a65f1919b5068d17.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + When communicating with the Block Storage API version 3.60 and higher, + you can apply time comparison filtering to the volume list command + on the ``created_at`` or ``updated_at`` fields. Time must be + expressed in ISO 8601 format: CCYY-MM-DDThh:mm:ss±hh:mm. The + ±hh:mm value, if included, returns the time zone as an offset from + UTC. + + See the `Block Storage service (cinder) command-line client + `_ + documentation for usage details. diff --git a/releasenotes/notes/support-bs-mv-3.66-5214deb20d164056.yaml b/releasenotes/notes/support-bs-mv-3.66-5214deb20d164056.yaml new file mode 100644 index 000000000..c4028b04f --- /dev/null +++ b/releasenotes/notes/support-bs-mv-3.66-5214deb20d164056.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Adds support for Block Storage API version 3.66, which drops the + requirement of a 'force' flag to create a snapshot of an in-use + volume. Although the 'force' flag is invalid for the ``snapshot-create`` + call for API versions 3.66 and higher, for backward compatibility the + cinderclient follows the Block Storage API in silently ignoring the + flag when it is passed with a value that evaluates to True. diff --git a/releasenotes/notes/support-create-volume-from-backup-c4e8aac89uy18uy2.yaml b/releasenotes/notes/support-create-volume-from-backup-c4e8aac89uy18uy2.yaml new file mode 100644 index 000000000..af090150e --- /dev/null +++ b/releasenotes/notes/support-create-volume-from-backup-c4e8aac89uy18uy2.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Support create volume from backup in microversion v3.47. diff --git a/releasenotes/notes/support-filter-type-7yt69ub7ccbf7419.yaml b/releasenotes/notes/support-filter-type-7yt69ub7ccbf7419.yaml new file mode 100644 index 000000000..9036c13c8 --- /dev/null +++ b/releasenotes/notes/support-filter-type-7yt69ub7ccbf7419.yaml @@ -0,0 +1,5 @@ +--- +features: + - New command option ``--filters`` is added to ``type-list`` + command to support filter types since 3.52, and it's only + valid for administrator. diff --git a/releasenotes/notes/support-filters-transfer-a1e7b728c7895a45.yaml b/releasenotes/notes/support-filters-transfer-a1e7b728c7895a45.yaml new file mode 100644 index 000000000..49308c98b --- /dev/null +++ b/releasenotes/notes/support-filters-transfer-a1e7b728c7895a45.yaml @@ -0,0 +1,6 @@ +--- +features: + - New command option ``--filters`` is added to ``transfer-list`` + command to support filtering. + The ``transfer-list`` command can be used with filters when + communicating with the Block Storage API version 3.52 and higher. \ No newline at end of file diff --git a/releasenotes/notes/support-generialized-resource-filter-8yf6w23f66bf5903.yaml b/releasenotes/notes/support-generialized-resource-filter-8yf6w23f66bf5903.yaml new file mode 100644 index 000000000..fdfb4c052 --- /dev/null +++ b/releasenotes/notes/support-generialized-resource-filter-8yf6w23f66bf5903.yaml @@ -0,0 +1,14 @@ +--- +features: + - | + Added new command ``list-filters`` to retrieve enabled resource filters, + Added new option ``--filters`` to these list commands: + + - list + - snapshot-list + - backup-list + - group-list + - group-snapshot-list + - attachment-list + - message-list + - get-pools diff --git a/releasenotes/notes/support-keystone-v3-for-httpClient-d48ebb24880f5821.yaml b/releasenotes/notes/support-keystone-v3-for-httpClient-d48ebb24880f5821.yaml new file mode 100644 index 000000000..81be20d9f --- /dev/null +++ b/releasenotes/notes/support-keystone-v3-for-httpClient-d48ebb24880f5821.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Support Keystone V3 authentication for httpClient. diff --git a/releasenotes/notes/support-like-filter-7434w23f66bf5587.yaml b/releasenotes/notes/support-like-filter-7434w23f66bf5587.yaml new file mode 100644 index 000000000..ebc1c80a3 --- /dev/null +++ b/releasenotes/notes/support-like-filter-7434w23f66bf5587.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Enabled like filter support in these list commands. + - list + - snapshot-list + - backup-list + - group-list + - group-snapshot-list + - attachment-list + - message-list diff --git a/releasenotes/notes/support-show-group-with-volume-ad820b8442e8a9e8.yaml b/releasenotes/notes/support-show-group-with-volume-ad820b8442e8a9e8.yaml new file mode 100644 index 000000000..9bed0f8dd --- /dev/null +++ b/releasenotes/notes/support-show-group-with-volume-ad820b8442e8a9e8.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Support show group with ``list-volume`` argument. + The command is : cinder group-show {group_id} --list-volume diff --git a/releasenotes/notes/support-volume-summary-d6d5bb2acfef6ad5.yaml b/releasenotes/notes/support-volume-summary-d6d5bb2acfef6ad5.yaml new file mode 100644 index 000000000..a9856e138 --- /dev/null +++ b/releasenotes/notes/support-volume-summary-d6d5bb2acfef6ad5.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Support get volume summary command in V3.12. diff --git a/releasenotes/notes/transfer-snapshots-555c61477835bcf7.yaml b/releasenotes/notes/transfer-snapshots-555c61477835bcf7.yaml new file mode 100644 index 000000000..945b85127 --- /dev/null +++ b/releasenotes/notes/transfer-snapshots-555c61477835bcf7.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Starting with microversion 3.55, the volume transfer command now has the + ability to exclude a volume's snapshots when transferring a volume to another + project. The new command format is `cinder transfer-create --no-snapshots`. diff --git a/releasenotes/notes/transfer-sort-ca622e9b8da605c1.yaml b/releasenotes/notes/transfer-sort-ca622e9b8da605c1.yaml new file mode 100644 index 000000000..5080f97a5 --- /dev/null +++ b/releasenotes/notes/transfer-sort-ca622e9b8da605c1.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Starting with microversion 3.59, the ``cinder transfer-list`` command now + supports the ``--sort`` argument to sort the returned results. This + argument takes either just the attribute to sort on, or the attribute and + the sort direction. Examples include ``cinder transfer-list --sort=id`` and + ``cinder transfer-list --sort=name:asc``. diff --git a/releasenotes/notes/ussuri-release-f0ebfc54cdac6680.yaml b/releasenotes/notes/ussuri-release-f0ebfc54cdac6680.yaml new file mode 100644 index 000000000..f1ac0b554 --- /dev/null +++ b/releasenotes/notes/ussuri-release-f0ebfc54cdac6680.yaml @@ -0,0 +1,28 @@ +--- +prelude: | + The Ussuri release of the python-cinderclient supports Block Storage + API version 2 and Block Storage API version 3 through microversion + 3.60. (The maximum microversion of the Block Storage API in the + Ussuri release is 3.60.) + + In addition to the features and bugfixes described below, this release + includes some documentation updates. + + Note that this release corresponds to a major bump in the version + number. See the "Upgrade Notes" section of this document for details. + + Please keep in mind that the minimum version of Python supported by + this release is Python 3.6. +upgrade: + - | + The ``--bypass-url`` command line argument, having been deprecated in + version 2.10, was removed in version 4.0.0. It was replaced by the + command line argument ``--os-endpoint`` for consistency with other + OpenStack clients. In this release, the initializer functions for + client objects no longer recognize ``bypass_url`` as a parameter name. + Instead, use ``os_endpoint``. This keeps the cinderclient consistent + both internally and with respect to other OpenStack clients. +fixes: + - | + Fixed an issue where the ``os_endpoint`` was not being passed to the + keystone session as the ``endpoint_override`` argument. diff --git a/releasenotes/notes/victoria-release-0d9c2b43845c3d9e.yaml b/releasenotes/notes/victoria-release-0d9c2b43845c3d9e.yaml new file mode 100644 index 000000000..485513f76 --- /dev/null +++ b/releasenotes/notes/victoria-release-0d9c2b43845c3d9e.yaml @@ -0,0 +1,11 @@ +--- +prelude: | + The Victoria release of the python-cinderclient supports Block Storage + API version 2 and Block Storage API version 3 through microversion + 3.62. (The maximum microversion of the Block Storage API in the + Victoria release is 3.62.) +features: + - | + Added support to display the ``cluster_name`` attribute in volume + detail output for admin users with Block Storage API version 3.61 + and higher. diff --git a/releasenotes/notes/volume-transfer-bug-23c760efb9f98a4d.yaml b/releasenotes/notes/volume-transfer-bug-23c760efb9f98a4d.yaml new file mode 100644 index 000000000..a22dd669c --- /dev/null +++ b/releasenotes/notes/volume-transfer-bug-23c760efb9f98a4d.yaml @@ -0,0 +1,8 @@ +--- +fixes: + - | + An issue was discovered with the way API microversions were handled for the + new volume-transfer with snapshot handling with microversion 3.55. This + release includes a fix to keep backwards compatibility with earlier + releases. See `bug #1784703 + `_ for more details. diff --git a/releasenotes/notes/wallaby-release-2535df50cc307fea.yaml b/releasenotes/notes/wallaby-release-2535df50cc307fea.yaml new file mode 100644 index 000000000..4943b873e --- /dev/null +++ b/releasenotes/notes/wallaby-release-2535df50cc307fea.yaml @@ -0,0 +1,16 @@ +--- +prelude: | + The Wallaby release of the python-cinderclient supports Block Storage + API version 2 and Block Storage API version 3 through microversion + 3.64. (The maximum microversion of the Block Storage API in the + Wallaby release is 3.64.) +features: + - | + Added support to display the ``volume_type_id`` attribute in volume + detail output when used with Block Storage API microversion 3.63 and + higher. + - | + Added support to display the ``encryption_key_id`` attribute in + volume detail and backup detail output when used with Block Storage + API microversion 3.64 and higher. + diff --git a/releasenotes/notes/xena-release-688918a69ada3a58.yaml b/releasenotes/notes/xena-release-688918a69ada3a58.yaml new file mode 100644 index 000000000..3bff19acf --- /dev/null +++ b/releasenotes/notes/xena-release-688918a69ada3a58.yaml @@ -0,0 +1,21 @@ +--- +prelude: | + The Xena release of the python-cinderclient supports Block Storage + API version 3 through microversion 3.66. (The maximum microversion + of the Block Storage API in the Xena release is 3.66.) +upgrade: + - | + The python-cinderclient no longer supports version 2 of the Block + Storage API. The last version of the python-cinderclient supporting + that API is the 7.x series. +features: + - | + Supports Block Storage API version 3.65, which displays a boolean + ``consumes_quota`` field on volume and snapshot detail responses + and which allows filtering volume and snapshot list responses using + the standard ``--filters [ [ ...]]`` option + to the ``cinder list`` or ``cinder snapshot-list`` commands. + + Filtering by this field may not always be possible in a cloud. + Use the ``cinder list-filters`` command to see what filters are + available in the cloud you are using. diff --git a/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml b/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml new file mode 100644 index 000000000..70e2e1cca --- /dev/null +++ b/releasenotes/notes/yoga-release-dcd35c98f6be478e.yaml @@ -0,0 +1,5 @@ +--- +prelude: | + The Yoga release of the python-cinderclient supports Block Storage + API version 3 through microversion 3.68. (The maximum microversion + of the Block Storage API in the Yoga release is 3.68.) diff --git a/releasenotes/source/2023.1.rst b/releasenotes/source/2023.1.rst new file mode 100644 index 000000000..cd913284d --- /dev/null +++ b/releasenotes/source/2023.1.rst @@ -0,0 +1,6 @@ +=========================== +2023.1 Series Release Notes +=========================== + +.. release-notes:: + :branch: 2023.1-eom diff --git a/releasenotes/source/2023.2.rst b/releasenotes/source/2023.2.rst new file mode 100644 index 000000000..a4838d7d0 --- /dev/null +++ b/releasenotes/source/2023.2.rst @@ -0,0 +1,6 @@ +=========================== +2023.2 Series Release Notes +=========================== + +.. release-notes:: + :branch: stable/2023.2 diff --git a/releasenotes/source/2024.1.rst b/releasenotes/source/2024.1.rst new file mode 100644 index 000000000..6896656be --- /dev/null +++ b/releasenotes/source/2024.1.rst @@ -0,0 +1,6 @@ +=========================== +2024.1 Series Release Notes +=========================== + +.. release-notes:: + :branch: unmaintained/2024.1 diff --git a/releasenotes/source/2024.2.rst b/releasenotes/source/2024.2.rst new file mode 100644 index 000000000..aaebcbc8c --- /dev/null +++ b/releasenotes/source/2024.2.rst @@ -0,0 +1,6 @@ +=========================== +2024.2 Series Release Notes +=========================== + +.. release-notes:: + :branch: stable/2024.2 diff --git a/releasenotes/source/2025.1.rst b/releasenotes/source/2025.1.rst new file mode 100644 index 000000000..3add0e53a --- /dev/null +++ b/releasenotes/source/2025.1.rst @@ -0,0 +1,6 @@ +=========================== +2025.1 Series Release Notes +=========================== + +.. release-notes:: + :branch: stable/2025.1 diff --git a/releasenotes/source/2025.2.rst b/releasenotes/source/2025.2.rst new file mode 100644 index 000000000..4dae18d86 --- /dev/null +++ b/releasenotes/source/2025.2.rst @@ -0,0 +1,6 @@ +=========================== +2025.2 Series Release Notes +=========================== + +.. release-notes:: + :branch: stable/2025.2 diff --git a/cinderclient/v1/contrib/__init__.py b/releasenotes/source/_static/.placeholder similarity index 100% rename from cinderclient/v1/contrib/__init__.py rename to releasenotes/source/_static/.placeholder diff --git a/cinderclient/v2/contrib/__init__.py b/releasenotes/source/_templates/.placeholder similarity index 100% rename from cinderclient/v2/contrib/__init__.py rename to releasenotes/source/_templates/.placeholder diff --git a/releasenotes/source/conf.py b/releasenotes/source/conf.py new file mode 100644 index 000000000..a0a7d091c --- /dev/null +++ b/releasenotes/source/conf.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- +# 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. + +# Cinder Client Release Notes documentation build configuration file, +# created by sphinx-quickstart on Tue Nov 4 17:02:44 2015. +# +# This file is execfile()d with the current directory set to its +# containing dir. +# +# Note that not all possible configuration values are present in this +# autogenerated file. +# +# All configuration values have a default; values that are commented out +# serve to show the default. + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. +# sys.path.insert(0, os.path.abspath('.')) + +# -- General configuration ------------------------------------------------ + +# If your documentation needs a minimal Sphinx version, state it here. +# needs_sphinx = '1.0' + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + 'openstackdocstheme', + 'reno.sphinxext', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix of source filenames. +source_suffix = '.rst' + +# The encoding of source files. +# source_encoding = 'utf-8-sig' + +# The master toctree document. +master_doc = 'index' + +# General information about the project. +project = 'Cinder Client Release Notes' +openstackdocs_auto_name = False +copyright = '2015, Cinder Developers' + +# Release notes are version independent, no need to set version and release +release = '' +version = '' + +# The language for content autogenerated by Sphinx. Refer to documentation +# for a list of supported languages. +# language = None + +# There are two options for replacing |today|: either, you set today to some +# non-false value, then it is used: +# today = '' +# Else, today_fmt is used as the format for a strftime call. +# today_fmt = '%B %d, %Y' + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = [] + +# The reST default role (used for this markup: `text`) to use for all +# documents. +# default_role = None + +# If true, '()' will be appended to :func: etc. cross-reference text. +# add_function_parentheses = True + +# If true, the current module name will be prepended to all description +# unit titles (such as .. function::). +# add_module_names = True + +# If true, sectionauthor and moduleauthor directives will be shown in the +# output. They are ignored by default. +# show_authors = False + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = 'native' + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# If true, keep warnings as "system message" paragraphs in the built documents. +# keep_warnings = False + + +# -- Options for HTML output ---------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'openstackdocs' + +# Theme options are theme-specific and customize the look and feel of a theme +# further. For a list of options available for each theme, see the +# documentation. +# html_theme_options = {} + +# Add any paths that contain custom themes here, relative to this directory. +# html_theme_path = [] + +# The name for this set of Sphinx documents. If None, it defaults to +# " v documentation". +# html_title = None + +# A shorter title for the navigation bar. Default is the same as html_title. +# html_short_title = None + +# The name of an image file (relative to this directory) to place at the top +# of the sidebar. +# html_logo = None + +# The name of an image file (within the static path) to use as favicon of the +# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# pixels large. +# html_favicon = None + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Add any extra paths that contain custom files (such as robots.txt or +# .htaccess) here, relative to this directory. These files are copied +# directly to the root of the documentation. +# html_extra_path = [] + +# If true, SmartyPants will be used to convert quotes and dashes to +# typographically correct entities. +# html_use_smartypants = True + +# Custom sidebar templates, maps document names to template names. +# html_sidebars = {} + +# Additional templates that should be rendered to pages, maps page names to +# template names. +# html_additional_pages = {} + +# If false, no module index is generated. +# html_domain_indices = True + +# If false, no index is generated. +# html_use_index = True + +# If true, the index is split into individual pages for each letter. +# html_split_index = False + +# If true, links to the reST sources are added to the pages. +# html_show_sourcelink = True + +# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. +# html_show_sphinx = True + +# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. +# html_show_copyright = True + +# If true, an OpenSearch description file will be output, and all pages will +# contain a tag referring to it. The value of this option must be the +# base URL from which the finished HTML is served. +# html_use_opensearch = '' + +# This is the file name suffix for HTML files (e.g. ".xhtml"). +# html_file_suffix = None + +# Output file base name for HTML help builder. +htmlhelp_basename = 'CinderClientReleaseNotesdoc' + + +# -- Options for LaTeX output --------------------------------------------- + +latex_elements = { + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + + # Additional stuff for the LaTeX preamble. + # 'preamble': '', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + ('index', 'CinderClientReleaseNotes.tex', + 'Cinder Client Release Notes Documentation', + 'Cinder Developers', 'manual'), +] + +# The name of an image file (relative to this directory) to place at the top of +# the title page. +# latex_logo = None + +# For "manual" documents, if this is true, then toplevel headings are parts, +# not chapters. +# latex_use_parts = False + +# If true, show page references after internal links. +# latex_show_pagerefs = False + +# If true, show URL addresses after external links. +# latex_show_urls = False + +# Documents to append as an appendix to all manuals. +# latex_appendices = [] + +# If false, no module index is generated. +# latex_domain_indices = True + + +# -- Options for manual page output --------------------------------------- + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + ('index', 'cinderclientreleasenotes', + 'Cinder Client Release Notes Documentation', + ['Cinder Developers'], 1) +] + +# If true, show URL addresses after external links. +# man_show_urls = False + + +# -- Options for Texinfo output ------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + ('index', 'CinderClientReleaseNotes', + 'Cinder Client Release Notes Documentation', + 'Cinder Developers', 'CinderClientReleaseNotes', + 'Block Storage Service client.', + 'Miscellaneous'), +] + +# Documents to append as an appendix to all manuals. +# texinfo_appendices = [] + +# If false, no module index is generated. +# texinfo_domain_indices = True + +# How to display URL addresses: 'footnote', 'no', or 'inline'. +# texinfo_show_urls = 'footnote' + +# If true, do not generate a @detailmenu in the "Top" node's menu. +# texinfo_no_detailmenu = False + +# -- Options for Internationalization output ------------------------------ +locale_dirs = ['locale/'] + +# -- Options for openstackdocstheme ------------------------------------------- +openstackdocs_repo_name = 'openstack/python-cinderclient' +openstackdocs_bug_project = 'cinderclient' +openstackdocs_bug_tag = '' diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst new file mode 100644 index 000000000..5f35d4467 --- /dev/null +++ b/releasenotes/source/index.rst @@ -0,0 +1,28 @@ +============================= + Cinder Client Release Notes +============================= + +.. toctree:: + :maxdepth: 1 + + unreleased + 2025.2 + 2025.1 + 2024.2 + 2024.1 + 2023.2 + 2023.1 + zed + yoga + xena + wallaby + victoria + ussuri + train + stein + rocky + queens + pike + ocata + newton + mitaka diff --git a/releasenotes/source/mitaka.rst b/releasenotes/source/mitaka.rst new file mode 100644 index 000000000..e54560965 --- /dev/null +++ b/releasenotes/source/mitaka.rst @@ -0,0 +1,6 @@ +=================================== + Mitaka Series Release Notes +=================================== + +.. release-notes:: + :branch: origin/stable/mitaka diff --git a/releasenotes/source/newton.rst b/releasenotes/source/newton.rst new file mode 100644 index 000000000..97036ed25 --- /dev/null +++ b/releasenotes/source/newton.rst @@ -0,0 +1,6 @@ +=================================== + Newton Series Release Notes +=================================== + +.. release-notes:: + :branch: origin/stable/newton diff --git a/releasenotes/source/ocata.rst b/releasenotes/source/ocata.rst new file mode 100644 index 000000000..ebe62f42e --- /dev/null +++ b/releasenotes/source/ocata.rst @@ -0,0 +1,6 @@ +=================================== + Ocata Series Release Notes +=================================== + +.. release-notes:: + :branch: origin/stable/ocata diff --git a/releasenotes/source/pike.rst b/releasenotes/source/pike.rst new file mode 100644 index 000000000..e43bfc0ce --- /dev/null +++ b/releasenotes/source/pike.rst @@ -0,0 +1,6 @@ +=================================== + Pike Series Release Notes +=================================== + +.. release-notes:: + :branch: stable/pike diff --git a/releasenotes/source/queens.rst b/releasenotes/source/queens.rst new file mode 100644 index 000000000..36ac6160c --- /dev/null +++ b/releasenotes/source/queens.rst @@ -0,0 +1,6 @@ +=================================== + Queens Series Release Notes +=================================== + +.. release-notes:: + :branch: stable/queens diff --git a/releasenotes/source/rocky.rst b/releasenotes/source/rocky.rst new file mode 100644 index 000000000..40dd517b7 --- /dev/null +++ b/releasenotes/source/rocky.rst @@ -0,0 +1,6 @@ +=================================== + Rocky Series Release Notes +=================================== + +.. release-notes:: + :branch: stable/rocky diff --git a/releasenotes/source/stein.rst b/releasenotes/source/stein.rst new file mode 100644 index 000000000..efaceb667 --- /dev/null +++ b/releasenotes/source/stein.rst @@ -0,0 +1,6 @@ +=================================== + Stein Series Release Notes +=================================== + +.. release-notes:: + :branch: stable/stein diff --git a/releasenotes/source/train.rst b/releasenotes/source/train.rst new file mode 100644 index 000000000..cd8d9a06c --- /dev/null +++ b/releasenotes/source/train.rst @@ -0,0 +1,6 @@ +============================ + Train Series Release Notes +============================ + +.. release-notes:: + :branch: stable/train diff --git a/releasenotes/source/unreleased.rst b/releasenotes/source/unreleased.rst new file mode 100644 index 000000000..cd22aabcc --- /dev/null +++ b/releasenotes/source/unreleased.rst @@ -0,0 +1,5 @@ +============================== + Current Series Release Notes +============================== + +.. release-notes:: diff --git a/releasenotes/source/ussuri.rst b/releasenotes/source/ussuri.rst new file mode 100644 index 000000000..e21e50e0c --- /dev/null +++ b/releasenotes/source/ussuri.rst @@ -0,0 +1,6 @@ +=========================== +Ussuri Series Release Notes +=========================== + +.. release-notes:: + :branch: stable/ussuri diff --git a/releasenotes/source/victoria.rst b/releasenotes/source/victoria.rst new file mode 100644 index 000000000..6d20e1553 --- /dev/null +++ b/releasenotes/source/victoria.rst @@ -0,0 +1,6 @@ +============================= +Victoria Series Release Notes +============================= + +.. release-notes:: + :branch: victoria-eom diff --git a/releasenotes/source/wallaby.rst b/releasenotes/source/wallaby.rst new file mode 100644 index 000000000..018303da0 --- /dev/null +++ b/releasenotes/source/wallaby.rst @@ -0,0 +1,6 @@ +============================ +Wallaby Series Release Notes +============================ + +.. release-notes:: + :branch: wallaby-eom diff --git a/releasenotes/source/xena.rst b/releasenotes/source/xena.rst new file mode 100644 index 000000000..62e0f6c7d --- /dev/null +++ b/releasenotes/source/xena.rst @@ -0,0 +1,6 @@ +========================= +Xena Series Release Notes +========================= + +.. release-notes:: + :branch: xena-eom diff --git a/releasenotes/source/yoga.rst b/releasenotes/source/yoga.rst new file mode 100644 index 000000000..8f1932add --- /dev/null +++ b/releasenotes/source/yoga.rst @@ -0,0 +1,6 @@ +========================= +Yoga Series Release Notes +========================= + +.. release-notes:: + :branch: yoga-eom diff --git a/releasenotes/source/zed.rst b/releasenotes/source/zed.rst new file mode 100644 index 000000000..1c3dd1e0c --- /dev/null +++ b/releasenotes/source/zed.rst @@ -0,0 +1,6 @@ +======================== +Zed Series Release Notes +======================== + +.. release-notes:: + :branch: zed-eom diff --git a/requirements.txt b/requirements.txt index 6938e0e8b..8cea5ad4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,10 @@ -# The order of packages is significant, because pip processes them in the order -# of appearance. Changing the order has an impact on the overall integration -# process, which may cause wedges in the gate later. -pbr>=0.11,<2.0 -argparse -PrettyTable>=0.7,<0.8 -python-keystoneclient>=1.3.0 -requests>=2.5.2 -simplejson>=2.2.0 -Babel>=1.3 -six>=1.9.0 +# Requirements lower bounds listed here are our best effort to keep them up to +# date but we do not test them so no guarantee of having them all correct. If +# you find any incorrect lower bounds, let us know or propose a fix. +pbr>=5.5.0 # Apache-2.0 +PrettyTable>=0.7.2 # BSD +keystoneauth1>=5.9.0 # Apache-2.0 +oslo.i18n>=5.0.1 # Apache-2.0 +oslo.utils>=4.8.0 # Apache-2.0 +requests>=2.25.1 # Apache-2.0 +stevedore>=3.3.0 # Apache-2.0 diff --git a/run_tests.sh b/run_tests.sh deleted file mode 100755 index 017f7e22e..000000000 --- a/run_tests.sh +++ /dev/null @@ -1,231 +0,0 @@ -#!/bin/bash - -set -eu - -function usage { - echo "Usage: $0 [OPTION]..." - echo "Run cinderclient's test suite(s)" - echo "" - echo " -V, --virtual-env Always use virtualenv. Install automatically if not present" - echo " -N, --no-virtual-env Don't use virtualenv. Run tests in local environment" - echo " -s, --no-site-packages Isolate the virtualenv from the global Python environment" - echo " -r, --recreate-db Recreate the test database (deprecated, as this is now the default)." - echo " -n, --no-recreate-db Don't recreate the test database." - echo " -f, --force Force a clean re-build of the virtual environment. Useful when dependencies have been added." - echo " -u, --update Update the virtual environment with any newer package versions" - echo " -p, --pep8 Just run PEP8 and HACKING compliance check" - echo " -P, --no-pep8 Don't run static code checks" - echo " -c, --coverage Generate coverage report" - echo " -d, --debug Run tests with testtools instead of testr. This allows you to use the debugger." - echo " -h, --help Print this usage message" - echo " --hide-elapsed Don't print the elapsed time for each test along with slow test list" - echo " --virtual-env-path Location of the virtualenv directory" - echo " Default: \$(pwd)" - echo " --virtual-env-name Name of the virtualenv directory" - echo " Default: .venv" - echo " --tools-path Location of the tools directory" - echo " Default: \$(pwd)" - echo "" - echo "Note: with no options specified, the script will try to run the tests in a virtual environment," - echo " If no virtualenv is found, the script will ask if you would like to create one. If you " - echo " prefer to run tests NOT in a virtual environment, simply pass the -N option." - exit -} - -function process_options { - i=1 - while [ $i -le $# ]; do - case "${!i}" in - -h|--help) usage;; - -V|--virtual-env) always_venv=1; never_venv=0;; - -N|--no-virtual-env) always_venv=0; never_venv=1;; - -s|--no-site-packages) no_site_packages=1;; - -r|--recreate-db) recreate_db=1;; - -n|--no-recreate-db) recreate_db=0;; - -f|--force) force=1;; - -u|--update) update=1;; - -p|--pep8) just_pep8=1;; - -P|--no-pep8) no_pep8=1;; - -c|--coverage) coverage=1;; - -d|--debug) debug=1;; - --virtual-env-path) - (( i++ )) - venv_path=${!i} - ;; - --virtual-env-name) - (( i++ )) - venv_dir=${!i} - ;; - --tools-path) - (( i++ )) - tools_path=${!i} - ;; - -*) testropts="$testropts ${!i}";; - *) testrargs="$testrargs ${!i}" - esac - (( i++ )) - done -} - -tool_path=${tools_path:-$(pwd)} -venv_path=${venv_path:-$(pwd)} -venv_dir=${venv_name:-.venv} -with_venv=tools/with_venv.sh -always_venv=0 -never_venv=0 -force=0 -no_site_packages=0 -installvenvopts= -testrargs= -testropts= -wrapper="" -just_pep8=0 -no_pep8=0 -coverage=0 -debug=0 -recreate_db=1 -update=0 - -LANG=en_US.UTF-8 -LANGUAGE=en_US:en -LC_ALL=C - -process_options $@ -# Make our paths available to other scripts we call -export venv_path -export venv_dir -export venv_name -export tools_dir -export venv=${venv_path}/${venv_dir} - -if [ $no_site_packages -eq 1 ]; then - installvenvopts="--no-site-packages" -fi - - -function run_tests { - # Cleanup *pyc - ${wrapper} find . -type f -name "*.pyc" -delete - - if [ $debug -eq 1 ]; then - if [ "$testropts" = "" ] && [ "$testrargs" = "" ]; then - # Default to running all tests if specific test is not - # provided. - testrargs="discover ./cinderclient/tests" - fi - ${wrapper} python -m testtools.run $testropts $testrargs - - # Short circuit because all of the testr and coverage stuff - # below does not make sense when running testtools.run for - # debugging purposes. - return $? - fi - - if [ $coverage -eq 1 ]; then - TESTRTESTS="$TESTRTESTS --coverage" - else - TESTRTESTS="$TESTRTESTS" - fi - - # Just run the test suites in current environment - set +e - testrargs=`echo "$testrargs" | sed -e's/^\s*\(.*\)\s*$/\1/'` - TESTRTESTS="$TESTRTESTS --testr-args='--subunit $testropts $testrargs'" - if [ setup.cfg -nt cinderclient.egg-info/entry_points.txt ] - then - ${wrapper} python setup.py egg_info - fi - echo "Running \`${wrapper} $TESTRTESTS\`" - if ${wrapper} which subunit-2to1 2>&1 > /dev/null - then - # subunit-2to1 is present, testr subunit stream should be in version 2 - # format. Convert to version one before colorizing. - bash -c "${wrapper} $TESTRTESTS | ${wrapper} subunit-2to1 | ${wrapper} tools/colorizer.py" - else - bash -c "${wrapper} $TESTRTESTS | ${wrapper} tools/colorizer.py" - fi - RESULT=$? - set -e - - copy_subunit_log - - if [ $coverage -eq 1 ]; then - echo "Generating coverage report in covhtml/" - # Don't compute coverage for common code, which is tested elsewhere - ${wrapper} coverage combine - ${wrapper} coverage html --include='cinderclient/*' --omit='cinderclient/openstack/common/*' -d covhtml -i - fi - - return $RESULT -} - -function copy_subunit_log { - LOGNAME=`cat .testrepository/next-stream` - LOGNAME=$(($LOGNAME - 1)) - LOGNAME=".testrepository/${LOGNAME}" - cp $LOGNAME subunit.log -} - -function run_pep8 { - echo "Running flake8 ..." - bash -c "${wrapper} flake8" -} - - -TESTRTESTS="python setup.py testr" - -if [ $never_venv -eq 0 ] -then - # Remove the virtual environment if --force used - if [ $force -eq 1 ]; then - echo "Cleaning virtualenv..." - rm -rf ${venv} - fi - if [ $update -eq 1 ]; then - echo "Updating virtualenv..." - python tools/install_venv.py $installvenvopts - fi - if [ -e ${venv} ]; then - wrapper="${with_venv}" - else - if [ $always_venv -eq 1 ]; then - # Automatically install the virtualenv - python tools/install_venv.py $installvenvopts - wrapper="${with_venv}" - else - echo -e "No virtual environment found...create one? (Y/n) \c" - read use_ve - if [ "x$use_ve" = "xY" -o "x$use_ve" = "x" -o "x$use_ve" = "xy" ]; then - # Install the virtualenv and run the test suite in it - python tools/install_venv.py $installvenvopts - wrapper=${with_venv} - fi - fi - fi -fi - -# Delete old coverage data from previous runs -if [ $coverage -eq 1 ]; then - ${wrapper} coverage erase -fi - -if [ $just_pep8 -eq 1 ]; then - run_pep8 - exit -fi - -if [ $recreate_db -eq 1 ]; then - rm -f tests.sqlite -fi - -run_tests - -# NOTE(sirp): we only want to run pep8 when we're running the full-test suite, -# not when we're running tests individually. To handle this, we need to -# distinguish between options (testropts), which begin with a '-', and -# arguments (testrargs). -if [ -z "$testrargs" ]; then - if [ $no_pep8 -eq 0 ]; then - run_pep8 - fi -fi diff --git a/setup.cfg b/setup.cfg index e5b46ee79..b8970d39f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,11 +1,12 @@ [metadata] name = python-cinderclient summary = OpenStack Block Storage API Client Library -description-file = +description_file = README.rst author = OpenStack -author-email = openstack-dev@lists.openstack.org -home-page = http://www.openstack.org/ +author_email = openstack-discuss@lists.openstack.org +home_page = https://docs.openstack.org/python-cinderclient/latest/ +python_requires = >=3.10 classifier = Development Status :: 5 - Production/Stable Environment :: Console @@ -15,15 +16,11 @@ classifier = License :: OSI Approved :: Apache Software License Operating System :: POSIX :: Linux Programming Language :: Python - Programming Language :: Python :: 2 - Programming Language :: Python :: 2.6 - Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3 - Programming Language :: Python :: 3.3 - -[global] -setup-hooks = - pbr.hooks.setup_hook + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 [files] packages = @@ -33,13 +30,5 @@ packages = console_scripts = cinder = cinderclient.shell:main -[build_sphinx] -all_files = 1 -source-dir = doc/source -build-dir = doc/build - -[upload_sphinx] -upload-dir = doc/build/html - -[wheel] -universal = 1 +keystoneauth1.plugin = + noauth = cinderclient.contrib.noauth:CinderNoAuthLoader diff --git a/setup.py b/setup.py index 736375744..cd35c3c35 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python # Copyright (c) 2013 Hewlett-Packard Development Company, L.P. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,17 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -# THIS FILE IS MANAGED BY THE GLOBAL REQUIREMENTS REPO - DO NOT EDIT import setuptools -# In python < 2.7.4, a lazy loading of package `pbr` will break -# setuptools if some other modules registered functions in `atexit`. -# solution from: http://bugs.python.org/issue15881#msg170215 -try: - import multiprocessing # noqa -except ImportError: - pass - setuptools.setup( - setup_requires=['pbr'], + setup_requires=['pbr>=2.0.0'], pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt index 3ceb21cbb..a898b7bf7 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,16 +1,12 @@ -# The order of packages is significant, because pip processes them in the order -# of appearance. Changing the order has an impact on the overall integration -# process, which may cause wedges in the gate later. # Hacking already pins down pep8, pyflakes and flake8 -hacking>=0.8.0,<0.9 -coverage>=3.6 -discover -fixtures>=0.3.14 -mock>=1.0 -oslosphinx>=2.5.0 # Apache-2.0 -python-subunit>=0.0.18 -requests-mock>=0.6.0 # Apache-2.0 -sphinx>=1.1.2,!=1.2.0,!=1.3b1,<1.3 -tempest-lib>=0.5.0 -testtools>=0.9.36,!=1.2.0 -testrepository>=0.0.18 +hacking>=7.0.0,<7.1.0 # Apache-2.0 +flake8-import-order # LGPLv3 +docutils>=0.16 +coverage>=5.5 # Apache-2.0 +ddt>=1.4.1 # MIT +fixtures>=3.0.0 # Apache-2.0/BSD +requests-mock>=1.2.0 # Apache-2.0 +testtools>=2.4.0 # MIT +stestr>=3.1.0 # Apache-2.0 +oslo.serialization>=4.1.0 # Apache-2.0 +doc8>=0.8.1 # Apache-2.0 diff --git a/tools/cinder.bash_completion b/tools/cinder.bash_completion index 60127b1b4..1d981089b 100644 --- a/tools/cinder.bash_completion +++ b/tools/cinder.bash_completion @@ -10,13 +10,13 @@ _cinder() prev="${COMP_WORDS[COMP_CWORD-1]}" if [ "x$_cinder_opts" == "x" ] ; then - cbc="`cinder bash-completion | sed -e "s/ *-h */ /" -e "s/ *-i */ /"`" + cbc="`cinder bash-completion 2>/dev/null | sed -e "s/ *-h */ /" -e "s/ *-i */ /"`" _cinder_opts="`echo "$cbc" | sed -e "s/--[a-z0-9_-]*//g" -e "s/ */ /g"`" _cinder_flags="`echo " $cbc" | sed -e "s/ [^-][^-][a-z0-9_-]*//g" -e "s/ */ /g"`" fi if [[ "$prev" != "help" ]] ; then - COMPLETION_CACHE=~/.cinderclient/*/*-cache + COMPLETION_CACHE=~/.cache/cinderclient/*/*-cache cflags="$_cinder_flags $_cinder_opts "$(cat $COMPLETION_CACHE 2> /dev/null | tr '\n' ' ') COMPREPLY=($(compgen -W "${cflags}" -- ${cur})) else diff --git a/tools/colorizer.py b/tools/colorizer.py deleted file mode 100755 index ebdd236f2..000000000 --- a/tools/colorizer.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env python - -# Copyright (c) 2013, Nebula, Inc. -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# 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. -# -# Colorizer Code is borrowed from Twisted: -# Copyright (c) 2001-2010 Twisted Matrix Laboratories. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -"""Display a subunit stream through a colorized unittest test runner.""" - -import heapq -import subunit -import sys -import unittest - -import six -import testtools - - -class _AnsiColorizer(object): - """ - A colorizer is an object that loosely wraps around a stream, allowing - callers to write text to the stream in a particular color. - - Colorizer classes must implement C{supported()} and C{write(text, color)}. - """ - _colors = dict(black=30, red=31, green=32, yellow=33, - blue=34, magenta=35, cyan=36, white=37) - - def __init__(self, stream): - self.stream = stream - - def supported(cls, stream=sys.stdout): - """ - A class method that returns True if the current platform supports - coloring terminal output using this method. Returns False otherwise. - """ - if not stream.isatty(): - return False # auto color only on TTYs - try: - import curses - except ImportError: - return False - else: - try: - try: - return curses.tigetnum("colors") > 2 - except curses.error: - curses.setupterm() - return curses.tigetnum("colors") > 2 - except Exception: - # guess false in case of error - return False - supported = classmethod(supported) - - def write(self, text, color): - """ - Write the given text to the stream in the given color. - - @param text: Text to be written to the stream. - - @param color: A string label for a color. e.g. 'red', 'white'. - """ - color = self._colors[color] - self.stream.write('\x1b[%s;1m%s\x1b[0m' % (color, text)) - - -class _Win32Colorizer(object): - """ - See _AnsiColorizer docstring. - """ - def __init__(self, stream): - import win32console - red, green, blue, bold = (win32console.FOREGROUND_RED, - win32console.FOREGROUND_GREEN, - win32console.FOREGROUND_BLUE, - win32console.FOREGROUND_INTENSITY) - self.stream = stream - self.screenBuffer = win32console.GetStdHandle( - win32console.STD_OUT_HANDLE) - self._colors = { - 'normal': red | green | blue, - 'red': red | bold, - 'green': green | bold, - 'blue': blue | bold, - 'yellow': red | green | bold, - 'magenta': red | blue | bold, - 'cyan': green | blue | bold, - 'white': red | green | blue | bold - } - - def supported(cls, stream=sys.stdout): - try: - import win32console - screenBuffer = win32console.GetStdHandle( - win32console.STD_OUT_HANDLE) - except ImportError: - return False - import pywintypes - try: - screenBuffer.SetConsoleTextAttribute( - win32console.FOREGROUND_RED | - win32console.FOREGROUND_GREEN | - win32console.FOREGROUND_BLUE) - except pywintypes.error: - return False - else: - return True - supported = classmethod(supported) - - def write(self, text, color): - color = self._colors[color] - self.screenBuffer.SetConsoleTextAttribute(color) - self.stream.write(text) - self.screenBuffer.SetConsoleTextAttribute(self._colors['normal']) - - -class _NullColorizer(object): - """ - See _AnsiColorizer docstring. - """ - def __init__(self, stream): - self.stream = stream - - def supported(cls, stream=sys.stdout): - return True - supported = classmethod(supported) - - def write(self, text, color): - self.stream.write(text) - - -def get_elapsed_time_color(elapsed_time): - if elapsed_time > 1.0: - return 'red' - elif elapsed_time > 0.25: - return 'yellow' - else: - return 'green' - - -class NovaTestResult(testtools.TestResult): - def __init__(self, stream, descriptions, verbosity): - super(NovaTestResult, self).__init__() - self.stream = stream - self.showAll = verbosity > 1 - self.num_slow_tests = 10 - self.slow_tests = [] # this is a fixed-sized heap - self.colorizer = None - # NOTE(vish): reset stdout for the terminal check - stdout = sys.stdout - sys.stdout = sys.__stdout__ - for colorizer in [_Win32Colorizer, _AnsiColorizer, _NullColorizer]: - if colorizer.supported(): - self.colorizer = colorizer(self.stream) - break - sys.stdout = stdout - self.start_time = None - self.last_time = {} - self.results = {} - self.last_written = None - - def _writeElapsedTime(self, elapsed): - color = get_elapsed_time_color(elapsed) - self.colorizer.write(" %.2f" % elapsed, color) - - def _addResult(self, test, *args): - try: - name = test.id() - except AttributeError: - name = 'Unknown.unknown' - test_class, test_name = name.rsplit('.', 1) - - elapsed = (self._now() - self.start_time).total_seconds() - item = (elapsed, test_class, test_name) - if len(self.slow_tests) >= self.num_slow_tests: - heapq.heappushpop(self.slow_tests, item) - else: - heapq.heappush(self.slow_tests, item) - - self.results.setdefault(test_class, []) - self.results[test_class].append((test_name, elapsed) + args) - self.last_time[test_class] = self._now() - self.writeTests() - - def _writeResult(self, test_name, elapsed, long_result, color, - short_result, success): - if self.showAll: - self.stream.write(' %s' % str(test_name).ljust(66)) - self.colorizer.write(long_result, color) - if success: - self._writeElapsedTime(elapsed) - self.stream.writeln() - else: - self.colorizer.write(short_result, color) - - def addSuccess(self, test): - super(NovaTestResult, self).addSuccess(test) - self._addResult(test, 'OK', 'green', '.', True) - - def addFailure(self, test, err): - if test.id() == 'process-returncode': - return - super(NovaTestResult, self).addFailure(test, err) - self._addResult(test, 'FAIL', 'red', 'F', False) - - def addError(self, test, err): - super(NovaTestResult, self).addFailure(test, err) - self._addResult(test, 'ERROR', 'red', 'E', False) - - def addSkip(self, test, reason=None, details=None): - super(NovaTestResult, self).addSkip(test, reason, details) - self._addResult(test, 'SKIP', 'blue', 'S', True) - - def startTest(self, test): - self.start_time = self._now() - super(NovaTestResult, self).startTest(test) - - def writeTestCase(self, cls): - if not self.results.get(cls): - return - if cls != self.last_written: - self.colorizer.write(cls, 'white') - self.stream.writeln() - for result in self.results[cls]: - self._writeResult(*result) - del self.results[cls] - self.stream.flush() - self.last_written = cls - - def writeTests(self): - time = self.last_time.get(self.last_written, self._now()) - if not self.last_written or (self._now() - time).total_seconds() > 2.0: - diff = 3.0 - while diff > 2.0: - classes =list(self.results) - oldest = min(classes, key=lambda x: self.last_time[x]) - diff = (self._now() - self.last_time[oldest]).total_seconds() - self.writeTestCase(oldest) - else: - self.writeTestCase(self.last_written) - - def done(self): - self.stopTestRun() - - def stopTestRun(self): - for cls in list(six.iterkeys(self.results)): - self.writeTestCase(cls) - self.stream.writeln() - self.writeSlowTests() - - def writeSlowTests(self): - # Pare out 'fast' tests - slow_tests = [item for item in self.slow_tests - if get_elapsed_time_color(item[0]) != 'green'] - if slow_tests: - slow_total_time = sum(item[0] for item in slow_tests) - slow = ("Slowest %i tests took %.2f secs:" - % (len(slow_tests), slow_total_time)) - self.colorizer.write(slow, 'yellow') - self.stream.writeln() - last_cls = None - # sort by name - for elapsed, cls, name in sorted(slow_tests, - key=lambda x: x[1] + x[2]): - if cls != last_cls: - self.colorizer.write(cls, 'white') - self.stream.writeln() - last_cls = cls - self.stream.write(' %s' % str(name).ljust(68)) - self._writeElapsedTime(elapsed) - self.stream.writeln() - - def printErrors(self): - if self.showAll: - self.stream.writeln() - self.printErrorList('ERROR', self.errors) - self.printErrorList('FAIL', self.failures) - - def printErrorList(self, flavor, errors): - for test, err in errors: - self.colorizer.write("=" * 70, 'red') - self.stream.writeln() - self.colorizer.write(flavor, 'red') - self.stream.writeln(": %s" % test.id()) - self.colorizer.write("-" * 70, 'red') - self.stream.writeln() - self.stream.writeln("%s" % err) - - -test = subunit.ProtocolTestCase(sys.stdin, passthrough=None) - -if sys.version_info[0:2] <= (2, 6): - runner = unittest.TextTestRunner(verbosity=2) -else: - runner = unittest.TextTestRunner(verbosity=2, resultclass=NovaTestResult) - -if runner.run(test).wasSuccessful(): - exit_code = 0 -else: - exit_code = 1 -sys.exit(exit_code) diff --git a/tools/install_venv.py b/tools/install_venv.py deleted file mode 100644 index cc2184378..000000000 --- a/tools/install_venv.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# All Rights Reserved. -# -# Copyright 2010 OpenStack Foundation -# Copyright 2013 IBM Corp. -# Copyright (c) 2013 Hewlett-Packard Development Company, L.P. -# -# 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 ConfigParser -import os -import sys - -import install_venv_common as install_venv # flake8: noqa - - -def print_help(project, venv, root): - help = """ - %(project)s development environment setup is complete. - - %(project)s development uses virtualenv to track and manage Python - dependencies while in development and testing. - - To activate the %(project)s virtualenv for the extent of your current - shell session you can run: - - $ source %(venv)s/bin/activate - - Or, if you prefer, you can run commands in the virtualenv on a case by - case basis by running: - - $ %(root)s/tools/with_venv.sh - """ - print help % dict(project=project, venv=venv, root=root) - - -def main(argv): - root = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - - if os.environ.get('tools_path'): - root = os.environ['tools_path'] - venv = os.path.join(root, '.venv') - if os.environ.get('venv'): - venv = os.environ['venv'] - - pip_requires = os.path.join(root, 'requirements.txt') - test_requires = os.path.join(root, 'test-requirements.txt') - py_version = "python%s.%s" % (sys.version_info[0], sys.version_info[1]) - setup_cfg = ConfigParser.ConfigParser() - setup_cfg.read('setup.cfg') - project = setup_cfg.get('metadata', 'name') - - install = install_venv.InstallVenv( - root, venv, pip_requires, test_requires, py_version, project) - options = install.parse_args(argv) - install.check_python_version() - install.check_dependencies() - install.create_virtualenv(no_site_packages=options.no_site_packages) - install.install_dependencies() - print_help(project, venv, root) - -if __name__ == '__main__': - main(sys.argv) diff --git a/tools/install_venv_common.py b/tools/install_venv_common.py deleted file mode 100644 index 3b7ac1065..000000000 --- a/tools/install_venv_common.py +++ /dev/null @@ -1,175 +0,0 @@ -# Copyright 2013 OpenStack Foundation -# Copyright 2013 IBM Corp. -# -# 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. - -"""Provides methods needed by installation script for OpenStack development -virtual environments. - -Since this script is used to bootstrap a virtualenv from the system's Python -environment, it should be kept strictly compatible with Python 2.6. - -Synced in from openstack-common -""" - -from __future__ import print_function - -import optparse -import os -import subprocess -import sys - - -class InstallVenv(object): - - def __init__(self, root, venv, requirements, - test_requirements, py_version, - project): - self.root = root - self.venv = venv - self.requirements = requirements - self.test_requirements = test_requirements - self.py_version = py_version - self.project = project - - def die(self, message, *args): - print(message % args, file=sys.stderr) - sys.exit(1) - - def check_python_version(self): - if sys.version_info < (2, 6): - self.die("Need Python Version >= 2.6") - - def run_command_with_code(self, cmd, redirect_output=True, - check_exit_code=True): - """Runs a command in an out-of-process shell. - - Returns the output of that command. Working directory is self.root. - """ - if redirect_output: - stdout = subprocess.PIPE - else: - stdout = None - - proc = subprocess.Popen(cmd, cwd=self.root, stdout=stdout) - output = proc.communicate()[0] - if check_exit_code and proc.returncode != 0: - self.die('Command "%s" failed.\n%s', ' '.join(cmd), output) - return (output, proc.returncode) - - def run_command(self, cmd, redirect_output=True, check_exit_code=True): - return self.run_command_with_code(cmd, redirect_output, - check_exit_code)[0] - - def get_distro(self): - if (os.path.exists('/etc/fedora-release') or - os.path.exists('/etc/redhat-release')): - return Fedora( - self.root, self.venv, self.requirements, - self.test_requirements, self.py_version, self.project) - else: - return Distro( - self.root, self.venv, self.requirements, - self.test_requirements, self.py_version, self.project) - - def check_dependencies(self): - self.get_distro().install_virtualenv() - - def create_virtualenv(self, no_site_packages=True): - """Creates the virtual environment and installs PIP. - - Creates the virtual environment and installs PIP only into the - virtual environment. - """ - if not os.path.isdir(self.venv): - print('Creating venv...', end=' ') - if no_site_packages: - self.run_command(['virtualenv', '-q', '--no-site-packages', - self.venv]) - else: - self.run_command(['virtualenv', '-q', self.venv]) - print('done.') - else: - print("venv already exists...") - pass - - def pip_install(self, *args): - self.run_command(['tools/with_venv.sh', - 'pip', 'install', '--upgrade'] + list(args), - redirect_output=False) - - def install_dependencies(self): - print('Installing dependencies with pip (this can take a while)...') - - # First things first, make sure our venv has the latest pip and - # setuptools and pbr - self.pip_install('pip>=1.4') - self.pip_install('setuptools') - self.pip_install('pbr') - - self.pip_install('-r', self.requirements, '-r', self.test_requirements) - - def parse_args(self, argv): - """Parses command-line arguments.""" - parser = optparse.OptionParser() - parser.add_option('-n', '--no-site-packages', - action='store_true', - help="Do not inherit packages from global Python " - "install") - return parser.parse_args(argv[1:])[0] - - def post_process(self, **kwargs): - pass - - -class Distro(InstallVenv): - - def check_cmd(self, cmd): - return bool(self.run_command(['which', cmd], - check_exit_code=False).strip()) - - def install_virtualenv(self): - if self.check_cmd('virtualenv'): - return - - if self.check_cmd('easy_install'): - print('Installing virtualenv via easy_install...', end=' ') - if self.run_command(['easy_install', 'virtualenv']): - print('Succeeded') - return - else: - print('Failed') - - self.die('ERROR: virtualenv not found.\n\n%s development' - ' requires virtualenv, please install it using your' - ' favorite package management tool' % self.project) - - -class Fedora(Distro): - """This covers all Fedora-based distributions. - - Includes: Fedora, RHEL, CentOS, Scientific Linux - """ - - def check_pkg(self, pkg): - return self.run_command_with_code(['rpm', '-q', pkg], - check_exit_code=False)[1] == 0 - - def install_virtualenv(self): - if self.check_cmd('virtualenv'): - return - - if not self.check_pkg('python-virtualenv'): - self.die("Please install 'python-virtualenv'.") - - super(Fedora, self).install_virtualenv() diff --git a/tools/lintstack.py b/tools/lintstack.py new file mode 100755 index 000000000..e6516c7e3 --- /dev/null +++ b/tools/lintstack.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +# Copyright (c) 2013, AT&T Labs, Yun Mao +# 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. + +"""pylint error checking.""" + +from io import StringIO +import json +import re +import sys + +from pylint import lint +from pylint.reporters import text + +ignore_codes = [ + # Note(maoy): E1103 is error code related to partial type inference + "E1103" +] + +ignore_messages = [ + # Note(fengqian): this message is the pattern of [E0611]. + "No name 'urllib' in module '_MovedItems'", + + # Note(xyang): these error messages are for the code [E1101]. + # They should be ignored because 'sha256' and 'sha224' are functions in + # 'hashlib'. + "Module 'hashlib' has no 'sha256' member", + "Module 'hashlib' has no 'sha224' member", + + # six.moves + "Instance of '_MovedItems' has no 'builtins' member", + + # This error message is for code [E1101] + "Instance of 'ResourceFilterManager' has no '_list' member", +] + +ignore_modules = ["cinderclient/tests/"] + +KNOWN_PYLINT_EXCEPTIONS_FILE = "tools/pylint_exceptions" + + +class LintOutput(object): + + _cached_filename = None + _cached_content = None + + def __init__(self, filename, lineno, line_content, code, message, + lintoutput): + self.filename = filename + self.lineno = lineno + self.line_content = line_content + self.code = code + self.message = message + self.lintoutput = lintoutput + + @classmethod + def from_line(cls, line): + m = re.search(r"(\S+):(\d+): \[(\S+)(, \S+)?] (.*)", line) + if m is None: + return None + matched = m.groups() + filename, lineno, code, message = (matched[0], int(matched[1]), + matched[2], matched[-1]) + if cls._cached_filename != filename: + with open(filename) as f: + cls._cached_content = list(f.readlines()) + cls._cached_filename = filename + line_content = cls._cached_content[lineno - 1].rstrip() + return cls(filename, lineno, line_content, code, message, + line.rstrip()) + + @classmethod + def from_msg_to_dict(cls, msg): + """Convert pylint output to a dict. + + From the output of pylint msg, to a dict, where each key + is a unique error identifier, value is a list of LintOutput + """ + result = {} + for line in msg.splitlines(): + obj = cls.from_line(line) + if obj is None or obj.is_ignored(): + continue + key = obj.key() + if key not in result: + result[key] = [] + result[key].append(obj) + return result + + def is_ignored(self): + if self.code in ignore_codes: + return True + if any(self.filename.startswith(name) for name in ignore_modules): + return True + if any(msg in self.message for msg in ignore_messages): + return True + return False + + def key(self): + if self.code in ["E1101", "E1103"]: + # These two types of errors are like Foo class has no member bar. + # We discard the source code so that the error will be ignored + # next time another Foo.bar is encountered. + return self.message, "" + return self.message, self.line_content.strip() + + def json(self): + return json.dumps(self.__dict__) + + def review_str(self): + return ("File %(filename)s\nLine %(lineno)d:%(line_content)s\n" + "%(code)s: %(message)s" % + {'filename': self.filename, + 'lineno': self.lineno, + 'line_content': self.line_content, + 'code': self.code, + 'message': self.message}) + + +class ErrorKeys(object): + + @classmethod + def print_json(cls, errors, output=sys.stdout): + print("# automatically generated by tools/lintstack.py", file=output) + for i in sorted(errors.keys()): + print(json.dumps(i), file=output) + + @classmethod + def from_file(cls, filename): + keys = set() + for line in open(filename): + if line and line[0] != "#": + d = json.loads(line) + keys.add(tuple(d)) + return keys + + +def run_pylint(): + buff = StringIO() + reporter = text.TextReporter(output=buff) + args = [ + "--msg-template='{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}'", + "-E", "cinderclient"] + lint.Run(args, reporter=reporter, do_exit=False) + val = buff.getvalue() + buff.close() + return val + + +def generate_error_keys(msg=None): + print("Generating", KNOWN_PYLINT_EXCEPTIONS_FILE) + if msg is None: + msg = run_pylint() + errors = LintOutput.from_msg_to_dict(msg) + with open(KNOWN_PYLINT_EXCEPTIONS_FILE, "w") as f: + ErrorKeys.print_json(errors, output=f) + + +def validate(newmsg=None): + print("Loading", KNOWN_PYLINT_EXCEPTIONS_FILE) + known = ErrorKeys.from_file(KNOWN_PYLINT_EXCEPTIONS_FILE) + if newmsg is None: + print("Running pylint. Be patient...") + newmsg = run_pylint() + errors = LintOutput.from_msg_to_dict(newmsg) + + print("Unique errors reported by pylint: was %d, now %d." + % (len(known), len(errors))) + passed = True + for err_key, err_list in errors.items(): + for err in err_list: + if err_key not in known: + print(err.lintoutput) + print() + passed = False + if passed: + print("Congrats! pylint check passed.") + redundant = known - set(errors.keys()) + if redundant: + print("Extra credit: some known pylint exceptions disappeared.") + for i in sorted(redundant): + print(json.dumps(i)) + print("Consider regenerating the exception file if you will.") + else: + print("Please fix the errors above. If you believe they are false " + "positives, run 'tools/lintstack.py generate' to overwrite.") + sys.exit(1) + + +def usage(): + print("""Usage: tools/lintstack.py [generate|validate] + To generate pylint_exceptions file: tools/lintstack.py generate + To validate the current commit: tools/lintstack.py + """) + + +def main(): + option = "validate" + if len(sys.argv) > 1: + option = sys.argv[1] + if option == "generate": + generate_error_keys() + elif option == "validate": + validate() + else: + usage() + + +if __name__ == "__main__": + main() diff --git a/tools/lintstack.sh b/tools/lintstack.sh new file mode 100755 index 000000000..d8591d03d --- /dev/null +++ b/tools/lintstack.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +# Copyright (c) 2012-2013, AT&T Labs, Yun Mao +# 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. + +# Use lintstack.py to compare pylint errors. +# We run pylint twice, once on HEAD, once on the code before the latest +# commit for review. +set -e +TOOLS_DIR=$(cd $(dirname "$0") && pwd) +# Get the current branch name. +GITHEAD=`git rev-parse --abbrev-ref HEAD` +if [[ "$GITHEAD" == "HEAD" ]]; then + # In detached head mode, get revision number instead + GITHEAD=`git rev-parse HEAD` + echo "Currently we are at commit $GITHEAD" +else + echo "Currently we are at branch $GITHEAD" +fi + +cp -f $TOOLS_DIR/lintstack.py $TOOLS_DIR/lintstack.head.py + +if git rev-parse HEAD^2 2>/dev/null; then + # The HEAD is a Merge commit. Here, the patch to review is + # HEAD^2, the master branch is at HEAD^1, and the patch was + # written based on HEAD^2~1. + PREV_COMMIT=`git rev-parse HEAD^2~1` + git checkout HEAD~1 + # The git merge is necessary for reviews with a series of patches. + # If not, this is a no-op so won't hurt either. + git merge $PREV_COMMIT +else + # The HEAD is not a merge commit. This won't happen on gerrit. + # Most likely you are running against your own patch locally. + # We assume the patch to examine is HEAD, and we compare it against + # HEAD~1 + git checkout HEAD~1 +fi + +# First generate tools/pylint_exceptions from HEAD~1 +$TOOLS_DIR/lintstack.head.py generate +# Then use that as a reference to compare against HEAD +git checkout $GITHEAD +$TOOLS_DIR/lintstack.head.py +echo "Check passed. FYI: the pylint exceptions are:" +cat $TOOLS_DIR/pylint_exceptions + diff --git a/tools/with_venv.sh b/tools/with_venv.sh deleted file mode 100755 index c8d2940fc..000000000 --- a/tools/with_venv.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -TOOLS=`dirname $0` -VENV=$TOOLS/../.venv -source $VENV/bin/activate && $@ diff --git a/tox.ini b/tox.ini index bd966b0a5..8e97545c7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,39 +1,125 @@ [tox] distribute = False -envlist = py26,py27,py33,pypy,pep8 -minversion = 1.6 -skipsdist = True +envlist = py3,pep8 +minversion = 4.11.0 +# specify virtualenv here to keep local runs consistent with the +# gate (it sets the versions of pip, setuptools, and wheel) +requires = virtualenv>=20.17.1 +# this allows tox to infer the base python from the environment name +# and override any basepython configured in this file +ignore_basepython_conflict=true [testenv] +basepython = python3 usedevelop = True -install_command = pip install -U {opts} {packages} -setenv = VIRTUAL_ENV={envdir} +setenv = + VIRTUAL_ENV={envdir} + OS_TEST_PATH=./cinderclient/tests/unit + OS_STDOUT_CAPTURE=1 + OS_STDERR_CAPTURE=1 + OS_TEST_TIMEOUT=60 +passenv = + *_proxy + *_PROXY -deps = -r{toxinidir}/requirements.txt +deps = + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt -commands = python setup.py testr --testr-args='{posargs}' +commands = find . -type f -name "*.pyc" -delete + stestr run {posargs} + stestr slowest +allowlist_externals = find [testenv:pep8] -commands = flake8 +commands = + flake8 + doc8 + +[testenv:pylint] +deps = + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -r{toxinidir}/requirements.txt + pylint==2.6.0 +commands = bash tools/lintstack.sh +allowlist_externals = bash [testenv:venv] commands = {posargs} [testenv:cover] -commands = python setup.py testr --coverage --testr-args='{posargs}' +setenv = + {[testenv]setenv} + PYTHON=coverage run --source cinderclient --parallel-mode +commands = + stestr run {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml [testenv:docs] -commands= - python setup.py build_sphinx +deps = + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -r{toxinidir}/doc/requirements.txt +commands = sphinx-build -W -b html doc/source doc/build/html + +[testenv:pdf-docs] +deps = + {[testenv:docs]deps} +commands = + {[testenv:docs]commands} + sphinx-build -W -b latex doc/source doc/build/pdf + make -C doc/build/pdf +allowlist_externals = + make + cp + +[testenv:releasenotes] +deps = + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -r{toxinidir}/doc/requirements.txt +commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html [testenv:functional] +deps = + {[testenv]deps} + tempest>=26.0.0 +commands = stestr run {posargs} setenv = - OS_TEST_PATH = ./cinderclient/tests/functional + # can't use {[testenv]setenv} here due to tox 4 issue + # https://github.com/tox-dev/tox/issues/2831 + VIRTUAL_ENV={envdir} + OS_STDOUT_CAPTURE=1 + OS_STDERR_CAPTURE=1 + OS_TEST_TIMEOUT=60 + OS_TEST_PATH=./cinderclient/tests/functional + OS_VOLUME_API_VERSION = 3 + # must define this here so it can be inherited by the -py3* environments + OS_CINDERCLIENT_EXEC_DIR = {envdir}/bin + # Our functional tests contain their own timeout handling, so + # turn off the timeout handling provided by the + # tempest.lib.base.BaseTestCase that our ClientTestBase class + # inherits from. + OS_TEST_TIMEOUT=0 + +# The OS_CACERT environment variable should be passed to the test +# environments to specify a CA bundle file to use in verifying a +# TLS (https) server certificate. +passenv = OS_* -[tox:jenkins] -downloadcache = ~/cache/pip +[testenv:functional-py{3,310,311,312,313}] +deps = {[testenv:functional]deps} +setenv = {[testenv:functional]setenv} +passenv = {[testenv:functional]passenv} +commands = {[testenv:functional]commands} [flake8] show-source = True -ignore = F811,F821,H302,H306,H404 -exclude=.venv,.git,.tox,dist,doc,*openstack/common*,*lib/python*,*egg,build,tools +ignore = H404,H405,E122,E123,E128,E251,W503,W504 +exclude = .venv,.git,.tox,dist,doc,*lib/python*,*egg,build +application-import-names = cinderclient +import-order-style = pep8 + +[doc8] +ignore-path=.tox,*.egg-info,doc/src/api,doc/source/drivers.rst,doc/build,.eggs/*/EGG-INFO/*.txt,doc/source/configuration/tables,./*.txt,releasenotes/build,doc/source/cli/details.rst +extension=.txt,.rst,.inc