diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..d669ccfbb --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[run] +branch = True +source = cinderclient +omit = cinderclient/tests/* + +[report] +ignore_errors = True diff --git a/.gitignore b/.gitignore index 6ef432f21..9d12a57b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,22 @@ -.coverage -.venv +/.* +!.gitignore +!.mailmap +!.stestr.conf +.*.sw? +subunit.log *,cover cover +covhtml *.pyc -.idea -*.swp -*~ AUTHORS +ChangeLog +doc/build +releasenotes/build build dist -.*egg-info 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/.mailmap b/.mailmap index f270bb6f5..f643222c8 100644 --- a/.mailmap +++ b/.mailmap @@ -13,3 +13,4 @@ Andy Smith termie Nikolay Sokolov Nokolay Sokolov Nikolay Sokolov Nokolay Sokolov + 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/.zuul.yaml b/.zuul.yaml new file mode 100644 index 000000000..1eca8230f --- /dev/null +++ b/.zuul.yaml @@ -0,0 +1,91 @@ +- job: + name: python-cinderclient-functional-base + abstract: true + parent: devstack-tox-functional + description: | + Abstract job for defining devstack-based functional test jobs for + python-cinderclient. Jobs for particular python versions use this + job as a parent, and control what verison of python is used by + specifying a nodeset having the desired version of python as the + system default. + 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: + devstack_localrc: + # current master devstack no longer supports jammy as a platform + # (change I796eddac96313584f4a), so we must force install it + FORCE: 'yes' + bindep_profile: test py310 + python_version: '3.10' + tox_envlist: functional-py310 + +- job: + name: python-cinderclient-functional-py313 + parent: python-cinderclient-functional-base + # Python 3.13 is the default on Debian Trixie + nodeset: devstack-single-node-debian-trixie + vars: + bindep_profile: test py310 + python_version: '3.13' + tox_envlist: functional-py313 + +- 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: + # non-voting because devstack support on python 3.10 + # is no longer tested (see change I796eddac96313584f4a) + voting: false + - python-cinderclient-functional-py313 + - openstack-tox-pylint: + voting: false + periodic-weekly: + jobs: + - openstack-tox-pep8: + branches: master + - openstack-tox-py310: + branches: master + - openstack-tox-py313: + branches: master + - openstack-tox-py314: + branches: master + - openstack-tox-docs: + branches: master + - build-openstack-releasenotes: + vars: + sphinx_python: python3 + branches: master + - python-cinderclient-functional-py313: + branches: master + gate: + jobs: + - python-cinderclient-functional-py313 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 b/HACKING deleted file mode 100644 index d9d1cb851..000000000 --- a/HACKING +++ /dev/null @@ -1,115 +0,0 @@ -Nova Style Commandments -======================= - -Step 1: Read http://www.python.org/dev/peps/pep-0008/ -Step 2: Read http://www.python.org/dev/peps/pep-0008/ again -Step 3: Read on - -Imports -------- -- thou shalt not import objects, only modules -- thou shalt not import more than one module per line -- thou shalt not make relative imports -- thou shalt organize your imports according to the following template - -:: - # vim: tabstop=4 shiftwidth=4 softtabstop=4 - {{stdlib imports in human alphabetical order}} - \n - {{cinder imports in human alphabetical order}} - \n - \n - {{begin your code}} - - -General -------- -- thou shalt put two newlines twixt toplevel code (funcs, classes, etc) -- thou shalt put one newline twixt methods in classes and anywhere else -- thou shalt not write "except:", use "except Exception:" at the very least -- thou shalt include your name with TODOs as in "TODO(termie)" -- thou shalt not name anything the same name as a builtin or reserved word -- thou shalt not violate causality in our time cone, or else - - -Human Alphabetical Order Examples ---------------------------------- -:: - import httplib - import logging - import random - import StringIO - import time - import unittest - - from cinder import flags - from cinder import test - from cinder.auth import users - from cinder.endpoint import api - from cinder.endpoint import cloud - -Docstrings ----------- - """A one line docstring looks like this and ends in a period.""" - - - """A multiline docstring has a one-line summary, less than 80 characters. - - Then a new paragraph after a newline that explains in more detail any - general information about the function, class or method. Example usages - are also great to have here if it is a complex class for function. After - you have finished your descriptions add an extra newline and close the - quotations. - - When writing the docstring for a class, an extra line should be placed - after the closing quotations. For more in-depth explanations for these - decisions see http://www.python.org/dev/peps/pep-0257/ - - If you are going to describe parameters and return values, use Sphinx, the - appropriate syntax is as follows. - - :param foo: the foo parameter - :param bar: the bar parameter - :returns: description of the return value - - """ - -Text encoding ----------- -- 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. - -- All external text that is not explicitly encoded (database storage, - commandline arguments, etc.) should be presumed to be encoded as utf-8. - - 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) diff --git a/HACKING.rst b/HACKING.rst new file mode 100644 index 000000000..82683e7b0 --- /dev/null +++ b/HACKING.rst @@ -0,0 +1,50 @@ +Cinder Client Style Commandments +================================ + +- Step 1: Read the OpenStack Style Commandments + https://docs.openstack.org/hacking/latest/ +- Step 2: Read on + +Cinder Client Specific Commandments +----------------------------------- + +General +------- +- Use 'raise' instead of 'raise e' to preserve original traceback or exception + being reraised:: + + except Exception as e: + ... + raise e # BAD + + except Exception: + ... + raise # OKAY + +Release Notes +------------- +- 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.) + +- Cinder Client uses Reno for release notes management. See the `Reno + Documentation`_ for more details on its usage. + +.. _Reno Documentation: https://docs.openstack.org/reno/latest/ + +- 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:: + + $ tox -e venv -- reno new add-awesome-command + $ vi releasenotes/notes/add-awesome-command-bb8bb8bb8bb8bb81.yaml + + Remove the extra template text from the release note and update the details + so it looks something like:: + + --- + features: + - Added shell command `cinder be-awesome` for Awesome Storage Feature. + +- 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 98bfc4574..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,10 +0,0 @@ -include AUTHORS -include ChangeLog -include HACKING -include LICENSE -include README.rst -include run_tests.sh tox.ini -include cinderclient/versioninfo -recursive-include doc * -recursive-include tests * -recursive-include tools * diff --git a/README.rst b/README.rst index ede24bc70..2740e963e 100644 --- a/README.rst +++ b/README.rst @@ -1,34 +1,46 @@ -Python bindings to the OpenStack Volume API +=========================================== +Python bindings to the OpenStack Cinder API =========================================== -This is a client for the OpenStack Volume API. There's a Python API (the -``cinderclient`` module), and a command-line script (``cinder``). Each -implements 100% of the OpenStack Volume API. - -[PENDING] `Full documentation is available`__. +.. image:: https://img.shields.io/pypi/v/python-cinderclient.svg + :target: https://pypi.org/project/python-cinderclient/ + :alt: Latest Version -__ http://packages.python.org/python-cinderclient/ +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. -You'll also probably want to read `OpenStack Compute Developer Guide API`__ -- -the first bit, at least -- to get an idea of the concepts. Rackspace is doing -the cloud hosting thing a bit differently from Amazon, and if you get the -concepts this library should make more sense. +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`_. -__ 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://wiki.openstack.org/GerritWorkflow - -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-client is licensed under the Apache License like the rest of OpenStack. +.. _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`_ + +.. _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: @@ -40,116 +52,295 @@ Installing this package gets you a shell command, ``cinder``, that you can use to interact with any Rackspace compatible API (including OpenStack). You'll need to provide your OpenStack username and password. You can do this -with the ``--os_username``, ``--os_password`` and ``--os_tenant_name`` +with the ``--os-username``, ``--os-password`` and ``--os-tenant-name`` params, but it's easier to just set them as environment variables:: export OS_USERNAME=openstack export OS_PASSWORD=yadayada 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 ``--version``. Or set them as an environment -variables as well:: - - export OS_AUTH_URL=http://example.com:8774/v1.1/ - export OS_COMPUTE_API_VERSION=1.1 - -If you are using Keystone, you need to set the CINDER_URL to the keystone +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. 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 +can specify the one you want with ``--os-region-name`` (or ``export OS_REGION_NAME``). It defaults to the first in the list returned. You'll find complete documentation on the shell by running ``cinder help``:: - usage: cinder [--debug] [--os_username OS_USERNAME] [--os_password OS_PASSWORD] - [--os_tenant_name OS_TENANT_NAME] [--os_auth_url OS_AUTH_URL] - [--os_region_name OS_REGION_NAME] [--service_type SERVICE_TYPE] - [--service_name SERVICE_NAME] [--endpoint_type ENDPOINT_TYPE] - [--version VERSION] [--username USERNAME] - [--region_name REGION_NAME] [--apikey APIKEY] - [--projectid PROJECTID] [--url URL] - ... - - Command-line interface to the OpenStack Nova API. + usage: cinder [--version] [-d] [--os-auth-system ] + [--service-type ] [--service-name ] + [--volume-service-name ] + [--os-endpoint-type ] + [--endpoint-type ] + [--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: - 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 - list List all the volumes. - show Show details about a volume. - snapshot-create Add a new snapshot. - snapshot-delete Remove a snapshot. - snapshot-list List all the snapshots. - snapshot-show Show details about a snapshot. - type-create Create a new volume type. - type-delete Delete a specific flavor - 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 + 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). + 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 Optional arguments: - --debug Print debugging output - --os_username OS_USERNAME - Defaults to env[OS_USERNAME]. - --os_password OS_PASSWORD - Defaults to env[OS_PASSWORD]. - --os_tenant_name OS_TENANT_NAME - Defaults to env[OS_TENANT_NAME]. - --os_auth_url OS_AUTH_URL - Defaults to env[OS_AUTH_URL]. - --os_region_name OS_REGION_NAME - Defaults to env[OS_REGION_NAME]. - --service_type SERVICE_TYPE - Defaults to compute for most actions - --service_name SERVICE_NAME - Defaults to env[CINDER_SERVICE_NAME] - --endpoint_type ENDPOINT_TYPE - Defaults to env[CINDER_ENDPOINT_TYPE] or publicURL. - --os_compute_api_version VERSION - Accepts 1.1, defaults to env[OS_COMPUTE_API_VERSION]. - --username USERNAME Deprecated - --region_name REGION_NAME - Deprecated - --apikey APIKEY, --password APIKEY - Deprecated - --projectid PROJECTID, --tenant_name PROJECTID - Deprecated - --url URL, --auth_url URL - Deprecated - - See "cinder help COMMAND" for help on a specific command. + --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 + OpenStack user name. Default=env[OS_USERNAME]. + --os-password + Password for OpenStack user. Default=env[OS_PASSWORD]. + --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 + 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 + 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]. + --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 ---------- -[PENDING] There's also a `complete Python API`__. - -__ http://packages.python.org/python-cinderclient/ +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="compute") - >>> nt.flavors.list() + # 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() [...] - >>> nt.servers.list() - [...] - >>> nt.keypairs.list() - [...] - -What's new? ------------ -[PENDING] See `the release notes `_. +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 e69de29bb..dac207385 100644 --- a/cinderclient/__init__.py +++ b/cinderclient/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) 2012 OpenStack Foundation +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# 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 pbr.version + + +__all__ = ['__version__'] + + +version_info = pbr.version.VersionInfo('python-cinderclient') +# We have a circular import problem when we first run python setup.py sdist +# It's harmless, so deflect it. +try: + __version__ = version_info.version_string() +except AttributeError: + __version__ = None 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/apiclient/base.py b/cinderclient/apiclient/base.py new file mode 100644 index 000000000..8caa0bc1b --- /dev/null +++ b/cinderclient/apiclient/base.py @@ -0,0 +1,563 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 OpenStack Foundation +# Copyright 2012 Grid Dynamics +# Copyright 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Base utilities to build API operation managers and objects on top of.""" + +# E1102: %s is not callable +# pylint: disable=E1102 + +import abc +import copy + +from oslo_utils import encodeutils +from oslo_utils import strutils +from requests import Response + + +from cinderclient.apiclient import exceptions +from cinderclient import utils + + +def getid(obj): + """Return id if argument is a Resource. + + Abstracts the common pattern of allowing both an object or an object's ID + (UUID) as a parameter when dealing with relationships. + """ + if getattr(obj, 'uuid', None): + return obj.uuid + else: + return getattr(obj, 'id', obj) + + +# TODO(aababilov): call run_hooks() in HookableMixin's child classes +class HookableMixin(object): + """Mixin so classes can register and run hooks.""" + _hooks_map = {} + + @classmethod + def add_hook(cls, hook_type, hook_func): + """Add a new hook of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param hook_func: hook function + """ + if hook_type not in cls._hooks_map: + cls._hooks_map[hook_type] = [] + + cls._hooks_map[hook_type].append(hook_func) + + @classmethod + def run_hooks(cls, hook_type, *args, **kwargs): + """Run all hooks of specified type. + + :param cls: class that registers hooks + :param hook_type: hook type, e.g., '__pre_parse_args__' + :param **args: args to be passed to every hook function + :param **kwargs: kwargs to be passed to every hook function + """ + hook_funcs = cls._hooks_map.get(hook_type) or [] + for hook_func in hook_funcs: + hook_func(*args, **kwargs) + + +class BaseManager(HookableMixin): + """Basic manager type providing common operations. + + Managers interact with a particular type of API (servers, flavors, images, + etc.) and provide CRUD operations for them. + """ + resource_class = None + + def __init__(self, client): + """Initializes BaseManager with `client`. + + :param client: instance of BaseClient descendant for HTTP requests + """ + super(BaseManager, self).__init__() + self.client = client + + def _list(self, url, response_key, obj_class=None, json=None): + """List the collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + :param obj_class: class for constructing the returned objects + (self.resource_class will be used by default) + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + """ + if json: + body = self.client.post(url, json=json).json() + else: + body = self.client.get(url).json() + + if obj_class is None: + obj_class = self.resource_class + + data = body[response_key] + # NOTE(ja): keystone returns values as list as {'values': [ ... ]} + # unlike other services which just return the list... + try: + data = data['values'] + except (KeyError, TypeError): + pass + + return [obj_class(self, res, loaded=True) for res in data if res] + + def _get(self, url, response_key): + """Get an object from collection. + + :param url: a partial URL, e.g., '/servers' + :param response_key: the key to be looked up in response dictionary, + e.g., 'server' + """ + body = self.client.get(url).json() + return self.resource_class(self, body[response_key], loaded=True) + + def _head(self, url): + """Retrieve request headers for an object. + + :param url: a partial URL, e.g., '/servers' + """ + resp = self.client.head(url) + return resp.status_code == 204 + + def _post(self, url, json, response_key, return_raw=False): + """Create an object. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + :param return_raw: flag to force returning raw JSON instead of + Python object of self.resource_class + """ + body = self.client.post(url, json=json).json() + if return_raw: + return body[response_key] + return self.resource_class(self, body[response_key]) + + def _put(self, url, json=None, response_key=None): + """Update an object with PUT method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + """ + resp = self.client.put(url, json=json) + # PUT requests may not return a body + if resp.content: + body = resp.json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _patch(self, url, json=None, response_key=None): + """Update an object with PATCH method. + + :param url: a partial URL, e.g., '/servers' + :param json: data that will be encoded as JSON and passed in POST + request (GET will be sent by default) + :param response_key: the key to be looked up in response dictionary, + e.g., 'servers' + """ + body = self.client.patch(url, json=json).json() + if response_key is not None: + return self.resource_class(self, body[response_key]) + else: + return self.resource_class(self, body) + + def _delete(self, url): + """Delete an object. + + :param url: a partial URL, e.g., '/servers/my-server' + """ + return self.client.delete(url) + + +class ManagerWithFind(BaseManager, metaclass=abc.ABCMeta): + """Manager with additional `find()`/`findall()` methods.""" + + @abc.abstractmethod + def list(self): + pass + + def find(self, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + matches = self.findall(**kwargs) + num_matches = len(matches) + if num_matches == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(msg) + elif num_matches > 1: + raise exceptions.NoUniqueMatch() + else: + return matches[0] + + def findall(self, **kwargs): + """Find all items with attributes matching ``**kwargs``. + + This isn't very efficient: it loads the entire list then filters on + the Python side. + """ + found = [] + searches = kwargs.items() + + for obj in self.list(): + try: + if all(getattr(obj, attr) == value + for (attr, value) in searches): + found.append(obj) + except AttributeError: + continue + + return found + + +class CrudManager(BaseManager): + """Base manager class for manipulating entities. + + Children of this class are expected to define a `collection_key` and `key`. + + - `collection_key`: Usually a plural noun by convention (e.g. `entities`); + used to refer collections in both URL's (e.g. `/v3/entities`) and JSON + objects containing a list of member resources (e.g. `{'entities': [{}, + {}, {}]}`). + - `key`: Usually a singular noun by convention (e.g. `entity`); used to + refer to an individual member of the collection. + + """ + collection_key = None + key = None + + def build_url(self, base_url=None, **kwargs): + """Builds a resource URL for the given kwargs. + + Given an example collection where `collection_key = 'entities'` and + `key = 'entity'`, the following URL's could be generated. + + By default, the URL will represent a collection of entities, e.g.:: + + /entities + + If kwargs contains an `entity_id`, then the URL will represent a + specific member, e.g.:: + + /entities/{entity_id} + + :param base_url: if provided, the generated URL will be appended to it + """ + url = base_url if base_url is not None else '' + + url += '/%s' % self.collection_key + + # do we have a specific entity? + entity_id = kwargs.get('%s_id' % self.key) + if entity_id is not None: + url += '/%s' % entity_id + + return url + + def _filter_kwargs(self, kwargs): + """Drop null values and handle ids.""" + for key, ref in kwargs.copy().items(): + if ref is None: + kwargs.pop(key) + else: + if isinstance(ref, Resource): + kwargs.pop(key) + kwargs['%s_id' % key] = getid(ref) + return kwargs + + def create(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._post( + self.build_url(**kwargs), + {self.key: kwargs}, + self.key) + + def get(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._get( + self.build_url(**kwargs), + self.key) + + def head(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + return self._head(self.build_url(**kwargs)) + + def list(self, base_url=None, **kwargs): + """List the collection. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': utils.build_query_param(kwargs), + }, + self.collection_key) + + def put(self, base_url=None, **kwargs): + """Update an element. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + return self._put(self.build_url(base_url=base_url, **kwargs)) + + def update(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + params = kwargs.copy() + params.pop('%s_id' % self.key) + + return self._patch( + self.build_url(**kwargs), + {self.key: params}, + self.key) + + def delete(self, **kwargs): + kwargs = self._filter_kwargs(kwargs) + + return self._delete( + self.build_url(**kwargs)) + + def find(self, base_url=None, **kwargs): + """Find a single item with attributes matching ``**kwargs``. + + :param base_url: if provided, the generated URL will be appended to it + """ + kwargs = self._filter_kwargs(kwargs) + + rl = self._list( + '%(base_url)s%(query)s' % { + 'base_url': self.build_url(base_url=base_url, **kwargs), + 'query': '?%s' % utils.build_query_param(kwargs), + }, + self.collection_key) + num = len(rl) + + if num == 0: + msg = "No %s matching %s." % (self.resource_class.__name__, kwargs) + raise exceptions.NotFound(404, msg) + elif num > 1: + raise exceptions.NoUniqueMatch + else: + return rl[0] + + +class Extension(HookableMixin): + """Extension descriptor.""" + + SUPPORTED_HOOKS = ('__pre_parse_args__', '__post_parse_args__') + manager_class = None + + def __init__(self, name, module): + super(Extension, self).__init__() + self.name = name + self.module = module + self._parse_extension_module() + + def _parse_extension_module(self): + self.manager_class = None + for attr_name, attr_value in self.module.__dict__.items(): + if attr_name in self.SUPPORTED_HOOKS: + self.add_hook(attr_name, attr_value) + else: + try: + if issubclass(attr_value, BaseManager): + self.manager_class = attr_value + except TypeError: + pass + + def __repr__(self): + return "" % self.name + + +class 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. + """ + + HUMAN_ID = False + NAME_ATTR = 'name' + + 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 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) + + @property + def human_id(self): + """Human-readable ID which can be used for bash completion. + """ + if self.NAME_ATTR in self.__dict__ and self.HUMAN_ID: + return strutils.to_slug(getattr(self, self.NAME_ATTR)) + return None + + def _add_details(self, info): + for (k, v) in info.items(): + try: + setattr(self, k, v) + except AttributeError: + # In this case we already defined the attribute on the class + continue + except UnicodeEncodeError: + setattr(self, encodeutils.safe_encode(k), v) + self._info[k] = v + + def __getattr__(self, k): + 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: + 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. + self.set_loaded(True) + if not hasattr(self.manager, 'get'): + return + + new = self.manager.get(self.id) + if new: + self._add_details(new._info) + + def __eq__(self, other): + if not isinstance(other, Resource): + return NotImplemented + # two resources of different types are not equal + if not isinstance(other, self.__class__): + return False + 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/apiclient/exceptions.py b/cinderclient/apiclient/exceptions.py new file mode 100644 index 000000000..71146c90f --- /dev/null +++ b/cinderclient/apiclient/exceptions.py @@ -0,0 +1,442 @@ +# Copyright 2010 Jacob Kaplan-Moss +# Copyright 2011 Nebula, Inc. +# Copyright 2013 Alessio Ababilov +# Copyright 2013 OpenStack Foundation +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Exception definitions. +""" + +import inspect +import sys + + +class ClientException(Exception): + """The base exception class for all exceptions this library raises. + """ + pass + + +class MissingArgs(ClientException): + """Supplied arguments are not sufficient for calling a function.""" + def __init__(self, missing): + self.missing = missing + msg = "Missing argument(s): %s" % ", ".join(missing) + super(MissingArgs, self).__init__(msg) + + +class ValidationError(ClientException): + """Error in validation on API client side.""" + pass + + +class UnsupportedVersion(ClientException): + """User is trying to use an unsupported version of the API.""" + pass + + +class CommandError(ClientException): + """Error in CLI tool.""" + pass + + +class AuthorizationFailure(ClientException): + """Cannot authorize API client.""" + pass + + +class ConnectionRefused(ClientException): + """Cannot connect to API service.""" + pass + + +class AuthPluginOptionsMissing(AuthorizationFailure): + """Auth plugin misses some options.""" + def __init__(self, opt_names): + super(AuthPluginOptionsMissing, self).__init__( + "Authentication failed. Missing options: %s" % + ", ".join(opt_names)) + self.opt_names = opt_names + + +class AuthSystemNotFound(AuthorizationFailure): + """User has specified a AuthSystem that is not installed.""" + def __init__(self, auth_system): + super(AuthSystemNotFound, self).__init__( + "AuthSystemNotFound: %s" % repr(auth_system)) + self.auth_system = auth_system + + +class NoUniqueMatch(ClientException): + """Multiple entities found instead of one.""" + pass + + +class EndpointException(ClientException): + """Something is rotten in Service Catalog.""" + pass + + +class EndpointNotFound(EndpointException): + """Could not find requested endpoint in Service Catalog.""" + pass + + +class AmbiguousEndpoints(EndpointException): + """Found more than one matching endpoint in Service Catalog.""" + def __init__(self, endpoints=None): + super(AmbiguousEndpoints, self).__init__( + "AmbiguousEndpoints: %s" % repr(endpoints)) + self.endpoints = endpoints + + +class HttpError(ClientException): + """The base exception class for all HTTP exceptions. + """ + http_status = 0 + message = "HTTP Error" + + def __init__(self, message=None, details=None, + response=None, request_id=None, + url=None, method=None, http_status=None): + self.http_status = http_status or self.http_status + self.message = message or self.message + self.details = details + self.request_id = request_id + self.response = response + self.url = url + self.method = method + formatted_string = "%s (HTTP %s)" % (self.message, self.http_status) + if request_id: + formatted_string += " (Request-ID: %s)" % request_id + super(HttpError, self).__init__(formatted_string) + + +class HTTPClientError(HttpError): + """Client-side HTTP error. + + Exception for cases in which the client seems to have erred. + """ + message = "HTTP Client Error" + + +class HttpServerError(HttpError): + """Server-side HTTP error. + + Exception for cases in which the server is aware that it has + erred or is incapable of performing the request. + """ + message = "HTTP Server Error" + + +class BadRequest(HTTPClientError): + """HTTP 400 - Bad Request. + + The request cannot be fulfilled due to bad syntax. + """ + http_status = 400 + message = "Bad Request" + + +class Unauthorized(HTTPClientError): + """HTTP 401 - Unauthorized. + + Similar to 403 Forbidden, but specifically for use when authentication + is required and has failed or has not yet been provided. + """ + http_status = 401 + message = "Unauthorized" + + +class PaymentRequired(HTTPClientError): + """HTTP 402 - Payment Required. + + Reserved for future use. + """ + http_status = 402 + message = "Payment Required" + + +class Forbidden(HTTPClientError): + """HTTP 403 - Forbidden. + + The request was a valid request, but the server is refusing to respond + to it. + """ + http_status = 403 + message = "Forbidden" + + +class NotFound(HTTPClientError): + """HTTP 404 - Not Found. + + The requested resource could not be found but may be available again + in the future. + """ + http_status = 404 + message = "Not Found" + + +class MethodNotAllowed(HTTPClientError): + """HTTP 405 - Method Not Allowed. + + A request was made of a resource using a request method not supported + by that resource. + """ + http_status = 405 + message = "Method Not Allowed" + + +class NotAcceptable(HTTPClientError): + """HTTP 406 - Not Acceptable. + + The requested resource is only capable of generating content not + acceptable according to the Accept headers sent in the request. + """ + http_status = 406 + message = "Not Acceptable" + + +class ProxyAuthenticationRequired(HTTPClientError): + """HTTP 407 - Proxy Authentication Required. + + The client must first authenticate itself with the proxy. + """ + http_status = 407 + message = "Proxy Authentication Required" + + +class RequestTimeout(HTTPClientError): + """HTTP 408 - Request Timeout. + + The server timed out waiting for the request. + """ + http_status = 408 + message = "Request Timeout" + + +class Conflict(HTTPClientError): + """HTTP 409 - Conflict. + + Indicates that the request could not be processed because of conflict + in the request, such as an edit conflict. + """ + http_status = 409 + message = "Conflict" + + +class Gone(HTTPClientError): + """HTTP 410 - Gone. + + Indicates that the resource requested is no longer available and will + not be available again. + """ + http_status = 410 + message = "Gone" + + +class LengthRequired(HTTPClientError): + """HTTP 411 - Length Required. + + The request did not specify the length of its content, which is + required by the requested resource. + """ + http_status = 411 + message = "Length Required" + + +class PreconditionFailed(HTTPClientError): + """HTTP 412 - Precondition Failed. + + The server does not meet one of the preconditions that the requester + put on the request. + """ + http_status = 412 + message = "Precondition Failed" + + +class RequestEntityTooLarge(HTTPClientError): + """HTTP 413 - Request Entity Too Large. + + The request is larger than the server is willing or able to process. + """ + http_status = 413 + message = "Request Entity Too Large" + + def __init__(self, *args, **kwargs): + try: + self.retry_after = int(kwargs.pop('retry_after')) + except (KeyError, ValueError): + self.retry_after = 0 + + super(RequestEntityTooLarge, self).__init__(*args, **kwargs) + + +class RequestUriTooLong(HTTPClientError): + """HTTP 414 - Request-URI Too Long. + + The URI provided was too long for the server to process. + """ + http_status = 414 + message = "Request-URI Too Long" + + +class UnsupportedMediaType(HTTPClientError): + """HTTP 415 - Unsupported Media Type. + + The request entity has a media type which the server or resource does + not support. + """ + http_status = 415 + message = "Unsupported Media Type" + + +class RequestedRangeNotSatisfiable(HTTPClientError): + """HTTP 416 - Requested Range Not Satisfiable. + + The client has asked for a portion of the file, but the server cannot + supply that portion. + """ + http_status = 416 + message = "Requested Range Not Satisfiable" + + +class ExpectationFailed(HTTPClientError): + """HTTP 417 - Expectation Failed. + + The server cannot meet the requirements of the Expect request-header field. + """ + http_status = 417 + message = "Expectation Failed" + + +class UnprocessableEntity(HTTPClientError): + """HTTP 422 - Unprocessable Entity. + + The request was well-formed but was unable to be followed due to semantic + errors. + """ + http_status = 422 + message = "Unprocessable Entity" + + +class InternalServerError(HttpServerError): + """HTTP 500 - Internal Server Error. + + A generic error message, given when no more specific message is suitable. + """ + http_status = 500 + message = "Internal Server Error" + + +# NotImplemented is a python keyword. +class HttpNotImplemented(HttpServerError): + """HTTP 501 - Not Implemented. + + The server either does not recognize the request method, or it lacks + the ability to fulfill the request. + """ + http_status = 501 + message = "Not Implemented" + + +class BadGateway(HttpServerError): + """HTTP 502 - Bad Gateway. + + The server was acting as a gateway or proxy and received an invalid + response from the upstream server. + """ + http_status = 502 + message = "Bad Gateway" + + +class ServiceUnavailable(HttpServerError): + """HTTP 503 - Service Unavailable. + + The server is currently unavailable. + """ + http_status = 503 + message = "Service Unavailable" + + +class GatewayTimeout(HttpServerError): + """HTTP 504 - Gateway Timeout. + + The server was acting as a gateway or proxy and did not receive a timely + response from the upstream server. + """ + http_status = 504 + message = "Gateway Timeout" + + +class HttpVersionNotSupported(HttpServerError): + """HTTP 505 - HttpVersion Not Supported. + + The server does not support the HTTP protocol version used in the request. + """ + http_status = 505 + message = "HTTP Version Not Supported" + + +# _code_map contains all the classes that have http_status attribute. +_code_map = dict( + (getattr(obj, 'http_status', None), obj) + for name, obj in vars(sys.modules[__name__]).items() + if inspect.isclass(obj) and getattr(obj, 'http_status', False) +) + + +def from_response(response, method, url): + """Returns an instance of :class:`HttpError` or subclass based on response. + + :param response: instance of `requests.Response` class + :param method: HTTP method used for request + :param url: URL used for request + """ + kwargs = { + "http_status": response.status_code, + "response": response, + "method": method, + "url": url, + "request_id": response.headers.get("x-compute-request-id"), + } + if "retry-after" in response.headers: + kwargs["retry_after"] = response.headers["retry-after"] + + content_type = response.headers.get("Content-Type", "") + if content_type.startswith("application/json"): + try: + body = response.json() + except ValueError: + pass + else: + if hasattr(body, "keys"): + error = body[list(body.keys())[0]] + kwargs["message"] = error.get("message", None) + kwargs["details"] = error.get("details", None) + elif content_type.startswith("text/"): + kwargs["details"] = response.text + + try: + cls = _code_map[response.status_code] + except KeyError: + if 500 <= response.status_code < 600: + cls = HttpServerError + elif 400 <= response.status_code < 500: + cls = HTTPClientError + else: + cls = HttpError + return cls(**kwargs) diff --git a/cinderclient/base.py b/cinderclient/base.py index 1ee621ae9..a99b29c8e 100644 --- a/cinderclient/base.py +++ b/cinderclient/base.py @@ -1,6 +1,6 @@ # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -18,20 +18,31 @@ """ Base utilities to build API operation managers and objects on top of. """ - +import abc import contextlib import hashlib import os + +from cinderclient.apiclient import base as common_base from cinderclient import exceptions from cinderclient import utils -# Python 2.4 compat -try: - all -except NameError: - def all(iterable): - return True not in (not x for x in iterable) +# 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 def getid(obj): @@ -39,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. @@ -55,8 +63,15 @@ class Manager(utils.HookableMixin): def __init__(self, api): self.api = api - def _list(self, url, response_key, obj_class=None, body=None): + @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 + if items is None: + items = [] if body: resp, body = self.api.client.post(url, body=body) else: @@ -74,10 +89,142 @@ 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"): - return [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] + 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: + items = items + items_new + + # It is possible that the length of the list we request is longer + # than osapi_max_limit, so we have to retrieve multiple times to + # get the complete list. + next = None + 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) + # 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): @@ -93,20 +240,21 @@ 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 + url).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, 0755) + os.makedirs(cache_dir, 0o750) except OSError: - # NOTE(kiall): This is typicaly either permission denied while + # NOTE(kiall): This is typically either permission denied while # attempting to create the directory, or the directory # already exists. Either way, don't fail. pass @@ -120,7 +268,7 @@ def completion_cache(self, cache_type, obj_class, mode): try: setattr(self, cache_attr, open(path, mode)) except IOError: - # NOTE(kiall): This is typicaly a permission denied while + # NOTE(kiall): This is typically a permission denied while # attempting to write the cache file. pass @@ -130,49 +278,99 @@ 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, **kwargs): + 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) - return 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 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(Manager): +class ManagerWithFind(Manager, metaclass=abc.ABCMeta): """ Like a `Manager`, but with additional `find()`/`findall()` methods. """ + + @abc.abstractmethod + def list(self): + pass + def find(self, **kwargs): """ Find a single item with attributes matching ``**kwargs``. - This isn't very efficient: it loads the entire list then filters on - the Python side. + 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) @@ -182,19 +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 = kwargs.items() - for obj in self.list(): + # 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. + 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): @@ -203,91 +425,3 @@ def findall(self, **kwargs): continue return found - - def list(self): - raise NotImplementedError - - -class Resource(object): - """ - A resource represents a particular instance of an object (server, flavor, - etc). This is pretty much just a bag for attributes. - - :param manager: Manager object - :param info: dictionary representing resource attributes - :param loaded: prevent lazy-loading if set to True - """ - HUMAN_ID = False - - def __init__(self, manager, info, loaded=False): - self.manager = manager - self._info = info - self._add_details(info) - self._loaded = loaded - - # NOTE(sirp): ensure `id` is already present because if it isn't we'll - # enter an infinite loop of __getattr__ -> get -> __init__ -> - # __getattr__ -> ... - if 'id' in self.__dict__ and len(str(self.id)) == 36: - self.manager.write_to_completion_cache('uuid', self.id) - - human_id = self.human_id - if human_id: - self.manager.write_to_completion_cache('human_id', human_id) - - @property - def human_id(self): - """Subclasses may override this provide a pretty ID which can be used - for bash completion. - """ - if 'name' in self.__dict__ and self.HUMAN_ID: - return utils.slugify(self.name) - return None - - def _add_details(self, info): - for (k, v) in info.iteritems(): - try: - setattr(self, k, v) - except AttributeError: - # In this case we already defined the attribute on the class - pass - - def __getattr__(self, k): - if k not in self.__dict__: - #NOTE(bcwaldon): disallow lazy-loading if already loaded once - if not self.is_loaded(): - self.get() - return self.__getattr__(k) - - raise AttributeError(k) - else: - return self.__dict__[k] - - def __repr__(self): - reprkeys = sorted(k for k in self.__dict__.keys() if k[0] != '_' and - k != 'manager') - info = ", ".join("%s=%s" % (k, getattr(self, k)) for k in reprkeys) - return "<%s %s>" % (self.__class__.__name__, info) - - def get(self): - # set_loaded() first ... so if we have to bail, we know we tried. - self.set_loaded(True) - if not hasattr(self.manager, 'get'): - return - - new = self.manager.get(self.id) - if new: - self._add_details(new._info) - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - if hasattr(self, 'id') and hasattr(other, 'id'): - return self.id == other.id - return self._info == other._info - - def is_loaded(self): - return self._loaded - - def set_loaded(self, val): - self._loaded = val diff --git a/cinderclient/client.py b/cinderclient/client.py index b8eaa6263..209d44197 100644 --- a/cinderclient/client.py +++ b/cinderclient/client.py @@ -1,137 +1,484 @@ +# Copyright (c) 2011 OpenStack Foundation # Copyright 2010 Jacob Kaplan-Moss -# Copyright 2011 OpenStack LLC. # Copyright 2011 Piston Cloud Computing, Inc. - # All Rights Reserved. -""" -OpenStack Client interface. Handles the REST calls and responses. -""" - -import httplib2 +# +# 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.""" + +import glob +import hashlib +import importlib.util +import itertools +import json import logging import os -import urlparse +import pkgutil +import re +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 +import cinderclient.extension + try: - import json -except ImportError: - import simplejson as json + osprofiler_web = importutils.try_import("osprofiler.web") +except Exception: + pass + + +_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', '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 -# Python 2.5 compat fix -if not hasattr(urlparse, 'parse_qsl'): - import cgi - urlparse.parse_qsl = cgi.parse_qsl + 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 -from cinderclient import exceptions -from cinderclient import service_catalog -from cinderclient import utils + 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): + scheme, netloc, path, query, frag = urlparse.urlsplit(url) + components = path.split("/") + + for version in _VALID_VERSIONS: + if version in components: + return version[1:] + + 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 __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) + + 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 + # keystoneauth, here we need to raise the cinderclient errors. + raise_exc = kwargs.pop('raise_exc', True) + 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) + 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) + + def post(self, url, **kwargs): + return self._cs_request(url, 'POST', **kwargs) + + def put(self, url, **kwargs): + return self._cs_request(url, 'PUT', **kwargs) + + def delete(self, url, **kwargs): + return self._cs_request(url, 'DELETE', **kwargs) + + def _get_base_url(self): + endpoint = self.get_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) + return self.get_token(auth) -_logger = logging.getLogger(__name__) -if 'CINDERCLIENT_DEBUG' in os.environ and os.environ['CINDERCLIENT_DEBUG']: - ch = logging.StreamHandler() - _logger.setLevel(logging.DEBUG) - _logger.addHandler(ch) + @property + def service_catalog(self): + # NOTE(jamielennox): This is ugly and should be deprecated. + auth = self.auth or self.session.auth + if isinstance(auth, base.BaseIdentityPlugin): + return auth.get_access(self.session).service_catalog -class HTTPClient(httplib2.Http): + 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, insecure=False, - timeout=None, proxy_tenant_id=None, - proxy_token=None, region_name=None, + def __init__(self, user, password, projectid, auth_url=None, + insecure=False, timeout=None, tenant_id=None, + proxy_tenant_id=None, proxy_token=None, region_name=None, endpoint_type='publicURL', service_type=None, - service_name=None, volume_service_name=None): - super(HTTPClient, self).__init__(timeout=timeout) + service_name=None, volume_service_name=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.auth_url = auth_url.rstrip('/') - self.version = 'v1' + 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) + + if not auth_url and auth_system and auth_system != 'keystone': + auth_url = auth_plugin.get_auth_url() + if not auth_url: + raise exceptions.EndpointNotFound() + + self.auth_url = auth_url.rstrip('/') if auth_url else None + 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.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 = 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: + if cacert: + self.verify_cert = cacert + else: + self.verify_cert = True + + self.auth_system = auth_system + self.auth_plugin = auth_plugin - # httplib2 overrides - self.force_exception_to_status_code = True - self.disable_ssl_certificate_validation = insecure + self._logger = logger or logging.getLogger(__name__) - def http_log(self, args, kwargs, resp, body): - if not _logger.isEnabledFor(logging.DEBUG): + 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: return string_parts = ['curl -i'] for element in args: - if element in ('GET', 'POST'): + if element in ('GET', 'POST', 'DELETE', 'PUT'): string_parts.append(' -X %s' % element) else: 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) - _logger.debug("REQ: %s\n" % "".join(string_parts)) - if 'body' in kwargs: - _logger.debug("REQ BODY: %s\n" % (kwargs['body'])) - _logger.debug("RESP:%s %s\n", resp, body) + if 'data' in kwargs: + data = strutils.mask_password(kwargs['data']) + string_parts.append(" -d '%s'" % (data)) + self._logger.debug("\nREQ: %s\n" % "".join(string_parts)) - def request(self, *args, **kwargs): + def http_log_resp(self, resp): + if not self.http_log_debug: + return + self._logger.debug( + "RESP: [%s] %s\nRESP BODY: %s\n", + resp.status_code, + resp.headers, + strutils.mask_password(resp.text)) + + def request(self, url, method, **kwargs): kwargs.setdefault('headers', kwargs.get('headers', {})) kwargs['headers']['User-Agent'] = self.USER_AGENT kwargs['headers']['Accept'] = 'application/json' - if 'body' in kwargs: - kwargs['headers']['Content-Type'] = 'application/json' - kwargs['body'] = json.dumps(kwargs['body']) - - resp, body = super(HTTPClient, self).request(*args, **kwargs) - self.http_log(args, kwargs, resp, body) + if osprofiler_web: + kwargs['headers'].update(osprofiler_web.get_trace_id_headers()) - if body: + if 'body' in kwargs: + kwargs['headers']['Content-Type'] = 'application/json' + 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) + self.http_log_req((url, method,), kwargs) + resp = requests.request( + method, + url, + verify=self.verify_cert, + cert=self.cert, + **kwargs) + self.http_log_resp(resp) + + body = None + if resp.text: try: - body = json.loads(body) - except ValueError: - pass - else: - body = None + body = json.loads(resp.text) + except ValueError as e: + self._logger.debug("Load http response text error: %s", e) - if resp.status >= 400: + if resp.status_code >= 400: raise exceptions.from_response(resp, body) return resp, body def _cs_request(self, url, method, **kwargs): - if not self.management_url: - self.authenticate() - - # Perform the request once. If we get a 401 back then it - # might be because the auth token expired, so try to - # re-authenticate and try again. If it still fails, bail. - try: + auth_attempts = 0 + attempts = 0 + backoff = 1 + while True: + attempts += 1 + if not self.management_url or not self.auth_token: + self.authenticate() kwargs.setdefault('headers', {})['X-Auth-Token'] = self.auth_token if self.projectid: kwargs['headers']['X-Auth-Project-Id'] = self.projectid - - resp, body = self.request(self.management_url + url, method, - **kwargs) - return resp, body - except exceptions.Unauthorized, ex: try: - self.authenticate() - 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: + if attempts > self.retries: + raise except exceptions.Unauthorized: - raise ex + if auth_attempts > 0: + raise + self._logger.debug("Unauthorized, reauthenticating.") + self.management_url = self.auth_token = None + # First reauth. Discount this attempt. + 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 + if 500 <= e.code <= 599: + pass + else: + raise + except requests.exceptions.ConnectionError as e: + self._logger.debug("Connection error: %s" % e) + if attempts > self.retries: + msg = 'Unable to establish connection: %s' % e + raise exceptions.ConnectionError(msg) + except requests.exceptions.Timeout as e: + self._logger.debug("Timeout error: %s" % e) + if attempts > self.retries: + raise + self._logger.debug( + "Failed attempt(%s of %s), retrying in %s seconds" % + (attempts, self.retries, backoff)) + sleep(backoff) + backoff *= 2 def get(self, url, **kwargs): return self._cs_request(url, 'GET', **kwargs) @@ -145,41 +492,59 @@ 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): + 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 == 200: # content must always present + We may get redirected to another site, fail or actually get + back a service catalog with a token and our endpoints. + """ + # content must always present + if resp.status_code == 200 or resp.status_code == 201: try: self.auth_url = url - self.service_catalog = \ - service_catalog.ServiceCatalog(body) + self.auth_ref = access.create(resp=resp, body=body) + self.service_catalog = self.auth_ref.service_catalog if extract_token: - self.auth_token = self.service_catalog.get_token() + self.auth_token = self.auth_ref.auth_token management_url = self.service_catalog.url_for( - attr='region', - filter_value=self.region_name, - endpoint_type=self.endpoint_type, + region_name=self.region_name, + interface=self.endpoint_type, service_type=self.service_type, - service_name=self.service_name, - volume_service_name=self.volume_service_name) + 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" + 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?" + print("Could not find any suitable endpoint. Correct region?") raise - elif resp.status == 305: - return resp['location'] + elif resp.status_code == 305: + return resp.headers['location'] else: raise exceptions.from_response(resp, body) @@ -198,12 +563,15 @@ def _fetch_endpoints_from_auth(self, url): # GET ...:5001/v2.0/tokens/#####/endpoints url = '/'.join([url, 'tokens', '%s?belongsTo=%s' % (self.proxy_token, self.proxy_tenant_id)]) - _logger.debug("Using Endpoint URL: %s" % url) + self._logger.debug("Using Endpoint URL: %s" % url) resp, body = self.request(url, "GET", - headers={'X-Auth_Token': self.auth_token}) + headers={'X-Auth-Token': self.auth_token}) 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 @@ -213,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. @@ -223,18 +591,19 @@ 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 "CINDER_RAX_AUTH" in os.environ: - auth_url = self._rax_auth(auth_url) - else: - auth_url = self._v2_auth(auth_url) + if not self.auth_system or self.auth_system == 'keystone': + 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: - self._fetch_endpoints_from_auth(admin_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 # with the endpoints any more, we need to replace # our service account token with the user token. @@ -249,7 +618,12 @@ 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.os_endpoint: + self.set_management_url(self.os_endpoint) + elif not self.management_url: + raise exceptions.Unauthorized('Cinder Client') def _v1_auth(self, url): if self.proxy_token: @@ -261,70 +635,218 @@ def _v1_auth(self, url): headers['X-Auth-Project-Id'] = self.projectid resp, body = self.request(url, 'GET', headers=headers) - if resp.status in (200, 204): # in some cases we get No Content + if resp.status_code in (200, 204): # in some cases we get No Content try: mgmt_header = 'x-server-management-url' - self.management_url = resp[mgmt_header].rstrip('/') - self.auth_token = resp['x-auth-token'] + self.management_url = resp.headers[mgmt_header].rstrip('/') + self.auth_token = resp.headers['x-auth-token'] self.auth_url = url - except KeyError: + except (KeyError, TypeError): raise exceptions.AuthorizationFailure() - elif resp.status == 305: - return resp['location'] + elif resp.status_code == 305: + return resp.headers['location'] else: raise exceptions.from_response(resp, body) - 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.projectid: - body['auth']['tenantName'] = self.projectid - - self._authenticate(url, body) + 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 - def _rax_auth(self, url): - """Authenticate against the Rackspace auth service.""" - body = {"auth": { - "RAX-KSKEY:apiKeyCredentials": { - "username": self.user, - "apiKey": self.password, - "tenantName": self.projectid}}} + 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 - tmp_follow_all_redirects = self.follow_all_redirects - self.follow_all_redirects = True - - try: - resp, body = self.request(token_url, "POST", body=body) - finally: - self.follow_all_redirects = tmp_follow_all_redirects + resp, body = self.request( + token_url, + "POST", + body=body, + allow_redirects=True) return self._extract_service_catalog(url, resp, body) +def _construct_http_client(username=None, password=None, project_id=None, + auth_url=None, insecure=False, timeout=None, + proxy_tenant_id=None, proxy_token=None, + region_name=None, endpoint_type='publicURL', + service_type='volume', + service_name=None, volume_service_name=None, + os_endpoint=None, retries=None, + http_log_debug=False, + auth_system='keystone', auth_plugin=None, + cacert=None, cert=None, tenant_id=None, + session=None, + auth=None, api_version=None, + **kwargs): + + 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, + auth_url=auth_url, + insecure=insecure, + timeout=timeout, + tenant_id=tenant_id, + proxy_token=proxy_token, + proxy_tenant_id=proxy_tenant_id, + region_name=region_name, + endpoint_type=endpoint_type, + service_type=service_type, + service_name=service_name, + volume_service_name=volume_service_name, + 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', + '3': 'cinderclient.v3.client.Client', } try: client_path = version_map[str(version)] except (KeyError, ValueError): msg = "Invalid client version '%s'. must be one of: %s" % ( - (version, ', '.join(version_map.keys()))) + (version, ', '.join(version_map))) raise exceptions.UnsupportedVersion(msg) - return utils.import_class(client_path) + 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, **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/__init__.py b/cinderclient/contrib/__init__.py similarity index 100% rename from cinderclient/openstack/common/__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 628adcd8f..4f0227ea9 100644 --- a/cinderclient/exceptions.py +++ b/cinderclient/exceptions.py @@ -1,12 +1,85 @@ # Copyright 2010 Jacob Kaplan-Moss +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """ Exception definitions. """ +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): """Indicates that the user is trying to use an unsupported - version of the API""" + version of the API. + """ + 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 @@ -22,9 +95,19 @@ class NoUniqueMatch(Exception): pass +class AuthSystemNotFound(Exception): + """When the user specifies an AuthSystem but not installed.""" + def __init__(self, auth_system): + self.auth_system = auth_system + + def __str__(self): + return "AuthSystemNotFound: %s" % repr(self.auth_system) + + class NoTokenLookupException(Exception): """This form of authentication does not support looking up - endpoints from an existing token.""" + endpoints from an existing token. + """ pass @@ -33,6 +116,11 @@ class EndpointNotFound(Exception): pass +class ConnectionError(Exception): + """Could not open a connection to the API service.""" + pass + + class AmbiguousEndpoints(Exception): """Found more than one matching endpoint in Service Catalog.""" def __init__(self, endpoints=None): @@ -46,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 @@ -93,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. @@ -100,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): @@ -118,30 +241,51 @@ 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 httplib2 response. + Return an instance of a ClientException or subclass + based on a requests response. Usage:: - resp, body = http.request(...) - if resp.status != 200: - raise exception_from_response(resp, body) + resp, body = requests.request(...) + if resp.status_code != 200: + raise exceptions.from_response(resp, resp.text) """ - cls = _code_map.get(response.status, ClientException) - request_id = response.get('x-compute-request-id') + cls = _code_map.get(response.status_code, ClientException) + if response.headers: + request_id = response.headers.get('x-compute-request-id') + else: + request_id = None if body: message = "n/a" details = "n/a" if hasattr(body, 'keys'): - error = body[body.keys()[0]] - message = error.get('message', None) - details = error.get('details', None) - return cls(code=response.status, message=message, details=details, - request_id=request_id) + # 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, response=response) else: - return cls(code=response.status, request_id=request_id) + return cls(code=response.status_code, request_id=request_id, + 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 ced67f0c7..a74cb91ef 100644 --- a/cinderclient/extension.py +++ b/cinderclient/extension.py @@ -1,4 +1,4 @@ -# Copyright 2011 OpenStack LLC. +# Copyright (c) 2011 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -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__') @@ -29,7 +30,7 @@ def __init__(self, name, module): def _parse_extension_module(self): self.manager_class = None - for attr_name, attr_value in self.module.__dict__.items(): + for attr_name, attr_value in list(self.module.__dict__.items()): if attr_name in self.SUPPORTED_HOOKS: self.add_hook(attr_name, attr_value) elif utils.safe_issubclass(attr_value, base.Manager): diff --git a/cinderclient/openstack/common/setup.py b/cinderclient/openstack/common/setup.py deleted file mode 100644 index dc7509cd7..000000000 --- a/cinderclient/openstack/common/setup.py +++ /dev/null @@ -1,330 +0,0 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2011 OpenStack LLC. -# 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. - -""" -Utilities with minimum-depends for use in setup.py -""" - -import datetime -import os -import re -import subprocess -import sys - -from setuptools.command import sdist - - -def parse_mailmap(mailmap='.mailmap'): - mapping = {} - if os.path.exists(mailmap): - fp = open(mailmap, 'r') - for l in fp: - l = l.strip() - if not l.startswith('#') and ' ' in l: - canonical_email, alias = [x for x in l.split(' ') - if x.startswith('<')] - mapping[alias] = canonical_email - return mapping - - -def canonicalize_emails(changelog, mapping): - """Takes in a string and an email alias mapping and replaces all - instances of the aliases in the string with their real email. - """ - for alias, email in mapping.iteritems(): - changelog = changelog.replace(alias, email) - return changelog - - -# Get requirements from the first file that exists -def get_reqs_from_files(requirements_files): - reqs_in = [] - for requirements_file in requirements_files: - if os.path.exists(requirements_file): - return open(requirements_file, 'r').read().split('\n') - return [] - - -def parse_requirements(requirements_files=['requirements.txt', - 'tools/pip-requires']): - requirements = [] - for line in get_reqs_from_files(requirements_files): - # For the requirements list, we need to inject only the portion - # after egg= so that distutils knows the package it's looking for - # such as: - # -e git://github.com/openstack/nova/master#egg=nova - if re.match(r'\s*-e\s+', line): - requirements.append(re.sub(r'\s*-e\s+.*#egg=(.*)$', r'\1', - line)) - # such as: - # http://github.com/openstack/nova/zipball/master#egg=nova - elif re.match(r'\s*https?:', line): - requirements.append(re.sub(r'\s*https?:.*#egg=(.*)$', r'\1', - line)) - # -f lines are for index locations, and don't get used here - elif re.match(r'\s*-f\s+', line): - pass - # argparse is part of the standard library starting with 2.7 - # adding it to the requirements list screws distro installs - elif line == 'argparse' and sys.version_info >= (2, 7): - pass - else: - requirements.append(line) - - return requirements - - -def parse_dependency_links(requirements_files=['requirements.txt', - 'tools/pip-requires']): - dependency_links = [] - # dependency_links inject alternate locations to find packages listed - # in requirements - for line in get_reqs_from_files(requirements_files): - # skip comments and blank lines - if re.match(r'(\s*#)|(\s*$)', line): - continue - # lines with -e or -f need the whole line, minus the flag - if re.match(r'\s*-[ef]\s+', line): - dependency_links.append(re.sub(r'\s*-[ef]\s+', '', line)) - # lines that are only urls can go in unmolested - elif re.match(r'\s*https?:', line): - dependency_links.append(line) - return dependency_links - - -def write_requirements(): - venv = os.environ.get('VIRTUAL_ENV', None) - if venv is not None: - with open("requirements.txt", "w") as req_file: - output = subprocess.Popen(["pip", "-E", venv, "freeze", "-l"], - stdout=subprocess.PIPE) - requirements = output.communicate()[0].strip() - req_file.write(requirements) - - -def _run_shell_command(cmd): - output = subprocess.Popen(["/bin/sh", "-c", cmd], - stdout=subprocess.PIPE) - out = output.communicate() - if len(out) == 0: - return None - if len(out[0].strip()) == 0: - return None - return out[0].strip() - - -def _get_git_next_version_suffix(branch_name): - datestamp = datetime.datetime.now().strftime('%Y%m%d') - if branch_name == 'milestone-proposed': - revno_prefix = "r" - else: - revno_prefix = "" - _run_shell_command("git fetch origin +refs/meta/*:refs/remotes/meta/*") - milestone_cmd = "git show meta/openstack/release:%s" % branch_name - milestonever = _run_shell_command(milestone_cmd) - if not milestonever: - milestonever = "" - post_version = _get_git_post_version() - revno = post_version.split(".")[-1] - return "%s~%s.%s%s" % (milestonever, datestamp, revno_prefix, revno) - - -def _get_git_current_tag(): - return _run_shell_command("git tag --contains HEAD") - - -def _get_git_tag_info(): - return _run_shell_command("git describe --tags") - - -def _get_git_post_version(): - current_tag = _get_git_current_tag() - if current_tag is not None: - return current_tag - else: - tag_info = _get_git_tag_info() - if tag_info is None: - base_version = "0.0" - cmd = "git --no-pager log --oneline" - out = _run_shell_command(cmd) - revno = len(out.split("\n")) - else: - tag_infos = tag_info.split("-") - base_version = "-".join(tag_infos[:-2]) - revno = tag_infos[-2] - return "%s.%s" % (base_version, revno) - - -def write_git_changelog(): - """Write a changelog based on the git changelog.""" - if os.path.isdir('.git'): - git_log_cmd = 'git log --stat' - changelog = _run_shell_command(git_log_cmd) - mailmap = parse_mailmap() - with open("ChangeLog", "w") as changelog_file: - changelog_file.write(canonicalize_emails(changelog, mailmap)) - - -def generate_authors(): - """Create AUTHORS file using git commits.""" - jenkins_email = 'jenkins@review.openstack.org' - old_authors = 'AUTHORS.in' - new_authors = 'AUTHORS' - if os.path.isdir('.git'): - # don't include jenkins email address in AUTHORS file - git_log_cmd = ("git log --format='%aN <%aE>' | sort -u | " - "grep -v " + jenkins_email) - changelog = _run_shell_command(git_log_cmd) - mailmap = parse_mailmap() - with open(new_authors, 'w') as new_authors_fh: - new_authors_fh.write(canonicalize_emails(changelog, mailmap)) - if os.path.exists(old_authors): - with open(old_authors, "r") as old_authors_fh: - new_authors_fh.write('\n' + old_authors_fh.read()) - -_rst_template = """%(heading)s -%(underline)s - -.. automodule:: %(module)s - :members: - :undoc-members: - :show-inheritance: -""" - - -def write_versioninfo(project, version): - """Write a simple file containing the version of the package.""" - open(os.path.join(project, 'versioninfo'), 'w').write("%s\n" % version) - - -def get_cmdclass(): - """Return dict of commands to run from setup.py.""" - - cmdclass = dict() - - def _find_modules(arg, dirname, files): - for filename in files: - if filename.endswith('.py') and filename != '__init__.py': - arg["%s.%s" % (dirname.replace('/', '.'), - filename[:-3])] = True - - class LocalSDist(sdist.sdist): - """Builds the ChangeLog and Authors files from VC first.""" - - def run(self): - write_git_changelog() - generate_authors() - # sdist.sdist is an old style class, can't use super() - sdist.sdist.run(self) - - cmdclass['sdist'] = LocalSDist - - # If Sphinx is installed on the box running setup.py, - # enable setup.py to build the documentation, otherwise, - # just ignore it - try: - from sphinx.setup_command import BuildDoc - - class LocalBuildDoc(BuildDoc): - def generate_autoindex(self): - print "**Autodocumenting from %s" % os.path.abspath(os.curdir) - modules = {} - option_dict = self.distribution.get_option_dict('build_sphinx') - source_dir = os.path.join(option_dict['source_dir'][1], 'api') - if not os.path.exists(source_dir): - os.makedirs(source_dir) - for pkg in self.distribution.packages: - if '.' not in pkg: - os.path.walk(pkg, _find_modules, modules) - module_list = modules.keys() - module_list.sort() - autoindex_filename = os.path.join(source_dir, 'autoindex.rst') - with open(autoindex_filename, 'w') as autoindex: - autoindex.write(""".. toctree:: - :maxdepth: 1 - -""") - for module in module_list: - output_filename = os.path.join(source_dir, - "%s.rst" % module) - heading = "The :mod:`%s` Module" % module - underline = "=" * len(heading) - values = dict(module=module, heading=heading, - underline=underline) - - print "Generating %s" % output_filename - with open(output_filename, 'w') as output_file: - output_file.write(_rst_template % values) - autoindex.write(" %s.rst\n" % module) - - def run(self): - if not os.getenv('SPHINX_DEBUG'): - self.generate_autoindex() - - for builder in ['html', 'man']: - self.builder = builder - self.finalize_options() - self.project = self.distribution.get_name() - self.version = self.distribution.get_version() - self.release = self.distribution.get_version() - BuildDoc.run(self) - cmdclass['build_sphinx'] = LocalBuildDoc - except ImportError: - pass - - return cmdclass - - -def get_git_branchname(): - for branch in _run_shell_command("git branch --color=never").split("\n"): - if branch.startswith('*'): - _branch_name = branch.split()[1].strip() - if _branch_name == "(no": - _branch_name = "no-branch" - return _branch_name - - -def get_pre_version(projectname, base_version): - """Return a version which is based""" - if os.path.isdir('.git'): - current_tag = _get_git_current_tag() - if current_tag is not None: - version = current_tag - else: - branch_name = os.getenv('BRANCHNAME', - os.getenv('GERRIT_REFNAME', - get_git_branchname())) - version_suffix = _get_git_next_version_suffix(branch_name) - version = "%s~%s" % (base_version, version_suffix) - write_versioninfo(projectname, version) - return version.split('~')[0] - else: - with open(os.path.join(projectname, 'versioninfo'), 'r') as vinfo: - full_version = vinfo.read().strip() - return full_version.split('~')[0] - - -def get_post_version(projectname): - """Return a version which is equal to the tag that's on the current - revision if there is one, or tag plus number of additional revisions - if the current revision has no tag.""" - - if os.path.isdir('.git'): - version = _get_git_post_version() - write_versioninfo(projectname, version) - return version - return open(os.path.join(projectname, 'versioninfo'), 'r').read().strip() diff --git a/cinderclient/service_catalog.py b/cinderclient/service_catalog.py deleted file mode 100644 index e1778db8d..000000000 --- a/cinderclient/service_catalog.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright 2011 OpenStack LLC. -# 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 not 'serviceCatalog' in self.catalog['access']: - return None - - # Full catalog ... - catalog = self.catalog['access']['serviceCatalog'] - - for service in catalog: - if service.get("type") != service_type: - continue - - if (service_name and service_type == 'compute' and - service.get('name') != service_name): - continue - - if (volume_service_name and service_type == 'volume' and - service.get('name') != volume_service_name): - 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: - raise cinderclient.exceptions.AmbiguousEndpoints( - endpoints=matching_endpoints) - else: - return matching_endpoints[0][endpoint_type] diff --git a/cinderclient/shell.py b/cinderclient/shell.py index cf9d92b9c..ae473839b 100644 --- a/cinderclient/shell.py +++ b/cinderclient/shell.py @@ -1,5 +1,4 @@ - -# Copyright 2011 OpenStack LLC. +# Copyright 2011-2014 OpenStack Foundation # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -15,29 +14,72 @@ # under the License. """ -Command-line interface to the OpenStack Volume API. +Command-line interface to the OpenStack Cinder API. """ import argparse -import glob -import httplib2 -import imp -import itertools -import os -import pkgutil -import sys +import collections +import getpass import logging - +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 -import cinderclient.extension from cinderclient import utils -from cinderclient.v1 import shell as shell_v1 -DEFAULT_OS_VOLUME_API_VERSION = "1" -DEFAULT_CINDER_ENDPOINT_TYPE = 'publicURL' -DEFAULT_CINDER_SERVICE_TYPE = 'compute' +try: + osprofiler_profiler = importutils.try_import("osprofiler.profiler") +except Exception: + pass + +DEFAULT_MAJOR_OS_VOLUME_API_VERSION = "3" +DEFAULT_CINDER_ENDPOINT_TYPE = 'publicURL' +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__) @@ -53,7 +95,7 @@ def error(self, message): exits. """ self.print_usage(sys.stderr) - #FIXME(lzyeval): if changes occur in argparse.ArgParser._check_value + # FIXME(lzyeval): if changes occur in argparse.ArgParser._check_value choose_from = ' (choose from' progparts = self.prog.partition(' ') self.exit(2, "error: %(errmsg)s\nTry '%(mainp)s help %(subp)s'" @@ -62,15 +104,42 @@ def error(self, message): 'mainp': progparts[0], 'subp': progparts[2]}) + def _get_option_tuples(self, option_string): + """Avoid ambiguity in argument abbreviation. + + The idea of this method is to override the default behaviour to + avoid ambiguity in the abbreviation feature of argparse. + In the case that the ambiguity is generated by 2 or more parameters + and only one is visible in the help and the others are with + help=argparse.SUPPRESS, the ambiguity is solved by taking the visible + one. + The use case is for parameters that are left hidden for backward + compatibility. + """ + + result = super(CinderClientArgumentParser, self)._get_option_tuples( + option_string) + + if len(result) > 1: + aux = [x for x in result if x[0].help != argparse.SUPPRESS] + if len(aux) == 1: + result = aux + + return result + 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='See "cinder help COMMAND" ' - 'for help on a specific command.', + epilog=_('Run "cinder help SUBCOMMAND" for help on a subcommand.'), add_help=False, formatter_class=OpenStackHelpFormatter, ) @@ -80,152 +149,223 @@ def get_base_parser(self): action='store_true', help=argparse.SUPPRESS) - parser.add_argument('--debug', - default=False, - action='store_true', - help="Print debugging output") + parser.add_argument('--version', + action='version', + version=cinderclient.__version__) - parser.add_argument('--os_username', - default=utils.env('OS_USERNAME', - 'CINDER_USERNAME'), - help='Defaults to env[OS_USERNAME].') - - parser.add_argument('--os_password', - default=utils.env('OS_PASSWORD', - 'CINDER_PASSWORD'), - help='Defaults to env[OS_PASSWORD].') - - parser.add_argument('--os_tenant_name', - default=utils.env('OS_TENANT_NAME', - 'CINDER_PROJECT_ID'), - help='Defaults to env[OS_TENANT_NAME].') - - parser.add_argument('--os_auth_url', - default=utils.env('OS_AUTH_URL', - 'CINDER_URL'), - help='Defaults to env[OS_AUTH_URL].') - - parser.add_argument('--os_region_name', - default=utils.env('OS_REGION_NAME', - 'CINDER_REGION_NAME'), - help='Defaults to env[OS_REGION_NAME].') + parser.add_argument('-d', '--debug', + action='store_true', + default=utils.env('CINDERCLIENT_DEBUG', + default=False), + help=_('Shows debugging output.')) + parser.add_argument('--service-type', + metavar='', + help=_('Service type. ' + 'For most actions, default is volume.')) parser.add_argument('--service_type', - help='Defaults to compute for most actions') + help=argparse.SUPPRESS) - parser.add_argument('--service_name', + parser.add_argument('--service-name', + metavar='', default=utils.env('CINDER_SERVICE_NAME'), - help='Defaults to 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', + parser.add_argument('--volume-service-name', + metavar='', default=utils.env('CINDER_VOLUME_SERVICE_NAME'), - help='Defaults to 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', + parser.add_argument('--os-endpoint-type', + metavar='', default=utils.env('CINDER_ENDPOINT_TYPE', - default=DEFAULT_CINDER_ENDPOINT_TYPE), - help='Defaults to env[CINDER_ENDPOINT_TYPE] or ' - + DEFAULT_CINDER_ENDPOINT_TYPE + '.') + default=utils.env('OS_ENDPOINT_TYPE', + default=DEFAULT_CINDER_ENDPOINT_TYPE)), + help=_('Endpoint type, which is publicURL or ' + 'internalURL. ' + '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', + parser.add_argument('--os-volume-api-version', + metavar='', default=utils.env('OS_VOLUME_API_VERSION', - default=DEFAULT_OS_VOLUME_API_VERSION), - help='Accepts 1,defaults ' - 'to env[OS_VOLUME_API_VERSION].') + default=None), + 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('--insecure', - default=utils.env('CINDERCLIENT_INSECURE', - default=False), - action='store_true', + 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[CINDER_ENDPOINT].")) + parser.add_argument('--os_endpoint', help=argparse.SUPPRESS) - # FIXME(dtroyer): The args below are here for diablo compatibility, - # remove them in folsum cycle + parser.add_argument('--retries', + metavar='', + type=int, + default=0, + 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', + 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. Defaults to ' + 'env[OS_PROFILE].')) + + self._append_global_identity_args(parser) - # alias for --os_username, left in for backwards compatibility - parser.add_argument('--username', - help='Deprecated') + return parser - # alias for --os_region_name, left in for backwards compatibility - parser.add_argument('--region_name', - help='Deprecated') + def _append_global_identity_args(self, parser): + 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.')) + parser.add_argument( + '--os_auth_strategy', + help=argparse.SUPPRESS) + + # 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) - # alias for --os_password, left in for backwards compatibility - parser.add_argument('--apikey', '--password', dest='apikey', - default=utils.env('CINDER_API_KEY'), - help='Deprecated') + parser.set_defaults(os_username=utils.env('OS_USERNAME', + 'CINDER_USERNAME')) + parser.add_argument('--os_username', + help=argparse.SUPPRESS) - # alias for --os_tenant_name, left in for backward compatibility - parser.add_argument('--projectid', '--tenant_name', dest='projectid', - default=utils.env('CINDER_PROJECT_ID'), - help='Deprecated') + parser.set_defaults(os_password=utils.env('OS_PASSWORD', + 'CINDER_PASSWORD')) + parser.add_argument('--os_password', + help=argparse.SUPPRESS) - # alias for --os_auth_url, left in for backward compatibility - parser.add_argument('--url', '--auth_url', dest='url', - default=utils.env('CINDER_URL'), - help='Deprecated') + parser.set_defaults(os_project_name=utils.env('OS_PROJECT_NAME', + 'CINDER_PROJECT_ID')) + parser.add_argument( + '--os_project_name', + help=argparse.SUPPRESS) - return parser + parser.set_defaults(os_project_id=utils.env('OS_PROJECT_ID', + 'CINDER_PROJECT_ID')) + parser.add_argument( + '--os_project_id', + help=argparse.SUPPRESS) - def get_subcommand_parser(self, version): - parser = self.get_base_parser() + parser.set_defaults(os_auth_url=utils.env('OS_AUTH_URL', + 'CINDER_URL')) + parser.add_argument('--os_auth_url', + help=argparse.SUPPRESS) - self.subcommands = {} - subparsers = parser.add_subparsers(metavar='') + parser.set_defaults(os_user_id=utils.env('OS_USER_ID')) + parser.add_argument( + '--os_user_id', + help=argparse.SUPPRESS) - try: - actions_module = { - '1.1': shell_v1, - '2': shell_v1, - }[version] - except KeyError: - actions_module = shell_v1 + parser.set_defaults( + os_user_domain_id=utils.env('OS_USER_DOMAIN_ID')) + parser.add_argument( + '--os_user_domain_id', + help=argparse.SUPPRESS) - self._find_actions(subparsers, actions_module) - self._find_actions(subparsers, self) + parser.set_defaults( + os_user_domain_name=utils.env('OS_USER_DOMAIN_NAME')) + parser.add_argument( + '--os_user_domain_name', + help=argparse.SUPPRESS) - for extension in self.extensions: - self._find_actions(subparsers, extension.module) + parser.set_defaults( + os_project_domain_id=utils.env('OS_PROJECT_DOMAIN_ID')) - self._add_bash_completion_subparser(subparsers) + parser.set_defaults( + os_project_domain_name=utils.env('OS_PROJECT_DOMAIN_NAME')) - return parser + parser.set_defaults( + os_region_name=utils.env('OS_REGION_NAME', + 'CINDER_REGION_NAME')) + parser.add_argument('--os_region_name', + help=argparse.SUPPRESS) - def _discover_extensions(self, version): - extensions = [] - for name, module in itertools.chain( - self._discover_via_python_path(version), - self._discover_via_contrib_path(version)): + parser.set_defaults(os_token=utils.env('OS_TOKEN')) + parser.add_argument( + '--os_token', + help=argparse.SUPPRESS) - extension = cinderclient.extension.Extension(name, module) - extensions.append(extension) + parser.add_argument( + '--os-url', metavar='', + default=utils.env('OS_URL'), + help=_('Defaults to env[OS_URL].')) + parser.add_argument( + '--os_url', + help=argparse.SUPPRESS) - return extensions + parser.set_defaults(insecure=utils.env('CINDERCLIENT_INSECURE', + default=False)) - 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) + def get_subcommand_parser(self, version, do_help=False, input_args=None): + parser = self.get_base_parser() - module = module_loader.load_module(name) - yield name, module + self.subcommands = {} + subparsers = parser.add_subparsers(metavar='') - 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") + actions_module = importutils.import_module(V3_SHELL) - for ext_path in glob.iglob(ext_glob): - name = os.path.basename(ext_path)[:-3] + self._find_actions(subparsers, actions_module, version, do_help, + input_args) + self._find_actions(subparsers, self, version, do_help, input_args) - if name == "__init__": - continue + for extension in self.extensions: + self._find_actions(subparsers, extension.module, version, do_help, + input_args) - module = imp.load_source(name, ext_path) - yield name, module + self._add_bash_completion_subparser(subparsers) + + return parser def _add_bash_completion_subparser(self, subparsers): subparser = subparsers.add_parser( @@ -236,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 hypen-separated instead of underscores. + # 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) @@ -257,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 @@ -268,30 +493,109 @@ 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.DEBUG) + logger.setLevel(logging.DEBUG if debug else logging.WARNING) logger.addHandler(streamhandler) - httplib2.debuglevel = 1 + self.client_logger = logging.getLogger(client.__name__) + ch = logging.StreamHandler() + self.client_logger.setLevel(logging.DEBUG) + self.client_logger.addHandler(ch) + if hasattr(requests, 'logging'): + requests.logging.getLogger(requests.__name__).addHandler(ch) + + 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 + """ + word = '--metadata' + tmp = [] + # flag is true in between metadata option and next option + metadata_options = False + if word in argv: + for arg in argv: + if arg == word: + metadata_options = True + elif metadata_options: + if arg.startswith('--'): + metadata_options = False + elif '=' not in arg: + tmp.append(u'--') + metadata_options = False + tmp.append(arg) + return tmp + 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 + # 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 + self.options = options + + do_help = ('help' in argv) or ( + '--help' in argv) or ('-h' in argv) or not argv + + 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 options.help and len(args) == 0: + 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 + argv = self._delimit_metadata_args(argv) args = subcommand_parser.parse_args(argv) self._run_extension_hooks('__post_parse_args__', args) @@ -303,119 +607,295 @@ def main(self, argv): self.do_bash_completion(args) return 0 - (os_username, os_password, os_tenant_name, os_auth_url, - os_region_name, endpoint_type, insecure, - service_type, service_name, volume_service_name, - username, apikey, projectid, url, region_name) = ( + (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.endpoint_type, - args.insecure, args.service_type, args.service_name, - args.volume_service_name, args.username, - args.apikey, args.projectid, - args.url, args.region_name) - - if not endpoint_type: - endpoint_type = DEFAULT_CINDER_ENDPOINT_TYPE + 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.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 service_type: - service_type = DEFAULT_CINDER_SERVICE_TYPE - service_type = utils.get_service_type(args.func) or service_type + service_type = client.SERVICE_TYPES[major_version_string] - #FIXME(usrleon): Here should be restrict for project id same as + # 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 not os_username: - if not username: - raise exc.CommandError( - "You must provide a username " - "via either --os_username or env[OS_USERNAME]") - else: - os_username = 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: - if not apikey: + # 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 + # user Ctl-D when prompted. + if not os_password: raise exc.CommandError("You must provide a password " - "via either --os_password or via " - "env[OS_PASSWORD]") - else: - os_password = apikey - - if not os_tenant_name: - if not projectid: - raise exc.CommandError("You must provide a tenant name " - "via either --os_tenant_name or " - "env[OS_TENANT_NAME]") - else: - os_tenant_name = projectid + "through --os-password, " + "env[OS_PASSWORD] " + "or, prompted response.") + + 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: - if not url: - raise exc.CommandError( - "You must provide an auth url " - "via either --os_auth_url or env[OS_AUTH_URL]") - else: - os_auth_url = url - - if not os_region_name and region_name: - os_region_name = region_name - - if not os_tenant_name: + raise exc.CommandError( + "You must provide an authentication URL " + "through --os-auth-url or env[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 a tenant name " - "via either --os_tenant_name or env[OS_TENANT_NAME]") - - if not os_auth_url: - raise exc.CommandError( - "You must provide an auth url " - "via either --os_auth_url or env[OS_AUTH_URL]") - - self.cs = client.Client(options.os_volume_api_version, os_username, - os_password, os_tenant_name, os_auth_url, - insecure, region_name=os_region_name, - endpoint_type=endpoint_type, - extensions=self.extensions, - service_type=service_type, - service_name=service_name, - volume_service_name=volume_service_name) + "You must provide an authentication URL " + "through --os-auth-url or env[OS_AUTH_URL].") + + 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): self.cs.authenticate() except exc.Unauthorized: - raise exc.CommandError("Invalid OpenStack Nova credentials.") + raise exc.CommandError("OpenStack credentials are not valid.") except exc.AuthorizationFailure: - raise exc.CommandError("Unable to authorize user") + 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 + # --os-volume-api-version or with the OS_VOLUME_API_VERSION environment + # variable. Fail safe is to use the default API setting. + try: + endpoint_api_version = \ + self.cs.get_volume_api_version_from_endpoint() + except exc.UnsupportedVersion: + endpoint_api_version = options.os_volume_api_version + # 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", + endpoint_api_version) + 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", + 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) + 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): - """Run hooks for all registered extensions.""" + """Runs hooks for all registered extensions.""" for extension in self.extensions: extension.run_hooks(hook_type, *args, **kwargs) def do_bash_completion(self, args): - """ - Prints all of the commands and options to stdout so that the - cinder.bash_completion script doesn't have to hard code them. + """Prints arguments for bash_completion. + + Prints all commands and options to stdout so that the + cinder.bash_completion script does not have to hard code them. """ commands = set() options = set() - for sc_str, sc in self.subcommands.items(): + for sc_str, sc in list(self.subcommands.items()): commands.add(sc_str) - for option in sc._optionals._option_string_actions.keys(): + for option in sc._optionals._option_string_actions: options.add(option) commands.remove('bash-completion') commands.remove('bash_completion') - print ' '.join(commands | options) + print(' '.join(commands | options)) @utils.arg('command', metavar='', nargs='?', - help='Display help for ') + help='Shows help for .') def do_help(self, args): """ - Display help about this program or one of its subcommands. + Shows help about this program or one of its subcommands. """ if args.command: if args.command in self.subcommands: @@ -426,24 +906,147 @@ def do_help(self, args): else: self.parser.print_help() + def get_v2_auth(self, v2_auth_url): + + username = self.options.os_username + password = self.options.os_password + tenant_id = self.options.os_project_id + tenant_name = self.options.os_project_name + + return v2_auth.Password( + v2_auth_url, + username=username, + password=password, + tenant_id=tenant_id, + tenant_name=tenant_name) + + def get_v3_auth(self, v3_auth_url): + + username = self.options.os_username + user_id = self.options.os_user_id + 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 + 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 + + return v3_auth.Password( + v3_auth_url, + username=username, + password=password, + user_id=user_id, + user_domain_name=user_domain_name, + user_domain_id=user_domain_id, + project_id=project_id, + project_name=project_name, + project_domain_name=project_domain_name, + project_domain_id=project_domain_id, + ) + + def _discover_auth_versions(self, session, auth_url): + # discover the API versions the server is supporting based on the + # given URL + v2_auth_url = None + v3_auth_url = None + try: + 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 exceptions.DiscoveryFailure: + # Discovery response mismatch. Raise the error + raise + except Exception: + # Some public clouds throw some other exception or doesn't support + # discovery. In that case try to determine version from auth_url + # API version from the original URL + url_parts = urlparse.urlparse(auth_url) + (scheme, netloc, path, params, query, fragment) = url_parts + path = path.lower() + if path.startswith('/v3'): + v3_auth_url = auth_url + elif path.startswith('/v2'): + v2_auth_url = auth_url + else: + raise exc.CommandError('Unable to determine the Keystone' + ' version to authenticate with ' + 'using the given auth_url.') + + return (v2_auth_url, v3_auth_url) + + 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: + verify = False + else: + verify = cacert or True + + ks_session = session.Session(verify=verify, cert=cert) + # discover the supported keystone versions using the given url + (v2_auth_url, v3_auth_url) = self._discover_auth_versions( + session=ks_session, + auth_url=self.options.os_auth_url) + + username = self.options.os_username or None + user_domain_name = self.options.os_user_domain_name or None + user_domain_id = self.options.os_user_domain_id or None + + auth = None + if v3_auth_url and v2_auth_url: + # support both v2 and v3 auth. Use v3 if possible. + if username: + if user_domain_name or user_domain_id: + # use v3 auth + auth = self.get_v3_auth(v3_auth_url) + else: + # use v2 auth + auth = self.get_v2_auth(v2_auth_url) + + elif v3_auth_url: + # support only v3 + auth = self.get_v3_auth(v3_auth_url) + elif v2_auth_url: + # support only v2 + auth = self.get_v2_auth(v2_auth_url) + else: + raise exc.CommandError('Unable to determine the Keystone version ' + 'to authenticate with using the given ' + 'auth_url.') + + ks_session.auth = auth + return ks_session # I'm picky about my shell help. + + 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: OpenStackCinderShell().main(sys.argv[1:]) - - except Exception, e: + except KeyboardInterrupt: + print("... terminating cinder client", file=sys.stderr) + sys.exit(130) + except Exception as e: logger.debug(e, exc_info=1) - print >> sys.stderr, "ERROR: %s" % str(e) + print("ERROR: %s" % str(e), file=sys.stderr) sys.exit(1) if __name__ == "__main__": + main() 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/v1/contrib/__init__.py b/cinderclient/tests/__init__.py similarity index 100% rename from cinderclient/v1/contrib/__init__.py rename to cinderclient/tests/__init__.py diff --git a/tests/__init__.py b/cinderclient/tests/functional/__init__.py similarity index 100% rename from tests/__init__.py rename to cinderclient/tests/functional/__init__.py diff --git a/cinderclient/tests/functional/base.py b/cinderclient/tests/functional/base.py new file mode 100644 index 000000000..2f03475a6 --- /dev/null +++ b/cinderclient/tests/functional/base.py @@ -0,0 +1,173 @@ +# 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 time + +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): + """Cinder base class, issues calls to cinderclient. + + """ + def setUp(self): + super(ClientTestBase, self).setUp() + self.clients = self._get_clients() + self.parser = output_parser + + def _get_clients(self): + cli_dir = os.environ.get( + 'OS_CINDERCLIENT_EXEC_DIR', + os.path.join(os.path.abspath('.'), '.tox/functional/bin')) + + return base.CLIClient(cli_dir=cli_dir, **credentials()) + + def cinder(self, *args, **kwargs): + return self.clients.cinder(*args, + **kwargs) + + def assertTableHeaders(self, output_lines, field_names): + """Verify that output table has headers item listed in field_names. + + :param output_lines: output table from cmd + :param field_names: field names from the output table of the cmd + """ + 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: + 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 new file mode 100644 index 000000000..87578a0b5 --- /dev/null +++ b/cinderclient/tests/functional/test_readonly_cli.py @@ -0,0 +1,92 @@ +# 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 CinderClientReadOnlyTests(base.ClientTestBase): + """Basic read-only test for cinderclient. + + Simple check of base list commands, verify they + respond and include the expected headers in the + resultant table. + + Not intended for testing things that require actual + resource creation/manipulation, thus the name 'read-only'. + + """ + + # Commands in order listed in 'cinder help' + def test_absolute_limits(self): + limits = self.cinder('absolute-limits') + self.assertTableHeaders(limits, ['Name', 'Value']) + + def test_availability_zones(self): + zone_list = self.cinder('availability-zone-list') + self.assertTableHeaders(zone_list, ['Name', 'Status']) + + def test_backup_list(self): + 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.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.cinder('list') + self.assertTableHeaders(list, ['ID', 'Status', 'Name', 'Size', + 'Volume Type', 'Bootable', + 'Attached to']) + + def test_qos_list(self): + qos_list = self.cinder('qos-list') + self.assertTableHeaders(qos_list, ['ID', 'Name', 'Consumer', 'specs']) + + def test_rate_limits(self): + 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.cinder('service-list') + self.assertTableHeaders(service_list, ['Binary', 'Host', 'Zone', + 'Status', 'State', + 'Updated_at']) + + def test_snapshot_list(self): + snapshot_list = self.cinder('snapshot-list') + self.assertTableHeaders(snapshot_list, ['ID', 'Volume ID', 'Status', + 'Name', 'Size']) + + def test_transfer_list(self): + transfer_list = self.cinder('transfer-list') + self.assertTableHeaders(transfer_list, ['ID', 'Volume ID', 'Name']) + + def test_type_list(self): + 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/tests/v1/__init__.py b/cinderclient/tests/unit/__init__.py similarity index 100% rename from tests/v1/__init__.py rename to cinderclient/tests/unit/__init__.py 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 new file mode 100644 index 000000000..018a75d69 --- /dev/null +++ b/cinderclient/tests/unit/fakes.py @@ -0,0 +1,128 @@ +# 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. +""" + + +def assert_has_keys(dict, required=None, optional=None): + required = required or [] + optional = optional or [] + + for k in required: + try: + assert k in dict + except AssertionError: + extra_keys = set(dict).difference(set(required + optional)) + raise AssertionError("found unexpected keys: %s" % + list(extra_keys)) + + +class FakeClient(object): + + def _dict_match(self, partial, real): + + result = True + try: + for key, value in partial.items(): + if isinstance(value, dict): + result = self._dict_match(value, real[key]) + else: + assert real[key] == value + result = True + except (AssertionError, KeyError): + 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.""" + expected = (method, url) + 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: + 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: + assert self._dict_match(partial_body, + self.client.callstack[pos][2]) + except AssertionError: + print(self.client.callstack[pos][2]) + print("does not contain") + print(partial_body) + raise + + def assert_called_anytime(self, method, url, body=None, partial_body=None): + """ + Assert than an API method was called anytime in the test. + """ + expected = (method, url) + + assert self.client.callstack, ("Expected %s %s but no calls " + "were made." % expected) + + found = False + for entry in self.client.callstack: + if expected == entry[0:2]: + found = True + break + + assert found, 'Expected %s %s; got %s' % ( + expected + (self.client.callstack, )) + + if body is not None: + try: + assert entry[2] == body + except AssertionError: + print(entry[2]) + print("!=") + print(body) + raise + + if partial_body is not None: + try: + assert self._dict_match(partial_body, entry[2]) + except AssertionError: + print(entry[2]) + print("does not contain") + print(partial_body) + raise + + def clear_callstack(self): + self.client.callstack = [] + + def authenticate(self): + pass diff --git a/cinderclient/tests/unit/fixture_data/__init__.py b/cinderclient/tests/unit/fixture_data/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinderclient/tests/unit/fixture_data/availability_zones.py b/cinderclient/tests/unit/fixture_data/availability_zones.py new file mode 100644 index 000000000..6f197ce0a --- /dev/null +++ b/cinderclient/tests/unit/fixture_data/availability_zones.py @@ -0,0 +1,88 @@ +# 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.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): + + base_url = 'os-availability-zone' + + def setUp(self): + super(Fixture, self).setUp() + + get_availability = { + "availabilityZoneInfo": [ + { + "zoneName": "zone-1", + "zoneState": {"available": True}, + "hosts": None, + }, + { + "zoneName": "zone-2", + "zoneState": {"available": False}, + "hosts": None, + }, + ] + } + 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) + get_detail = { + "availabilityZoneInfo": [ + { + "zoneName": "zone-1", + "zoneState": {"available": True}, + "hosts": { + "fake_host-1": { + "cinder-volume": { + "active": True, + "available": True, + "updated_at": updated_1, + } + } + } + }, + { + "zoneName": "internal", + "zoneState": {"available": True}, + "hosts": { + "fake_host-1": { + "cinder-sched": { + "active": True, + "available": True, + "updated_at": updated_2, + } + } + } + }, + { + "zoneName": "zone-2", + "zoneState": {"available": False}, + "hosts": None, + }, + ] + } + 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 new file mode 100644 index 000000000..9406daf9e --- /dev/null +++ b/cinderclient/tests/unit/fixture_data/base.py @@ -0,0 +1,38 @@ +# 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 + +IDENTITY_URL = 'http://identityserver:5000/v2.0' +VOLUME_URL = 'http://volume.host' + + +class Fixture(fixtures.Fixture): + + base_url = None + json_headers = {'Content-Type': 'application/json'} + + def __init__(self, requests, + volume_url=VOLUME_URL, + identity_url=IDENTITY_URL): + super(Fixture, self).__init__() + self.requests = requests + self.volume_url = volume_url + self.identity_url = identity_url + + def url(self, *args): + url_args = [self.volume_url] + + if self.base_url: + url_args.append(self.base_url) + + return '/'.join(str(a).strip('/') for a in tuple(url_args) + args) diff --git a/cinderclient/tests/unit/fixture_data/client.py b/cinderclient/tests/unit/fixture_data/client.py new file mode 100644 index 000000000..9fe975666 --- /dev/null +++ b/cinderclient/tests/unit/fixture_data/client.py @@ -0,0 +1,48 @@ +# 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 keystoneauth1 import fixture + +from cinderclient.tests.unit.fixture_data import base +from cinderclient.v3 import client as v3client + + +class Base(base.Fixture): + + def __init__(self, *args, **kwargs): + super(Base, self).__init__(*args, **kwargs) + + self.token = fixture.V2Token() + self.token.set_scope() + + def setUp(self): + super(Base, self).setUp() + + auth_url = '%s/tokens' % self.identity_url + self.requests.register_uri('POST', auth_url, + json=self.token, + headers=self.json_headers) + + +class V3(Base): + + def __init__(self, *args, **kwargs): + super(V3, self).__init__(*args, **kwargs) + + svc = self.token.add_service('volumev3') + svc.add_endpoint(self.volume_url) + + def new_client(self): + 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 new file mode 100644 index 000000000..81767c556 --- /dev/null +++ b/cinderclient/tests/unit/fixture_data/keystone_client.py @@ -0,0 +1,263 @@ +# 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 copy +import json + +from oslo_utils import uuidutils + + +# these are copied from python-keystoneclient tests +BASE_HOST = 'http://keystone.example.com' +BASE_URL = "%s:5000/" % BASE_HOST +UPDATED = '2013-03-06T00:00:00Z' + +V2_URL = "%sv2.0" % BASE_URL +V2_DESCRIBED_BY_HTML = {'href': 'http://docs.openstack.org/api/' + 'openstack-identity-service/2.0/content/', + 'rel': 'describedby', + 'type': 'text/html'} + +V2_DESCRIBED_BY_PDF = {'href': 'http://docs.openstack.org/api/openstack-ident' + 'ity-service/2.0/identity-dev-guide-2.0.pdf', + 'rel': 'describedby', + 'type': 'application/pdf'} + +V2_VERSION = {'id': 'v2.0', + 'links': [{'href': V2_URL, 'rel': 'self'}, + V2_DESCRIBED_BY_HTML, V2_DESCRIBED_BY_PDF], + 'status': 'stable', + 'updated': UPDATED} + +V3_URL = "%sv3" % BASE_URL +V3_MEDIA_TYPES = [{'base': 'application/json', + 'type': 'application/vnd.openstack.identity-v3+json'}, + {'base': 'application/xml', + 'type': 'application/vnd.openstack.identity-v3+xml'}] + +V3_VERSION = {'id': 'v3.0', + 'links': [{'href': V3_URL, 'rel': 'self'}], + 'media-types': V3_MEDIA_TYPES, + 'status': 'stable', + 'updated': UPDATED} + +WRONG_VERSION_RESPONSE = {'id': 'v2.0', + 'links': [V2_DESCRIBED_BY_HTML, V2_DESCRIBED_BY_PDF], + 'status': 'stable', + 'updated': UPDATED} + + +def _create_version_list(versions): + return json.dumps({'versions': {'values': versions}}) + + +def _create_single_version(version): + return json.dumps({'version': version}) + + +V3_VERSION_LIST = _create_version_list([V3_VERSION, V2_VERSION]) +V2_VERSION_LIST = _create_version_list([V2_VERSION]) + +V3_VERSION_ENTRY = _create_single_version(V3_VERSION) +V2_VERSION_ENTRY = _create_single_version(V2_VERSION) + +CINDER_ENDPOINT = 'http://www.cinder.com/v1' + + +def _get_normalized_token_data(**kwargs): + ref = copy.deepcopy(kwargs) + # normalized token data + 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', 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', + 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) + + return ref + + +def generate_v2_project_scoped_token(**kwargs): + """Generate a Keystone V2 token based on auth request.""" + ref = _get_normalized_token_data(**kwargs) + token = uuidutils.generate_uuid(dashed=False) + + o = {'access': {'token': {'id': token, + 'expires': '2099-05-22T00:02:43.941430Z', + 'issued_at': '2013-05-21T00:02:43.941473Z', + 'tenant': {'enabled': True, + 'id': ref.get('project_id'), + 'name': ref.get('project_id') + } + }, + 'user': {'id': ref.get('user_id'), + 'name': uuidutils.generate_uuid(dashed=False), + 'username': ref.get('username'), + 'roles': ref.get('roles'), + 'roles_links': ref.get('roles_links') + } + }} + + # Add endpoint Keystone + o['access']['serviceCatalog'] = [ + { + '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 + + +def generate_v3_project_scoped_token(**kwargs): + """Generate a Keystone V3 token based on auth request.""" + ref = _get_normalized_token_data(**kwargs) + + o = {'token': {'expires_at': '2099-05-22T00:02:43.941430Z', + 'issued_at': '2013-05-21T00:02:43.941473Z', + 'methods': ['password'], + 'project': {'id': ref.get('project_id'), + 'name': ref.get('project_name'), + 'domain': {'id': ref.get('project_domain_id'), + 'name': ref.get( + 'project_domain_name') + } + }, + 'user': {'id': ref.get('user_id'), + 'name': ref.get('username'), + 'domain': {'id': ref.get('user_domain_id'), + 'name': ref.get('user_domain_name') + } + }, + 'roles': ref.get('roles') + }} + + # we only care about Neutron and Keystone endpoints + o['token']['catalog'] = [ + {'endpoints': [ + { + 'id': uuidutils.generate_uuid(dashed=False), + 'interface': 'public', + 'region': 'RegionOne', + 'url': 'public_' + ref.get('cinder_url') + }, + { + 'id': uuidutils.generate_uuid(dashed=False), + 'interface': 'internal', + 'region': 'RegionOne', + 'url': 'internal_' + ref.get('cinder_url') + }, + { + 'id': uuidutils.generate_uuid(dashed=False), + 'interface': 'admin', + 'region': 'RegionOne', + 'url': 'admin_' + ref.get('cinder_url') + }], + 'id': uuidutils.generate_uuid(dashed=False), + 'type': 'network'}, + {'endpoints': [ + { + 'id': uuidutils.generate_uuid(dashed=False), + 'interface': 'public', + 'region': 'RegionOne', + 'url': ref.get('auth_url') + }, + { + 'id': uuidutils.generate_uuid(dashed=False), + 'interface': 'admin', + 'region': 'RegionOne', + 'url': ref.get('auth_url') + }], + '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 = uuidutils.generate_uuid(dashed=False) + + return token_id, o + + +def keystone_request_callback(request, context): + context.headers['Content-Type'] = 'application/json' + + if request.url == BASE_URL: + return V3_VERSION_LIST + 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: + return str(WRONG_VERSION_RESPONSE) + else: + context.status_code = 500 + return str(WRONG_VERSION_RESPONSE) diff --git a/cinderclient/tests/unit/fixture_data/snapshots.py b/cinderclient/tests/unit/fixture_data/snapshots.py new file mode 100644 index 000000000..83adf487f --- /dev/null +++ b/cinderclient/tests/unit/fixture_data/snapshots.py @@ -0,0 +1,66 @@ +# 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.fixture_data import base + + +REQUEST_ID = 'req-test-request-id' + + +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 Fixture(base.Fixture): + + base_url = 'snapshots' + + 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}, + headers={'x-openstack-request-id': REQUEST_ID} + ) + + def action_1234(request, context): + return '' + + 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 new file mode 100644 index 000000000..5653a7b41 --- /dev/null +++ b/cinderclient/tests/unit/test_auth_plugins.py @@ -0,0 +1,43 @@ +# Copyright 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.contrib import noauth +from cinderclient.tests.unit import utils + + +class CinderNoAuthPluginTest(utils.TestCase): + def setUp(self): + super(CinderNoAuthPluginTest, self).setUp() + self.plugin = noauth.CinderNoAuthPlugin('user', 'project', + endpoint='example.com') + + def test_auth_token(self): + auth_token = 'user:project' + self.assertEqual(auth_token, self.plugin.auth_token) + + def test_auth_token_no_project(self): + auth_token = 'user:user' + plugin = noauth.CinderNoAuthPlugin('user') + self.assertEqual(auth_token, plugin.auth_token) + + 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)) + + 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 new file mode 100644 index 000000000..36503e152 --- /dev/null +++ b/cinderclient/tests/unit/test_base.py @@ -0,0 +1,172 @@ +# -*- 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. + +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.tests.unit import test_utils +from cinderclient.tests.unit import utils +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)) + + class TmpObject(object): + id = 4 + self.assertEqual(4, base.getid(TmpObject)) + + def test_eq(self): + # 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 resources of different types: never equal + r1 = base.Resource(None, {'id': 1}) + r2 = volumes.Volume(None, {'id': 1}) + self.assertNotEqual(r1, r2) + + # Two resources with no ID: equal if their info is equal + r1 = base.Resource(None, {'name': 'joe', 'age': 12}) + r2 = base.Resource(None, {'name': 'joe', 'age': 12}) + self.assertEqual(r1, r2) + + def test_findall_invalid_attribute(self): + # Make sure findall with an invalid attribute doesn't cause errors. + # The following should not raise an exception. + cs.volumes.findall(vegetable='carrot') + + # However, find() should raise an error + 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 new file mode 100644 index 000000000..1ed615f05 --- /dev/null +++ b/cinderclient/tests/unit/test_client.py @@ -0,0 +1,475 @@ +# 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 logging +from unittest import mock + +import ddt +import fixtures +from keystoneauth1 import adapter +from keystoneauth1 import exceptions as keystone_exception +from oslo_serialization import jsonutils + +from cinderclient import api_versions +import cinderclient.client +from cinderclient import exceptions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + + +@ddt.ddt +class ClientTest(utils.TestCase): + + def test_get_client_class_v2(self): + 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( + format="%(message)s", + level=logging.DEBUG, + nuke_handlers=True + ) + ) + + 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") + cs.http_log_debug = True + cs.http_log_req('PUT', kwargs) + + output = self.logger.output.split('\n') + + self.assertNotIn("fakePassword", output[1]) + self.assertIn("fakeUser", output[1]) + + def test_versions(self): + v2_url = 'http://fakeurl/v2/tenants' + v3_url = 'http://fakeurl/v3/tenants' + unknown_url = 'http://fakeurl/v9/tenants' + + 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('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): + kwargs = { + "body": { + "volume": { + "status": "creating", + "imageRef": "username", + "attach_status": "detached" + }, + "authenticated": "True" + } + } + + resp = { + "text": { + "volume": { + "status": "creating", + "id": "431253c0-e203-4da2-88df-60c756942aaf", + "size": 1 + } + }, + "code": 202 + } + + request_id = "req-f551871a-4950-4225-9b2c-29a14c8f075e" + mock_response = utils.TestResponse({ + "status_code": 202, + "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 + 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.LegacyJsonAdapter, '_request') + def test_sessionclient_request_method_raises_badrequest( + self, mock_request): + kwargs = { + "body": { + "volume": { + "status": "creating", + "imageRef": "username", + "attach_status": "detached" + }, + "authenticated": "True" + } + } + + resp = { + "badRequest": { + "message": "Invalid image identifier or unable to access " + "requested image.", + "code": 400 + } + } + + mock_response = utils.TestResponse({ + "status_code": 400, + "text": json.dumps(resp).encode("latin-1"), + }) + + # 'request' method of Adaptor will return 400 response + mock_request.return_value = mock_response + session_client = cinderclient.client.SessionClient( + session=mock.Mock()) + + # 'from_response' method will raise BadRequest because + # resp.status_code is 400 + self.assertRaises(exceptions.BadRequest, session_client.request, + 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( + self, mock_from_resp): + + kwargs = { + "body": { + "volume": { + "status": "creating", + "imageRef": "username", + "attach_status": "detached" + }, + "authenticated": "True" + } + } + + with mock.patch.object(adapter.LegacyJsonAdapter, '_request', + side_effect= + keystone_exception.AuthorizationFailure()): + session_client = cinderclient.client.SessionClient( + session=mock.Mock()) + self.assertRaises(keystone_exception.AuthorizationFailure, + session_client.request, + 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 new file mode 100644 index 000000000..75276f8f0 --- /dev/null +++ b/cinderclient/tests/unit/test_http.py @@ -0,0 +1,420 @@ +# 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 +from unittest import mock +import uuid + +import requests + +from cinderclient import client +from cinderclient import exceptions +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_201_response = utils.TestResponse({ + "status_code": 201, + "text": json.dumps(fake_auth_response), +}) +mock_201_request = mock.Mock(return_value=(fake_201_response)) + +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": '', +}) +bad_400_request = mock.Mock(return_value=(bad_400_response)) + +bad_401_response = utils.TestResponse({ + "status_code": 401, + "text": '{"error": {"message": "FAILED!", "details": "DETAILS!"}}', +}) +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!"}}', +}) +bad_500_request = mock.Mock(return_value=(bad_500_response)) + +connection_error_request = mock.Mock( + side_effect=requests.exceptions.ConnectionError) + +timeout_error_request = mock.Mock( + side_effect=requests.exceptions.Timeout) + + +def get_client(retries=0, **kwargs): + cl = client.HTTPClient("username", "password", + "project_id", "auth_test", retries=retries, + **kwargs) + return cl + + +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_endpoint_url(retries=0): + cl = client.HTTPClient("username", "password", + "project_id", "auth_test", + os_endpoint="volume/v100/", retries=retries) + cl.auth_token = "token" + return cl + + +class ClientTest(utils.TestCase): + + def test_get(self): + cl = get_authed_client() + + @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", + "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_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) + + self.requests = [bad_401_request, mock_request] + + def request(*args, **kwargs): + next_request = self.requests.pop(0) + return next_request(*args, **kwargs) + + def reauth(): + cl.management_url = "http://example.com" + cl.auth_token = "token" + + @mock.patch.object(cl, 'authenticate', reauth) + @mock.patch.object(requests, "request", request) + @mock.patch('time.time', mock.Mock(return_value=1234)) + def test_get_call(): + resp, body = cl.get("/hi") + + test_get_call() + self.assertEqual([], self.requests) + + def test_get_retry_500(self): + cl = get_authed_client(retries=1) + + self.requests = [bad_500_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") + + test_get_call() + self.assertEqual([], self.requests) + + def test_get_retry_connection_error(self): + cl = get_authed_client(retries=1) + + self.requests = [connection_error_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") + + test_get_call() + 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) + + self.requests = [bad_500_request, bad_500_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") + + self.assertRaises(exceptions.ClientException, test_get_call) + self.assertEqual([mock_request], self.requests) + + def test_get_no_retry_400(self): + cl = get_authed_client(retries=0) + + self.requests = [bad_400_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)) + def test_get_call(): + resp, body = cl.get("/hi") + + self.assertRaises(exceptions.BadRequest, test_get_call) + self.assertEqual([mock_request], self.requests) + + def test_get_retry_400_socket(self): + cl = get_authed_client(retries=1) + + self.requests = [bad_400_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") + + test_get_call() + self.assertEqual([], self.requests) + + def test_get_no_auth_url(self): + client.HTTPClient("username", "password", + "project_id", retries=0) + + def test_post(self): + cl = get_authed_client() + + @mock.patch.object(requests, "request", mock_request) + def test_post_call(): + cl.post("/hi", body=[1, 2, 3]) + headers = { + "X-Auth-Token": "token", + "X-Auth-Project-Id": "project_id", + "Content-Type": "application/json", + 'Accept': 'application/json', + "User-Agent": cl.USER_AGENT + } + mock_request.assert_called_with( + "POST", + "http://example.com/hi", + headers=headers, + data='[1, 2, 3]', + **self.TEST_REQUEST_BASE) + + test_post_call() + + 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) + def test_auth_call(): + self.assertRaises(exceptions.AuthorizationFailure, + cl.authenticate) + + test_auth_call() + + def test_auth_with_keystone_v3(self): + cl = get_authed_client() + cl.auth_url = 'http://example.com:5000/v3' + + @mock.patch.object(requests, "request", mock_201_request) + def test_auth_call(): + 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() + + def test_get_retry_timeout_error(self): + cl = get_authed_client(retries=1) + + self.requests = [timeout_error_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") + + test_get_call() + self.assertEqual([], self.requests) diff --git a/cinderclient/tests/unit/test_shell.py b/cinderclient/tests/unit/test_shell.py new file mode 100644 index 000000000..574351042 --- /dev/null +++ b/cinderclient/tests/unit/test_shell.py @@ -0,0 +1,582 @@ +# 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 io +import json +import re +import sys +from unittest import mock + +import ddt +import fixtures +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 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 import fake_actions_module +from cinderclient.tests.unit.fixture_data import keystone_client +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + + +@ddt.ddt +class ShellTest(utils.TestCase): + + FAKE_ENV = { + 'OS_USERNAME': 'username', + 'OS_PASSWORD': 'password', + '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, 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): + super(ShellTest, self).setUp() + for var in self.FAKE_ENV: + self.useFixture(fixtures.EnvironmentVariable(var, + self.FAKE_ENV[var])) + + self.mock_completion() + + def shell(self, argstr): + orig = sys.stdout + try: + sys.stdout = io.StringIO() + _shell = shell.OpenStackCinderShell() + _shell.main(argstr.split()) + except SystemExit: + exc_type, exc_value, exc_traceback = sys.exc_info() + self.assertEqual(0, exc_value.code) + finally: + out = sys.stdout.getvalue() + sys.stdout.close() + sys.stdout = orig + + 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 = [ + 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: + self.assertThat(help_text, + matchers.MatchesRegex(r, re.DOTALL | re.MULTILINE)) + + def test_help_on_subcommand(self): + required = [ + 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) + + @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" + self.register_keystone_auth_fixture(mocker, 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(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(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 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_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}) + + _shell = shell.OpenStackCinderShell() + + # This "fails" but instantiates the client. + self.assertRaises(exceptions.CommandError, _shell.main, ['list']) + + self.assertEqual(False, _shell.cs.client.verify_cert) + + @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() + + # 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) + + @mock.patch('keystoneauth1.session.Session.__init__', + side_effect=RuntimeError()) + def test_http_client_with_cert_and_key(self, mock_session): + _shell = shell.OpenStackCinderShell() + + # 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) + + +class CinderClientArgumentParserTest(utils.TestCase): + + def setUp(self): + super(CinderClientArgumentParserTest, self).setUp() + + self.mock_completion() + + def test_ambiguity_solved_for_one_visible_argument(self): + parser = shell.CinderClientArgumentParser(add_help=False) + parser.add_argument('--test-parameter', + dest='visible_param', + action='store_true') + parser.add_argument('--test_parameter', + dest='hidden_param', + action='store_true', + help=argparse.SUPPRESS) + + opts = parser.parse_args(['--test']) + + # visible argument must be set + self.assertTrue(opts.visible_param) + self.assertFalse(opts.hidden_param) + + def test_raise_ambiguity_error_two_visible_argument(self): + parser = shell.CinderClientArgumentParser(add_help=False) + parser.add_argument('--test-parameter', + dest="visible_param1", + action='store_true') + parser.add_argument('--test_parameter', + dest="visible_param2", + action='store_true') + + self.assertRaises(SystemExit, parser.parse_args, ['--test']) + + def test_raise_ambiguity_error_two_hidden_argument(self): + parser = shell.CinderClientArgumentParser(add_help=False) + parser.add_argument('--test-parameter', + dest="hidden_param1", + action='store_true', + help=argparse.SUPPRESS) + parser.add_argument('--test_parameter', + dest="hidden_param2", + action='store_true', + 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 new file mode 100644 index 000000000..69b0d0454 --- /dev/null +++ b/cinderclient/tests/unit/test_utils.py @@ -0,0 +1,350 @@ +# 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 collections +import io +import sys +from unittest import mock + +import ddt + +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, **kwargs): + self.id = _id + try: + self.name = properties['name'] + except KeyError: + pass + + def append_request_ids(self, resp): + pass + + +class FakeManager(base.ManagerWithFind): + + resource_class = FakeResource + + resources = [ + FakeResource('1234', {'name': 'entity_one'}), + FakeResource(UUID, {'name': 'entity_two'}), + FakeResource('5678', {'name': '9876'}) + ] + + 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, **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): + + def setUp(self): + super(FindResourceTestCase, self).setUp() + self.manager = FakeManager(None) + + def test_find_none(self): + self.manager.find = mock.Mock(side_effect=self.manager.find) + self.assertRaises(exceptions.CommandError, + utils.find_resource, + self.manager, + 'asdf') + self.assertEqual(2, self.manager.find.call_count) + + def test_find_by_integer_id(self): + output = utils.find_resource(self.manager, 1234) + self.assertEqual(self.manager.get('1234'), output) + + def test_find_by_str_id(self): + output = utils.find_resource(self.manager, '1234') + self.assertEqual(self.manager.get('1234'), output) + + def test_find_by_uuid(self): + output = utils.find_resource(self.manager, UUID) + self.assertEqual(self.manager.get(UUID), output) + + def test_find_by_str_name(self): + output = utils.find_resource(self.manager, 'entity_one') + self.assertEqual(self.manager.get('1234'), output) + + def test_find_by_str_displayname(self): + 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 = io.StringIO() + sys.stdout = self.stringio + return self + + def __exit__(self, *args): + sys.stdout = self.real_stdout + self.stringio.seek(0) + 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: + shell_utils.print_list(to_print, ['a', 'b']) + # Output should be sorted by the first key (a) + self.assertEqual("""\ ++---+---+ +| a | b | ++---+---+ +| 1 | 2 | +| 3 | 4 | ++---+---+ +""", cso.read()) + + 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: + shell_utils.print_list(to_print, ['a', 'b']) + # Output should be sorted by the first key (a) + self.assertEqual("""\ ++---+---+ +| a | b | ++---+---+ +| 1 | 2 | +| 3 | - | ++---+---+ +""", cso.read()) + + 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: + shell_utils.print_list(to_print, ['a', 'b'], sortby_index=1) + # Output should be sorted by the second key (b) + self.assertEqual("""\ ++---+---+ +| a | b | ++---+---+ +| 2 | 1 | +| 4 | 3 | ++---+---+ +""", cso.read()) + + 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: + shell_utils.print_list(to_print, ['a', 'b'], sortby_index=None) + # Output should be in the order given + self.assertEqual("""\ ++---+---+ +| a | b | ++---+---+ +| 3 | 4 | +| 1 | 2 | ++---+---+ +""", cso.read()) + + def test_print_list_with_generator(self): + Row = collections.namedtuple('Row', ['a', 'b']) + + def gen_rows(): + for row in [Row(a=1, b=2), Row(a=3, b=4)]: + yield row + with CaptureStdout() as cso: + shell_utils.print_list(gen_rows(), ['a', 'b']) + self.assertEqual("""\ ++---+---+ +| a | b | ++---+---+ +| 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 new file mode 100644 index 000000000..2bf242fad --- /dev/null +++ b/cinderclient/tests/unit/utils.py @@ -0,0 +1,134 @@ +# 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 os +from unittest import mock + +import fixtures +import requests +from requests_mock.contrib import fixture as requests_mock_fixture +import testtools + + +REQUEST_ID = ['req-test-request-id'] + + +class TestCase(testtools.TestCase): + TEST_REQUEST_BASE = { + 'verify': True, + 'cert': None + } + + def setUp(self): + super(TestCase, self).setUp() + if (os.environ.get('OS_STDOUT_CAPTURE') == 'True' or + os.environ.get('OS_STDOUT_CAPTURE') == '1'): + stdout = self.useFixture(fixtures.StringStream('stdout')).stream + self.useFixture(fixtures.MonkeyPatch('sys.stdout', stdout)) + if (os.environ.get('OS_STDERR_CAPTURE') == 'True' or + os.environ.get('OS_STDERR_CAPTURE') == '1'): + 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): + + client_fixture_class = None + data_fixture_class = None + + def setUp(self): + super(FixturedTestCase, self).setUp() + + self.requests = self.useFixture(requests_mock_fixture.Fixture()) + self.data_fixture = None + self.client_fixture = None + self.cs = None + + if self.client_fixture_class: + fix = self.client_fixture_class(self.requests) + self.client_fixture = self.useFixture(fix) + self.cs = self.client_fixture.new_client() + + if self.data_fixture_class: + fix = self.data_fixture_class(self.requests) + self.data_fixture = self.useFixture(fix) + + def assert_called(self, method, path, body=None): + 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, bytes): + req_data = req_data.decode('utf-8') + 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(body, req_data) + + +class TestResponse(requests.Response): + """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 + + if isinstance(data, dict): + self.status_code = data.get('status_code', None) + self.headers = data.get('headers', None) + self.reason = data.get('reason', '') + # 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/v3/__init__.py b/cinderclient/tests/unit/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinderclient/tests/unit/v3/contrib/__init__.py b/cinderclient/tests/unit/v3/contrib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinderclient/tests/unit/v3/contrib/test_list_extensions.py b/cinderclient/tests/unit/v3/contrib/test_list_extensions.py new file mode 100644 index 000000000..1d7eb357d --- /dev/null +++ b/cinderclient/tests/unit/v3/contrib/test_list_extensions.py @@ -0,0 +1,35 @@ +# 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 extension +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3.contrib import list_extensions + +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.assertGreater(len(all_exts), 0) + for r in all_exts: + 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/v3/fakes_base.py b/cinderclient/tests/unit/v3/fakes_base.py new file mode 100644 index 000000000..9702b42c2 --- /dev/null +++ b/cinderclient/tests/unit/v3/fakes_base.py @@ -0,0 +1,1325 @@ +# 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 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 + + +REQUEST_ID = 'req-test-request-id' + + +def _stub_volume(*args, **kwargs): + volume = { + "migration_status": None, + "attachments": [{'server_id': '1234', + 'id': '3f88836f-adde-4296-9f6b-2c59a0bcda9a', + 'attachment_id': '5678'}], + "links": [ + { + "href": "http://localhost/v2/fake/volumes/1234", + "rel": "self" + }, + { + "href": "http://localhost/fake/volumes/1234", + "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 + + +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 _stub_consistencygroup(detailed=True, **kwargs): + consistencygroup = { + "name": "cg", + "id": "11111111-1111-1111-1111-111111111111", + } + if detailed: + details = { + "created_at": "2012-08-28T16:30:31.000000", + "description": None, + "availability_zone": "myzone", + "status": "available", + } + consistencygroup.update(details) + consistencygroup.update(kwargs) + return consistencygroup + + +def _stub_cgsnapshot(detailed=True, **kwargs): + cgsnapshot = { + "name": None, + "id": "11111111-1111-1111-1111-111111111111", + } + if detailed: + details = { + "created_at": "2012-08-28T16:30:31.000000", + "description": None, + "name": None, + "id": "11111111-1111-1111-1111-111111111111", + "status": "available", + "consistencygroup_id": "00000000-0000-0000-0000-000000000000", + } + cgsnapshot.update(details) + cgsnapshot.update(kwargs) + return cgsnapshot + + +def _stub_type_access(**kwargs): + access = {'volume_type_id': '11111111-1111-1111-1111-111111111111', + 'project_id': '00000000-0000-0000-0000-000000000000'} + access.update(kwargs) + return access + + +def _self_href(base_uri, tenant_id, backup_id): + return '%s/v2/%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_qos_full(id, base_uri, tenant_id, name=None, specs=None): + if not name: + name = 'fake-name' + if not specs: + specs = {} + + return { + 'qos_specs': { + 'id': id, + 'name': name, + 'consumer': 'back-end', + 'specs': specs, + }, + 'links': { + 'href': _bookmark_href(base_uri, tenant_id, id), + 'rel': 'bookmark' + } + } + + +def _stub_qos_associates(id, name): + return { + 'assoications_type': 'volume_type', + 'name': name, + 'id': id, + } + + +def _stub_restore(): + return {'volume_id': '712f4980-5ac1-41e5-9383-390aa7c9f58b'} + + +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'} + + +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 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, version_header=None, **kwargs): + self.username = 'username' + self.password = 'password' + self.auth_url = 'auth_url' + self.callstack = [] + 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 + 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) + url_split = url.rsplit('?', 1) + munged_url = url_split[0] + if len(url_split) > 1: + parameters = url_split[1] + if 'marker' in parameters: + self.marker = int(parameters.rsplit('marker=', 1)[1]) + else: + self.marker = None + else: + self.marker = None + 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) + # 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, + "headers": headers, + }) + return r, 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): + if kw.get('with_count', False): + return (200, {}, {'snapshots': [ + _stub_snapshot(), + ], 'count': 1}) + 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 post_snapshots(self, **kw): + metadata = kw['body']['snapshot'].get('metadata', None) + snapshot = _stub_snapshot(id='1234', volume_id='1234') + if snapshot is not None: + snapshot.update({'metadata': metadata}) + return (202, {}, {'snapshot': snapshot}) + + 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'] + 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) + + 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): + if self.marker == 1234: + return (200, {}, {"volumes": [ + {'id': 5678, 'name': 'sample-volume2'} + ]}) + elif self.osapi_max_limit == 1: + return (200, {}, {"volumes": [ + {'id': 1234, 'name': 'sample-volume'} + ], "volumes_links": [ + {'href': "/volumes?limit=1&marker=1234", 'rel': 'next'} + ]}) + else: + return (200, {}, {"volumes": [ + {'id': 1234, 'name': 'sample-volume'}, + {'id': 5678, 'name': 'sample-volume2'} + ]}) + + 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": [ + _stub_volume(id=kw.get('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_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) + + 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': + keys = sorted(list(body[action])) + assert (keys == ['instance_uuid', 'mode', 'mountpoint'] or + keys == ['host_name', 'mode', 'mountpoint']) + elif action == 'os-detach': + 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': 'bars'}}) + 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' or 'attach_status' or 'migration_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-retype': + assert 'new_type' in body[action] + elif action == 'os-set_bootable': + assert list(body[action]) == ['bootable'] + elif action == 'os-unmanage': + assert body[action] is None + 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) + + def post_volumes(self, **kw): + size = kw['body']['volume'].get('size', 1) + volume = _stub_volume(id='1234', size=size) + return (202, {}, {'volume': volume}) + + def delete_volumes_1234(self, **kw): + return (202, {}, None) + + def delete_volumes_5678(self, **kw): + return (202, {}, None) + + # + # Consistencygroups + # + + def get_consistencygroups_detail(self, **kw): + return (200, {}, {"consistencygroups": [ + _stub_consistencygroup(id='1234'), + _stub_consistencygroup(id='4567')]}) + + def get_consistencygroups(self, **kw): + return (200, {}, {"consistencygroups": [ + _stub_consistencygroup(detailed=False, id='1234'), + _stub_consistencygroup(detailed=False, id='4567')]}) + + def get_consistencygroups_1234(self, **kw): + return (200, {}, {'consistencygroup': + _stub_consistencygroup(id='1234')}) + + def post_consistencygroups(self, **kw): + return (202, {}, {'consistencygroup': {}}) + + def put_consistencygroups_1234(self, **kw): + return (200, {}, {'consistencygroup': {}}) + + def post_consistencygroups_1234_delete(self, **kw): + return (202, {}, {}) + + def post_consistencygroups_create_from_src(self, **kw): + return (200, + {}, + {'consistencygroup': _stub_consistencygroup( + id='1234', cgsnapshot_id='1234')}) + + # + # Cgsnapshots + # + + def get_cgsnapshots_detail(self, **kw): + return (200, {}, {"cgsnapshots": [ + _stub_cgsnapshot(id='1234'), + _stub_cgsnapshot(id='4567')]}) + + def get_cgsnapshots(self, **kw): + return (200, {}, {"cgsnapshots": [ + _stub_cgsnapshot(detailed=False, id='1234'), + _stub_cgsnapshot(detailed=False, id='4567')]}) + + def get_cgsnapshots_1234(self, **kw): + return (200, {}, {'cgsnapshot': _stub_cgsnapshot(id='1234')}) + + def post_cgsnapshots(self, **kw): + return (202, {}, {'cgsnapshot': {}}) + + def put_cgsnapshots_1234(self, **kw): + return (200, {}, {'cgsnapshot': {}}) + + def delete_cgsnapshots_1234(self, **kw): + return (202, {}, {}) + + # + # 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, + 'per_volume_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, + 'per_volume_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, + 'per_volume_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', + 'volumes': 1, + 'snapshots': 1, + 'gigabytes': 1, + 'backups': 1, + 'backup_gigabytes': 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']) + return (200, {}, {'quota_class_set': { + 'volumes': 2, + 'snapshots': 2, + 'gigabytes': 1, + 'backups': 1, + 'backup_gigabytes': 1, + 'per_volume_gigabytes': 1}}) + + # + # VolumeTypes + # + + def get_types(self, **kw): + return (200, {}, { + 'volume_types': [{'id': 1, + 'name': 'test-type-1', + 'description': 'test_type-1-desc', + 'extra_specs': {}}, + {'id': 2, + 'name': 'test-type-2', + 'description': 'test_type-2-desc', + 'extra_specs': {}}]}) + + def get_types_1(self, **kw): + return (200, {}, {'volume_type': {'id': 1, + 'name': 'test-type-1', + 'description': 'test_type-1-desc', + 'extra_specs': {'key': 'value'}}}) + + def get_types_2(self, **kw): + return (200, {}, {'volume_type': {'id': 2, + 'name': 'test-type-2', + 'description': 'test_type-2-desc', + 'extra_specs': {}}}) + + def get_types_3(self, **kw): + return (200, {}, {'volume_type': {'id': 3, + 'name': 'test-type-3', + 'description': 'test_type-3-desc', + 'extra_specs': {}, + 'os-volume-type-access:is_public': False}}) + + def get_types_default(self, **kw): + return self.get_types_1() + + def post_types(self, body, **kw): + return (202, {}, {'volume_type': {'id': 3, + 'name': 'test-type-3', + 'description': 'test_type-3-desc', + 'extra_specs': {}}}) + + def post_types_3_action(self, body, **kw): + _body = None + resp = 202 + assert len(list(body)) == 1 + action = list(body)[0] + if action == 'addProjectAccess': + assert 'project' in body['addProjectAccess'] + elif action == 'removeProjectAccess': + assert 'project' in body['removeProjectAccess'] + else: + raise AssertionError('Unexpected action: %s' % action) + return (resp, {}, _body) + + 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_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 + # + + def get_types_3_os_volume_type_access(self, **kw): + return (200, {}, {'volume_type_access': [ + _stub_type_access() + ]}) + + # + # 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_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) + + # + # 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_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), + _stub_backup_full(backup2, base_uri, tenant_id)]}) + + 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' + 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()}) + + 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, + {}, + {'backup-record': {'backup_service': 'fake-backup-service', + 'backup_url': 'fake-backup-url'}}) + + def get_backups_1234_export_record(self, **kw): + return (200, + {}, + {'backup-record': {'backup_service': 'fake-backup-service', + 'backup_url': 'fake-backup-url'}}) + + def post_backups_import_record(self, **kw): + base_uri = 'http://localhost:8776' + tenant_id = '0fa851f6668144cf9cd8c8419c1646c1' + backup1 = '76a17945-3c6f-435c-975b-b5685db10b62' + return (200, + {}, + {'backup': _stub_backup(backup1, base_uri, tenant_id)}) + + # + # 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_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' + 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_1234(self, **kw): + return (202, {}, None) + + 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)}) + + 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 + # + 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 = [i for i in services if i['host'] == host] + if binary: + services = [i for i in services if i['binary'] == binary] + 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"}}) + + 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 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 = [ + { + "name": "ubuntu@lvm#backend_name", + "capabilities": { + "pool_name": "backend_name", + "QoS_support": False, + "timestamp": "2014-11-21T18:15:28.141161", + "allocated_capacity_gb": 0, + "volume_backend_name": "backend_name", + "free_capacity_gb": 7.01, + "driver_version": "2.0.0", + "total_capacity_gb": 10.01, + "reserved_percentage": 0, + "vendor_name": "Open Source", + "storage_protocol": "iSCSI", + } + }, + ] + 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/tests/v1/test_auth.py b/cinderclient/tests/unit/v3/test_auth.py similarity index 50% rename from tests/v1/test_auth.py rename to cinderclient/tests/unit/v3/test_auth.py index 6cced17da..3e5e70890 100644 --- a/tests/v1/test_auth.py +++ b/cinderclient/tests/unit/v3/test_auth.py @@ -1,53 +1,62 @@ -import httplib2 -import json -import mock +# 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.v1 import client -from cinderclient import exceptions -from tests import utils +import json +from unittest import mock +import requests -def to_http_response(resp_dict): - """Converts dict of response attributes to httplib response.""" - resp = httplib2.Response(resp_dict) - for k, v in resp_dict['headers'].items(): - resp[k] = v - return resp +from cinderclient import exceptions +from cinderclient.tests.unit import utils +from cinderclient.v3 import client class AuthenticateAgainstKeystoneTests(utils.TestCase): def test_authenticate_success(self): cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') + "http://localhost:8776/v2", service_type='volumev2') resp = { "access": { "token": { - "expires": "12345", + "expires": "2014-11-01T03:32:15-05:00", "id": "FAKE_ID", }, "serviceCatalog": [ { - "type": "compute", + "type": "volumev2", "endpoints": [ { "region": "RegionOne", - "adminURL": "http://localhost:8774/v1", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "adminURL": "http://localhost:8776/v2", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", }, ], }, ], }, } - auth_response = httplib2.Response({ - "status": 200, - "body": json.dumps(resp), }) + auth_response = utils.TestResponse({ + "status_code": 200, + "text": json.dumps(resp), + }) - mock_request = mock.Mock(return_value=(auth_response, - json.dumps(resp))) + mock_request = mock.Mock(return_value=(auth_response)) - @mock.patch.object(httplib2.Http, "request", mock_request) + @mock.patch.object(requests, "request", mock_request) def test_auth_call(): cs.client.authenticate() headers = { @@ -66,30 +75,109 @@ def test_auth_call(): } token_url = cs.client.auth_url + "/tokens" - mock_request.assert_called_with(token_url, "POST", - headers=headers, - body=json.dumps(body)) + 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/v2", + tenant_id='tenant_id', service_type='volumev2') + 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": 'volumev2', + "endpoints": [ + { + "region": "RegionOne", + "adminURL": "http://localhost:8776/v2", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2", + }, + ], + }, + ], + }, + } + 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(cs.client.management_url, public_url) + self.assertEqual(public_url, cs.client.management_url) token_id = resp["access"]["token"]["id"] - self.assertEqual(cs.client.auth_token, 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", - "auth_url/v2.0") + "http://localhost:8776/v2") resp = {"unauthorized": {"message": "Unauthorized", "code": "401"}} - auth_response = httplib2.Response({ - "status": 401, - "body": json.dumps(resp), }) + auth_response = utils.TestResponse({ + "status_code": 401, + "text": json.dumps(resp), + }) - mock_request = mock.Mock(return_value=(auth_response, - json.dumps(resp))) + mock_request = mock.Mock(return_value=(auth_response)) - @mock.patch.object(httplib2.Http, "request", mock_request) + @mock.patch.object(requests, "request", mock_request) def test_auth_call(): self.assertRaises(exceptions.Unauthorized, cs.client.authenticate) @@ -97,22 +185,22 @@ def test_auth_call(): def test_auth_redirect(self): cs = client.Client("username", "password", "project_id", - "auth_url/v1", service_type='compute') + "http://localhost:8776/v2", service_type='volumev2') dict_correct_response = { "access": { "token": { - "expires": "12345", + "expires": "2014-11-01T03:32:15-05:00", "id": "FAKE_ID", }, "serviceCatalog": [ { - "type": "compute", + "type": "volumev2", "endpoints": [ { - "adminURL": "http://localhost:8774/v1", + "adminURL": "http://localhost:8776/v2", "region": "RegionOne", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", + "internalURL": "http://localhost:8776/v2", + "publicURL": "http://localhost:8776/v2/", }, ], }, @@ -121,30 +209,29 @@ def test_auth_redirect(self): } correct_response = json.dumps(dict_correct_response) dict_responses = [ - {"headers": {'location':'http://127.0.0.1:5001'}, - "status": 305, - "body": "Use proxy"}, + {"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": 200, - "body": correct_response}, + "status_code": 200, + "text": correct_response}, {"headers": {}, - "status": 200, - "body": correct_response} + "status_code": 200, + "text": correct_response} ] - responses = [(to_http_response(resp), resp['body']) - for resp in dict_responses] + 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(httplib2.Http, "request", mock_request) + @mock.patch.object(requests, "request", mock_request) def test_auth_call(): cs.client.authenticate() headers = { @@ -163,68 +250,20 @@ def test_auth_call(): } token_url = cs.client.auth_url + "/tokens" - mock_request.assert_called_with(token_url, "POST", - headers=headers, - body=json.dumps(body)) + 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(cs.client.management_url, public_url) + self.assertEqual(public_url, cs.client.management_url) token_id = resp["access"]["token"]["id"] - self.assertEqual(cs.client.auth_token, token_id) - - test_auth_call() - - def test_ambiguous_endpoints(self): - cs = client.Client("username", "password", "project_id", - "auth_url/v2.0", service_type='compute') - resp = { - "access": { - "token": { - "expires": "12345", - "id": "FAKE_ID", - }, - "serviceCatalog": [ - { - "adminURL": "http://localhost:8774/v1", - "type": "compute", - "name": "Compute CLoud", - "endpoints": [ - { - "region": "RegionOne", - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", - }, - ], - }, - { - "adminURL": "http://localhost:8774/v1", - "type": "compute", - "name": "Hyper-compute Cloud", - "endpoints": [ - { - "internalURL": "http://localhost:8774/v1", - "publicURL": "http://localhost:8774/v1/", - }, - ], - }, - ], - }, - } - auth_response = httplib2.Response( - { - "status": 200, - "body": json.dumps(resp), - }) - - mock_request = mock.Mock(return_value=(auth_response, - json.dumps(resp))) - - @mock.patch.object(httplib2.Http, "request", mock_request) - def test_auth_call(): - self.assertRaises(exceptions.AmbiguousEndpoints, - cs.client.authenticate) + self.assertEqual(token_id, cs.client.auth_token) test_auth_call() @@ -232,15 +271,17 @@ def test_auth_call(): class AuthenticationTests(utils.TestCase): def test_authenticate_success(self): cs = client.Client("username", "password", "project_id", "auth_url") - management_url = 'https://servers.api.rackspacecloud.com/v1.1/443470' - auth_response = httplib2.Response({ - 'status': 204, - 'x-server-management-url': management_url, - 'x-auth-token': '1b751d74-de0c-46ae-84f0-915744b582d1', + management_url = 'https://localhost/v2.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, None)) + mock_request = mock.Mock(return_value=(auth_response)) - @mock.patch.object(httplib2.Http, "request", mock_request) + @mock.patch.object(requests, "request", mock_request) def test_auth_call(): cs.client.authenticate() headers = { @@ -250,21 +291,25 @@ def test_auth_call(): 'X-Auth-Project-Id': 'project_id', 'User-Agent': cs.client.USER_AGENT } - mock_request.assert_called_with(cs.client.auth_url, 'GET', - headers=headers) - self.assertEqual(cs.client.management_url, - auth_response['x-server-management-url']) - self.assertEqual(cs.client.auth_token, - auth_response['x-auth-token']) + 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 = httplib2.Response({'status': 401}) - mock_request = mock.Mock(return_value=(auth_response, None)) + auth_response = utils.TestResponse({"status_code": 401}) + mock_request = mock.Mock(return_value=(auth_response)) - @mock.patch.object(httplib2.Http, "request", mock_request) + @mock.patch.object(requests, "request", mock_request) def test_auth_call(): self.assertRaises(exceptions.Unauthorized, cs.client.authenticate) @@ -280,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() @@ -291,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/v3/test_availability_zone.py b/cinderclient/tests/unit/v3/test_availability_zone.py new file mode 100644 index 000000000..ebacf83ae --- /dev/null +++ b/cinderclient/tests/unit/v3/test_availability_zone.py @@ -0,0 +1,89 @@ +# 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. + +from cinderclient.v3 import availability_zones +from cinderclient.v3 import shell + +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.V3 + 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') + self._assert_request_id(zones) + + for zone in zones: + self.assertIsInstance(zone, + availability_zones.AvailabilityZone) + + self.assertEqual(2, len(zones)) + + l0 = ['zone-1', 'available'] + l1 = ['zone-2', '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') + self._assert_request_id(zones) + + for zone in zones: + self.assertIsInstance(zone, + availability_zones.AvailabilityZone) + + self.assertEqual(3, len(zones)) + + 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))) + + 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/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/v3/test_cgsnapshots.py b/cinderclient/tests/unit/v3/test_cgsnapshots.py new file mode 100644 index 000000000..5f0cec76c --- /dev/null +++ b/cinderclient/tests/unit/v3/test_cgsnapshots.py @@ -0,0 +1,95 @@ +# Copyright (C) 2012 - 2014 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 + + +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + + +class cgsnapshotsTest(utils.TestCase): + + def test_delete_cgsnapshot(self): + v = cs.cgsnapshots.list()[0] + vol = v.delete() + self._assert_request_id(vol) + cs.assert_called('DELETE', '/cgsnapshots/1234') + vol = cs.cgsnapshots.delete('1234') + cs.assert_called('DELETE', '/cgsnapshots/1234') + 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): + vol = cs.cgsnapshots.create('cgsnap') + cs.assert_called('POST', '/cgsnapshots') + self._assert_request_id(vol) + + def test_create_cgsnapshot_with_cg_id(self): + vol = cs.cgsnapshots.create('1234') + expected = {'cgsnapshot': {'status': 'creating', + 'description': None, + 'user_id': None, + 'name': None, + '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'}} + vol = v.update(name='cgs2') + cs.assert_called('PUT', '/cgsnapshots/1234', body=expected) + self._assert_request_id(vol) + vol = cs.cgsnapshots.update('1234', name='cgs2') + cs.assert_called('PUT', '/cgsnapshots/1234', body=expected) + 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): + lst = cs.cgsnapshots.list() + cs.assert_called('GET', '/cgsnapshots/detail') + self._assert_request_id(lst) + + def test_list_cgsnapshot_detailed_false(self): + lst = cs.cgsnapshots.list(detailed=False) + cs.assert_called('GET', '/cgsnapshots') + self._assert_request_id(lst) + + def test_list_cgsnapshot_with_search_opts(self): + 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): + 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' + 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/v3/test_consistencygroups.py b/cinderclient/tests/unit/v3/test_consistencygroups.py new file mode 100644 index 000000000..d265aabd3 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_consistencygroups.py @@ -0,0 +1,175 @@ +# Copyright (C) 2012 - 2014 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 + +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + + +class ConsistencygroupsTest(utils.TestCase): + + def test_delete_consistencygroup(self): + v = cs.consistencygroups.list()[0] + vol = v.delete(force='True') + self._assert_request_id(vol) + cs.assert_called('POST', '/consistencygroups/1234/delete') + vol = cs.consistencygroups.delete('1234', force=True) + self._assert_request_id(vol) + cs.assert_called('POST', '/consistencygroups/1234/delete') + vol = cs.consistencygroups.delete(v, force=True) + self._assert_request_id(vol) + cs.assert_called('POST', '/consistencygroups/1234/delete') + + def test_create_consistencygroup(self): + 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): + vol = cs.consistencygroups.create('type1,type2', 'cg') + expected = {'consistencygroup': {'status': 'creating', + 'description': None, + 'availability_zone': None, + 'user_id': None, + 'name': 'cg', + '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'}} + vol = v.update(name='cg2') + cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + self._assert_request_id(vol) + vol = cs.consistencygroups.update('1234', name='cg2') + cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + 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'}} + vol = v.update(description='cg2 desc') + cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + self._assert_request_id(vol) + vol = cs.consistencygroups.update('1234', description='cg2 desc') + cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + 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}} + vol = v.update(add_volumes=uuids) + cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + self._assert_request_id(vol) + vol = cs.consistencygroups.update('1234', add_volumes=uuids) + cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + 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}} + vol = v.update(remove_volumes=uuids) + cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + self._assert_request_id(vol) + vol = cs.consistencygroups.update('1234', remove_volumes=uuids) + cs.assert_called('PUT', '/consistencygroups/1234', body=expected) + 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.assertIsNone(cs.consistencygroups.update('1234')) + + def test_update_consistencygroup_no_props(self): + cs.consistencygroups.update('1234') + + def test_create_consistencygroup_from_src_snap(self): + vol = cs.consistencygroups.create_from_src('5678', None, name='cg') + expected = { + 'consistencygroup-from-src': { + 'status': 'creating', + 'description': None, + 'user_id': None, + 'name': 'cg', + 'cgsnapshot_id': '5678', + '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): + lst = cs.consistencygroups.list() + cs.assert_called('GET', '/consistencygroups/detail') + self._assert_request_id(lst) + + def test_list_consistencygroup_detailed_false(self): + lst = cs.consistencygroups.list(detailed=False) + cs.assert_called('GET', '/consistencygroups') + self._assert_request_id(lst) + + def test_list_consistencygroup_with_search_opts(self): + 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): + 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' + 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/v3/test_limits.py b/cinderclient/tests/unit/v3/test_limits.py new file mode 100644 index 000000000..a42f770fe --- /dev/null +++ b/cinderclient/tests/unit/v3/test_limits.py @@ -0,0 +1,181 @@ +# 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. + +from unittest import mock + +import ddt + +from cinderclient.tests.unit import utils +from cinderclient.v3 import limits + + +REQUEST_ID = 'req-test-request-id' + + +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): + limit = limits.Limits(None, {"foo": "bar"}, resp=REQUEST_ID) + self.assertEqual("", repr(limit)) + self._assert_request_id(limit) + + def test_absolute(self): + 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 limit.absolute: + self.assertIn(item, [l1, l2]) + self._assert_request_id(limit) + + def test_rate(self): + 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 limit.rate: + self.assertIn(item, [l1, l2]) + self._assert_request_id(limit) + + +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)) + + +@ddt.ddt +class TestLimitsManager(utils.TestCase): + @ddt.data(None, 'test') + def test_get(self, tenant_id): + 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(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 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/v3/test_pools.py b/cinderclient/tests/unit/v3/test_pools.py new file mode 100644 index 000000000..6af90578b --- /dev/null +++ b/cinderclient/tests/unit/v3/test_pools.py @@ -0,0 +1,48 @@ +# Copyright (C) 2015 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 import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3.pools import Pool + + +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + + +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")) + self.assertFalse(hasattr(s, "capabilities")) + # basic list should not have volume_backend_name (or any other + # entries from capabilities) + self.assertFalse(hasattr(s, "volume_backend_name")) + + 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) + self.assertTrue(hasattr(s, "name")) + self.assertFalse(hasattr(s, "capabilities")) + # detail list should have a volume_backend_name (from capabilities) + self.assertTrue(hasattr(s, "volume_backend_name")) diff --git a/cinderclient/tests/unit/v3/test_qos.py b/cinderclient/tests/unit/v3/test_qos.py new file mode 100644 index 000000000..f6133900a --- /dev/null +++ b/cinderclient/tests/unit/v3/test_qos.py @@ -0,0 +1,90 @@ +# 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 import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + + +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + + +class QoSSpecsTest(utils.TestCase): + + def test_create(self): + specs = dict(k1='v1', k2='v2') + 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' + 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): + lst = cs.qos_specs.list() + cs.assert_called('GET', '/qos-specs') + self._assert_request_id(lst) + + def test_delete(self): + 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' + 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']} + 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' + 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' + 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' + 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' + 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/v3/test_quota_classes.py b/cinderclient/tests/unit/v3/test_quota_classes.py new file mode 100644 index 000000000..29f4d0c23 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_quota_classes.py @@ -0,0 +1,69 @@ +# 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 api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + + +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + + +class QuotaClassSetsTest(utils.TestCase): + + def test_class_quotas_get(self): + class_name = 'test' + 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, + 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') + 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) + self.assertEqual(q.per_volume_gigabytes, q2.per_volume_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.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.per_volume_gigabytes, q2.per_volume_gigabytes) + self._assert_request_id(q) + self._assert_request_id(q2) diff --git a/cinderclient/tests/unit/v3/test_quotas.py b/cinderclient/tests/unit/v3/test_quotas.py new file mode 100644 index 000000000..e67c47764 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_quotas.py @@ -0,0 +1,90 @@ +# Copyright (c) 2017 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 api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes + + +cs = fakes.FakeClient(api_versions.APIVersion('3.0')) + + +class QuotaSetsTest(utils.TestCase): + + def test_tenant_quotas_get(self): + tenant_id = 'test' + 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' + 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') + q.update(volumes=2) + q.update(snapshots=2) + q.update(gigabytes=2000) + q.update(backups=2) + q.update(backup_gigabytes=2000) + 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') + q2 = cs.quotas.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) + self.assertEqual(q.per_volume_gigabytes, q2.per_volume_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.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.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' + 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/v3/test_services_base.py b/cinderclient/tests/unit/v3/test_services_base.py new file mode 100644 index 000000000..711a5361d --- /dev/null +++ b/cinderclient/tests/unit/v3/test_services_base.py @@ -0,0 +1,87 @@ +# 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 api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3 import services + + +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)) + 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') + 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] + self._assert_request_id(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] + self._assert_request_id(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] + self._assert_request_id(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) + self._assert_request_id(s) + + 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) + self._assert_request_id(s) + + 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) + 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/v3/test_snapshot_actions.py b/cinderclient/tests/unit/v3/test_snapshot_actions.py new file mode 100644 index 000000000..8d2b23a0d --- /dev/null +++ b/cinderclient/tests/unit/v3/test_snapshot_actions.py @@ -0,0 +1,58 @@ +# 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.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.V3 + data_fixture_class = snapshots.Fixture + + def test_update_snapshot_status(self): + snap = self.cs.volume_snapshots.get('1234') + self._assert_request_id(snap) + stat = {'status': 'available'} + 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%'} + 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/v3/test_type_access.py b/cinderclient/tests/unit/v3/test_type_access.py new file mode 100644 index 000000000..5b2dbb3e4 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_type_access.py @@ -0,0 +1,45 @@ +# 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.v3 import fakes +from cinderclient.v3 import volume_type_access + +cs = fakes.FakeClient() + +PROJECT_UUID = '11111111-1111-1111-111111111111' + + +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.assertIsInstance(a, volume_type_access.VolumeTypeAccess) + + def test_add_project_access(self): + 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): + 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/v3/test_types.py b/cinderclient/tests/unit/v3/test_types.py new file mode 100644 index 000000000..fdbd32317 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_types.py @@ -0,0 +1,127 @@ +# 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.v3 import fakes +from cinderclient.v3 import volume_types + +cs = fakes.FakeClient() + + +class TypesTest(utils.TestCase): + + def test_list_types(self): + tl = cs.volume_types.list() + 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): + 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') + cs.assert_called('POST', '/types', + {'volume_type': { + 'name': 'test-type-3', + 'description': 'test-type-3-desc', + '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) + cs.assert_called('POST', '/types', + {'volume_type': { + 'name': 'test-type-3', + 'description': 'test-type-3-desc', + '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', False) + cs.assert_called('PUT', + '/types/1', + {'volume_type': {'name': 'test_type_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) + 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_unset_keys(self): + t = cs.volume_types.get(1) + 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): + 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/v3/test_volume_encryption_types.py b/cinderclient/tests/unit/v3/test_volume_encryption_types.py new file mode 100644 index 000000000..9e38d5165 --- /dev/null +++ b/cinderclient/tests/unit/v3/test_volume_encryption_types.py @@ -0,0 +1,134 @@ +# 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.tests.unit import utils +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): + """ + 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?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): + """ + 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) + self._assert_request_id(encryption_type) + + 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') + self._assert_request_id(encryption_type) + + 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) + 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. + """ + 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): + """ + 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.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/v3/test_volumes_base.py b/cinderclient/tests/unit/v3/test_volumes_base.py new file mode 100644 index 000000000..cca808aac --- /dev/null +++ b/cinderclient/tests/unit/v3/test_volumes_base.py @@ -0,0 +1,326 @@ +# -*- 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. + +from cinderclient import api_versions +from cinderclient.tests.unit import utils +from cinderclient.tests.unit.v3 import fakes +from cinderclient.v3.volumes import Volume + +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): + lst = cs.volumes.list(marker=1234, limit=2) + cs.assert_called('GET', '/volumes/detail?limit=2&marker=1234') + self._assert_request_id(lst) + + def test__list(self): + # There only 2 volumes available for our tests, so we set limit to 2. + limit = 2 + url = "/volumes?limit=%s" % limit + response_key = "volumes" + fake_volume1234 = Volume(self, {'id': 1234, + 'name': 'sample-volume'}, + loaded=True) + fake_volume5678 = Volume(self, {'id': 5678, + 'name': 'sample-volume2'}, + loaded=True) + fake_volumes = [fake_volume1234, fake_volume5678] + # 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) + + # When we change the osapi_max_limit to 1, the next link should be + # generated. If limit equals 2 and id passed as an argument, we can + # still get 2 volumes back, because the method _list will fetch the + # volume from the next link. + 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] + del_v = v.delete() + cs.assert_called('DELETE', '/volumes/1234') + self._assert_request_id(del_v) + del_v = cs.volumes.delete('1234') + cs.assert_called('DELETE', '/volumes/1234') + self._assert_request_id(del_v) + del_v = cs.volumes.delete(v) + cs.assert_called('DELETE', '/volumes/1234') + self._assert_request_id(del_v) + + def test_attach(self): + v = cs.volumes.get('1234') + 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') + 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') + 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') + 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') + cs.volumes.begin_detaching(v) + cs.assert_called('POST', '/volumes/1234/action') + + def test_roll_detaching(self): + v = cs.volumes.get('1234') + 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') + 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') + 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): + vol = cs.volumes.set_metadata(1234, {'k1': 'v2', 'тест': 'тест'}) + cs.assert_called('POST', '/volumes/1234/metadata', + {'metadata': {'k1': 'v2', 'тест': 'тест'}}) + self._assert_request_id(vol) + + def test_delete_metadata(self): + keys = ['key1'] + 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') + 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): + 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') + 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): + 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') + 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') + 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') + 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): + 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): + 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') + 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_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_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): + vol = cs.volumes.get_pools('') + cs.assert_called('GET', '/scheduler-stats/get_pools') + self._assert_request_id(vol) + + def test_get_pools_detail(self): + 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.assertIsNone(cs.volumes._format_sort_param(s)) + + def test_format_sort_string_single_key(self): + s = 'id' + self.assertEqual('id', cs.volumes._format_sort_param(s)) + + def test_format_sort_string_single_key_and_dir(self): + s = 'id:asc' + self.assertEqual('id:asc', cs.volumes._format_sort_param(s)) + + def test_format_sort_string_multiple(self): + s = 'id:asc,status,size:desc' + self.assertEqual('id:asc,status,size:desc', + cs.volumes._format_sort_param(s)) + + def test_format_sort_string_mappings(self): + s = 'id:asc,name,size:desc' + self.assertEqual('id:asc,display_name,size:desc', + cs.volumes._format_sort_param(s)) + + def test_format_sort_whitespace_trailing_comma(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(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']]: + self.assertRaises(ValueError, + cs.volumes._format_sort_param, + s) diff --git a/cinderclient/utils.py b/cinderclient/utils.py index 52f4da906..565c61a3f 100644 --- a/cinderclient/utils.py +++ b/cinderclient/utils.py @@ -1,9 +1,24 @@ +# 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 collections import os -import re -import sys +from urllib import parse import uuid -import prettytable +import stevedore from cinderclient import exceptions @@ -16,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 @@ -37,44 +61,27 @@ def add_arg(f, *args, **kwargs): # NOTE(sirp): avoid dups that can occur when the module is shared across # tests. if (args, kwargs) not in f.arguments: - # Because of the sematics of decorator composition if we just append + # Because of the semantics of decorator composition if we just append # to the options list positional options will appear to be backwards. f.arguments.insert(0, (args, kwargs)) -def add_resource_manager_extra_kwargs_hook(f, hook): - """Adds hook to bind CLI arguments to ResourceManager calls. - - The `do_foo` calls in shell.py will receive CLI args and then in turn pass - them through to the ResourceManager. Before passing through the args, the - hooks registered here will be called, giving us a chance to add extra - kwargs (taken from the command-line) to what's passed to the - ResourceManager. - """ - if not hasattr(f, 'resource_manager_kwargs_hooks'): - f.resource_manager_kwargs_hooks = [] - - names = [h.__name__ for h in f.resource_manager_kwargs_hooks] - if hook.__name__ not in names: - f.resource_manager_kwargs_hooks.append(hook) - +def add_exclusive_arg(f, group_name, required, *args, **kwargs): + """Bind CLI mutally exclusive arguments to a shell.py `do_foo` function.""" -def get_resource_manager_extra_kwargs(f, args, allow_conflicts=False): - """Return extra_kwargs by calling resource manager kwargs hooks.""" - hooks = getattr(f, "resource_manager_kwargs_hooks", []) - extra_kwargs = {} - for hook in hooks: - hook_name = hook.__name__ - hook_kwargs = hook(args) + if not hasattr(f, 'exclusive_args'): + f.exclusive_args = collections.defaultdict(list) + # Default required to False + f.exclusive_args['__required__'] = collections.defaultdict(bool) - conflicting_keys = set(hook_kwargs.keys()) & set(extra_kwargs.keys()) - if conflicting_keys and not allow_conflicts: - raise Exception("Hook '%(hook_name)s' is attempting to redefine" - " attributes '%(conflicting_keys)s'" % locals()) - - extra_kwargs.update(hook_kwargs) - - return extra_kwargs + # 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): @@ -98,93 +105,79 @@ 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 get_service_type(f): - """ - Retrieves service type from function - """ - return getattr(f, 'service_type', None) +def build_query_param(params, sort=False): + """parse list to url query parameters""" + if not params: + return "" -def pretty_choice_list(l): - return ', '.join("'%s'" % i for i in l) + 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_list(objs, fields, formatters={}): - mixed_case_fields = ['serverId'] - pt = prettytable.PrettyTable([f for f in fields], caching=False) - pt.aligns = ['l' for f in fields] + # 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=", "~=") - 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(' ', '_') - data = getattr(o, field_name, '') - row.append(data) - pt.add_row(row) + if query_string: + query_string = "?%s" % (query_string,) - print pt.get_string(sortby=fields[0]) + return 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 d.iteritems()] - print pt.get_string(sortby=property) - - -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 - - # now try to get entity as uuid - try: - uuid.UUID(str(name_or_id)) - return manager.get(name_or_id) - except (ValueError, exceptions.NotFound): - pass + else: + # 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 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: - try: - # Volumes does not have name, but display_name - return manager.find(display_name=name_or_id) - except 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) + 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(), @@ -192,34 +185,9 @@ def find_resource(manager, name_or_id): raise exceptions.CommandError(msg) -def _format_servers_list_networks(server): - output = [] - for (network, addresses) in 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 find_volume(cs, volume): + """Get a volume by name or ID.""" + return find_resource(cs.volumes, volume) def safe_issubclass(*args): @@ -234,28 +202,19 @@ def safe_issubclass(*args): return False -def import_class(import_str): - """Returns a class from a string including module and class.""" - mod_str, _sep, class_str = import_str.rpartition('.') - __import__(mod_str) - return getattr(sys.modules[mod_str], class_str) - -_slugify_strip_re = re.compile(r'[^\w\s-]') -_slugify_hyphenate_re = re.compile(r'[-\s]+') +def _load_entry_point(ep_name, name=None): + """Try to load the entry point ep_name that matches name.""" + 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 -# http://code.activestate.com/recipes/ -# 577257-slugify-make-a-string-usable-in-a-url-or-filename/ -def slugify(value): - """ - Normalizes string, converts to lowercase, removes non-alpha characters, - and converts spaces to hyphens. - - From Django's "django/template/defaultfilters.py". - """ - import unicodedata - if not isinstance(value, unicode): - value = unicode(value) - value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore') - value = unicode(_slugify_strip_re.sub('', value).strip().lower()) - return _slugify_hyphenate_re.sub('-', value) +def get_function_name(func): + return "%s.%s" % (func.__module__, func.__qualname__) diff --git a/cinderclient/v1/client.py b/cinderclient/v1/client.py deleted file mode 100644 index a8d405ceb..000000000 --- a/cinderclient/v1/client.py +++ /dev/null @@ -1,72 +0,0 @@ -from cinderclient import client -from cinderclient.v1 import volumes -from cinderclient.v1 import volume_snapshots -from cinderclient.v1 import volume_types - - -class Client(object): - """ - Top-level object to access the OpenStack Compute API. - - Create an instance with your creds:: - - >>> client = Client(USERNAME, PASSWORD, PROJECT_ID, AUTH_URL) - - Then call methods on its managers:: - - >>> client.servers.list() - ... - >>> client.flavors.list() - ... - - """ - - # FIXME(jesse): project_id isn't required to authenticate - def __init__(self, username, api_key, project_id, auth_url, - insecure=False, timeout=None, proxy_tenant_id=None, - proxy_token=None, region_name=None, - endpoint_type='publicURL', extensions=None, - service_type='compute', service_name=None, - volume_service_name=None): - # FIXME(comstud): Rename the api_key argument above when we - # know it's not being used as keyword argument - password = api_key - - # extensions - self.volumes = volumes.VolumeManager(self) - self.volume_snapshots = volume_snapshots.SnapshotManager(self) - self.volume_types = volume_types.VolumeTypeManager(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.HTTPClient( - username, - password, - project_id, - auth_url, - insecure=insecure, - timeout=timeout, - proxy_token=proxy_token, - proxy_tenant_id=proxy_tenant_id, - region_name=region_name, - endpoint_type=endpoint_type, - service_type=service_type, - service_name=service_name, - volume_service_name=volume_service_name) - - 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() diff --git a/cinderclient/v1/shell.py b/cinderclient/v1/shell.py deleted file mode 100644 index 6dbe1d751..000000000 --- a/cinderclient/v1/shell.py +++ /dev/null @@ -1,297 +0,0 @@ -# Copyright 2010 Jacob Kaplan-Moss - -# Copyright 2011 OpenStack LLC. -# 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 -import sys -import time - -from cinderclient import utils - - -def _poll_for_status(poll_fn, obj_id, action, final_ok_states, - poll_period=5, show_progress=True): - """Block while an action is being performed, periodically printing - 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" % locals() - break - else: - print_progress(progress) - time.sleep(poll_period) - - -def _find_volume(cs, volume): - """Get a volume by ID.""" - return utils.find_resource(cs.volumes, volume) - - -def _find_volume_snapshot(cs, snapshot): - """Get a volume snapshot by ID.""" - return utils.find_resource(cs.volume_snapshots, snapshot) - - -def _print_volume(cs, volume): - utils.print_dict(volume._info) - - -def _print_volume_snapshot(cs, snapshot): - utils.print_dict(snapshot._info) - - -def _translate_volume_keys(collection): - convert = [('displayName', 'display_name'), ('volumeType', 'volume_type')] - for item in collection: - keys = item.__dict__.keys() - 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_snapshot_keys(collection): - convert = [('displayName', 'display_name'), ('volumeId', 'volume_id')] - for item in collection: - keys = item.__dict__.keys() - 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 _extract_metadata(arg_list): - metadata = {} - for metadatum in arg_list: - assert(metadatum.find('=') > -1), "Improperly formatted metadata "\ - "input (%s)" % metadatum - (key, value) = metadatum.split('=', 1) - metadata[key] = value - - return metadata - - -@utils.arg('--all_tenants', - dest='all_tenants', - metavar='<0|1>', - nargs='?', - type=int, - const=1, - default=0, - help='Display information from all tenants (Admin only).') -@utils.service_type('volume') -def do_list(cs, args): - """List all the volumes.""" - all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) - search_opts = {'all_tenants': all_tenants} - volumes = cs.volumes.list(search_opts=search_opts) - _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))) - utils.print_list(volumes, ['ID', 'Status', 'Display Name', - 'Size', 'Volume Type', 'Attached to']) - - -@utils.arg('volume', metavar='', help='ID of the volume.') -@utils.service_type('volume') -def do_show(cs, args): - """Show details about a volume.""" - volume = _find_volume(cs, args.volume) - _print_volume(cs, volume) - - -@utils.arg('size', - metavar='', - type=int, - help='Size of volume in GB') -@utils.arg( - '--snapshot_id', - metavar='', - help='Optional snapshot id to create the volume from. (Default=None)', - default=None) -@utils.arg('--image_id', - metavar='', - help='Optional image id to create the volume from. (Default=None)', - default=None) -@utils.arg('--display_name', metavar='', - help='Optional volume name. (Default=None)', - default=None) -@utils.arg('--display_description', metavar='', - help='Optional volume description. (Default=None)', - default=None) -@utils.arg('--volume_type', - metavar='', - help='Optional volume type. (Default=None)', - default=None) -@utils.arg('--availability_zone', metavar='', - help='Optional availability zone for volume. (Default=None)', - default=None) -@utils.arg('--metadata', - type=str, - nargs='*', - metavar='', - help='Optional metadata kv pairs. (Default=None)', - default=None) -@utils.service_type('volume') -def do_create(cs, args): - """Add a new volume.""" - - volume_metadata = None - if args.metadata is not None: - volume_metadata = _extract_metadata(args.metadata) - - cs.volumes.create(args.size, - args.snapshot_id, - args.display_name, - args.display_description, - args.volume_type, - availability_zone=args.availability_zone, - imageRef=args.image_id, - metadata=volume_metadata) - - -@utils.arg('volume', metavar='', help='ID of the volume to delete.') -@utils.service_type('volume') -def do_delete(cs, args): - """Remove a volume.""" - volume = _find_volume(cs, args.volume) - volume.delete() - - -@utils.arg('--all_tenants', - dest='all_tenants', - metavar='<0|1>', - nargs='?', - type=int, - const=1, - default=0, - help='Display information from all tenants (Admin only).') -@utils.service_type('volume') -def do_snapshot_list(cs, args): - """List all the snapshots.""" - all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) - search_opts = {'all_tenants': all_tenants} - - 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='ID of the snapshot.') -@utils.service_type('volume') -def do_snapshot_show(cs, args): - """Show details about a snapshot.""" - snapshot = _find_volume_snapshot(cs, args.snapshot) - _print_volume_snapshot(cs, snapshot) - - -@utils.arg('volume_id', - metavar='', - help='ID of the volume to snapshot') -@utils.arg('--force', - metavar='', - help='Optional flag to indicate whether ' - 'to snapshot a volume even if its ' - 'attached to an instance. (Default=False)', - default=False) -@utils.arg('--display_name', metavar='', - help='Optional snapshot name. (Default=None)', - default=None) -@utils.arg('--display_description', metavar='', - help='Optional snapshot description. (Default=None)', - default=None) -@utils.service_type('volume') -def do_snapshot_create(cs, args): - """Add a new snapshot.""" - cs.volume_snapshots.create(args.volume_id, - args.force, - args.display_name, - args.display_description) - - -@utils.arg('snapshot_id', - metavar='', - help='ID of the snapshot to delete.') -@utils.service_type('volume') -def do_snapshot_delete(cs, args): - """Remove a snapshot.""" - snapshot = _find_volume_snapshot(cs, args.snapshot_id) - snapshot.delete() - - -def _print_volume_type_list(vtypes): - utils.print_list(vtypes, ['ID', 'Name']) - - -@utils.service_type('volume') -def do_type_list(cs, args): - """Print a list of available 'volume types'.""" - vtypes = cs.volume_types.list() - _print_volume_type_list(vtypes) - - -@utils.arg('name', - metavar='', - help="Name of the new flavor") -@utils.service_type('volume') -def do_type_create(cs, args): - """Create a new volume type.""" - vtype = cs.volume_types.create(args.name) - _print_volume_type_list([vtype]) - - -@utils.arg('id', - metavar='', - help="Unique ID of the volume type to delete") -@utils.service_type('volume') -def do_type_delete(cs, args): - """Delete a specific flavor""" - cs.volume_types.delete(args.id) - - -def do_endpoints(cs, args): - """Discover endpoints that get returned from the authenticate services""" - catalog = cs.client.service_catalog.catalog - for e in catalog['access']['serviceCatalog']: - utils.print_dict(e['endpoints'][0], e['name']) - - -def do_credentials(cs, args): - """Show user credentials returned from auth""" - catalog = cs.client.service_catalog.catalog - utils.print_dict(catalog['access']['user'], "User Credentials") - utils.print_dict(catalog['access']['token'], "Token") diff --git a/cinderclient/v1/volume_snapshots.py b/cinderclient/v1/volume_snapshots.py deleted file mode 100644 index f283fdc9e..000000000 --- a/cinderclient/v1/volume_snapshots.py +++ /dev/null @@ -1,110 +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). -""" - -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) - - @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') - - -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 search_opts.iteritems(): - if val: - qparams[opt] = val - - query_string = "?%s" % urllib.urlencode(qparams) if qparams else "" - - 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)) diff --git a/cinderclient/v1/volume_types.py b/cinderclient/v1/volume_types.py deleted file mode 100644 index e6d644df4..000000000 --- a/cinderclient/v1/volume_types.py +++ /dev/null @@ -1,77 +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 - - -class VolumeTypeManager(base.ManagerWithFind): - """ - Manage :class:`VolumeType` resources. - """ - resource_class = VolumeType - - def list(self): - """ - 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 ID of the :class:`VolumeType` to get. - """ - self._delete("/types/%s" % base.getid(volume_type)) - - def create(self, name): - """ - Create 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 fdec34738..000000000 --- a/cinderclient/v1/volumes.py +++ /dev/null @@ -1,288 +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). -""" - -import urllib -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 attach(self, instance_uuid, mountpoint): - """ - Set attachment metadata. - - :param instance_uuid: uuid of the attaching instance. - :param mountpoint: mountpoint on the attaching instance. - """ - return self.manager.attach(self, instance_uuid, mountpoint) - - 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 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) - - -class VolumeManager(base.ManagerWithFind): - """ - Manage :class:`Volume` resources. - """ - resource_class = Volume - - def create(self, size, snapshot_id=None, - display_name=None, display_description=None, - volume_type=None, user_id=None, - project_id=None, availability_zone=None, - metadata=None, imageRef=None): - """ - Create 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 - :rtype: :class:`Volume` - :param user_id: User id derived from context - :param project_id: Project id derived from context - :param availability_zone: Availability Zone to use - :param metadata: Optional metadata to set on volume creation - :param imageRef: reference to an image stored in glance - """ - - if volume_type is None: - volume_type_id = None - else: - volume_type_id = volume_type.get('id', None) - - 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_id': volume_type_id, - 'user_id': user_id, - 'project_id': project_id, - 'availability_zone': availability_zone, - 'status': "creating", - 'attach_status': "detached", - 'metadata': volume_metadata, - 'imageRef': imageRef, - }} - return self._create('/volumes', body, 'volume') - - def get(self, volume_id): - """ - Get a volume. - - :param volume_id: The ID of the volume to delete. - :rtype: :class:`Volume` - """ - return self._get("/volumes/%s" % volume_id, "volume") - - def list(self, detailed=True, search_opts=None): - """ - Get a list of all volumes. - - :rtype: list of :class:`Volume` - """ - if search_opts is None: - search_opts = {} - - qparams = {} - - for opt, val in search_opts.iteritems(): - if val: - qparams[opt] = val - - query_string = "?%s" % urllib.urlencode(qparams) if qparams else "" - - 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 create_server_volume(self, server_id, volume_id, device): - """ - Attach a volume identified by the volume ID to the given server ID - - :param server_id: The ID of the server - :param volume_id: The ID of the volume to attach. - :param device: The device name - :rtype: :class:`Volume` - """ - body = {'volumeAttachment': {'volumeId': volume_id, - 'device': device}} - return self._create("/servers/%s/os-volume_attachments" % server_id, - body, "volumeAttachment") - - def get_server_volume(self, server_id, attachment_id): - """ - Get the volume identified by the attachment ID, that is attached to - the given server ID - - :param server_id: The ID of the server - :param attachment_id: The ID of the attachment - :rtype: :class:`Volume` - """ - return self._get("/servers/%s/os-volume_attachments/%s" % (server_id, - attachment_id,), "volumeAttachment") - - def get_server_volumes(self, server_id): - """ - Get a list of all the attached volumes for the given server ID - - :param server_id: The ID of the server - :rtype: list of :class:`Volume` - """ - return self._list("/servers/%s/os-volume_attachments" % server_id, - "volumeAttachments") - - def delete_server_volume(self, server_id, attachment_id): - """ - Detach a volume identified by the attachment ID from the given server - - :param server_id: The ID of the server - :param attachment_id: The ID of the attachment - """ - self._delete("/servers/%s/os-volume_attachments/%s" % - (server_id, attachment_id,)) - - 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): - """ - 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. - """ - return self._action('os-attach', - volume, - {'instance_uuid': instance_uuid, - 'mountpoint': mountpoint}) - - 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 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}) diff --git a/cinderclient/v1/__init__.py b/cinderclient/v3/__init__.py similarity index 86% rename from cinderclient/v1/__init__.py rename to cinderclient/v3/__init__.py index cecfacd23..714e3f573 100644 --- a/cinderclient/v1/__init__.py +++ b/cinderclient/v3/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2012 OpenStack, LLC. +# Copyright (c) 2013 OpenStack Foundation # # All Rights Reserved. # @@ -14,4 +14,4 @@ # License for the specific language governing permissions and limitations # under the License. -from cinderclient.v1.client import Client +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/v3/availability_zones.py b/cinderclient/v3/availability_zones.py new file mode 100644 index 000000000..db6b8da26 --- /dev/null +++ b/cinderclient/v3/availability_zones.py @@ -0,0 +1,42 @@ +# 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 (v3 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/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/v3/cgsnapshots.py b/cinderclient/v3/cgsnapshots.py new file mode 100644 index 000000000..1f5abc6e3 --- /dev/null +++ b/cinderclient/v3/cgsnapshots.py @@ -0,0 +1,112 @@ +# Copyright (C) 2012 - 2014 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. + +"""cgsnapshot interface (v3 extension).""" + +from cinderclient.apiclient import base as common_base +from cinderclient import base +from cinderclient import utils + + +class Cgsnapshot(base.Resource): + """A cgsnapshot is snapshot of a consistency group.""" + def __repr__(self): + return "" % self.id + + def delete(self): + """Delete this cgsnapshot.""" + return self.manager.delete(self) + + def update(self, **kwargs): + """Update the name or description for this cgsnapshot.""" + return self.manager.update(self, **kwargs) + + +class CgsnapshotManager(base.ManagerWithFind): + """Manage :class:`Cgsnapshot` resources.""" + resource_class = Cgsnapshot + + def create(self, consistencygroup_id, name=None, description=None, + user_id=None, + project_id=None): + """Creates a cgsnapshot. + + :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 + :param project_id: Project id derived from context + :rtype: :class:`Cgsnapshot` + """ + + body = {'cgsnapshot': {'consistencygroup_id': consistencygroup_id, + 'name': name, + 'description': description, + 'user_id': user_id, + 'project_id': project_id, + 'status': "creating", + }} + + return self._create('/cgsnapshots', body, 'cgsnapshot') + + def get(self, cgsnapshot_id): + """Get a cgsnapshot. + + :param cgsnapshot_id: The ID of the cgsnapshot to get. + :rtype: :class:`Cgsnapshot` + """ + return self._get("/cgsnapshots/%s" % cgsnapshot_id, "cgsnapshot") + + def list(self, detailed=True, search_opts=None): + """Lists all cgsnapshots. + + :rtype: list of :class:`Cgsnapshot` + """ + query_string = utils.build_query_param(search_opts) + + detail = "" + if detailed: + detail = "/detail" + + return self._list("/cgsnapshots%s%s" % (detail, query_string), + "cgsnapshots") + + def delete(self, cgsnapshot): + """Delete a cgsnapshot. + + :param cgsnapshot: The :class:`Cgsnapshot` to delete. + """ + return self._delete("/cgsnapshots/%s" % base.getid(cgsnapshot)) + + def update(self, cgsnapshot, **kwargs): + """Update the name or description for a cgsnapshot. + + :param cgsnapshot: The :class:`Cgsnapshot` to update. + """ + if not kwargs: + return + + body = {"cgsnapshot": kwargs} + + return self._update("/cgsnapshots/%s" % base.getid(cgsnapshot), body) + + def _action(self, action, cgsnapshot, info=None, **kwargs): + """Perform a cgsnapshot "action." + """ + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/cgsnapshots/%s/action' % base.getid(cgsnapshot) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v3/client.py b/cinderclient/v3/client.py new file mode 100644 index 000000000..8ecaf0069 --- /dev/null +++ b/cinderclient/v3/client.py @@ -0,0 +1,154 @@ +# 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 api_versions +from cinderclient import client +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): + """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='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) + + 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: + 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, + 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): + """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/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/v3/consistencygroups.py b/cinderclient/v3/consistencygroups.py new file mode 100644 index 000000000..13bc2eefb --- /dev/null +++ b/cinderclient/v3/consistencygroups.py @@ -0,0 +1,149 @@ +# Copyright (C) 2012 - 2014 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. + +"""Consistencygroup interface (v3 extension).""" + +from cinderclient.apiclient import base as common_base +from cinderclient import base +from cinderclient import utils + + +class Consistencygroup(base.Resource): + """A Consistencygroup of volumes.""" + def __repr__(self): + return "" % self.id + + def delete(self, force='False'): + """Delete this consistency group.""" + return self.manager.delete(self, force) + + def update(self, **kwargs): + """Update the name or description for this consistency group.""" + return self.manager.update(self, **kwargs) + + +class ConsistencygroupManager(base.ManagerWithFind): + """Manage :class:`Consistencygroup` resources.""" + resource_class = Consistencygroup + + def create(self, volume_types, name=None, + description=None, user_id=None, + project_id=None, availability_zone=None): + """Creates a consistency group. + + :param name: Name of the ConsistencyGroup + :param description: Description of the ConsistencyGroup + :param volume_types: Types 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 + :rtype: :class:`Consistencygroup` + """ + + body = {'consistencygroup': {'name': name, + 'description': description, + 'volume_types': volume_types, + 'user_id': user_id, + 'project_id': project_id, + 'availability_zone': availability_zone, + 'status': "creating", + }} + + return self._create('/consistencygroups', body, 'consistencygroup') + + def create_from_src(self, cgsnapshot_id, source_cgid, name=None, + description=None, user_id=None, + project_id=None): + """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: 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", + }} + + self.run_hooks('modify_body_for_update', body, + 'consistencygroup-from-src') + resp, body = self.api.client.post( + "/consistencygroups/create_from_src", body=body) + return common_base.DictWithMeta(body['consistencygroup'], resp) + + def get(self, group_id): + """Get a consistency group. + + :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 consistency groups. + + :rtype: list of :class:`Consistencygroup` + """ + + query_string = utils.build_query_param(search_opts) + + detail = "" + if detailed: + detail = "/detail" + + return self._list("/consistencygroups%s%s" % (detail, query_string), + "consistencygroups") + + def delete(self, consistencygroup, force=False): + """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) + 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 consistency group. + + :param Consistencygroup: The :class:`Consistencygroup` to update. + """ + if not kwargs: + return + + body = {"consistencygroup": kwargs} + + return self._update("/consistencygroups/%s" % + base.getid(consistencygroup), body) + + def _action(self, action, consistencygroup, info=None, **kwargs): + """Perform a consistency group "action." + """ + body = {action: info} + self.run_hooks('modify_body_for_action', body, **kwargs) + url = '/consistencygroups/%s/action' % base.getid(consistencygroup) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v3/contrib/__init__.py b/cinderclient/v3/contrib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cinderclient/v3/contrib/list_extensions.py b/cinderclient/v3/contrib/list_extensions.py new file mode 100644 index 000000000..548cbec46 --- /dev/null +++ b/cinderclient/v3/contrib/list_extensions.py @@ -0,0 +1,44 @@ +# 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 base +from cinderclient import shell_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') + + +def do_list_extensions(client, _args): + """Lists all available os-api extensions.""" + extensions = client.list_extensions.show_all() + fields = ["Name", "Summary", "Alias", "Updated"] + 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/v3/limits.py b/cinderclient/v3/limits.py new file mode 100644 index 000000000..69f053f11 --- /dev/null +++ b/cinderclient/v3/limits.py @@ -0,0 +1,98 @@ +# Copyright 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 cinderclient import base +from cinderclient import utils + + +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, tenant_id=None): + """Get a specific extension. + + :rtype: :class:`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/v3/pools.py b/cinderclient/v3/pools.py new file mode 100644 index 000000000..5303f8422 --- /dev/null +++ b/cinderclient/v3/pools.py @@ -0,0 +1,60 @@ +# Copyright (C) 2015 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. + +"""Pools interface (v3 extension)""" + +from cinderclient import base + + +class Pool(base.Resource): + NAME_ATTR = 'name' + + def __repr__(self): + return "" % self.name + + +class PoolManager(base.Manager): + """Manage :class:`Pool` resources.""" + resource_class = Pool + + def list(self, detailed=False): + """Lists all + + :rtype: list of :class:`Pool` + """ + if detailed is True: + pools = self._list("/scheduler-stats/get_pools?detail=True", + "pools") + # Other than the name, all of the pool data is buried below in + # a 'capabilities' dictionary. In order to be consistent with the + # get-pools command line, these elements are moved up a level to + # be attributes of the pool itself. + for pool in pools: + if hasattr(pool, 'capabilities'): + for k, v in pool.capabilities.items(): + setattr(pool, k, v) + + # Remove the capabilities dictionary since all of its + # elements have been copied up to the containing pool + del pool.capabilities + return pools + else: + pools = self._list("/scheduler-stats/get_pools", "pools") + + # avoid cluttering the basic pool list with capabilities dict + for pool in pools: + if hasattr(pool, 'capabilities'): + del pool.capabilities + return pools diff --git a/cinderclient/v3/qos_specs.py b/cinderclient/v3/qos_specs.py new file mode 100644 index 000000000..972316482 --- /dev/null +++ b/cinderclient/v3/qos_specs.py @@ -0,0 +1,156 @@ +# Copyright (c) 2013 eBay Inc. +# 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. +# 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.apiclient import base as common_base +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, search_opts=None): + """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. + """ + return 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): + """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 + :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): + """Remove keys from a qos specs. + + :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 + """ + 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. + + :param qos_specs: The qos specs to be associated with + :param vol_type_id: The volume type id to be associated with + """ + 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 + """ + 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/v3/quota_classes.py b/cinderclient/v3/quota_classes.py new file mode 100644 index 000000000..1958fa133 --- /dev/null +++ b/cinderclient/v3/quota_classes.py @@ -0,0 +1,47 @@ +# 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 base + + +class QuotaClassSet(base.Resource): + + @property + def id(self): + """Needed by base.Resource to self-refresh and be indexed.""" + return self.class_name + + def update(self, *args, **kwargs): + return 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): + quota_class_set = {} + + for update in updates: + quota_class_set[update] = updates[update] + + 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/v3/quotas.py b/cinderclient/v3/quotas.py new file mode 100644 index 000000000..1295f6064 --- /dev/null +++ b/cinderclient/v3/quotas.py @@ -0,0 +1,61 @@ +# 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 base + + +class QuotaSet(base.Resource): + + @property + def id(self): + """Needed by base.Resource to self-refresh and be indexed.""" + 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): + skip_validation = updates.pop('skip_validation', True) + + body = {'quota_set': {'tenant_id': tenant_id}} + for update in updates: + body['quota_set'][update] = updates[update] + + 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, + '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/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/v3/shell_base.py b/cinderclient/v3/shell_base.py new file mode 100644 index 000000000..25d99bb0f --- /dev/null +++ b/cinderclient/v3/shell_base.py @@ -0,0 +1,2436 @@ +# 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 copy +import os + +from oslo_utils import strutils + +from cinderclient import base +from cinderclient import exceptions +from cinderclient import shell_utils +from cinderclient import utils +from cinderclient.v3 import availability_zones + + +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', + 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.') +@utils.arg('--display-name', + help=argparse.SUPPRESS) +@utils.arg('--status', + 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', + nargs='*', + metavar='', + default=None, + help='Filters results by a image metadata key and value pair. ' + 'Default=None.') +@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).') +def do_list(cs, args): + """Lists all volumes.""" + # NOTE(thingee): Backwards-compatibility with v1 args + if args.display_name is not None: + args.name = args.display_name + + 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), + } + + # 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=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 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 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) + + +@utils.arg('volume', + metavar='', + help='Name or ID of volume.') +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) + 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 ((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) + + +@utils.arg('size', + metavar='', + nargs='?', + type=int, + action=CheckSizeArgForCreate, + help='Size of volume, in GiBs. (Required unless ' + 'snapshot-id/source-volid 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('--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('--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.') +@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.') +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 + + volume = cs.volumes.create(args.size, + args.consisgroup_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) + + 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) + 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.') +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(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)) + 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 or volumes to delete.') +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.') +@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", "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=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. + + Note that this does not affect whether the volume is actually attached to + the Nova compute host or instance and can result in an unusable volume. + Being a database change only, this has no impact on the true state of the + volume and may not match the actual state. This can render a volume + 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, + args.attach_status, + migration_status) + 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('name', + nargs='?', + metavar='', + help='New name for volume.') +@utils.arg('--description', metavar='', + help='Volume description. Default=None.', + default=None) +@utils.arg('--display-description', + help=argparse.SUPPRESS) +@utils.arg('--display_description', + help=argparse.SUPPRESS) +def do_rename(cs, args): + """Renames a volume.""" + kwargs = {} + + if args.name is not None: + kwargs['name'] = args.name + if args.display_description is not None: + kwargs['description'] = args.display_description + elif args.description is not None: + kwargs['description'] = args.description + + if not any(kwargs): + msg = 'Must supply either name or description.' + raise exceptions.ClientException(code=1, message=msg) + + utils.find_volume(cs, args.volume).update(**kwargs) + + +@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_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)) + + +@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>', + 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('--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.') +@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 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 = (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, + 'name': args.name, + 'status': args.status, + 'volume_id': args.volume_id, + 'project_id': args.tenant, + } + + 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.') +def do_snapshot_show(cs, args): + """Shows snapshot details.""" + 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.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: + 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)) + 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('name', nargs='?', metavar='', + help='New name for snapshot.') +@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) +def do_snapshot_rename(cs, args): + """Renames a snapshot.""" + kwargs = {} + + if args.name is not None: + kwargs['name'] = args.name + + if args.description is not None: + kwargs['description'] = args.description + elif args.display_description is not None: + kwargs['description'] = args.display_description + + if not any(kwargs): + msg = 'Must supply either name or description.' + raise exceptions.ClientException(code=1, message=msg) + + 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='+', + 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.')) +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: + 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) + 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 do_type_list(cs, args): + """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) + + +def do_type_default(cs, args): + """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() + 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', + metavar='', + help='ID of the volume type.') +@utils.arg('--name', + metavar='', + help='Name of the volume type.') +@utils.arg('--description', + metavar='', + help='Description of the volume type.') +@utils.arg('--is-public', + metavar='', + help='Make type accessible to the public or not.') +def do_type_update(cs, args): + """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]) + + +def do_extra_specs_list(cs, args): + """Lists current volume types and extra specs.""" + vtypes = cs.volume_types.list() + shell_utils.print_list(vtypes, ['ID', 'Name', 'extra_specs']) + + +@utils.arg('name', + metavar='', + help='Name of new volume type.') +@utils.arg('--description', + metavar='', + help='Description of new volume type.') +@utils.arg('--is-public', + metavar='', + default=True, + help='Make type accessible to the public (default true).') +def do_type_create(cs, args): + """Creates a volume type.""" + is_public = strutils.bool_from_string(args.is_public, strict=True) + vtype = cs.volume_types.create(args.name, args.description, is_public) + shell_utils.print_volume_type_list([vtype]) + + +@utils.arg('vol_type', + metavar='', nargs='+', + help='Name or ID of volume type or types to delete.') +def do_type_delete(cs, args): + """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', + 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=[], + help='The extra specs key and value pair to set or unset. ' + 'For unset, specify only the key.') +def do_type_key(cs, args): + """Sets or unsets extra_spec for a volume type.""" + vtype = shell_utils.find_volume_type(cs, args.vtype) + keypair = shell_utils.extract_metadata(args) + + if args.action == 'set': + vtype.set_keys(keypair) + elif args.action == 'unset': + vtype.unset_keys(list(keypair)) + + +@utils.arg('--volume-type', metavar='', required=True, + help='Filter results by volume type name or ID.') +def do_type_access_list(cs, args): + """Print access information about the given 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'] + 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.') +def do_type_access_add(cs, args): + """Adds volume type access for the given project.""" + vtype = shell_utils.find_volume_type(cs, args.volume_type) + cs.volume_type_access.add_project_access(vtype, args.project_id) + + +@utils.arg('--volume-type', metavar='', required=True, + help=('Volume type name or ID to remove access ' + 'for the given project.')) +@utils.arg('--project-id', metavar='', required=True, + help='Project ID to remove volume type access for.') +def do_type_access_remove(cs, args): + """Removes volume type access for the given project.""" + vtype = shell_utils.find_volume_type(cs, args.volume_type) + cs.volume_type_access.remove_project_access( + vtype, args.project_id) + + +@utils.arg('tenant', + metavar='', + help='ID of tenant for which to list quotas.') +def do_quota_show(cs, args): + """Lists quotas for a tenant.""" + + shell_utils.quota_show(cs.quotas.get(args.tenant)) + + +@utils.arg('tenant', metavar='', + help='ID of tenant for which to list quota usage.') +def do_quota_usage(cs, args): + """Lists quota usage for a tenant.""" + + 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.') +def do_quota_defaults(cs, args): + """Lists default quotas for a tenant.""" + + shell_utils.quota_show(cs.quotas.defaults(args.tenant)) + + +@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('--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.') +def do_quota_update(cs, args): + """Updates quotas for a tenant.""" + + shell_utils.quota_update(cs.quotas, args.tenant, args) + + +@utils.arg('tenant', metavar='', + help='UUID of tenant to delete the quotas for.') +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.') +def do_quota_class_show(cs, args): + """Lists quotas for a quota class.""" + + shell_utils.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('--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.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.""" + + shell_utils.quota_update(cs.quota_classes, args.class_name, args) + + +@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(args.tenant).absolute + columns = ['Name', 'Value'] + shell_utils.print_list(limits, columns) + + +@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(args.tenant).rate + columns = ['Verb', 'URI', 'Value', 'Remain', 'Unit', 'Next_Available'] + shell_utils.print_list(limits, columns) + + +@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) +def do_upload_to_image(cs, args): + """Uploads volume to Image Service as an image.""" + volume = utils.find_volume(cs, args.volume) + 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. Takes the form: ' + 'host@backend-name#pool') +@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) + 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='', + help='Name or ID of volume for which to modify type.') +@utils.arg('new_type', metavar='', help='New volume type.') +@utils.arg('--migration-policy', metavar='', required=False, + choices=['never', 'on-demand'], default='never', + help='Migration policy during retype of volume.') +def do_retype(cs, args): + """Changes the volume type for a volume.""" + volume = utils.find_volume(cs, args.volume) + volume.retype(args.new_type, args.migration_policy) + + +@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.') +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 + + 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) + + info = {"volume_id": volume.id} + info.update(backup._info) + + if 'links' in info: + info.pop('links') + + shell_utils.print_dict(info) + + +@utils.arg('backup', metavar='', help='Name or ID of backup.') +def do_backup_show(cs, args): + """Shows backup details.""" + backup = shell_utils.find_backup(cs, args.backup) + info = dict() + info.update(backup._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@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.""" + + 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'] + if args.sort: + sortby_index = None + else: + sortby_index = 0 + shell_utils.print_list(backups, columns, sortby_index=sortby_index) + + +@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 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='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 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.') +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 + + 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.') +def do_backup_export(cs, args): + """Export backup metadata record.""" + info = cs.backups.export_record(args.backup) + 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.') +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) + + 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='', + 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) +def do_transfer_create(cs, args): + """Creates a volume transfer.""" + if args.display_name is not None: + args.name = args.display_name + + volume = utils.find_volume(cs, args.volume) + transfer = cs.transfers.create(volume.id, + args.name) + info = dict() + info.update(transfer._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('transfer', metavar='', nargs='+', + help='Name or ID of transfer to delete.') +def do_transfer_delete(cs, args): + """Undoes a transfer.""" + 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.') +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) + + 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) +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'] + shell_utils.print_list(transfers, columns) + + +@utils.arg('transfer', metavar='', + help='Name or ID of transfer to accept.') +def do_transfer_show(cs, args): + """Shows transfer details.""" + transfer = shell_utils.find_transfer(cs, args.transfer) + info = dict() + info.update(transfer._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('volume', metavar='', + help='Name or ID of volume to extend.') +@utils.arg('new_size', + metavar='', + type=int, + 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) + 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.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") + shell_utils.print_list(result, columns) + + +@utils.arg('host', metavar='', help='Host name.') +@utils.arg('binary', metavar='', help='Service binary.') +def do_service_enable(cs, args): + """Enables the service.""" + result = cs.services.enable(args.host, args.binary) + columns = ["Host", "Binary", "Status"] + 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.') +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) + shell_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 + + +def do_availability_zone_list(cs, _args): + """Lists all availability zones.""" + try: + availability_zones = cs.availability_zones.list() + except exceptions.Forbidden: # policy doesn't allow probably + try: + availability_zones = cs.availability_zones.list(detailed=False) + except Exception: + raise + + result = [] + for zone in availability_zones: + result += treeizeAvailabilityZone(zone) + shell_utils.translate_availability_zone_keys(result) + shell_utils.print_list(result, ['Name', 'Status']) + + +def do_encryption_type_list(cs, args): + """Shows encryption type details for volume types. Admin only.""" + result = cs.volume_encryption_types.list() + shell_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.') +def do_encryption_type_show(cs, args): + """Shows encryption type details for a volume type. Admin only.""" + 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'): + shell_utils.print_volume_encryption_type_list([result]) + else: + shell_utils.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 encryption provider format. ' + 'For example, "luks" or "plain".') +@utils.arg('--cipher', + metavar='', + type=str, + required=False, + default=None, + help='The encryption algorithm or 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('--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.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 = shell_utils.find_volume_type(cs, args.volume_type) + + 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) + 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.') +def do_encryption_type_delete(cs, args): + """Deletes encryption type for a volume type. Admin only.""" + volume_type = shell_utils.find_volume_type(cs, args.volume_type) + cs.volume_encryption_types.delete(volume_type) + + +@utils.arg('name', + metavar='', + help='Name of new QoS specifications.') +@utils.arg('metadata', + metavar='', + nargs='+', + default=[], + help='QoS specifications.') +def do_qos_create(cs, args): + """Creates a qos specs.""" + keypair = None + if args.metadata is not None: + keypair = shell_utils.extract_metadata(args) + qos_specs = cs.qos_specs.create(args.name, keypair) + shell_utils.print_qos_specs(qos_specs) + + +def do_qos_list(cs, args): + """Lists qos specs.""" + qos_specs = cs.qos_specs.list() + shell_utils.print_qos_specs_list(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of QoS specifications to show.') +def do_qos_show(cs, args): + """Shows qos specs details.""" + qos_specs = shell_utils.find_qos_specs(cs, args.qos_specs) + shell_utils.print_qos_specs(qos_specs) + + +@utils.arg('qos_specs', metavar='', + help='ID of QoS specifications to delete.') +@utils.arg('--force', + metavar='', + const=True, + nargs='?', + default=False, + help='Enables or disables deletion of in-use ' + 'QoS specifications. Default=False.') +def do_qos_delete(cs, args): + """Deletes a specified 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) + + +@utils.arg('qos_specs', metavar='', + help='ID of QoS specifications.') +@utils.arg('vol_type_id', metavar='', + help='ID of volume type with which to associate ' + 'QoS specifications.') +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 with which to associate ' + 'QoS specifications.') +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 on which to operate.') +def do_qos_disassociate_all(cs, args): + """Disassociates qos specs from all its 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 = shell_utils.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.') +def do_qos_get_association(cs, args): + """Lists all associations for specified qos specs.""" + associations = cs.qos_specs.get_associations(args.qos_specs) + shell_utils.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='Metadata key and value pair to set or unset. ' + 'For unset, specify only the key.') +def do_snapshot_metadata(cs, args): + """Sets or deletes snapshot metadata.""" + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + metadata = shell_utils.extract_metadata(args) + + if args.action == 'set': + metadata = snapshot.set_metadata(metadata) + shell_utils.print_dict(metadata._info) + elif args.action == 'unset': + snapshot.delete_metadata(list(metadata.keys())) + + +@utils.arg('snapshot', metavar='', + help='ID of snapshot.') +def do_snapshot_metadata_show(cs, args): + """Shows snapshot metadata.""" + 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.') +def do_metadata_show(cs, args): + """Shows volume metadata.""" + volume = utils.find_volume(cs, args.volume) + 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', + 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.') +def do_metadata_update_all(cs, args): + """Updates volume metadata.""" + volume = utils.find_volume(cs, args.volume) + metadata = shell_utils.extract_metadata(args) + metadata = volume.update_all_metadata(metadata) + shell_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 to update.') +def do_snapshot_metadata_update_all(cs, args): + """Updates snapshot metadata.""" + snapshot = shell_utils.find_volume_snapshot(cs, args.snapshot) + metadata = shell_utils.extract_metadata(args) + metadata = snapshot.update_all_metadata(metadata) + shell_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.') +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, + strict=True)) + + +@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.') +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, + strict=True)) + + +@utils.arg('host', + metavar='', + help='Cinder host on which the existing volume resides; ' + 'takes the form: host@backend-name#pool') +@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', + 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) + + info = {} + volume = cs.volumes.get(volume.id) + info.update(volume._info) + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('volume', metavar='', + help='Name or ID of the volume to unmanage.') +def do_unmanage(cs, args): + """Stop managing a volume.""" + volume = utils.find_volume(cs, args.volume) + cs.volumes.unmanage(volume.id) + + +@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.') +def do_consisgroup_list(cs, args): + """Lists all consistency groups.""" + search_opts = {'all_tenants': args.all_tenants} + + consistencygroups = cs.consistencygroups.list(search_opts=search_opts) + + columns = ['ID', 'Status', 'Name'] + shell_utils.print_list(consistencygroups, columns) + + +@utils.arg('consistencygroup', + metavar='', + help='Name or ID of a consistency group.') +def do_consisgroup_show(cs, args): + """Shows details of a consistency group.""" + info = dict() + consistencygroup = shell_utils.find_consistencygroup(cs, + args.consistencygroup) + info.update(consistencygroup._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('volumetypes', + metavar='', + help='Volume types.') +@utils.arg('--name', + metavar='', + help='Name of a consistency group.') +@utils.arg('--description', + metavar='', + default=None, + help='Description of a consistency group. Default=None.') +@utils.arg('--availability-zone', + metavar='', + default=None, + help='Availability zone for volume. Default=None.') +def do_consisgroup_create(cs, args): + """Creates a consistency group.""" + + consistencygroup = cs.consistencygroups.create( + args.volumetypes, + args.name, + args.description, + availability_zone=args.availability_zone) + + info = dict() + consistencygroup = cs.consistencygroups.get(consistencygroup.id) + info.update(consistencygroup._info) + + info.pop('links', None) + 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.') +def do_consisgroup_create_from_src(cs, args): + """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 if cgsnapshot else None, + source_cg.id if source_cg else None, + args.name, + args.description) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('consistencygroup', + metavar='', nargs='+', + help='Name or ID of one or more consistency groups ' + 'to be deleted.') +@utils.arg('--force', + action='store_true', + default=False, + help='Allows or disallows consistency groups ' + 'to be deleted. If the consistency group is empty, ' + '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.') +def do_consisgroup_delete(cs, args): + """Removes one or more consistency groups.""" + failure_count = 0 + for consistencygroup in args.consistencygroup: + try: + shell_utils.find_consistencygroup( + cs, consistencygroup).delete(args.force) + except Exception as e: + failure_count += 1 + print("Delete for consistency group %s failed: %s" % + (consistencygroup, e)) + if failure_count == len(args.consistencygroup): + raise exceptions.CommandError("Unable to delete any of the specified " + "consistency groups.") + + +@utils.arg('consistencygroup', + metavar='', + help='Name or ID of a consistency group.') +@utils.arg('--name', metavar='', + help='New name for consistency group. Default=None.') +@utils.arg('--description', metavar='', + help='New description for consistency group. Default=None.') +@utils.arg('--add-volumes', + metavar='', + help='UUID of one or more volumes ' + 'to be added to the consistency group, ' + 'separated by commas. Default=None.') +@utils.arg('--remove-volumes', + metavar='', + help='UUID of one or more volumes ' + 'to be removed from the consistency group, ' + 'separated by commas. Default=None.') +def do_consisgroup_update(cs, args): + """Updates a consistency 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_consistencygroup( + cs, args.consistencygroup).update(**kwargs) + print("Request to update consistency group '%s' has been accepted." % ( + args.consistencygroup)) + + +@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('--status', + metavar='', + default=None, + help='Filters results by a status. Default=None.') +@utils.arg('--consistencygroup-id', + metavar='', + default=None, + help='Filters results by a consistency group ID. Default=None.') +def do_cgsnapshot_list(cs, args): + """Lists all cgsnapshots.""" + + all_tenants = int(os.environ.get("ALL_TENANTS", args.all_tenants)) + + search_opts = { + 'all_tenants': all_tenants, + 'status': args.status, + 'consistencygroup_id': args.consistencygroup_id, + } + + cgsnapshots = cs.cgsnapshots.list(search_opts=search_opts) + + columns = ['ID', 'Status', 'Name'] + shell_utils.print_list(cgsnapshots, columns) + + +@utils.arg('cgsnapshot', + metavar='', + help='Name or ID of cgsnapshot.') +def do_cgsnapshot_show(cs, args): + """Shows cgsnapshot details.""" + info = dict() + cgsnapshot = shell_utils.find_cgsnapshot(cs, args.cgsnapshot) + info.update(cgsnapshot._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('consistencygroup', + metavar='', + help='Name or ID of a consistency group.') +@utils.arg('--name', + metavar='', + default=None, + help='Cgsnapshot name. Default=None.') +@utils.arg('--description', + metavar='', + default=None, + help='Cgsnapshot description. Default=None.') +def do_cgsnapshot_create(cs, args): + """Creates a cgsnapshot.""" + consistencygroup = shell_utils.find_consistencygroup(cs, + args.consistencygroup) + cgsnapshot = cs.cgsnapshots.create( + consistencygroup.id, + args.name, + args.description) + + info = dict() + cgsnapshot = cs.cgsnapshots.get(cgsnapshot.id) + info.update(cgsnapshot._info) + + info.pop('links', None) + shell_utils.print_dict(info) + + +@utils.arg('cgsnapshot', + metavar='', nargs='+', + help='Name or ID of one or more cgsnapshots to be deleted.') +def do_cgsnapshot_delete(cs, args): + """Removes one or more cgsnapshots.""" + failure_count = 0 + for cgsnapshot in args.cgsnapshot: + try: + shell_utils.find_cgsnapshot(cs, cgsnapshot).delete() + except Exception as e: + failure_count += 1 + print("Delete for cgsnapshot %s failed: %s" % (cgsnapshot, e)) + if failure_count == len(args.cgsnapshot): + raise exceptions.CommandError("Unable to delete any of the specified " + "cgsnapshots.") + + +@utils.arg('--detail', + action='store_true', + help='Show detailed information about pools.') +def do_get_pools(cs, args): + """Show pool information for backends. Admin only.""" + 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) + + +@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/v3/volume_backups_restore.py b/cinderclient/v3/volume_backups_restore.py new file mode 100644 index 000000000..8a35ed162 --- /dev/null +++ b/cinderclient/v3/volume_backups_restore.py @@ -0,0 +1,44 @@ +# 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 (v3 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, 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, 'name': name}} + return self._create("/backups/%s/restore" % backup_id, + body, "restore") diff --git a/cinderclient/v3/volume_encryption_types.py b/cinderclient/v3/volume_encryption_types.py new file mode 100644 index 000000000..531e4d229 --- /dev/null +++ b/cinderclient/v3/volume_encryption_types.py @@ -0,0 +1,105 @@ +# 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.apiclient import base as common_base +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.encryption_id + + +class VolumeEncryptionTypeManager(base.ManagerWithFind): + """ + Manage :class: `VolumeEncryptionType` resources. + """ + resource_class = VolumeEncryptionType + + def list(self, search_opts=None): + """ + List all volume encryption 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) + + list_of_resp.extend(encryption_type.request_ids) + + return common_base.ListWithMeta(encryption_types, list_of_resp) + + 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 + """ + body = {'encryption': specs} + return self._update("/types/%s/encryption/provider" % + base.getid(volume_type), body) + + 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/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/v3/volume_transfers.py b/cinderclient/v3/volume_transfers.py new file mode 100644 index 000000000..bcf0e0cc0 --- /dev/null +++ b/cinderclient/v3/volume_transfers.py @@ -0,0 +1,104 @@ +# 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 (v3 extension).""" + +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, 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): + """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}} + 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') + + 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` + """ + 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, 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` + """ + resource_type = 'os-volume-transfer' + if self.api_version.matches('3.55'): + resource_type = 'volume-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. + """ + 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/v3/volume_type_access.py b/cinderclient/v3/volume_type_access.py new file mode 100644 index 000000000..bdd2e7028 --- /dev/null +++ b/cinderclient/v3/volume_type_access.py @@ -0,0 +1,53 @@ +# 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. + +"""Volume type access interface.""" + +from cinderclient.apiclient import base as common_base +from cinderclient import base + + +class VolumeTypeAccess(base.Resource): + def __repr__(self): + return "" % self.project_id + + +class VolumeTypeAccessManager(base.ManagerWithFind): + """ + Manage :class:`VolumeTypeAccess` resources. + """ + resource_class = VolumeTypeAccess + + def list(self, volume_type): + return self._list( + '/types/%s/os-volume-type-access' % base.getid(volume_type), + 'volume_type_access') + + def add_project_access(self, volume_type, project): + """Add a project to the given volume type access list.""" + info = {'project': project} + 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} + 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) + resp, body = self.api.client.post(url, body=body) + return common_base.TupleWithMeta((resp, body), resp) diff --git a/cinderclient/v3/volume_types.py b/cinderclient/v3/volume_types.py new file mode 100644 index 000000000..e82fbb3ac --- /dev/null +++ b/cinderclient/v3/volume_types.py @@ -0,0 +1,168 @@ +# 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. + + +"""Volume Type interface.""" +from urllib import parse + +from cinderclient.apiclient import base as common_base +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 + + @property + 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", + self._info.get("is_public", 'N/A')) + + 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 volue 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 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, body = self.manager._delete( + "/types/%s/extra_specs/%s" % ( + base.getid(self), k)) + 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=None): + """Lists all volume types. + + :param search_opts: Optional search filters. + :param is_public: Whether to only get public types. + :return: List of :class:`VolumeType`. + """ + 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. + + :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 default(self): + """Get the default volume type. + + :rtype: :class:`VolumeType` + """ + return self._get("/types/default", "volume_type") + + def delete(self, volume_type): + """Deletes a specific volume_type. + + :param volume_type: The name or ID of the :class:`VolumeType` to get. + """ + 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 volume type + :param is_public: Volume type visibility + :rtype: :class:`VolumeType` + """ + + body = { + "volume_type": { + "name": name, + "description": description, + "os-volume-type-access:is_public": is_public, + } + } + + return self._create("/types", body, "volume_type") + + 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 volume type. + :rtype: :class:`VolumeType` + """ + + body = { + "volume_type": { + "name": name, + "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/v3/volumes_base.py b/cinderclient/v3/volumes_base.py new file mode 100644 index 000000000..c41361cdf --- /dev/null +++ b/cinderclient/v3/volumes_base.py @@ -0,0 +1,538 @@ +# 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. + +"""Base Volume interface.""" + +from cinderclient.apiclient import base as common_base +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, cascade=False): + """Delete this volume.""" + return self.manager.delete(self, cascade=cascade) + + def update(self, **kwargs): + """Update the name or description for this volume.""" + return self.manager.update(self, **kwargs) + + 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 or host. + :param mode: the access mode. + :param host_name: name of the attaching host. + """ + return self.manager.attach(self, instance_uuid, mountpoint, mode, + host_name) + + def detach(self): + """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): + """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 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. + """ + return self.manager.force_delete(self) + + 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. + """ + return self.manager.extend(self, new_size) + + def retype(self, volume_type, policy): + """Change a volume's type.""" + return self.manager.retype(self, volume_type, policy) + + 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. + """ + return self.manager.update_readonly_flag(self, read_only) + + 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.""" + return self.manager.unmanage(volume) + + def get_pools(self, detail): + """Show pool information for backends.""" + return self.manager.get_pools(detail) + + +class VolumeManager(base.ManagerWithFind): + """Manage :class:`Volume` resources.""" + resource_class = 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, marker=None, limit=None, + sort=None): + """Lists all volumes. + + :param detailed: Whether to return detailed volume info. + :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:`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. + """ + + 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. + + :param volume: The :class:`Volume` to update. + """ + if not kwargs: + return + + body = {"volume": kwargs} + + 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) + 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', + 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 or host. + :param mode: the access mode. + :param host_name: name of the attaching host. + """ + 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, + {'attachment_id': attachment_uuid}) + + 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. + """ + 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. + + :param volume: The :class:`Volume` (or its ID). + :param connector: connector dict from nova. + """ + return 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. + """ + 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) + + 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): + """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): + """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, 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}) + + 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 + """ + 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, 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, + '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. + + :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) + 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. + + :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 retype(self, volume, volume_type, policy): + """Change a volume's type. + + :param volume: The :class:`Volume` to retype + :param volume_type: New volume type + :param policy: Policy for migration during the retype + """ + return self._action('os-retype', + volume, + {'new_type': volume_type, + 'migration_policy': policy}) + + def set_bootable(self, volume, flag): + return self._action('os-set_bootable', + base.getid(volume), + {'bootable': flag}) + + def manage(self, host, ref, name=None, description=None, + volume_type=None, availability_zone=None, metadata=None, + bootable=False): + """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 + }} + return self._create('/os-volume-manage', body, 'volume') + + def unmanage(self, volume): + """Unmanage a volume.""" + return self._action('os-unmanage', volume, None) + + 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) 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/version.py b/cinderclient/version.py new file mode 100644 index 000000000..b553b44e3 --- /dev/null +++ b/cinderclient/version.py @@ -0,0 +1,20 @@ +# 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 pbr.version + + +version_info = pbr.version.VersionInfo('python-cinderclient') +__version__ = version_info.version_string() diff --git a/doc/.gitignore b/doc/.gitignore deleted file mode 100644 index 567609b12..000000000 --- a/doc/.gitignore +++ /dev/null @@ -1 +0,0 @@ -build/ diff --git a/doc/Makefile b/doc/Makefile deleted file mode 100644 index f948ac903..000000000 --- a/doc/Makefile +++ /dev/null @@ -1,90 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXSOURCE = source -PAPER = -BUILDDIR = build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) $(SPHINXSOURCE) - -.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-cinderclient.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-cinderclient.qhc" - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ - "run these through (pdf)latex." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." diff --git a/doc/ext/__init__.py b/doc/ext/__init__.py new file mode 100644 index 000000000..e69de29bb 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/api.rst b/doc/source/api.rst deleted file mode 100644 index 1e184bbf4..000000000 --- a/doc/source/api.rst +++ /dev/null @@ -1,67 +0,0 @@ -The :mod:`cinderclient` Python API -================================== - -.. module:: cinderclient - :synopsis: A client for the OpenStack Nova API. - -.. currentmodule:: cinderclient - -Usage ------ - -First create an instance of :class:`OpenStack` with your credentials:: - - >>> from cinderclient import OpenStack - >>> cinder = OpenStack(USERNAME, PASSWORD, AUTH_URL) - -Then call methods on the :class:`OpenStack` object: - -.. class:: OpenStack - - .. attribute:: backup_schedules - - A :class:`BackupScheduleManager` -- manage automatic backup images. - - .. attribute:: flavors - - A :class:`FlavorManager` -- query available "flavors" (hardware - configurations). - - .. attribute:: images - - An :class:`ImageManager` -- query and create server disk images. - - .. attribute:: ipgroups - - A :class:`IPGroupManager` -- manage shared public IP addresses. - - .. attribute:: servers - - A :class:`ServerManager` -- start, stop, and manage virtual machines. - - .. automethod:: authenticate - -For example:: - - >>> cinder.servers.list() - [] - - >>> cinder.flavors.list() - [, - , - , - , - , - , - ] - - >>> fl = cinder.flavors.find(ram=512) - >>> cinder.servers.create("my-server", flavor=fl) - - -For more information, see the reference: - -.. toctree:: - :maxdepth: 2 - - ref/index 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 ccbe64b54..d00b7659c 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,31 +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 sys, os +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 ----------------------------------------------------- +# -- General configuration ---------------------------------------------------- -# Add any Sphinx extension module names here, as strings. They can be extensions +# 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', 'sphinx.ext.intersphinx'] +extensions = [ + 'sphinx.ext.autodoc', + 'openstackdocstheme', + 'reno.sphinxext', + 'cli', +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -33,45 +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 = u'python-cinderclient' -copyright = u'Rackspace, based on work by Jacob Kaplan-Moss' - -# 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. -# -# The short X.Y version. -version = '2.6' -# The full version, including alpha/beta/rc tags. -release = '2.6.10' +copyright = 'OpenStack Contributors' -# 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 @@ -79,123 +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' +pygments_style = 'native' -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - - -# -- Options for HTML output --------------------------------------------------- +# -- 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 = {} +# html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%Y-%m-%d %H:%M' -# If false, no module index is generated. -#html_use_modindex = 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 +# -- Options for manual page output ------------------------------------------ -# 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 -------------------------------------------------- +# -- 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', u'python-cinderclient Documentation', - u'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}', +} -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} +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 d992f7c62..eeb706c7d 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -1,45 +1,366 @@ -Python bindings to the OpenStack Nova API -================================================== +Python API +========== -This is a client for OpenStack Nova API. There's :doc:`a Python API -` (the :mod:`cinderclient` module), and a :doc:`command-line script -` (installed as :program:`cinder`). Each implements the entire -OpenStack Nova 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:: -You'll need an `OpenStack Nova` account, which you can get by using `cinder-manage`. + >>> from cinderclient import client + >>> 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) + >>> myvol.id + ce06d0a8-5c1b-4e2c-81d2-39eca6bbfb70 + >>> cinder.volumes.list() + [] + >>> myvol.delete() -.. seealso:: +Alternatively, you can create a client instance using the keystoneauth session +API:: - You may want to read `Rackspace's API guide`__ (PDF) -- the first bit, at - least -- to get an idea of the concepts. Rackspace is doing the cloud - hosting thing a bit differently from Amazon, and if you get the concepts - this library should make more sense. + >>> 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() + [] - __ http://docs.rackspacecloud.com/servers/api/cs-devguide-latest.pdf +User Guides +~~~~~~~~~~~ -Contents: +.. toctree:: + :maxdepth: 2 + + user/shell + user/no_auth + +Command-Line Reference +~~~~~~~~~~~~~~~~~~~~~~ + +.. toctree:: + :maxdepth: 2 + + cli/index + cli/details + +Developer Guides +~~~~~~~~~~~~~~~~ .. toctree:: :maxdepth: 2 - shell - api - ref/index - releases + 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 + +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. +* Modify consistency groups using the consisgroup-update subcommand. Change the + name, description, add volumes, or remove volumes. +* Create consistency group from consistency group snapshot using the + 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 + +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. +* Force the client to use a particular Cinder API endpoint with --bypass-url. +* Create a volume from an image by image name. +* New type-default subcommand will display the default volume type. +* New type-update subcommand allows updating a volume type's description. +* type-list subcommand displays volume type description. +* type-create subcommand allows setting the description. +* Show pools to a backend when doing a service-list subcommand. +* List and update consistency group quotas. +* Create volume types that are non-public and have particular project access. +* -d is available as a shorter option to --debug. +* transfer-list subcommand has an option for --all-tenants. +* --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 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 + +** 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 + +1.1.0 +------ + +* Add support for ConsistencyGroups +* 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 + +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 + +.. _966329: https://bugs.launchpad.net/python-cinderclient/+bug/966329 +.. _1256043: https://bugs.launchpad.net/python-cinderclient/+bug/1256043 +.. _1254951: http://bugs.launchpad.net/python-cinderclient/+bug/1254951 +.. _1254587: http://bugs.launchpad.net/python-cinderclient/+bug/1254587 +.. _1253142: http://bugs.launchpad.net/python-cinderclient/+bug/1253142 +.. _1252665: http://bugs.launchpad.net/python-cinderclient/+bug/1252665 +.. _1255876: http://bugs.launchpad.net/python-cinderclient/+bug/1255876 +.. _1251385: http://bugs.launchpad.net/python-cinderclient/+bug/1251385 +.. _1264415: http://bugs.launchpad.net/python-cinderclient/+bug/1264415 +.. _1258489: http://bugs.launchpad.net/python-cinderclient/+bug/1258489 +.. _1248519: http://bugs.launchpad.net/python-cinderclient/+bug/1248519 +.. _1257747: http://bugs.launchpad.net/python-cinderclient/+bug/1257747 + +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 +* Add quota-usage command +* Fix exception deprecation warning message +* Report error when no args supplied to rename cmd + +.. _1241941: http://bugs.launchpad.net/python-cinderclient/+bug/1241941 +.. _1242816: http://bugs.launchpad.net/python-cinderclient/+bug/1242816 +.. _1233311: http://bugs.launchpad.net/python-cinderclient/+bug/1233311 +.. _1227307: http://bugs.launchpad.net/python-cinderclient/+bug/1227307 +.. _1240151: http://bugs.launchpad.net/python-cinderclient/+bug/1240151 +.. _1241682: http://bugs.launchpad.net/python-cinderclient/+bug/1241682 + + +1.0.6 +----- + +* Add support for multiple endpoints +* Add response info for backup command +* Add metadata option to cinder list command +* Add timeout parameter for requests +* Add update action for snapshot metadata +* Add encryption metadata support +* Add volume migrate support +* Add support for QoS specs + +.. _1221104: http://bugs.launchpad.net/python-cinderclient/+bug/1221104 +.. _1220590: http://bugs.launchpad.net/python-cinderclient/+bug/1220590 +.. _1220147: http://bugs.launchpad.net/python-cinderclient/+bug/1220147 +.. _1214176: http://bugs.launchpad.net/python-cinderclient/+bug/1214176 +.. _1210874: http://bugs.launchpad.net/python-cinderclient/+bug/1210874 +.. _1210296: http://bugs.launchpad.net/python-cinderclient/+bug/1210296 +.. _1210292: http://bugs.launchpad.net/python-cinderclient/+bug/1210292 +.. _1207635: http://bugs.launchpad.net/python-cinderclient/+bug/1207635 +.. _1207609: http://bugs.launchpad.net/python-cinderclient/+bug/1207609 +.. _1207260: http://bugs.launchpad.net/python-cinderclient/+bug/1207260 +.. _1206968: http://bugs.launchpad.net/python-cinderclient/+bug/1206968 +.. _1203471: http://bugs.launchpad.net/python-cinderclient/+bug/1203471 +.. _1200214: http://bugs.launchpad.net/python-cinderclient/+bug/1200214 +.. _1195014: http://bugs.launchpad.net/python-cinderclient/+bug/1195014 + +1.0.5 +----- + +* Add CLI man page +* Add Availability Zone list command +* Add support for scheduler-hints +* Add support to extend volumes +* Add support to reset state on volumes and snapshots +* Add snapshot support for quota class -Contributing -============ +.. _1190853: http://bugs.launchpad.net/python-cinderclient/+bug/1190853 +.. _1190731: http://bugs.launchpad.net/python-cinderclient/+bug/1190731 +.. _1169455: http://bugs.launchpad.net/python-cinderclient/+bug/1169455 +.. _1188452: http://bugs.launchpad.net/python-cinderclient/+bug/1188452 +.. _1180393: http://bugs.launchpad.net/python-cinderclient/+bug/1180393 +.. _1182678: http://bugs.launchpad.net/python-cinderclient/+bug/1182678 +.. _1179008: http://bugs.launchpad.net/python-cinderclient/+bug/1179008 +.. _1180059: http://bugs.launchpad.net/python-cinderclient/+bug/1180059 +.. _1170565: http://bugs.launchpad.net/python-cinderclient/+bug/1170565 -Development takes place `on GitHub`__; please file bugs/pull requests there. +1.0.4 +----- -__ https://github.com/rackspace/python-cinderclient +* Added support for backup-service commands -Run tests with ``python setup.py test``. +.. _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 +.. _1161857: http://bugs.launchpad.net/python-cinderclient/+bug/1161857 +.. _1156994: http://bugs.launchpad.net/python-cinderclient/+bug/1156994 -Indices and tables -================== +1.0.3 +----- -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +* Added support for V2 Cinder API +* Corrected upload-volume-to-image help messaging +* Align handling of metadata args for all methods +* Update OSLO version +* Correct parsing of volume metadata +* Enable force delete of volumes and snapshots in error state +* Implement clone volume API call +* Add list-extensions call to cinderclient +* Add bootable column to list output +* 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 +.. _1052161: http://bugs.launchpad.net/python-cinderclient/+bug/1052161 +.. _1071003: http://bugs.launchpad.net/python-cinderclient/+bug/1071003 +.. _1065275: http://bugs.launchpad.net/python-cinderclient/+bug/1065275 +.. _1053432: http://bugs.launchpad.net/python-cinderclient/+bug/1053432 diff --git a/doc/source/ref/backup_schedules.rst b/doc/source/ref/backup_schedules.rst deleted file mode 100644 index cbd69e3dd..000000000 --- a/doc/source/ref/backup_schedules.rst +++ /dev/null @@ -1,60 +0,0 @@ -Backup schedules -================ - -.. currentmodule:: cinderclient - -Rackspace allows scheduling of weekly and/or daily backups for virtual -servers. You can access these backup schedules either off the API object as -:attr:`OpenStack.backup_schedules`, or directly off a particular -:class:`Server` instance as :attr:`Server.backup_schedule`. - -Classes -------- - -.. autoclass:: BackupScheduleManager - :members: create, delete, update, get - -.. autoclass:: BackupSchedule - :members: update, delete - - .. attribute:: enabled - - Is this backup enabled? (boolean) - - .. attribute:: weekly - - The day of week upon which to perform a weekly backup. - - .. attribute:: daily - - The daily time period during which to perform a daily backup. - -Constants ---------- - -Constants for selecting weekly backup days: - - .. data:: BACKUP_WEEKLY_DISABLED - .. data:: BACKUP_WEEKLY_SUNDAY - .. data:: BACKUP_WEEKLY_MONDAY - .. data:: BACKUP_WEEKLY_TUESDAY - .. data:: BACKUP_WEEKLY_WEDNESDA - .. data:: BACKUP_WEEKLY_THURSDAY - .. data:: BACKUP_WEEKLY_FRIDAY - .. data:: BACKUP_WEEKLY_SATURDAY - -Constants for selecting hourly backup windows: - - .. data:: BACKUP_DAILY_DISABLED - .. data:: BACKUP_DAILY_H_0000_0200 - .. data:: BACKUP_DAILY_H_0200_0400 - .. data:: BACKUP_DAILY_H_0400_0600 - .. data:: BACKUP_DAILY_H_0600_0800 - .. data:: BACKUP_DAILY_H_0800_1000 - .. data:: BACKUP_DAILY_H_1000_1200 - .. data:: BACKUP_DAILY_H_1200_1400 - .. data:: BACKUP_DAILY_H_1400_1600 - .. data:: BACKUP_DAILY_H_1600_1800 - .. data:: BACKUP_DAILY_H_1800_2000 - .. data:: BACKUP_DAILY_H_2000_2200 - .. data:: BACKUP_DAILY_H_2200_0000 diff --git a/doc/source/ref/exceptions.rst b/doc/source/ref/exceptions.rst deleted file mode 100644 index 23618e3ef..000000000 --- a/doc/source/ref/exceptions.rst +++ /dev/null @@ -1,14 +0,0 @@ -Exceptions -========== - -.. currentmodule:: cinderclient - -Exceptions ----------- - -Exceptions that the API might throw: - -.. automodule:: cinderclient - :members: OpenStackException, BadRequest, Unauthorized, Forbidden, - NotFound, OverLimit - diff --git a/doc/source/ref/flavors.rst b/doc/source/ref/flavors.rst deleted file mode 100644 index 12b396ac5..000000000 --- a/doc/source/ref/flavors.rst +++ /dev/null @@ -1,35 +0,0 @@ -Flavors -======= - -From Rackspace's API documentation: - - A flavor is an available hardware configuration for a server. Each flavor - has a unique combination of disk space, memory capacity and priority for - CPU time. - -Classes -------- - -.. currentmodule:: cinderclient - -.. autoclass:: FlavorManager - :members: get, list, find, findall - -.. autoclass:: Flavor - :members: - - .. attribute:: id - - This flavor's ID. - - .. attribute:: name - - A human-readable name for this flavor. - - .. attribute:: ram - - The amount of RAM this flavor has, in MB. - - .. attribute:: disk - - The amount of disk space this flavor has, in MB diff --git a/doc/source/ref/images.rst b/doc/source/ref/images.rst deleted file mode 100644 index 6ba6c24ea..000000000 --- a/doc/source/ref/images.rst +++ /dev/null @@ -1,54 +0,0 @@ -Images -====== - -.. currentmodule:: cinderclient - -An "image" is a snapshot from which you can create new server instances. - -From Rackspace's own API documentation: - - An image is a collection of files used to create or rebuild a server. - Rackspace provides a number of pre-built OS images by default. You may - also create custom images from cloud servers you have launched. These - custom images are useful for backup purposes or for producing "gold" - server images if you plan to deploy a particular server configuration - frequently. - -Classes -------- - -.. autoclass:: ImageManager - :members: get, list, find, findall, create, delete - -.. autoclass:: Image - :members: delete - - .. attribute:: id - - This image's ID. - - .. attribute:: name - - This image's name. - - .. attribute:: created - - The date/time this image was created. - - .. attribute:: updated - - The date/time this instance was updated. - - .. attribute:: status - - The status of this image (usually ``"SAVING"`` or ``ACTIVE``). - - .. attribute:: progress - - During saving of an image this'll be set to something between - 0 and 100, representing a rough percentage done. - - .. attribute:: serverId - - If this image was created from a :class:`Server` then this attribute - will be set to the ID of the server whence this image came. diff --git a/doc/source/ref/index.rst b/doc/source/ref/index.rst deleted file mode 100644 index c1fe136bb..000000000 --- a/doc/source/ref/index.rst +++ /dev/null @@ -1,12 +0,0 @@ -API Reference -============= - -.. toctree:: - :maxdepth: 1 - - backup_schedules - exceptions - flavors - images - ipgroups - servers \ No newline at end of file diff --git a/doc/source/ref/ipgroups.rst b/doc/source/ref/ipgroups.rst deleted file mode 100644 index 4c29f2ee9..000000000 --- a/doc/source/ref/ipgroups.rst +++ /dev/null @@ -1,46 +0,0 @@ -Shared IP addresses -=================== - -From the Rackspace API guide: - - Public IP addresses can be shared across multiple servers for use in - various high availability scenarios. When an IP address is shared to - another server, the cloud network restrictions are modified to allow each - server to listen to and respond on that IP address (you may optionally - specify that the target server network configuration be modified). Shared - IP addresses can be used with many standard heartbeat facilities (e.g. - ``keepalived``) that monitor for failure and manage IP failover. - - A shared IP group is a collection of servers that can share IPs with other - members of the group. Any server in a group can share one or more public - IPs with any other server in the group. With the exception of the first - server in a shared IP group, servers must be launched into shared IP - groups. A server may only be a member of one shared IP group. - -.. seealso:: - - Use :meth:`Server.share_ip` and `Server.unshare_ip` to share and unshare - IPs in a group. - -Classes -------- - -.. currentmodule:: cinderclient - -.. autoclass:: IPGroupManager - :members: get, list, find, findall, create, delete - -.. autoclass:: IPGroup - :members: delete - - .. attribute:: id - - Shared group ID. - - .. attribute:: name - - Name of the group. - - .. attribute:: servers - - A list of server IDs in this group. diff --git a/doc/source/ref/servers.rst b/doc/source/ref/servers.rst deleted file mode 100644 index b02fca5be..000000000 --- a/doc/source/ref/servers.rst +++ /dev/null @@ -1,73 +0,0 @@ -Servers -======= - -A virtual machine instance. - -Classes -------- - -.. currentmodule:: cinderclient - -.. autoclass:: ServerManager - :members: get, list, find, findall, create, update, delete, share_ip, - unshare_ip, reboot, rebuild, resize, confirm_resize, - revert_resize - -.. autoclass:: Server - :members: update, delete, share_ip, unshare_ip, reboot, rebuild, resize, - confirm_resize, revert_resize - - .. attribute:: id - - This server's ID. - - .. attribute:: name - - The name you gave the server when you booted it. - - .. attribute:: imageId - - The :class:`Image` this server was booted with. - - .. attribute:: flavorId - - This server's current :class:`Flavor`. - - .. attribute:: hostId - - Rackspace doesn't document this value. It appears to be SHA1 hash. - - .. attribute:: status - - The server's status (``BOOTING``, ``ACTIVE``, etc). - - .. attribute:: progress - - When booting, resizing, updating, etc., this will be set to a - value between 0 and 100 giving a rough estimate of the progress - of the current operation. - - .. attribute:: addresses - - The public and private IP addresses of this server. This'll be a dict - of the form:: - - { - "public" : ["67.23.10.138"], - "private" : ["10.176.42.19"] - } - - You *can* get more than one public/private IP provisioned, but not - directly from the API; you'll need to open a support ticket. - - .. attribute:: metadata - - The metadata dict you gave when creating the server. - -Constants ---------- - -Reboot types: - -.. data:: REBOOT_SOFT -.. data:: REBOOT_HARD diff --git a/doc/source/releases.rst b/doc/source/releases.rst deleted file mode 100644 index 783b1cad5..000000000 --- a/doc/source/releases.rst +++ /dev/null @@ -1,99 +0,0 @@ -============= -Release notes -============= - -2.5.8 (July 11, 2011) -===================== -* returns all public/private ips, not just first one -* better 'cinder list' search options - -2.5.7 - 2.5.6 = minor tweaks - -2.5.5 (June 21, 2011) -===================== -* zone-boot min/max instance count added thanks to comstud -* create for user added thanks to cerberus -* fixed tests - -2.5.3 (June 15, 2011) -===================== -* ProjectID can be None for backwards compatability. -* README/docs updated for projectId thanks to usrleon - -2.5.1 (June 10, 2011) -===================== -* ProjectID now part of authentication - -2.5.0 (June 3, 2011) -================= - -* better logging thanks to GridDynamics - -2.4.4 (June 1, 2011) -================= - -* added support for GET /servers with reservation_id (and /servers/detail) - -2.4.3 (May 27, 2011) -================= - -* added support for POST /zones/select (client only, not cmdline) - -2.4 (March 7, 2011) -================= - -* added Jacob Kaplan-Moss copyright notices to older/untouched files. - - -2.3 (March 2, 2011) -================= - -* package renamed to python-cinderclient. Module to cinderclient - - -2.2 (March 1, 2011) -================= - -* removed some license/copywrite notices from source that wasn't - significantly changed. - - -2.1 (Feb 28, 2011) -================= - -* shell renamed to cinder from cindertools - -* license changed from BSD to Apache - -2.0 (Feb 7, 2011) -================= - -* Forked from https://github.com/jacobian/python-cloudservers - -* Rebranded to python-cindertools - -* Auth URL support - -* New OpenStack specific commands added (pause, suspend, etc) - -1.2 (August 15, 2010) -===================== - -* Support for Python 2.4 - 2.7. - -* Improved output of :program:`cloudservers ipgroup-list`. - -* Made ``cloudservers boot --ipgroup `` work (as well as ``--ipgroup - ``). - -1.1 (May 6, 2010) -================= - -* Added a ``--files`` option to :program:`cloudservers boot` supporting - the upload of (up to five) files at boot time. - -* Added a ``--key`` option to :program:`cloudservers boot` to key the server - with an SSH public key at boot time. This is just a shortcut for ``--files``, - but it's a useful shortcut. - -* Changed the default server image to Ubuntu 10.04 LTS. diff --git a/doc/source/shell.rst b/doc/source/shell.rst deleted file mode 100644 index cff5cc73b..000000000 --- a/doc/source/shell.rst +++ /dev/null @@ -1,52 +0,0 @@ -The :program:`cinder` shell utility -========================================= - -.. program:: cinder -.. highlight:: bash - -The :program:`cinder` shell utility interacts with OpenStack Nova API -from the command line. It supports the entirety of the OpenStack Nova API. - -First, you'll need an OpenStack Nova account and an API key. You get this -by using the `cinder-manage` command in OpenStack Nova. - -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_id` options, but it's easier to just set them as -environment variables by setting two environment variables: - -.. envvar:: OS_USERNAME - - Your OpenStack Nova username. - -.. envvar:: OS_PASSWORD - - Your password. - -.. envvar:: OS_TENANT_NAME - - Project for work. - -.. envvar:: OS_AUTH_URL - - The OpenStack API server URL. - -.. envvar:: OS_COMPUTE_API_VERSION - - The OpenStack API version. - -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_COMPUTE_API_VERSION=1.1 - -From there, all shell commands take the form:: - - cinder [arguments...] - -Run :program:`cinder help` to get a full list of all possible commands, -and run :program:`cinder help ` to get detailed help for that -command. 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/user/shell.rst b/doc/source/user/shell.rst new file mode 100644 index 000000000..7d478cf4f --- /dev/null +++ b/doc/source/user/shell.rst @@ -0,0 +1,57 @@ +The :program:`cinder` shell utility +=================================== + +.. program:: cinder +.. highlight:: bash + +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 `--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 + + Your OpenStack Cinder username. + +.. envvar:: OS_PASSWORD or CINDER_PASSWORD + + Your password. + +.. envvar:: OS_PROJECT_NAME or CINDER_PROJECT_ID + + Project for work. + +.. envvar:: OS_AUTH_URL or CINDER_URL + + The OpenStack API server URL. + +.. envvar:: OS_VOLUME_API_VERSION + + The OpenStack Block Storage API version. + +For example, in Bash you'd use:: + + export OS_USERNAME=yourname + export OS_PASSWORD=yadayadayada + 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:: + + cinder [arguments...] + +Run :program:`cinder help` to get a full list of all possible commands, +and run :program:`cinder help ` to get detailed help for that +command. 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/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/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..fa180a17d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["pbr>=7.0.0"] +build-backend = "pbr.build" + +[project] +name = "python-cinderclient" +description = "OpenStack Block Storage API Client Library" +authors = [ + {name = "OpenStack", email = "openstack-discuss@lists.openstack.org"}, + ] +readme = {file = "README.rst", content-type = "text/x-rst"} +license = {text = "Apache-2.0"} +dynamic = ["version", "dependencies"] +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: OpenStack", + "Intended Audience :: Information Technology", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + ] + +[project.urls] +Homepage = "https://docs.openstack.org/python-cinderclient/latest/" +Repository = "https://opendev.org/openstack/python-cinderclient" +Bugs = "https://launchpad.net/python-cinderclient" + +[tool.setuptools] +packages = [ + "cinderclient", + ] + +[project.scripts] +cinder = "cinderclient.shell:main" + +[project.entry-points."keystoneauth1.plugin"] +noauth = "cinderclient.contrib.noauth:CinderNoAuthLoader" 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/releasenotes/source/2026.1.rst b/releasenotes/source/2026.1.rst new file mode 100644 index 000000000..3d2861580 --- /dev/null +++ b/releasenotes/source/2026.1.rst @@ -0,0 +1,6 @@ +=========================== +2026.1 Series Release Notes +=========================== + +.. release-notes:: + :branch: stable/2026.1 diff --git a/releasenotes/source/_static/.placeholder b/releasenotes/source/_static/.placeholder new file mode 100644 index 000000000..e69de29bb diff --git a/releasenotes/source/_templates/.placeholder b/releasenotes/source/_templates/.placeholder new file mode 100644 index 000000000..e69de29bb 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..fd726e48d --- /dev/null +++ b/releasenotes/source/index.rst @@ -0,0 +1,29 @@ +============================= + Cinder Client Release Notes +============================= + +.. toctree:: + :maxdepth: 1 + + unreleased + 2026.1 + 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 new file mode 100644 index 000000000..8cea5ad4f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# 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 96bd437d6..000000000 --- a/run_tests.sh +++ /dev/null @@ -1,154 +0,0 @@ -#!/bin/bash - -set -eu - -function usage { - echo "Usage: $0 [OPTION]..." - echo "Run python-cinderclient test suite" - 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 " -x, --stop Stop running tests after the first error or failure." - echo " -f, --force Force a clean re-build of the virtual environment. Useful when dependencies have been added." - echo " -p, --pep8 Just run pep8" - echo " -P, --no-pep8 Don't run pep8" - echo " -c, --coverage Generate coverage report" - 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 "" - 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_option { - case "$1" 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;; - -f|--force) force=1;; - -p|--pep8) just_pep8=1;; - -P|--no-pep8) no_pep8=1;; - -c|--coverage) coverage=1;; - -*) noseopts="$noseopts $1";; - *) noseargs="$noseargs $1" - esac -} - -venv=.venv -with_venv=tools/with_venv.sh -always_venv=0 -never_venv=0 -force=0 -no_site_packages=0 -installvenvopts= -noseargs= -noseopts= -wrapper="" -just_pep8=0 -no_pep8=0 -coverage=0 - -for arg in "$@"; do - process_option $arg -done - -# If enabled, tell nose to collect coverage data -if [ $coverage -eq 1 ]; then - noseopts="$noseopts --with-coverage --cover-package=cinderclient" -fi - -if [ $no_site_packages -eq 1 ]; then - installvenvopts="--no-site-packages" -fi - -function run_tests { - # Cleanup *.pyc - ${wrapper} find . -type f -name "*.pyc" -delete - # Just run the test suites in current environment - ${wrapper} $NOSETESTS - # If we get some short import error right away, print the error log directly - RESULT=$? - return $RESULT -} - -function run_pep8 { - echo "Running pep8 ..." - srcfiles="cinderclient tests" - # Just run PEP8 in current environment - # - # NOTE(sirp): W602 (deprecated 3-arg raise) is being ignored for the - # following reasons: - # - # 1. It's needed to preserve traceback information when re-raising - # exceptions; this is needed b/c Eventlet will clear exceptions when - # switching contexts. - # - # 2. There doesn't appear to be an alternative, "pep8-tool" compatible way of doing this - # in Python 2 (in Python 3 `with_traceback` could be used). - # - # 3. Can find no corroborating evidence that this is deprecated in Python 2 - # other than what the PEP8 tool claims. It is deprecated in Python 3, so, - # perhaps the mistake was thinking that the deprecation applied to Python 2 - # as well. - pep8_opts="--ignore=E202,W602 --repeat" - ${wrapper} pep8 ${pep8_opts} ${srcfiles} -} - -NOSETESTS="nosetests $noseopts $noseargs" - -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 [ -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 - -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 (noseopts), which begin with a '-', and -# arguments (noseargs). -if [ -z "$noseargs" ]; then - if [ $no_pep8 -eq 0 ]; then - run_pep8 - fi -fi - -if [ $coverage -eq 1 ]; then - echo "Generating coverage report in covhtml/" - ${wrapper} coverage html -d covhtml -i -fi diff --git a/setup.cfg b/setup.cfg index 2d8f47ad6..964093097 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,15 +1,2 @@ -[nosetests] -cover-package = cinderclient -cover-html = true -cover-erase = true -cover-inclusive = true -verbosity=2 -detailed-errors=1 - -[build_sphinx] -all_files = 1 -source-dir = doc/source -build-dir = doc/build - -[upload_sphinx] -upload-dir = doc/build/html +[metadata] +name = python-cinderclient diff --git a/setup.py b/setup.py index e69a45188..cd35c3c35 100644 --- a/setup.py +++ b/setup.py @@ -1,57 +1,20 @@ -# Copyright 2011 OpenStack, LLC +# 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 +# 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. +# 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 import setuptools -import sys - - -from cinderclient.openstack.common import setup - -requires = setup.parse_requirements() -depend_links = setup.parse_dependency_links() - - -def read_file(file_name): - return open(os.path.join(os.path.dirname(__file__), file_name)).read() - setuptools.setup( - name="python-cinderclient", - version=setup.get_post_version('cinderclient'), - author="Rackspace, based on work by Jacob Kaplan-Moss", - author_email="github@racklabs.com", - description="Client library for OpenStack Cinder API.", - long_description=read_file("README.rst"), - license="Apache License, Version 2.0", - url="https://github.com/openstack/python-cinderclient", - packages=setuptools.find_packages(exclude=['tests', 'tests.*']), - cmdclass=setup.get_cmdclass(), - install_requires=requires, - dependency_links=depend_links, - tests_require=["nose", "mock"], - test_suite="nose.collector", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python" - ], - entry_points={ - "console_scripts": ["cinder = cinderclient.shell:main"] - } -) + setup_requires=['pbr>=2.0.0'], + pbr=True) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 000000000..a898b7bf7 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,12 @@ +# Hacking already pins down pep8, pyflakes and flake8 +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/tests/fakes.py b/tests/fakes.py deleted file mode 100644 index 58f632839..000000000 --- a/tests/fakes.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -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. -""" - - -def assert_has_keys(dict, required=[], optional=[]): - keys = dict.keys() - for k in required: - try: - assert k in keys - except AssertionError: - extra_keys = set(keys).difference(set(required + optional)) - raise AssertionError("found unexpected keys: %s" % - list(extra_keys)) - - -class FakeClient(object): - - def assert_called(self, method, url, body=None, pos=-1): - """ - 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) - - assert (expected == called, 'Expected %s %s; got %s %s' % - (expected + called)) - - if body is not None: - assert self.client.callstack[pos][2] == 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.client.callstack, - "Expected %s %s but no calls were made." % expected) - - found = False - for entry in self.client.callstack: - if expected == entry[0:2]: - found = True - break - - assert(found, 'Expected %s %s; got %s' % - (expected, self.client.callstack)) - - if body is not None: - try: - assert entry[2] == body - except AssertionError: - print entry[2] - print "!=" - print body - raise - - self.client.callstack = [] - - def clear_callstack(self): - self.client.callstack = [] - - def authenticate(self): - pass diff --git a/tests/test_base.py b/tests/test_base.py deleted file mode 100644 index 7eba9864a..000000000 --- a/tests/test_base.py +++ /dev/null @@ -1,48 +0,0 @@ -from cinderclient import base -from cinderclient import exceptions -from cinderclient.v1 import volumes -from tests import utils -from tests.v1 import fakes - - -cs = fakes.FakeClient() - - -class BaseTest(utils.TestCase): - - def test_resource_repr(self): - r = base.Resource(None, dict(foo="bar", baz="spam")) - self.assertEqual(repr(r), "") - - def test_getid(self): - self.assertEqual(base.getid(4), 4) - - class TmpObject(object): - id = 4 - self.assertEqual(base.getid(TmpObject), 4) - - def test_eq(self): - # Two resources of the same type with the same id: equal - r1 = base.Resource(None, {'id': 1, 'name': 'hi'}) - r2 = base.Resource(None, {'id': 1, 'name': 'hello'}) - self.assertEqual(r1, r2) - - # Two resoruces of different types: never equal - r1 = base.Resource(None, {'id': 1}) - r2 = volumes.Volume(None, {'id': 1}) - self.assertNotEqual(r1, r2) - - # Two resources with no ID: equal if their info is equal - r1 = base.Resource(None, {'name': 'joe', 'age': 12}) - r2 = base.Resource(None, {'name': 'joe', 'age': 12}) - self.assertEqual(r1, r2) - - def test_findall_invalid_attribute(self): - # Make sure findall with an invalid attribute doesn't cause errors. - # The following should not raise an exception. - cs.volumes.findall(vegetable='carrot') - - # However, find() should raise an error - self.assertRaises(exceptions.NotFound, - cs.volumes.find, - vegetable='carrot') diff --git a/tests/test_client.py b/tests/test_client.py deleted file mode 100644 index f5e4bab59..000000000 --- a/tests/test_client.py +++ /dev/null @@ -1,18 +0,0 @@ - -import cinderclient.client -import cinderclient.v1.client -from tests import utils - - -class ClientTest(utils.TestCase): - - def setUp(self): - pass - - def test_get_client_class_v1(self): - output = cinderclient.client.get_client_class('1') - self.assertEqual(output, cinderclient.v1.client.Client) - - def test_get_client_class_unknown(self): - self.assertRaises(cinderclient.exceptions.UnsupportedVersion, - cinderclient.client.get_client_class, '0') diff --git a/tests/test_http.py b/tests/test_http.py deleted file mode 100644 index 0b6ec6ff7..000000000 --- a/tests/test_http.py +++ /dev/null @@ -1,73 +0,0 @@ -import httplib2 -import mock - -from cinderclient import client -from cinderclient import exceptions -from tests import utils - - -fake_response = httplib2.Response({"status": 200}) -fake_body = '{"hi": "there"}' -mock_request = mock.Mock(return_value=(fake_response, fake_body)) - - -def get_client(): - cl = client.HTTPClient("username", "password", - "project_id", "auth_test") - return cl - - -def get_authed_client(): - cl = get_client() - cl.management_url = "http://example.com" - cl.auth_token = "token" - return cl - - -class ClientTest(utils.TestCase): - - def test_get(self): - cl = get_authed_client() - - @mock.patch.object(httplib2.Http, "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", - "User-Agent": cl.USER_AGENT, - 'Accept': 'application/json', } - mock_request.assert_called_with("http://example.com/hi", - "GET", headers=headers) - # Automatic JSON parsing - self.assertEqual(body, {"hi": "there"}) - - test_get_call() - - def test_post(self): - cl = get_authed_client() - - @mock.patch.object(httplib2.Http, "request", mock_request) - def test_post_call(): - cl.post("/hi", body=[1, 2, 3]) - headers = { - "X-Auth-Token": "token", - "X-Auth-Project-Id": "project_id", - "Content-Type": "application/json", - 'Accept': 'application/json', - "User-Agent": cl.USER_AGENT - } - mock_request.assert_called_with("http://example.com/hi", "POST", - headers=headers, body='[1, 2, 3]') - - test_post_call() - - def test_auth_failure(self): - cl = get_client() - - # response must not have x-server-management-url header - @mock.patch.object(httplib2.Http, "request", mock_request) - def test_auth_call(): - self.assertRaises(exceptions.AuthorizationFailure, cl.authenticate) - - test_auth_call() diff --git a/tests/test_service_catalog.py b/tests/test_service_catalog.py deleted file mode 100644 index 8dabd99a6..000000000 --- a/tests/test_service_catalog.py +++ /dev/null @@ -1,127 +0,0 @@ -from cinderclient import exceptions -from cinderclient import service_catalog -from tests 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": "Nova Volumes", - "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" - }, - ], - }, - ], - "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.assertEquals(sc.url_for('tenantId', '1', service_type='compute'), - "https://compute1.host/v1/1234") - self.assertEquals(sc.url_for('tenantId', '2', service_type='compute'), - "https://compute1.host/v1/3456") - - 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.assertEquals(sc.url_for('tenantId', '1', service_type='volume'), - "https://volume1.host/v1/1234") - self.assertEquals(sc.url_for('tenantId', '2', service_type='volume'), - "https://volume1.host/v1/3456") - - self.assertRaises(exceptions.EndpointNotFound, sc.url_for, - "region", "North", service_type='volume') diff --git a/tests/test_shell.py b/tests/test_shell.py deleted file mode 100644 index 902aec5b8..000000000 --- a/tests/test_shell.py +++ /dev/null @@ -1,75 +0,0 @@ -import cStringIO -import os -import httplib2 -import sys - -from cinderclient import exceptions -import cinderclient.shell -from tests import utils - - -class ShellTest(utils.TestCase): - - # Patch os.environ to avoid required auth info. - def setUp(self): - global _old_env - fake_env = { - 'OS_USERNAME': 'username', - 'OS_PASSWORD': 'password', - 'OS_TENANT_NAME': 'tenant_name', - 'OS_AUTH_URL': 'http://no.where', - } - _old_env, os.environ = os.environ, fake_env.copy() - - def shell(self, argstr): - orig = sys.stdout - try: - sys.stdout = cStringIO.StringIO() - _shell = cinderclient.shell.OpenStackCinderShell() - _shell.main(argstr.split()) - except SystemExit: - exc_type, exc_value, exc_traceback = sys.exc_info() - self.assertEqual(exc_value.code, 0) - finally: - out = sys.stdout.getvalue() - sys.stdout.close() - sys.stdout = orig - - return out - - def tearDown(self): - global _old_env - os.environ = _old_env - - def test_help_unknown_command(self): - self.assertRaises(exceptions.CommandError, self.shell, 'help foofoo') - - def test_debug(self): - httplib2.debuglevel = 0 - self.shell('--debug help') - assert httplib2.debuglevel == 1 - - def test_help(self): - required = [ - '^usage: ', - '(?m)^\s+create\s+Add a new volume.', - '(?m)^See "cinder help COMMAND" for help on a specific command', - ] - for argstr in ['--help', 'help']: - help_text = self.shell(argstr) - for r in required: - self.assertRegexpMatches(help_text, r) - - def test_help_on_subcommand(self): - required = [ - '^usage: cinder list', - '(?m)^List all the volumes.', - ] - argstrings = [ - 'list --help', - 'help list', - ] - for argstr in argstrings: - help_text = self.shell(argstr) - for r in required: - self.assertRegexpMatches(help_text, r) diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 39fb2c913..000000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,74 +0,0 @@ - -from cinderclient import exceptions -from cinderclient import utils -from cinderclient import base -from tests import utils as test_utils - -UUID = '8e8ec658-c7b0-4243-bdf8-6f7f2952c0d0' - - -class FakeResource(object): - - def __init__(self, _id, properties): - self.id = _id - try: - self.name = properties['name'] - except KeyError: - pass - try: - self.display_name = properties['display_name'] - except KeyError: - pass - - -class FakeManager(base.ManagerWithFind): - - resource_class = FakeResource - - 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): - for resource in self.resources: - if resource.id == str(resource_id): - return resource - raise exceptions.NotFound(resource_id) - - def list(self): - return self.resources - - -class FindResourceTestCase(test_utils.TestCase): - - def setUp(self): - self.manager = FakeManager(None) - - def test_find_none(self): - self.assertRaises(exceptions.CommandError, - utils.find_resource, - self.manager, - 'asdf') - - def test_find_by_integer_id(self): - output = utils.find_resource(self.manager, 1234) - self.assertEqual(output, self.manager.get('1234')) - - def test_find_by_str_id(self): - output = utils.find_resource(self.manager, '1234') - self.assertEqual(output, self.manager.get('1234')) - - def test_find_by_uuid(self): - output = utils.find_resource(self.manager, UUID) - self.assertEqual(output, self.manager.get(UUID)) - - def test_find_by_str_name(self): - output = utils.find_resource(self.manager, 'entity_one') - self.assertEqual(output, self.manager.get('1234')) - - def test_find_by_str_displayname(self): - output = utils.find_resource(self.manager, 'entity_three') - self.assertEqual(output, self.manager.get('4242')) diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 7f1c5dc71..000000000 --- a/tests/utils.py +++ /dev/null @@ -1,5 +0,0 @@ -import unittest2 - - -class TestCase(unittest2.TestCase): - pass diff --git a/tests/v1/fakes.py b/tests/v1/fakes.py deleted file mode 100644 index d93c9a46f..000000000 --- a/tests/v1/fakes.py +++ /dev/null @@ -1,789 +0,0 @@ -# Copyright (c) 2011 X.commerce, a business unit of eBay Inc. -# Copyright 2011 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. - -import httplib2 -import urlparse - -from cinderclient import client as base_client -from cinderclient.v1 import client -from tests import fakes - - -class FakeClient(fakes.FakeClient, client.Client): - - def __init__(self, *args, **kwargs): - client.Client.__init__(self, 'username', 'password', - 'project_id', 'auth_url') - self.client = FakeHTTPClient(**kwargs) - - -class FakeHTTPClient(base_client.HTTPClient): - - def __init__(self, **kwargs): - self.username = 'username' - self.password = 'password' - self.auth_url = 'auth_url' - self.callstack = [] - - 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, body = getattr(self, callback)(**kwargs) - if hasattr(status, 'items'): - return httplib2.Response(status), body - else: - return httplib2.Response({"status": status}), body - - # - # Limits - # - - def get_limits(self, **kw): - return (200, {"limits": { - "rate": [ - { - "uri": "*", - "regex": ".*", - "limit": [ - { - "value": 10, - "verb": "POST", - "remaining": 2, - "unit": "MINUTE", - "next-available": "2011-12-15T22:42:45Z" - }, - { - "value": 10, - "verb": "PUT", - "remaining": 2, - "unit": "MINUTE", - "next-available": "2011-12-15T22:42:45Z" - }, - { - "value": 100, - "verb": "DELETE", - "remaining": 100, - "unit": "MINUTE", - "next-available": "2011-12-15T22:42:45Z" - } - ] - }, - { - "uri": "*/servers", - "regex": "^/servers", - "limit": [ - { - "verb": "POST", - "value": 25, - "remaining": 24, - "unit": "DAY", - "next-available": "2011-12-15T22:42:45Z" - } - ] - } - ], - "absolute": { - "maxTotalRAMSize": 51200, - "maxServerMeta": 5, - "maxImageMeta": 5, - "maxPersonality": 5, - "maxPersonalitySize": 10240}, }, }) - - # - # Servers - # - - 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': 1234, - 'name': 'sample-volume', - 'attachments': [{'server_id': 1234}]}, - ]}) - - def get_volumes_1234(self, **kw): - r = {'volume': self.get_volumes_detail()[1]['volumes'][0]} - return (200, r) - - def post_volumes_1234_action(self, body, **kw): - _body = None - resp = 202 - assert len(body.keys()) == 1 - action = body.keys()[0] - if action == 'os-attach': - assert body[action].keys() == ['instance_uuid', 'mountpoint'] - elif action == 'os-detach': - assert body[action] == None - elif action == 'os-reserve': - assert body[action] == None - elif action == 'os-unreserve': - assert body[action] == None - elif action == 'os-initialize_connection': - assert body[action].keys() == ['connector'] - return (202, {'connection_info': 'foos'}) - elif action == 'os-terminate_connection': - assert body[action].keys() == ['connector'] - else: - raise AssertionError("Unexpected server action: %s" % action) - return (resp, _body) - - def post_servers(self, body, **kw): - assert set(body.keys()) <= set(['server', 'os:scheduler_hints']) - fakes.assert_has_keys(body['server'], - required=['name', 'imageRef', 'flavorRef'], - optional=['metadata', 'personality']) - if 'personality' in body['server']: - for pfile in body['server']['personality']: - fakes.assert_has_keys(pfile, required=['path', 'contents']) - return (202, self.get_servers_1234()[1]) - - def get_servers_1234(self, **kw): - r = {'server': self.get_servers_detail()[1]['servers'][0]} - return (200, r) - - def get_servers_5678(self, **kw): - r = {'server': self.get_servers_detail()[1]['servers'][1]} - return (200, r) - - def put_servers_1234(self, body, **kw): - assert body.keys() == ['server'] - fakes.assert_has_keys(body['server'], optional=['name', 'adminPass']) - return (204, None) - - def post_volumes(self, **kw): - return (202, {'volume': {}}) - - def delete_servers_1234(self, **kw): - return (202, None) - - def delete_volumes_1234(self, **kw): - return (202, None) - - def delete_servers_1234_metadata_test_key(self, **kw): - return (204, None) - - def delete_servers_1234_metadata_key1(self, **kw): - return (204, None) - - def delete_servers_1234_metadata_key2(self, **kw): - return (204, None) - - def post_servers_1234_metadata(self, **kw): - return (204, {'metadata': {'test_key': 'test_value'}}) - - def get_servers_1234_diagnostics(self, **kw): - return (200, {'data': 'Fake diagnostics'}) - - def get_servers_1234_actions(self, **kw): - return ( - 200, {'actions': [ - { - 'action': 'rebuild', - 'error': None, - 'created_at': '2011-12-30 11:45:36' - }, - { - 'action': 'reboot', - 'error': 'Failed!', - 'created_at': '2011-12-30 11:40:29' - }, - ]}) - - # - # Server Addresses - # - - def get_servers_1234_ips(self, **kw): - return (200, {'addresses': - self.get_servers_1234()[1]['server']['addresses']}) - - def get_servers_1234_ips_public(self, **kw): - return (200, {'public': - self.get_servers_1234_ips()[1]['addresses']['public']}) - - def get_servers_1234_ips_private(self, **kw): - return (200, {'private': - self.get_servers_1234_ips()[1]['addresses']['private']}) - - def delete_servers_1234_ips_public_1_2_3_4(self, **kw): - return (202, None) - - # - # Server actions - # - - def post_servers_1234_action(self, body, **kw): - _body = None - resp = 202 - assert len(body.keys()) == 1 - action = body.keys()[0] - if action == 'reboot': - assert body[action].keys() == ['type'] - assert body[action]['type'] in ['HARD', 'SOFT'] - elif action == 'rebuild': - keys = body[action].keys() - if 'adminPass' in keys: - keys.remove('adminPass') - assert keys == ['imageRef'] - _body = self.get_servers_1234()[1] - elif action == 'resize': - assert body[action].keys() == ['flavorRef'] - elif action == 'confirmResize': - assert body[action] is None - # This one method returns a different response code - return (204, None) - elif action == 'revertResize': - assert body[action] is None - elif action == 'migrate': - assert body[action] is None - elif action == 'rescue': - assert body[action] is None - elif action == 'unrescue': - assert body[action] is None - elif action == 'lock': - assert body[action] is None - elif action == 'unlock': - assert body[action] is None - elif action == 'addFixedIp': - assert body[action].keys() == ['networkId'] - elif action == 'removeFixedIp': - assert body[action].keys() == ['address'] - elif action == 'addFloatingIp': - assert body[action].keys() == ['address'] - elif action == 'removeFloatingIp': - assert body[action].keys() == ['address'] - elif action == 'createImage': - assert set(body[action].keys()) == set(['name', 'metadata']) - resp = dict(status=202, location="http://blah/images/456") - elif action == 'changePassword': - assert body[action].keys() == ['adminPass'] - elif action == 'os-getConsoleOutput': - assert body[action].keys() == ['length'] - return (202, {'output': 'foo'}) - elif action == 'os-getVNCConsole': - assert body[action].keys() == ['type'] - elif action == 'os-migrateLive': - assert set(body[action].keys()) == set(['host', - 'block_migration', - 'disk_over_commit']) - else: - raise AssertionError("Unexpected server action: %s" % action) - return (resp, _body) - - # - # Cloudpipe - # - - def get_os_cloudpipe(self, **kw): - return (200, {'cloudpipes': [ - {'project_id':1} - ]}) - - def post_os_cloudpipe(self, **ks): - return (202, {'instance_id': '9d5824aa-20e6-4b9f-b967-76a699fc51fd'}) - - # - # Flavors - # - - def get_flavors(self, **kw): - return (200, {'flavors': [ - {'id': 1, 'name': '256 MB Server'}, - {'id': 2, 'name': '512 MB Server'} - ]}) - - def get_flavors_detail(self, **kw): - return (200, {'flavors': [ - {'id': 1, 'name': '256 MB Server', 'ram': 256, 'disk': 10, - 'OS-FLV-EXT-DATA:ephemeral': 10}, - {'id': 2, 'name': '512 MB Server', 'ram': 512, 'disk': 20, - 'OS-FLV-EXT-DATA:ephemeral': 20} - ]}) - - def get_flavors_1(self, **kw): - return (200, {'flavor': self.get_flavors_detail()[1]['flavors'][0]}) - - def get_flavors_2(self, **kw): - return (200, {'flavor': self.get_flavors_detail()[1]['flavors'][1]}) - - def get_flavors_3(self, **kw): - # Diablo has no ephemeral - return (200, {'flavor': {'id': 3, 'name': '256 MB Server', - 'ram': 256, 'disk': 10}}) - - def delete_flavors_flavordelete(self, **kw): - return (202, None) - - def post_flavors(self, body, **kw): - return (202, {'flavor': self.get_flavors_detail()[1]['flavors'][0]}) - - # - # Floating ips - # - - def get_os_floating_ip_pools(self): - return (200, {'floating_ip_pools': [{'name': 'foo', 'name': 'bar'}]}) - - def get_os_floating_ips(self, **kw): - return (200, {'floating_ips': [ - {'id': 1, 'fixed_ip': '10.0.0.1', 'ip': '11.0.0.1'}, - {'id': 2, 'fixed_ip': '10.0.0.2', 'ip': '11.0.0.2'}, - ]}) - - def get_os_floating_ips_1(self, **kw): - return (200, {'floating_ip': - {'id': 1, 'fixed_ip': '10.0.0.1', 'ip': '11.0.0.1'} - }) - - def post_os_floating_ips(self, body, **kw): - return (202, self.get_os_floating_ips_1()[1]) - - def post_os_floating_ips(self, body): - if body.get('pool'): - return (200, {'floating_ip': - {'id': 1, 'fixed_ip': '10.0.0.1', 'ip': '11.0.0.1', - 'pool': 'cinder'}}) - else: - return (200, {'floating_ip': - {'id': 1, 'fixed_ip': '10.0.0.1', 'ip': '11.0.0.1', - 'pool': None}}) - - def delete_os_floating_ips_1(self, **kw): - return (204, None) - - def get_os_floating_ip_dns(self, **kw): - return (205, {'domain_entries': - [{'domain': 'example.org'}, - {'domain': 'example.com'}]}) - - def get_os_floating_ip_dns_testdomain_entries(self, **kw): - if kw.get('ip'): - return (205, {'dns_entries': - [{'dns_entry': - {'ip': kw.get('ip'), - 'name': "host1", - 'type': "A", - 'domain': 'testdomain'}}, - {'dns_entry': - {'ip': kw.get('ip'), - 'name': "host2", - 'type': "A", - 'domain': 'testdomain'}}]}) - else: - return (404, None) - - def get_os_floating_ip_dns_testdomain_entries_testname(self, **kw): - return (205, {'dns_entry': - {'ip': "10.10.10.10", - 'name': 'testname', - 'type': "A", - 'domain': 'testdomain'}}) - - def put_os_floating_ip_dns_testdomain(self, body, **kw): - if body['domain_entry']['scope'] == 'private': - fakes.assert_has_keys(body['domain_entry'], - required=['availability_zone', 'scope']) - elif body['domain_entry']['scope'] == 'public': - fakes.assert_has_keys(body['domain_entry'], - required=['project', 'scope']) - - else: - fakes.assert_has_keys(body['domain_entry'], - required=['project', 'scope']) - return (205, None) - - def put_os_floating_ip_dns_testdomain_entries_testname(self, body, **kw): - fakes.assert_has_keys(body['dns_entry'], - required=['ip', 'dns_type']) - return (205, None) - - def delete_os_floating_ip_dns_testdomain(self, **kw): - return (200, None) - - def delete_os_floating_ip_dns_testdomain_entries_testname(self, **kw): - return (200, None) - - # - # Images - # - def get_images(self, **kw): - return (200, {'images': [ - {'id': 1, 'name': 'CentOS 5.2'}, - {'id': 2, 'name': 'My Server Backup'} - ]}) - - def get_images_detail(self, **kw): - return (200, {'images': [ - { - 'id': 1, - 'name': 'CentOS 5.2', - "updated": "2010-10-10T12:00:00Z", - "created": "2010-08-10T12:00:00Z", - "status": "ACTIVE", - "metadata": { - "test_key": "test_value", - }, - "links": {}, - }, - { - "id": 743, - "name": "My Server Backup", - "serverId": 1234, - "updated": "2010-10-10T12:00:00Z", - "created": "2010-08-10T12:00:00Z", - "status": "SAVING", - "progress": 80, - "links": {}, - } - ]}) - - def get_images_1(self, **kw): - return (200, {'image': self.get_images_detail()[1]['images'][0]}) - - def get_images_2(self, **kw): - return (200, {'image': self.get_images_detail()[1]['images'][1]}) - - def post_images(self, body, **kw): - assert body.keys() == ['image'] - fakes.assert_has_keys(body['image'], required=['serverId', 'name']) - return (202, self.get_images_1()[1]) - - def post_images_1_metadata(self, body, **kw): - assert body.keys() == ['metadata'] - fakes.assert_has_keys(body['metadata'], - required=['test_key']) - return (200, - {'metadata': self.get_images_1()[1]['image']['metadata']}) - - def delete_images_1(self, **kw): - return (204, None) - - def delete_images_1_metadata_test_key(self, **kw): - return (204, None) - - # - # Keypairs - # - def get_os_keypairs(self, *kw): - return (200, {"keypairs": [ - {'fingerprint': 'FAKE_KEYPAIR', 'name': 'test'} - ]}) - - def delete_os_keypairs_test(self, **kw): - return (202, None) - - def post_os_keypairs(self, body, **kw): - assert body.keys() == ['keypair'] - fakes.assert_has_keys(body['keypair'], - required=['name']) - r = {'keypair': self.get_os_keypairs()[1]['keypairs'][0]} - return (202, r) - - # - # Virtual Interfaces - # - def get_servers_1234_os_virtual_interfaces(self, **kw): - return (200, {"virtual_interfaces": [ - {'id': 'fakeid', 'mac_address': 'fakemac'} - ]}) - - # - # Quotas - # - - def get_os_quota_sets_test(self, **kw): - return (200, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'injected_file_content_bytes': 1, - 'volumes': 1, - 'gigabytes': 1, - 'ram': 1, - 'floating_ips': 1, - 'instances': 1, - 'injected_files': 1, - 'cores': 1}}) - - def get_os_quota_sets_test_defaults(self): - return (200, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'injected_file_content_bytes': 1, - 'volumes': 1, - 'gigabytes': 1, - 'ram': 1, - 'floating_ips': 1, - 'instances': 1, - 'injected_files': 1, - 'cores': 1}}) - - def put_os_quota_sets_test(self, body, **kw): - assert body.keys() == ['quota_set'] - fakes.assert_has_keys(body['quota_set'], - required=['tenant_id']) - return (200, {'quota_set': { - 'tenant_id': 'test', - 'metadata_items': [], - 'injected_file_content_bytes': 1, - 'volumes': 2, - 'gigabytes': 1, - 'ram': 1, - 'floating_ips': 1, - 'instances': 1, - 'injected_files': 1, - 'cores': 1}}) - - # - # Quota Classes - # - - def get_os_quota_class_sets_test(self, **kw): - return (200, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], - 'injected_file_content_bytes': 1, - 'volumes': 1, - 'gigabytes': 1, - 'ram': 1, - 'floating_ips': 1, - 'instances': 1, - 'injected_files': 1, - 'cores': 1}}) - - def put_os_quota_class_sets_test(self, body, **kw): - assert body.keys() == ['quota_class_set'] - fakes.assert_has_keys(body['quota_class_set'], - required=['class_name']) - return (200, {'quota_class_set': { - 'class_name': 'test', - 'metadata_items': [], - 'injected_file_content_bytes': 1, - 'volumes': 2, - 'gigabytes': 1, - 'ram': 1, - 'floating_ips': 1, - 'instances': 1, - 'injected_files': 1, - 'cores': 1}}) - - # - # Security Groups - # - def get_os_security_groups(self, **kw): - return (200, {"security_groups": - [ - {'id': 1, 'name': 'test', - 'description': 'FAKE_SECURITY_GROUP'} - ]}) - - def get_os_security_groups_1(self, **kw): - return (200, {"security_group": - {'id': 1, 'name': 'test', 'description': 'FAKE_SECURITY_GROUP'} - }) - - def delete_os_security_groups_1(self, **kw): - return (202, None) - - def post_os_security_groups(self, body, **kw): - assert body.keys() == ['security_group'] - fakes.assert_has_keys(body['security_group'], - required=['name', 'description']) - r = {'security_group': - self.get_os_security_groups()[1]['security_groups'][0]} - return (202, r) - - # - # Security Group Rules - # - def get_os_security_group_rules(self, **kw): - return (200, {"security_group_rules": [ - {'id': 1, 'parent_group_id': 1, 'group_id': 2, - 'ip_protocol': 'TCP', 'from_port': '22', 'to_port': 22, - 'cidr': '10.0.0.0/8'} - ]}) - - def delete_os_security_group_rules_1(self, **kw): - return (202, None) - - def post_os_security_group_rules(self, body, **kw): - assert body.keys() == ['security_group_rule'] - fakes.assert_has_keys(body['security_group_rule'], - required=['parent_group_id'], - optional=['group_id', 'ip_protocol', 'from_port', - 'to_port', 'cidr']) - r = {'security_group_rule': - self.get_os_security_group_rules()[1]['security_group_rules'][0]} - return (202, r) - - # - # Tenant Usage - # - def get_os_simple_tenant_usage(self, **kw): - return (200, {u'tenant_usages': [{ - u'total_memory_mb_usage': 25451.762807466665, - u'total_vcpus_usage': 49.71047423333333, - u'total_hours': 49.71047423333333, - u'tenant_id': u'7b0a1d73f8fb41718f3343c207597869', - u'stop': u'2012-01-22 19:48:41.750722', - u'server_usages': [{ - u'hours': 49.71047423333333, - u'uptime': 27035, u'local_gb': 0, u'ended_at': None, - u'name': u'f15image1', - u'tenant_id': u'7b0a1d73f8fb41718f3343c207597869', - u'vcpus': 1, u'memory_mb': 512, u'state': u'active', - u'flavor': u'm1.tiny', - u'started_at': u'2012-01-20 18:06:06.479998'}], - u'start': u'2011-12-25 19:48:41.750687', - u'total_local_gb_usage': 0.0}]}) - - def get_os_simple_tenant_usage_tenantfoo(self, **kw): - return (200, {u'tenant_usage': { - u'total_memory_mb_usage': 25451.762807466665, - u'total_vcpus_usage': 49.71047423333333, - u'total_hours': 49.71047423333333, - u'tenant_id': u'7b0a1d73f8fb41718f3343c207597869', - u'stop': u'2012-01-22 19:48:41.750722', - u'server_usages': [{ - u'hours': 49.71047423333333, - u'uptime': 27035, u'local_gb': 0, u'ended_at': None, - u'name': u'f15image1', - u'tenant_id': u'7b0a1d73f8fb41718f3343c207597869', - u'vcpus': 1, u'memory_mb': 512, u'state': u'active', - u'flavor': u'm1.tiny', - u'started_at': u'2012-01-20 18:06:06.479998'}], - u'start': u'2011-12-25 19:48:41.750687', - u'total_local_gb_usage': 0.0}}) - - # - # Certificates - # - def get_os_certificates_root(self, **kw): - return (200, {'certificate': {'private_key': None, 'data': 'foo'}}) - - def post_os_certificates(self, **kw): - return (200, {'certificate': {'private_key': 'foo', 'data': 'bar'}}) - - # - # Aggregates - # - def get_os_aggregates(self, *kw): - return (200, {"aggregates": [ - {'id':'1', - 'name': 'test', - 'availability_zone': 'cinder1'}, - {'id':'2', - 'name': 'test2', - 'availability_zone': 'cinder1'}, - ]}) - - def _return_aggregate(self): - r = {'aggregate': self.get_os_aggregates()[1]['aggregates'][0]} - return (200, r) - - def get_os_aggregates_1(self, **kw): - return self._return_aggregate() - - def post_os_aggregates(self, body, **kw): - return self._return_aggregate() - - def put_os_aggregates_1(self, body, **kw): - return self._return_aggregate() - - def put_os_aggregates_2(self, body, **kw): - return self._return_aggregate() - - def post_os_aggregates_1_action(self, body, **kw): - return self._return_aggregate() - - def post_os_aggregates_2_action(self, body, **kw): - return self._return_aggregate() - - def delete_os_aggregates_1(self, **kw): - return (202, None) - - # - # Hosts - # - def get_os_hosts_host(self, *kw): - return (200, {'host': - [{'resource': {'project': '(total)', 'host': 'dummy', - 'cpu': 16, 'memory_mb': 32234, 'disk_gb': 128}}, - {'resource': {'project': '(used_now)', 'host': 'dummy', - 'cpu': 1, 'memory_mb': 2075, 'disk_gb': 45}}, - {'resource': {'project': '(used_max)', 'host': 'dummy', - 'cpu': 1, 'memory_mb': 2048, 'disk_gb': 30}}, - {'resource': {'project': 'admin', 'host': 'dummy', - 'cpu': 1, 'memory_mb': 2048, 'disk_gb': 30}}]}) - - def get_os_hosts_sample_host(self, *kw): - return (200, {'host': [{'resource': {'host': 'sample_host'}}], }) - - def put_os_hosts_sample_host_1(self, body, **kw): - return (200, {'host': 'sample-host_1', - 'status': 'enabled'}) - - def put_os_hosts_sample_host_2(self, body, **kw): - return (200, {'host': 'sample-host_2', - 'maintenance_mode': 'on_maintenance'}) - - def put_os_hosts_sample_host_3(self, body, **kw): - return (200, {'host': 'sample-host_3', - 'status': 'enabled', - 'maintenance_mode': 'on_maintenance'}) - - def get_os_hosts_sample_host_startup(self, **kw): - return (200, {'host': 'sample_host', - 'power_action': 'startup'}) - - def get_os_hosts_sample_host_reboot(self, **kw): - return (200, {'host': 'sample_host', - 'power_action': 'reboot'}) - - def get_os_hosts_sample_host_shutdown(self, **kw): - return (200, {'host': 'sample_host', - 'power_action': 'shutdown'}) - - def put_os_hosts_sample_host(self, body, **kw): - result = {'host': 'dummy'} - result.update(body) - return (200, result) diff --git a/tests/v1/test_shell.py b/tests/v1/test_shell.py deleted file mode 100644 index 7efad0e60..000000000 --- a/tests/v1/test_shell.py +++ /dev/null @@ -1,77 +0,0 @@ -# Copyright 2010 Jacob Kaplan-Moss - -# Copyright 2011 OpenStack LLC. -# 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 cinderclient import client -from cinderclient import shell -from tests.v1 import fakes -from tests import utils - - -class ShellTest(utils.TestCase): - - # Patch os.environ to avoid required auth info. - def setUp(self): - """Run before each test.""" - self.old_environment = os.environ.copy() - os.environ = { - 'CINDER_USERNAME': 'username', - 'CINDER_PASSWORD': 'password', - 'CINDER_PROJECT_ID': 'project_id', - 'OS_COMPUTE_API_VERSION': '1.1', - 'CINDER_URL': 'http://no.where', - } - - 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 - - def tearDown(self): - os.environ = self.old_environment - # 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 - - 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_list(self): - self.run_command('list') - # NOTE(jdg): we default to detail currently - self.assert_called('GET', '/volumes/detail') - - def test_show(self): - self.run_command('show 1234') - self.assert_called('GET', '/volumes/1234') - - def test_delete(self): - self.run_command('delete 1234') diff --git a/tests/v1/test_volumes.py b/tests/v1/test_volumes.py deleted file mode 100644 index aa46574f9..000000000 --- a/tests/v1/test_volumes.py +++ /dev/null @@ -1,52 +0,0 @@ -from cinderclient.v1 import volumes -from tests import utils -from tests.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_keypair(self): - kp = 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') - 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.reserve(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') diff --git a/tests/v1/testfile.txt b/tests/v1/testfile.txt deleted file mode 100644 index e4e860f38..000000000 --- a/tests/v1/testfile.txt +++ /dev/null @@ -1 +0,0 @@ -BLAH diff --git a/tests/v1/utils.py b/tests/v1/utils.py deleted file mode 100644 index f878a5e26..000000000 --- a/tests/v1/utils.py +++ /dev/null @@ -1,29 +0,0 @@ -from nose.tools import ok_ - - -def fail(msg): - raise AssertionError(msg) - - -def assert_in(thing, seq, msg=None): - msg = msg or "'%s' not found in %s" % (thing, seq) - ok_(thing in seq, msg) - - -def assert_not_in(thing, seq, msg=None): - msg = msg or "unexpected '%s' found in %s" % (thing, seq) - ok_(thing not in seq, msg) - - -def assert_has_keys(dict, required=[], optional=[]): - keys = dict.keys() - for k in required: - assert_in(k, keys, "required key %s missing from %s" % (k, dict)) - allowed_keys = set(required) | set(optional) - extra_keys = set(keys).difference(set(required + optional)) - if extra_keys: - fail("found unexpected keys: %s" % list(extra_keys)) - - -def assert_isinstance(thing, kls): - ok_(isinstance(thing, kls), "%s is not an instance of %s" % (thing, kls)) diff --git a/tools/cinder.bash_completion b/tools/cinder.bash_completion index 060bf1f7d..1d981089b 100644 --- a/tools/cinder.bash_completion +++ b/tools/cinder.bash_completion @@ -1,15 +1,27 @@ +_cinder_opts="" # lazy init +_cinder_flags="" # lazy init +_cinder_opts_exp="" # lazy init + _cinder() { - local cur prev opts + local cur prev cbc cflags COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - opts="$(cinder bash_completion)" - - COMPLETION_CACHE=~/.cinderclient/*/*-cache - opts+=" "$(cat $COMPLETION_CACHE 2> /dev/null | tr '\n' ' ') + if [ "x$_cinder_opts" == "x" ] ; then + 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 - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + if [[ "$prev" != "help" ]] ; then + COMPLETION_CACHE=~/.cache/cinderclient/*/*-cache + cflags="$_cinder_flags $_cinder_opts "$(cat $COMPLETION_CACHE 2> /dev/null | tr '\n' ' ') + COMPREPLY=($(compgen -W "${cflags}" -- ${cur})) + else + COMPREPLY=($(compgen -W "${_cinder_opts}" -- ${cur})) + fi + return 0 } complete -F _cinder cinder diff --git a/tools/install_venv.py b/tools/install_venv.py deleted file mode 100644 index db0e32bce..000000000 --- a/tools/install_venv.py +++ /dev/null @@ -1,245 +0,0 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# All Rights Reserved. -# -# Copyright 2010 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. - -""" -Installation script for Nova's development virtualenv -""" - -import optparse -import os -import subprocess -import sys -import platform - - -ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) -VENV = os.path.join(ROOT, '.venv') -PIP_REQUIRES = os.path.join(ROOT, 'tools', 'pip-requires') -TEST_REQUIRES = os.path.join(ROOT, 'tools', 'test-requires') -PY_VERSION = "python%s.%s" % (sys.version_info[0], sys.version_info[1]) - - -def die(message, *args): - print >> sys.stderr, message % args - sys.exit(1) - - -def check_python_version(): - if sys.version_info < (2, 6): - die("Need Python Version >= 2.6") - - -def run_command_with_code(cmd, redirect_output=True, check_exit_code=True): - """ - Runs a command in an out-of-process shell, returning the - output of that command. Working directory is ROOT. - """ - if redirect_output: - stdout = subprocess.PIPE - else: - stdout = None - - proc = subprocess.Popen(cmd, cwd=ROOT, stdout=stdout) - output = proc.communicate()[0] - if check_exit_code and proc.returncode != 0: - die('Command "%s" failed.\n%s', ' '.join(cmd), output) - return (output, proc.returncode) - - -def run_command(cmd, redirect_output=True, check_exit_code=True): - return run_command_with_code(cmd, redirect_output, check_exit_code)[0] - - -class Distro(object): - - def check_cmd(self, cmd): - return bool(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...', - if run_command(['easy_install', 'virtualenv']): - print 'Succeeded' - return - else: - print 'Failed' - - die('ERROR: virtualenv not found.\n\nDevelopment' - ' requires virtualenv, please install it using your' - ' favorite package management tool') - - def post_process(self): - """Any distribution-specific post-processing gets done here. - - In particular, this is useful for applying patches to code inside - the venv.""" - pass - - -class Debian(Distro): - """This covers all Debian-based distributions.""" - - def check_pkg(self, pkg): - return run_command_with_code(['dpkg', '-l', pkg], - check_exit_code=False)[1] == 0 - - def apt_install(self, pkg, **kwargs): - run_command(['sudo', 'apt-get', 'install', '-y', pkg], **kwargs) - - def apply_patch(self, originalfile, patchfile): - run_command(['patch', originalfile, patchfile]) - - def install_virtualenv(self): - if self.check_cmd('virtualenv'): - return - - if not self.check_pkg('python-virtualenv'): - self.apt_install('python-virtualenv', check_exit_code=False) - - super(Debian, self).install_virtualenv() - - -class Fedora(Distro): - """This covers all Fedora-based distributions. - - Includes: Fedora, RHEL, CentOS, Scientific Linux""" - - def check_pkg(self, pkg): - return run_command_with_code(['rpm', '-q', pkg], - check_exit_code=False)[1] == 0 - - def yum_install(self, pkg, **kwargs): - run_command(['sudo', 'yum', 'install', '-y', pkg], **kwargs) - - def apply_patch(self, originalfile, patchfile): - run_command(['patch', originalfile, patchfile]) - - def install_virtualenv(self): - if self.check_cmd('virtualenv'): - return - - if not self.check_pkg('python-virtualenv'): - self.yum_install('python-virtualenv', check_exit_code=False) - - super(Fedora, self).install_virtualenv() - - -def get_distro(): - if os.path.exists('/etc/fedora-release') or \ - os.path.exists('/etc/redhat-release'): - return Fedora() - elif os.path.exists('/etc/debian_version'): - return Debian() - else: - return Distro() - - -def check_dependencies(): - get_distro().install_virtualenv() - - -def create_virtualenv(venv=VENV, no_site_packages=True): - """Creates the virtual environment and installs PIP only into the - virtual environment - """ - print 'Creating venv...', - if no_site_packages: - run_command(['virtualenv', '-q', '--no-site-packages', VENV]) - else: - run_command(['virtualenv', '-q', VENV]) - print 'done.' - print 'Installing pip in virtualenv...', - if not run_command(['tools/with_venv.sh', 'easy_install', - 'pip>1.0']).strip(): - die("Failed to install pip.") - print 'done.' - - -def pip_install(*args): - run_command(['tools/with_venv.sh', - 'pip', 'install', '--upgrade'] + list(args), - redirect_output=False) - - -def install_dependencies(venv=VENV): - print 'Installing dependencies with pip (this can take a while)...' - - # First things first, make sure our venv has the latest pip and distribute. - pip_install('pip') - pip_install('distribute') - - pip_install('-r', PIP_REQUIRES) - pip_install('-r', TEST_REQUIRES) - # Tell the virtual env how to "import cinder" - pthfile = os.path.join(venv, "lib", PY_VERSION, "site-packages", - "cinderclient.pth") - f = open(pthfile, 'w') - f.write("%s\n" % ROOT) - - -def post_process(): - get_distro().post_process() - - -def print_help(): - help = """ - python-cinderclient development environment setup is complete. - - python-cinderclient development uses virtualenv to track and manage Python - dependencies while in development and testing. - - To activate the python-cinderclient virtualenv for the extent of your - current shell session you can run: - - $ source .venv/bin/activate - - Or, if you prefer, you can run commands in the virtualenv on a case by case - basis by running: - - $ tools/with_venv.sh - - Also, make test will automatically use the virtualenv. - """ - print help - - -def parse_args(): - """Parse command-line arguments""" - parser = optparse.OptionParser() - parser.add_option("-n", "--no-site-packages", dest="no_site_packages", - default=False, action="store_true", - help="Do not inherit packages from global Python install") - return parser.parse_args() - - -def main(argv): - (options, args) = parse_args() - check_python_version() - check_dependencies() - create_virtualenv(no_site_packages=options.no_site_packages) - install_dependencies() - post_process() - print_help() - -if __name__ == '__main__': - main(sys.argv) diff --git a/tools/lintstack.py b/tools/lintstack.py new file mode 100755 index 000000000..52251e875 --- /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, 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/pip-requires b/tools/pip-requires deleted file mode 100644 index 32cdbc0b5..000000000 --- a/tools/pip-requires +++ /dev/null @@ -1,4 +0,0 @@ -argparse -httplib2 -prettytable -simplejson diff --git a/tools/rfc.sh b/tools/rfc.sh deleted file mode 100755 index d4dc59745..000000000 --- a/tools/rfc.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/sh -e -# Copyright (c) 2010-2011 Gluster, Inc. -# This initial version of this file was taken from the source tree -# of GlusterFS. It was not directly attributed, but is assumed to be -# Copyright (c) 2010-2011 Gluster, Inc and release GPLv3 -# Subsequent modifications are Copyright (c) 2011 OpenStack, LLC. -# -# GlusterFS is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published -# by the Free Software Foundation; either version 3 of the License, -# or (at your option) any later version. -# -# GlusterFS is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see -# . - - -branch="master"; - -set_hooks_commit_msg() -{ - top_dir=`git rev-parse --show-toplevel` - f="${top_dir}/.git/hooks/commit-msg"; - u="https://review.openstack.org/tools/hooks/commit-msg"; - - if [ -x "$f" ]; then - return; - fi - - curl -o $f $u || wget -O $f $u; - - chmod +x $f; - - GIT_EDITOR=true git commit --amend -} - -add_remote() -{ - username=$1 - project=$2 - - echo "No remote set, testing ssh://$username@review.openstack.org:29418" - if project_list=`ssh -p29418 -o StrictHostKeyChecking=no $username@review.openstack.org gerrit ls-projects 2>/dev/null` - then - echo "$username@review.openstack.org:29418 worked." - if echo $project_list | grep $project >/dev/null - then - echo "Creating a git remote called gerrit that maps to:" - echo " ssh://$username@review.openstack.org:29418/$project" - git remote add gerrit ssh://$username@review.openstack.org:29418/$project - else - echo "The current project name, $project, is not a known project." - echo "Please either reclone from github/gerrit or create a" - echo "remote named gerrit that points to the intended project." - return 1 - fi - - return 0 - fi - return 1 -} - -check_remote() -{ - if ! git remote | grep gerrit >/dev/null 2>&1 - then - origin_project=`git remote show origin | grep 'Fetch URL' | perl -nle '@fields = split(m|[:/]|); $len = $#fields; print $fields[$len-1], "/", $fields[$len];'` - if add_remote $USERNAME $origin_project - then - return 0 - else - echo "Your local name doesn't work on Gerrit." - echo -n "Enter Gerrit username (same as launchpad): " - read gerrit_user - if add_remote $gerrit_user $origin_project - then - return 0 - else - echo "Can't infer where gerrit is - please set a remote named" - echo "gerrit manually and then try again." - echo - echo "For more information, please see:" - echo "\thttp://wiki.openstack.org/GerritWorkflow" - exit 1 - fi - fi - fi -} - -rebase_changes() -{ - git fetch; - - GIT_EDITOR=true git rebase -i origin/$branch || exit $?; -} - - -assert_diverge() -{ - if ! git diff origin/$branch..HEAD | grep -q . - then - echo "No changes between the current branch and origin/$branch." - exit 1 - fi -} - - -main() -{ - set_hooks_commit_msg; - - check_remote; - - rebase_changes; - - assert_diverge; - - bug=$(git show --format='%s %b' | perl -nle 'if (/\b([Bb]ug|[Ll][Pp])\s*[#:]?\s*(\d+)/) {print "$2"; exit}') - - bp=$(git show --format='%s %b' | perl -nle 'if (/\b([Bb]lue[Pp]rint|[Bb][Pp])\s*[#:]?\s*([0-9a-zA-Z-_]+)/) {print "$2"; exit}') - - if [ "$DRY_RUN" = 1 ]; then - drier='echo -e Please use the following command to send your commits to review:\n\n' - else - drier= - fi - - local_branch=`git branch | grep -Ei "\* (.*)" | cut -f2 -d' '` - if [ -z "$bug" ]; then - if [ -z "$bp" ]; then - $drier git push gerrit HEAD:refs/for/$branch/$local_branch; - else - $drier git push gerrit HEAD:refs/for/$branch/bp/$bp; - fi - else - $drier git push gerrit HEAD:refs/for/$branch/bug/$bug; - fi -} - -main "$@" diff --git a/tools/test-requires b/tools/test-requires deleted file mode 100644 index b02b502b1..000000000 --- a/tools/test-requires +++ /dev/null @@ -1,9 +0,0 @@ - -distribute>=0.6.24 -mock -nose -nosexcover -openstack.nose_plugin -pep8==1.2 -sphinx>=1.1.2 -unittest2 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 c54b59615..ee03dcd22 100644 --- a/tox.ini +++ b/tox.ini @@ -1,42 +1,125 @@ [tox] -envlist = py26,py27,pep8 +distribute = False +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] -setenv = VIRTUAL_ENV={envdir} - NOSE_WITH_OPENSTACK=1 - NOSE_OPENSTACK_COLOR=1 - NOSE_OPENSTACK_RED=0.05 - NOSE_OPENSTACK_YELLOW=0.025 - NOSE_OPENSTACK_SHOW_ELAPSED=1 -deps = -r{toxinidir}/tools/pip-requires - -r{toxinidir}/tools/test-requires -commands = nosetests +basepython = python3 +usedevelop = True +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 = + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = find . -type f -name "*.pyc" -delete + stestr run {posargs} + stestr slowest +allowlist_externals = find [testenv:pep8] -deps = pep8 -commands = pep8 --repeat --show-source cinderclient setup.py +commands = + flake8 + doc8 + +[testenv:pylint] +deps = + -c{env:TOX_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} + -r{toxinidir}/requirements.txt + pylint==4.0.5 +commands = bash tools/lintstack.sh +allowlist_externals = bash [testenv:venv] commands = {posargs} [testenv:cover] -commands = nosetests --cover-erase --cover-package=cinderclient --with-xcoverage +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 -[tox:jenkins] -downloadcache = ~/cache/pip +[testenv:docs] +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:jenkins26] -basepython = python2.6 -setenv = NOSE_WITH_XUNIT=1 +[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:jenkins27] -basepython = python2.7 -setenv = NOSE_WITH_XUNIT=1 +[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:jenkinscover] -setenv = NOSE_WITH_XUNIT=1 -commands = nosetests --cover-erase --cover-package=cinderclient --with-xcoverage +[testenv:functional] +deps = + {[testenv]deps} + tempest>=26.0.0 +commands = stestr run {posargs} +setenv = + # 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 -[testenv:jenkinsvenv] -setenv = NOSE_WITH_XUNIT=1 -commands = {posargs} +# 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_* + +[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 = 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