diff options
37 files changed, 1408 insertions, 467 deletions
@@ -14,6 +14,7 @@ python_glanceclient.egg-info ChangeLog run_tests.err.log .testrepository +.stestr/ .tox doc/source/api doc/build diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..44d7432 --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./glanceclient/tests/unit +top_path=./ diff --git a/.testr.conf b/.testr.conf deleted file mode 100644 index a2f6f4d..0000000 --- a/.testr.conf +++ /dev/null @@ -1,4 +0,0 @@ -[DEFAULT] -test_command=${PYTHON:-python} -m subunit.run discover -t ./ ${OS_TEST_PATH:-./glanceclient/tests/unit} $LISTOPT $IDOPTION -test_id_option=--load-list $IDFILE -test_list_option=--list @@ -1,16 +1,54 @@ - job: + name: glanceclient-dsvm-functional-v1 + parent: devstack-tox-functional + description: | + Devstack-based functional tests for glanceclient + against the Image API v1. + + The Image API v1 is removed from glance in Rocky, but + is still supported by glanceclient until the S cycle, + so we test it against glance stable/queens. + + THIS JOB SHOULD BE REMOVED AT THE BEGINNING OF THE S + CYCLE. + override-checkout: stable/queens + required-projects: + - name: openstack/python-glanceclient + override-checkout: master + timeout: 4200 + vars: + tox_envlist: functional-v1 + devstack_localrc: + GLANCE_V1_ENABLED: true + devstack_services: + # turn off ceilometer + ceilometer-acentral: false + ceilometer-acompute: false + ceilometer-alarm-evaluator: false + ceilometer-alarm-notifier: false + ceilometer-anotification: false + ceilometer-api: false + ceilometer-collector: false + # turn on swift + s-account: true + s-container: true + s-object: true + s-proxy: true + # Hardcode glanceclient path so the job can be run on glance patches + zuul_work_dir: src/git.openstack.org/openstack/python-glanceclient + +- job: name: glanceclient-dsvm-functional parent: devstack-tox-functional description: | - devstack-based functional tests for glanceclient + Devstack-based functional tests for glanceclient. + + These test glanceclient against Image API v2 only. required-projects: - openstack/python-glanceclient timeout: 4200 vars: devstack_localrc: - # TODO(rosmaita): remove when glanceclient tests no longer - # use the Images v1 API - GLANCE_V1_ENABLED: true LIBS_FROM_GIT: python-glanceclient devstack_services: # turn off ceilometer @@ -30,18 +68,76 @@ zuul_work_dir: src/git.openstack.org/openstack/python-glanceclient - job: - name: glanceclient-dsvm-functional-identity-v3-only + name: glanceclient-tox-keystone-tips-base + parent: tox + description: Abstract job for glanceclient vs. keystone + required-projects: + - name: openstack/keystoneauth + +- job: + name: glanceclient-tox-py27-keystone-tips + parent: glanceclient-tox-keystone-tips-base + description: | + glanceclient py27 unit tests vs. keystone masters + vars: + tox_envlist: py27 + +- job: + name: glanceclient-tox-py35-keystone-tips + parent: glanceclient-tox-keystone-tips-base + description: | + glanceclient py35 unit tests vs. keystone masters + vars: + tox_envlist: py35 + +- job: + name: glanceclient-tox-oslo-tips-base + parent: tox + description: Abstract job for glanceclient vs. oslo + required-projects: + - name: openstack/oslo.i18n + - name: openstack/oslo.utils + +- job: + name: glanceclient-tox-py27-oslo-tips + parent: glanceclient-tox-oslo-tips-base + description: | + glanceclient py27 unit tests vs. oslo masters + vars: + tox_envlist: py27 + +- job: + name: glanceclient-tox-py35-oslo-tips + parent: glanceclient-tox-oslo-tips-base + description: | + glanceclient py35 unit tests vs. oslo masters + vars: + tox_envlist: py35 + +- job: + name: glanceclient-dsvm-functional-py3 parent: glanceclient-dsvm-functional vars: devstack_localrc: - ENABLE_IDENTITY_V2: false + USE_PYTHON3: true - project: check: jobs: + - glanceclient-dsvm-functional-v1 - glanceclient-dsvm-functional - - glanceclient-dsvm-functional-identity-v3-only: - voting: false + - openstack-tox-lower-constraints gate: jobs: + - glanceclient-dsvm-functional-v1 - glanceclient-dsvm-functional + - openstack-tox-lower-constraints + periodic: + jobs: + - glanceclient-tox-py27-keystone-tips + - glanceclient-tox-py35-keystone-tips + - glanceclient-tox-py27-oslo-tips + - glanceclient-tox-py35-oslo-tips + experimental: + jobs: + - glanceclient-dsvm-functional-py3 @@ -22,13 +22,9 @@ Python bindings to the OpenStack Images API =========================================== .. image:: https://img.shields.io/pypi/v/python-glanceclient.svg - :target: https://pypi.python.org/pypi/python-glanceclient/ + :target: https://pypi.org/project/python-glanceclient/ :alt: Latest Version -.. image:: https://img.shields.io/pypi/dm/python-glanceclient.svg - :target: https://pypi.python.org/pypi/python-glanceclient/ - :alt: Downloads - This is a client library for Glance built on the OpenStack Images API. It provides a Python API (the ``glanceclient`` module) and a command-line tool (``glance``). This library fully supports the v1 Images API, while support for the v2 API is in progress. Development takes place via the usual OpenStack processes as outlined in the `developer guide <http://docs.openstack.org/infra/manual/developers.html>`_. The master repository is in `Git <https://git.openstack.org/cgit/openstack/python-glanceclient>`_. @@ -45,7 +41,7 @@ See release notes and more at `<http://docs.openstack.org/python-glanceclient/>` * `Specs`_ * `How to Contribute`_ -.. _PyPi: https://pypi.python.org/pypi/python-glanceclient +.. _PyPi: https://pypi.org/project/python-glanceclient .. _Online Documentation: https://docs.openstack.org/python-glanceclient/latest/ .. _Launchpad project: https://launchpad.net/python-glanceclient .. _Blueprints: https://blueprints.launchpad.net/python-glanceclient @@ -53,4 +49,4 @@ See release notes and more at `<http://docs.openstack.org/python-glanceclient/>` .. _Source: https://git.openstack.org/cgit/openstack/python-glanceclient .. _How to Contribute: https://docs.openstack.org/infra/manual/developers.html .. _Specs: https://specs.openstack.org/openstack/glance-specs/ - +.. _Release notes: https://docs.openstack.org/releasenotes/python-glanceclient diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000..4faabc1 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,7 @@ +# The order of packages is significant, because pip processes them in the order +# of appearance. Changing the order has an impact on the overall integration +# process, which may cause wedges in the gate later. +openstackdocstheme>=1.18.1 # Apache-2.0 +reno>=2.5.0 # Apache-2.0 +sphinx!=1.6.6,!=1.6.7,>=1.6.2 # BSD +sphinxcontrib-apidoc>=0.2.0 # BSD diff --git a/doc/source/cli/property-keys.rst b/doc/source/cli/property-keys.rst index a169cdc..9e59124 100644 --- a/doc/source/cli/property-keys.rst +++ b/doc/source/cli/property-keys.rst @@ -2,354 +2,28 @@ Image service property keys =========================== -The following keys, together with the components to which they are specific, -can be used with the property option for both the -:command:`openstack image set` and :command:`openstack image create` commands. +You can use the glanceclient command line interface to set image properties +that can be consumed by other services to affect the behavior of those other +services. + +Properties can be set on an image at the time of image creation or they +can be set on an existing image. Use the :command:`openstack image create` +and :command:`openstack image set` commands respectively. + For example: .. code-block:: console $ openstack image set IMG-UUID --property architecture=x86_64 +For a list of image properties that can be used to affect the behavior +of other services, refer to `Useful image properties +<https://docs.openstack.org/glance/latest/admin/useful-image-properties.html>`_ +in the Glance Administration Guide. + .. note:: Behavior set using image properties overrides behavior set using flavors. - For more information, refer to the `Manage images + For more information, refer to `Manage images <https://docs.openstack.org/glance/latest/admin/manage-images.html>`_ - in the OpenStack Administrator Guide. - -.. list-table:: Image service property keys - :widths: 15 35 50 90 - :header-rows: 1 - - * - Specific to - - Key - - Description - - Supported values - * - All - - ``architecture`` - - The CPU architecture that must be supported by the hypervisor. For - example, ``x86_64``, ``arm``, or ``ppc64``. Run :command:`uname -m` - to get the architecture of a machine. We strongly recommend using - the architecture data vocabulary defined by the `libosinfo project - <http://libosinfo.org/>`_ for this purpose. - - * ``alpha`` - `DEC 64-bit RISC - <https://en.wikipedia.org/wiki/DEC_Alpha>`_ - * ``armv7l`` - `ARM Cortex-A7 MPCore - <https://en.wikipedia.org/wiki/ARM_architecture>`_ - * ``cris`` - `Ethernet, Token Ring, AXis—Code Reduced Instruction - Set <https://en.wikipedia.org/wiki/ETRAX_CRIS>`_ - * ``i686`` - `Intel sixth-generation x86 (P6 micro architecture) - <https://en.wikipedia.org/wiki/X86>`_ - * ``ia64`` - `Itanium <https://en.wikipedia.org/wiki/Itanium>`_ - * ``lm32`` - `Lattice Micro32 - <https://en.wikipedia.org/wiki/Milkymist>`_ - * ``m68k`` - `Motorola 68000 - <https://en.wikipedia.org/wiki/Motorola_68000_family>`_ - * ``microblaze`` - `Xilinx 32-bit FPGA (Big Endian) - <https://en.wikipedia.org/wiki/MicroBlaze>`_ - * ``microblazeel`` - `Xilinx 32-bit FPGA (Little Endian) - <https://en.wikipedia.org/wiki/MicroBlaze>`_ - * ``mips`` - `MIPS 32-bit RISC (Big Endian) - <https://en.wikipedia.org/wiki/MIPS_architecture>`_ - * ``mipsel`` - `MIPS 32-bit RISC (Little Endian) - <https://en.wikipedia.org/wiki/MIPS_architecture>`_ - * ``mips64`` - `MIPS 64-bit RISC (Big Endian) - <https://en.wikipedia.org/wiki/MIPS_architecture>`_ - * ``mips64el`` - `MIPS 64-bit RISC (Little Endian) - <https://en.wikipedia.org/wiki/MIPS_architecture>`_ - * ``openrisc`` - `OpenCores RISC - <https://en.wikipedia.org/wiki/OpenRISC#QEMU_support>`_ - * ``parisc`` - `HP Precision Architecture RISC - <https://en.wikipedia.org/wiki/PA-RISC>`_ - * parisc64 - `HP Precision Architecture 64-bit RISC - <https://en.wikipedia.org/wiki/PA-RISC>`_ - * ppc - `PowerPC 32-bit <https://en.wikipedia.org/wiki/PowerPC>`_ - * ppc64 - `PowerPC 64-bit <https://en.wikipedia.org/wiki/PowerPC>`_ - * ppcemb - `PowerPC (Embedded 32-bit) - <https://en.wikipedia.org/wiki/PowerPC>`_ - * s390 - `IBM Enterprise Systems Architecture/390 - <https://en.wikipedia.org/wiki/S390>`_ - * s390x - `S/390 64-bit <https://en.wikipedia.org/wiki/S390x>`_ - * sh4 - `SuperH SH-4 (Little Endian) - <https://en.wikipedia.org/wiki/SuperH>`_ - * sh4eb - `SuperH SH-4 (Big Endian) - <https://en.wikipedia.org/wiki/SuperH>`_ - * sparc - `Scalable Processor Architecture, 32-bit - <https://en.wikipedia.org/wiki/Sparc>`_ - * sparc64 - `Scalable Processor Architecture, 64-bit - <https://en.wikipedia.org/wiki/Sparc>`_ - * unicore32 - `Microprocessor Research and Development Center RISC - Unicore32 <https://en.wikipedia.org/wiki/Unicore>`_ - * x86_64 - `64-bit extension of IA-32 - <https://en.wikipedia.org/wiki/X86>`_ - * xtensa - `Tensilica Xtensa configurable microprocessor core - <https://en.wikipedia.org/wiki/Xtensa#Processor_Cores>`_ - * xtensaeb - `Tensilica Xtensa configurable microprocessor core - <https://en.wikipedia.org/wiki/Xtensa#Processor_Cores>`_ (Big Endian) - * - All - - ``hypervisor_type`` - - The hypervisor type. Note that ``qemu`` is used for both QEMU and KVM - hypervisor types. - - ``hyperv``, ``ironic``, ``lxc``, ``qemu``, ``uml``, ``vmware``, or - ``xen``. - * - All - - ``instance_type_rxtx_factor`` - - Optional property allows created servers to have a different bandwidth - cap than that defined in the network they are attached to. This factor - is multiplied by the ``rxtx_base`` property of the network. The - ``rxtx_base`` property defaults to ``1.0``, which is the same as the - attached network. This parameter is only available for Xen or NSX based - systems. - - Float (default value is ``1.0``) - * - All - - ``instance_uuid`` - - For snapshot images, this is the UUID of the server used to create this - image. - - Valid server UUID - * - All - - ``img_config_drive`` - - Specifies whether the image needs a config drive. - - ``mandatory`` or ``optional`` (default if property is not used). - * - All - - ``kernel_id`` - - The ID of an image stored in the Image service that should be used as - the kernel when booting an AMI-style image. - - Valid image ID - * - All - - ``os_distro`` - - The common name of the operating system distribution in lowercase - (uses the same data vocabulary as the - `libosinfo project`_). Specify only a recognized - value for this field. Deprecated values are listed to assist you in - searching for the recognized value. - - * ``arch`` - Arch Linux. Do not use ``archlinux`` or ``org.archlinux``. - * ``centos`` - Community Enterprise Operating System. Do not use - ``org.centos`` or ``CentOS``. - * ``debian`` - Debian. Do not use ``Debian` or ``org.debian``. - * ``fedora`` - Fedora. Do not use ``Fedora``, ``org.fedora``, or - ``org.fedoraproject``. - * ``freebsd`` - FreeBSD. Do not use ``org.freebsd``, ``freeBSD``, or - ``FreeBSD``. - * ``gentoo`` - Gentoo Linux. Do not use ``Gentoo`` or ``org.gentoo``. - * ``mandrake`` - Mandrakelinux (MandrakeSoft) distribution. Do not use - ``mandrakelinux`` or ``MandrakeLinux``. - * ``mandriva`` - Mandriva Linux. Do not use ``mandrivalinux``. - * ``mes`` - Mandriva Enterprise Server. Do not use ``mandrivaent`` or - ``mandrivaES``. - * ``msdos`` - Microsoft Disc Operating System. Do not use ``ms-dos``. - * ``netbsd`` - NetBSD. Do not use ``NetBSD`` or ``org.netbsd``. - * ``netware`` - Novell NetWare. Do not use ``novell`` or ``NetWare``. - * ``openbsd`` - OpenBSD. Do not use ``OpenBSD`` or ``org.openbsd``. - * ``opensolaris`` - OpenSolaris. Do not use ``OpenSolaris`` or - ``org.opensolaris``. - * ``opensuse`` - openSUSE. Do not use ``suse``, ``SuSE``, or - `` org.opensuse``. - * ``rhel`` - Red Hat Enterprise Linux. Do not use ``redhat``, ``RedHat``, - or ``com.redhat``. - * ``sled`` - SUSE Linux Enterprise Desktop. Do not use ``com.suse``. - * ``ubuntu`` - Ubuntu. Do not use ``Ubuntu``, ``com.ubuntu``, - ``org.ubuntu``, or ``canonical``. - * ``windows`` - Microsoft Windows. Do not use ``com.microsoft.server`` - or ``windoze``. - * - All - - ``os_version`` - - The operating system version as specified by the distributor. - - Valid version number (for example, ``11.10``). - * - All - - ``os_secure_boot`` - - Secure Boot is a security standard. When the instance starts, - Secure Boot first examines software such as firmware and OS by their - signature and only allows them to run if the signatures are valid. - - For Hyper-V: Images must be prepared as Generation 2 VMs. Instance must - also contain ``hw_machine_type=hyperv-gen2`` image property. Linux - guests will also require bootloader's digital signature provided as - ``os_secure_boot_signature`` and - ``hypervisor_version_requires'>=10.0'`` image properties. - - * ``required`` - Enable the Secure Boot feature. - * ``disabled`` or ``optional`` - (default) Disable the Secure Boot - feature. - * - All - - ``ramdisk_id`` - - The ID of image stored in the Image service that should be used as the - ramdisk when booting an AMI-style image. - - Valid image ID. - * - All - - ``vm_mode`` - - The virtual machine mode. This represents the host/guest ABI - (application binary interface) used for the virtual machine. - - * ``hvm`` - Fully virtualized. This is the mode used by QEMU and KVM. - * ``xen`` - Xen 3.0 paravirtualized. - * ``uml`` - User Mode Linux paravirtualized. - * ``exe`` - Executables in containers. This is the mode used by LXC. - * - libvirt API driver - - ``hw_cpu_sockets`` - - The preferred number of sockets to expose to the guest. - - Integer. - * - libvirt API driver - - ``hw_cpu_cores`` - - The preferred number of cores to expose to the guest. - - Integer. - * - libvirt API driver - - ``hw_cpu_threads`` - - The preferred number of threads to expose to the guest. - - Integer. - * - libvirt API driver - - ``hw_disk_bus`` - - Specifies the type of disk controller to attach disk devices to. - - One of ``scsi``, ``virtio``, ``uml``, ``xen``, ``ide``, or ``usb``. - * - libvirt API driver - - ``hw_pointer_model`` - - Input devices that allow interaction with a graphical framebuffer, - for example to provide a graphic tablet for absolute cursor movement. - Currently only supported by the KVM/QEMU hypervisor configuration - and VNC or SPICE consoles must be enabled. - - ``usbtablet`` - * - libvirt API driver - - ``hw_rng_model`` - - Adds a random-number generator device to the image's instances. The - cloud administrator can enable and control device behavior by - configuring the instance's flavor. By default: - - * The generator device is disabled. - * ``/dev/random`` is used as the default entropy source. To specify a - physical HW RNG device, use the following option in the nova.conf - file: - - .. code-block:: ini - - rng_dev_path=/dev/hwrng - - - ``virtio``, or other supported device. - * - libvirt API driver, Hyper-V driver - - ``hw_machine_type`` - - For libvirt: Enables booting an ARM system using the specified machine - type. By default, if an ARM image is used and its type is not specified, - Compute uses ``vexpress-a15`` (for ARMv7) or ``virt`` (for AArch64) - machine types. - - For Hyper-V: Specifies whether the Hyper-V instance will be a generation - 1 or generation 2 VM. By default, if the property is not provided, the - instances will be generation 1 VMs. If the image is specific for - generation 2 VMs but the property is not provided accordingly, the - instance will fail to boot. - - For libvirt: Valid types can be viewed by using the - :command:`virsh capabilities` command (machine types are displayed in - the ``machine`` tag). - - For hyper-V: Acceptable values are either ``hyperv-gen1`` or - ``hyperv-gen2``. - * - libvirt API driver, XenAPI driver - - ``os_type`` - - The operating system installed on the image. The ``libvirt`` API driver - and ``XenAPI`` driver contains logic that takes different actions - depending on the value of the ``os_type`` parameter of the image. - For example, for ``os_type=windows`` images, it creates a FAT32-based - swap partition instead of a Linux swap partition, and it limits the - injected host name to less than 16 characters. - - ``linux`` or ``windows``. - - * - libvirt API driver - - ``hw_scsi_model`` - - Enables the use of VirtIO SCSI (``virtio-scsi``) to provide block - device access for compute instances; by default, instances use VirtIO - Block (``virtio-blk``). VirtIO SCSI is a para-virtualized SCSI - controller device that provides improved scalability and performance, - and supports advanced SCSI hardware. - - ``virtio-scsi`` - * - libvirt API driver - - ``hw_serial_port_count`` - - Specifies the count of serial ports that should be provided. If - ``hw:serial_port_count`` is not set in the flavor's extra_specs, then - any count is permitted. If ``hw:serial_port_count`` is set, then this - provides the default serial port count. It is permitted to override the - default serial port count, but only with a lower value. - - Integer - * - libvirt API driver - - ``hw_video_model`` - - The video image driver used. - - ``vga``, ``cirrus``, ``vmvga``, ``xen``, or ``qxl``. - * - libvirt API driver - - ``hw_video_ram`` - - Maximum RAM for the video image. Used only if a ``hw_video:ram_max_mb`` - value has been set in the flavor's extra_specs and that value is higher - than the value set in ``hw_video_ram``. - - Integer in MB (for example, ``64``). - * - libvirt API driver - - ``hw_watchdog_action`` - - Enables a virtual hardware watchdog device that carries out the - specified action if the server hangs. The watchdog uses the - ``i6300esb`` device (emulating a PCI Intel 6300ESB). If - ``hw_watchdog_action`` is not specified, the watchdog is disabled. - - * ``disabled`` - (default) The device is not attached. Allows the user to - disable the watchdog for the image, even if it has been enabled using - the image's flavor. - * ``reset`` - Forcefully reset the guest. - * ``poweroff`` - Forcefully power off the guest. - * ``pause`` - Pause the guest. - * ``none`` - Only enable the watchdog; do nothing if the server hangs. - * - libvirt API driver - - ``os_command_line`` - - The kernel command line to be used by the ``libvirt`` driver, instead - of the default. For Linux Containers (LXC), the value is used as - arguments for initialization. This key is valid only for Amazon kernel, - ``ramdisk``, or machine images (``aki``, ``ari``, or ``ami``). - - - * - libvirt API driver and VMware API driver - - ``hw_vif_model`` - - Specifies the model of virtual network interface device to use. - - The valid options depend on the configured hypervisor. - * ``KVM`` and ``QEMU``: ``e1000``, ``ne2k_pci``, ``pcnet``, - ``rtl8139``, and ``virtio``. - * VMware: ``e1000``, ``e1000e``, ``VirtualE1000``, ``VirtualE1000e``, - ``VirtualPCNet32``, ``VirtualSriovEthernetCard``, and - ``VirtualVmxnet``. - * Xen: ``e1000``, ``netfront``, ``ne2k_pci``, ``pcnet``, and - ``rtl8139``. - * - libvirt API driver - - ``hw_vif_multiqueue_enabled`` - - If ``true``, this enables the ``virtio-net multiqueue`` feature. In - this case, the driver sets the number of queues equal to the number - of guest vCPUs. This makes the network performance scale across a - number of vCPUs. - - ``true`` | ``false`` - * - libvirt API driver - - ``hw_boot_menu`` - - If ``true``, enables the BIOS bootmenu. In cases where both the image - metadata and Extra Spec are set, the Extra Spec setting is used. This - allows for flexibility in setting/overriding the default behavior as - needed. - - ``true`` or ``false`` - * - libvirt API driver - - ``img_hide_hypervisor_id`` - - Some hypervisors add a signature to their guests. While the presence - of the signature can enable some paravirtualization features on the - guest, it can also have the effect of preventing some drivers from - loading. Hiding the signature by setting this property to ``true`` - may allow such drivers to load and work. - - ``true`` or ``false`` - * - VMware API driver - - ``vmware_adaptertype`` - - The virtual SCSI or IDE controller used by the hypervisor. - - ``lsiLogic``, ``lsiLogicsas``, ``busLogic``, ``ide``, or - ``paraVirtual``. - * - VMware API driver - - ``vmware_ostype`` - - A VMware GuestID which describes the operating system installed in - the image. This value is passed to the hypervisor when creating a - virtual machine. If not specified, the key defaults to ``otherGuest``. - - See `thinkvirt.com <http://www.thinkvirt.com/?q=node/181>`_. - * - VMware API driver - - ``vmware_image_version`` - - Currently unused. - - ``1`` - * - XenAPI driver - - ``auto_disk_config`` - - If ``true``, the root partition on the disk is automatically resized - before the instance boots. This value is only taken into account by - the Compute service when using a Xen-based hypervisor with the - ``XenAPI`` driver. The Compute service will only attempt to resize if - there is a single partition on the image, and only if the partition - is in ``ext3`` or ``ext4`` format. - - ``true`` or ``false`` + in the Glance Administration Guide. diff --git a/doc/source/conf.py b/doc/source/conf.py index 98ab8c9..7d18119 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -26,10 +26,18 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), # 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', 'openstackdocstheme', + 'sphinxcontrib.apidoc', ] +# sphinxcontrib.apidoc options +apidoc_module_dir = '../../glanceclient' +apidoc_output_dir = 'reference/api' +apidoc_excluded_paths = [ + 'tests/*', + 'tests'] +apidoc_separate_modules = True + # openstackdocstheme options repository_name = 'openstack/python-glanceclient' bug_project = 'python-glanceclient' diff --git a/doc/source/index.rst b/doc/source/index.rst index 43ee57f..31b921c 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -3,7 +3,7 @@ ============================================== This is a client for the OpenStack Images API. There's :doc:`a Python -API <reference/api/index>` (the :mod:`glanceclient` module) and a +API <reference/api/modules>` (the :mod:`glanceclient` module) and a :doc:`command-line script <cli/glance>` (installed as :program:`glance`). diff --git a/doc/source/reference/api/index.rst b/doc/source/reference/api/index.rst deleted file mode 100644 index df916b6..0000000 --- a/doc/source/reference/api/index.rst +++ /dev/null @@ -1,8 +0,0 @@ -====================== - Python API Reference -====================== - -.. toctree:: - :maxdepth: 2 - - autoindex diff --git a/doc/source/reference/index.rst b/doc/source/reference/index.rst index 33ce8b2..7710a1f 100644 --- a/doc/source/reference/index.rst +++ b/doc/source/reference/index.rst @@ -23,5 +23,5 @@ done so, you can use the API like so:: .. toctree:: :maxdepth: 2 - api/index + Python API Reference <api/modules> apiv2 diff --git a/glanceclient/common/http.py b/glanceclient/common/http.py index fc635ff..84cdc69 100644 --- a/glanceclient/common/http.py +++ b/glanceclient/common/http.py @@ -24,6 +24,7 @@ from oslo_utils import importutils from oslo_utils import netutils import requests import six +import six.moves.urllib.parse as urlparse try: import json @@ -53,8 +54,26 @@ def encode_headers(headers): :returns: Dictionary with encoded headers' names and values """ - return dict((encodeutils.safe_encode(h), encodeutils.safe_encode(v)) - for h, v in headers.items() if v is not None) + # NOTE(rosmaita): This function's rejection of any header name without a + # corresponding value is arguably justified by RFC 7230. In any case, that + # behavior was already here and there is an existing unit test for it. + + # Bug #1766235: According to RFC 8187, headers must be encoded as ASCII. + # So we first %-encode them to get them into range < 128 and then turn + # them into ASCII. + if six.PY2: + # incoming items may be unicode, so get them into something + # the py2 version of urllib can handle before percent encoding + encoded_dict = dict((urlparse.quote(encodeutils.safe_encode(h)), + urlparse.quote(encodeutils.safe_encode(v))) + for h, v in headers.items() if v is not None) + else: + encoded_dict = dict((urlparse.quote(h), urlparse.quote(v)) + for h, v in headers.items() if v is not None) + + return dict((encodeutils.safe_encode(h, encoding='ascii'), + encodeutils.safe_encode(v, encoding='ascii')) + for h, v in encoded_dict.items()) class _BaseHTTPClient(object): diff --git a/glanceclient/common/utils.py b/glanceclient/common/utils.py index d194e65..dee9978 100644 --- a/glanceclient/common/utils.py +++ b/glanceclient/common/utils.py @@ -177,6 +177,12 @@ def pretty_choice_list(l): def print_list(objs, fields, formatters=None, field_settings=None): + '''Prints a list of objects. + + @param objs: Objects to print + @param fields: Fields on each object to be printed + @param formatters: Custom field formatters + ''' formatters = formatters or {} field_settings = field_settings or {} pt = prettytable.PrettyTable([f for f in fields], caching=False) @@ -196,11 +202,36 @@ def print_list(objs, fields, formatters=None, field_settings=None): field_name = field.lower().replace(' ', '_') data = getattr(o, field_name, None) or '' row.append(data) + count = 0 + # Converts unicode values in list to string + for part in row: + count = count + 1 + if isinstance(part, list): + part = unicode_key_value_to_string(part) + row[count - 1] = part pt.add_row(row) print(encodeutils.safe_decode(pt.get_string())) +def _encode(src): + """remove extra 'u' in PY2.""" + if six.PY2 and isinstance(src, unicode): + return src.encode('utf-8') + return src + + +def unicode_key_value_to_string(src): + """Recursively converts dictionary keys to strings.""" + if isinstance(src, dict): + return dict((_encode(k), + _encode(unicode_key_value_to_string(v))) + for k, v in src.items()) + if isinstance(src, list): + return [unicode_key_value_to_string(l) for l in src] + return _encode(src) + + def print_dict(d, max_column_width=80): pt = prettytable.PrettyTable(['Property', 'Value'], caching=False) pt.align = 'l' @@ -380,7 +411,7 @@ def strip_version(endpoint): (scheme, netloc, path, __, __, __) = url_parts path = path.lstrip('/') # regex to match 'v1' or 'v2.0' etc - if re.match('v\d+\.?\d*', path): + if re.match(r'v\d+\.?\d*', path): version = float(path.lstrip('v')) endpoint = scheme + '://' + netloc return endpoint, version @@ -390,6 +421,8 @@ def print_image(image_obj, human_readable=False, max_col_width=None): ignore = ['self', 'access', 'file', 'schema'] image = dict([item for item in image_obj.items() if item[0] not in ignore]) + if 'virtual_size' in image: + image['virtual_size'] = image.get('virtual_size') or 'Not available' if human_readable: image['size'] = make_size_human_readable(image['size']) if str(max_col_width).isdigit(): diff --git a/glanceclient/tests/functional/base.py b/glanceclient/tests/functional/base.py index 0efc079..578dc39 100644 --- a/glanceclient/tests/functional/base.py +++ b/glanceclient/tests/functional/base.py @@ -48,9 +48,10 @@ class ClientTestBase(base.ClientTestBase): def _get_clients(self): self.creds = credentials().get_auth_args() + venv_name = os.environ.get('OS_TESTENV_NAME', 'functional') cli_dir = os.environ.get( 'OS_GLANCECLIENT_EXEC_DIR', - os.path.join(os.path.abspath('.'), '.tox/functional/bin')) + os.path.join(os.path.abspath('.'), '.tox/%s/bin' % venv_name)) return base.CLIClient( username=self.creds['username'], diff --git a/glanceclient/tests/functional/v1/__init__.py b/glanceclient/tests/functional/v1/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/glanceclient/tests/functional/v1/__init__.py diff --git a/glanceclient/tests/functional/v1/test_readonly_glance.py b/glanceclient/tests/functional/v1/test_readonly_glance.py new file mode 100644 index 0000000..122c61b --- /dev/null +++ b/glanceclient/tests/functional/v1/test_readonly_glance.py @@ -0,0 +1,73 @@ +# 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 re + +from tempest.lib import exceptions + +from glanceclient.tests.functional import base + + +class SimpleReadOnlyGlanceClientTest(base.ClientTestBase): + + """Read only functional python-glanceclient tests. + + This only exercises client commands that are read only. + """ + + def test_list_v1(self): + out = self.glance('--os-image-api-version 1 image-list') + endpoints = self.parser.listing(out) + self.assertTableStruct(endpoints, [ + 'ID', 'Name', 'Disk Format', 'Container Format', + 'Size', 'Status']) + + def test_fake_action(self): + self.assertRaises(exceptions.CommandFailed, + self.glance, + 'this-does-not-exist') + + def test_member_list_v1(self): + tenant_name = '--tenant-id %s' % self.creds['project_name'] + out = self.glance('--os-image-api-version 1 member-list', + params=tenant_name) + endpoints = self.parser.listing(out) + self.assertTableStruct(endpoints, + ['Image ID', 'Member ID', 'Can Share']) + + def test_help(self): + help_text = self.glance('--os-image-api-version 1 help') + lines = help_text.split('\n') + self.assertFirstLineStartsWith(lines, 'usage: glance') + + commands = [] + cmds_start = lines.index('Positional arguments:') + cmds_end = lines.index('Optional arguments:') + command_pattern = re.compile('^ {4}([a-z0-9\-\_]+)') + for line in lines[cmds_start:cmds_end]: + match = command_pattern.match(line) + if match: + commands.append(match.group(1)) + commands = set(commands) + wanted_commands = {'bash-completion', 'help', + 'image-create', 'image-delete', + 'image-download', 'image-list', + 'image-show', 'image-update', + 'member-create', 'member-delete', + 'member-list'} + self.assertEqual(commands, wanted_commands) + + def test_version(self): + self.glance('', flags='--version') + + def test_debug_list(self): + self.glance('--os-image-api-version 1 image-list', flags='--debug') diff --git a/glanceclient/tests/functional/v2/__init__.py b/glanceclient/tests/functional/v2/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/glanceclient/tests/functional/v2/__init__.py diff --git a/glanceclient/tests/functional/test_http_headers.py b/glanceclient/tests/functional/v2/test_http_headers.py index 1596444..1596444 100644 --- a/glanceclient/tests/functional/test_http_headers.py +++ b/glanceclient/tests/functional/v2/test_http_headers.py diff --git a/glanceclient/tests/functional/test_readonly_glance.py b/glanceclient/tests/functional/v2/test_readonly_glance.py index ccd49d6..c024303 100644 --- a/glanceclient/tests/functional/test_readonly_glance.py +++ b/glanceclient/tests/functional/v2/test_readonly_glance.py @@ -24,13 +24,6 @@ class SimpleReadOnlyGlanceClientTest(base.ClientTestBase): This only exercises client commands that are read only. """ - def test_list_v1(self): - out = self.glance('--os-image-api-version 1 image-list') - endpoints = self.parser.listing(out) - self.assertTableStruct(endpoints, [ - 'ID', 'Name', 'Disk Format', 'Container Format', - 'Size', 'Status']) - def test_list_v2(self): out = self.glance('--os-image-api-version 2 image-list') endpoints = self.parser.listing(out) @@ -41,14 +34,6 @@ class SimpleReadOnlyGlanceClientTest(base.ClientTestBase): self.glance, 'this-does-not-exist') - def test_member_list_v1(self): - tenant_name = '--tenant-id %s' % self.creds['project_name'] - out = self.glance('--os-image-api-version 1 member-list', - params=tenant_name) - endpoints = self.parser.listing(out) - self.assertTableStruct(endpoints, - ['Image ID', 'Member ID', 'Can Share']) - def test_member_list_v2(self): try: # NOTE(flwang): If set disk-format and container-format, Jenkins diff --git a/glanceclient/tests/unit/test_http.py b/glanceclient/tests/unit/test_http.py index cec94e0..efd15bf 100644 --- a/glanceclient/tests/unit/test_http.py +++ b/glanceclient/tests/unit/test_http.py @@ -216,10 +216,15 @@ class TestClient(testtools.TestCase): def test_headers_encoding(self): value = u'ni\xf1o' - headers = {"test": value, "none-val": None} + headers = {"test": value, "none-val": None, "Name": "value"} encoded = http.encode_headers(headers) - self.assertEqual(b"ni\xc3\xb1o", encoded[b"test"]) + # Bug #1766235: According to RFC 8187, headers must be + # encoded as 7-bit ASCII, so expect to see only displayable + # chars in percent-encoding + self.assertEqual(b"ni%C3%B1o", encoded[b"test"]) self.assertNotIn("none-val", encoded) + self.assertNotIn(b"none-val", encoded) + self.assertEqual(b"value", encoded[b"Name"]) @mock.patch('keystoneauth1.adapter.Adapter.request') def test_http_duplicate_content_type_headers(self, mock_ksarq): @@ -466,4 +471,7 @@ class TestClient(testtools.TestCase): http_client.auth_token = unicode_token http_client.get(path) headers = self.mock.last_request.headers - self.assertEqual(b'ni\xc3\xb1o', headers['X-Auth-Token']) + # Bug #1766235: According to RFC 8187, headers must be + # encoded as 7-bit ASCII, so expect to see only displayable + # chars in percent-encoding + self.assertEqual(b'ni%C3%B1o', headers['X-Auth-Token']) diff --git a/glanceclient/tests/unit/test_shell.py b/glanceclient/tests/unit/test_shell.py index 898a9eb..0f15007 100644 --- a/glanceclient/tests/unit/test_shell.py +++ b/glanceclient/tests/unit/test_shell.py @@ -15,10 +15,7 @@ # under the License. import argparse -try: - from collections import OrderedDict -except ImportError: - from ordereddict import OrderedDict +from collections import OrderedDict import hashlib import logging import os @@ -802,10 +799,10 @@ class ShellCacheSchemaTest(testutils.TestCase): open.mock_calls[0]) self.assertEqual(mock.call(self.cache_files[1], 'w'), open.mock_calls[4]) - self.assertEqual(mock.call().write(json.dumps(schema_odict)), - open.mock_calls[2]) - self.assertEqual(mock.call().write(json.dumps(schema_odict)), - open.mock_calls[6]) + actual = json.loads(open.mock_calls[2][1][0]) + self.assertEqual(schema_odict, actual) + actual = json.loads(open.mock_calls[6][1][0]) + self.assertEqual(schema_odict, actual) @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) @mock.patch('os.path.exists', side_effect=[True, False, False, False]) @@ -825,10 +822,10 @@ class ShellCacheSchemaTest(testutils.TestCase): open.mock_calls[0]) self.assertEqual(mock.call(self.cache_files[1], 'w'), open.mock_calls[4]) - self.assertEqual(mock.call().write(json.dumps(schema_odict)), - open.mock_calls[2]) - self.assertEqual(mock.call().write(json.dumps(schema_odict)), - open.mock_calls[6]) + actual = json.loads(open.mock_calls[2][1][0]) + self.assertEqual(schema_odict, actual) + actual = json.loads(open.mock_calls[6][1][0]) + self.assertEqual(schema_odict, actual) @mock.patch('six.moves.builtins.open', new=mock.mock_open(), create=True) @mock.patch('os.path.exists', return_value=True) diff --git a/glanceclient/tests/unit/test_utils.py b/glanceclient/tests/unit/test_utils.py index cd87e21..3ef585a 100644 --- a/glanceclient/tests/unit/test_utils.py +++ b/glanceclient/tests/unit/test_utils.py @@ -115,6 +115,80 @@ class TestUtils(testtools.TestCase): ''', output_dict.getvalue()) + def test_print_list_with_list_no_unicode(self): + class Struct(object): + def __init__(self, **entries): + self.__dict__.update(entries) + + # test for removing 'u' from lists in print_list output + columns = ['ID', 'Tags'] + images = [Struct(**{'id': 'b8e1c57e-907a-4239-aed8-0df8e54b8d2d', + 'tags': [u'Name1', u'Tag_123', u'veeeery long']})] + saved_stdout = sys.stdout + try: + sys.stdout = output_list = six.StringIO() + utils.print_list(images, columns) + + finally: + sys.stdout = saved_stdout + + self.assertEqual('''\ ++--------------------------------------+--------------------------------------+ +| ID | Tags | ++--------------------------------------+--------------------------------------+ +| b8e1c57e-907a-4239-aed8-0df8e54b8d2d | ['Name1', 'Tag_123', 'veeeery long'] | ++--------------------------------------+--------------------------------------+ +''', + output_list.getvalue()) + + def test_print_image_virtual_size_available(self): + image = {'id': '42', 'virtual_size': 1337} + saved_stdout = sys.stdout + try: + sys.stdout = output_list = six.StringIO() + utils.print_image(image) + finally: + sys.stdout = saved_stdout + + self.assertEqual('''\ ++--------------+-------+ +| Property | Value | ++--------------+-------+ +| id | 42 | +| virtual_size | 1337 | ++--------------+-------+ +''', + output_list.getvalue()) + + def test_print_image_virtual_size_not_available(self): + image = {'id': '42', 'virtual_size': None} + saved_stdout = sys.stdout + try: + sys.stdout = output_list = six.StringIO() + utils.print_image(image) + finally: + sys.stdout = saved_stdout + + self.assertEqual('''\ ++--------------+---------------+ +| Property | Value | ++--------------+---------------+ +| id | 42 | +| virtual_size | Not available | ++--------------+---------------+ +''', + output_list.getvalue()) + + def test_unicode_key_value_to_string(self): + src = {u'key': u'\u70fd\u7231\u5a77'} + expected = {'key': '\xe7\x83\xbd\xe7\x88\xb1\xe5\xa9\xb7'} + if six.PY2: + self.assertEqual(expected, utils.unicode_key_value_to_string(src)) + else: + # u'xxxx' in PY3 is str, we will not get extra 'u' from cli + # output in PY3 + self.assertEqual(src, utils.unicode_key_value_to_string(src)) + def test_schema_args_with_list_types(self): # NOTE(flaper87): Regression for bug # https://bugs.launchpad.net/python-glanceclient/+bug/1401032 diff --git a/glanceclient/tests/unit/v2/base.py b/glanceclient/tests/unit/v2/base.py index 7391595..d6f5cc5 100644 --- a/glanceclient/tests/unit/v2/base.py +++ b/glanceclient/tests/unit/v2/base.py @@ -106,6 +106,10 @@ class BaseController(testtools.TestCase): resp = self.controller.deassociate(*args) self._assertRequestId(resp) + def image_import(self, *args): + resp = self.controller.image_import(*args) + self._assertRequestId(resp) + class BaseResourceTypeController(BaseController): def __init__(self, api, schema_api, controller_class): diff --git a/glanceclient/tests/unit/v2/fixtures.py b/glanceclient/tests/unit/v2/fixtures.py index 7f0e99c..5a603c0 100644 --- a/glanceclient/tests/unit/v2/fixtures.py +++ b/glanceclient/tests/unit/v2/fixtures.py @@ -303,12 +303,15 @@ schema_fixture = { "readOnly": True, "description": "Status of the image", "enum": [ + "deactivated", "queued", "saving", "active", "killed", "deleted", - "pending_delete" + "pending_delete", + "uploading", + "importing" ], "type": "string" }, diff --git a/glanceclient/tests/unit/v2/test_images.py b/glanceclient/tests/unit/v2/test_images.py index 579392b..23cbb43 100644 --- a/glanceclient/tests/unit/v2/test_images.py +++ b/glanceclient/tests/unit/v2/test_images.py @@ -215,6 +215,9 @@ data_fixtures = { '/v2/images/87b634c1-f893-33c9-28a9-e5673c99239a/actions/deactivate': { 'POST': ({}, None) }, + '/v2/images/606b0e88-7c5a-4d54-b5bb-046105d4de6f/import': { + 'POST': ({}, None) + }, '/v2/images?limit=%d&visibility=public' % images.DEFAULT_PAGE_SIZE: { 'GET': ( {}, @@ -867,6 +870,16 @@ class TestController(testtools.TestCase): body = ''.join([b for b in body]) self.assertEqual('CCC', body) + def test_image_import(self): + uri = 'http://example.com/image.qcow' + data = [('method', {'name': 'web-download', + 'uri': uri})] + image_id = '606b0e88-7c5a-4d54-b5bb-046105d4de6f' + self.controller.image_import(image_id, 'web-download', uri) + expect = [('POST', '/v2/images/%s/import' % image_id, {}, + data)] + self.assertEqual(expect, self.api.calls) + def test_download_no_data(self): resp = utils.FakeResponse(headers={}, status_code=204) self.controller.controller.http_client.get = mock.Mock( diff --git a/glanceclient/tests/unit/v2/test_shell_v2.py b/glanceclient/tests/unit/v2/test_shell_v2.py index d75613f..f2f30e0 100644 --- a/glanceclient/tests/unit/v2/test_shell_v2.py +++ b/glanceclient/tests/unit/v2/test_shell_v2.py @@ -14,10 +14,12 @@ # License for the specific language governing permissions and limitations # under the License. import argparse +from copy import deepcopy import json import mock import os import six +import sys import tempfile import testtools @@ -157,6 +159,46 @@ class ShellV2Test(testtools.TestCase): self.assertEqual('error: Must provide --disk-format when using stdin.', e.message) + @mock.patch('sys.stderr') + def test_create_via_import_glance_direct_missing_disk_format(self, __): + e = self.assertRaises(exc.CommandError, self._run_command, + '--os-image-api-version 2 ' + 'image-create-via-import ' + '--file fake_src --container-format bare') + self.assertEqual('error: Must provide --disk-format when using ' + '--file.', e.message) + + @mock.patch('sys.stderr') + def test_create_via_import_glance_direct_missing_container_format( + self, __): + e = self.assertRaises(exc.CommandError, self._run_command, + '--os-image-api-version 2 ' + 'image-create-via-import ' + '--file fake_src --disk-format qcow2') + self.assertEqual('error: Must provide --container-format when ' + 'using --file.', e.message) + + @mock.patch('sys.stderr') + def test_create_via_import_web_download_missing_disk_format(self, __): + e = self.assertRaises(exc.CommandError, self._run_command, + '--os-image-api-version 2 ' + 'image-create-via-import ' + + '--import-method web-download ' + + '--uri fake_uri --container-format bare') + self.assertEqual('error: Must provide --disk-format when using ' + '--uri.', e.message) + + @mock.patch('sys.stderr') + def test_create_via_import_web_download_missing_container_format( + self, __): + e = self.assertRaises(exc.CommandError, self._run_command, + '--os-image-api-version 2 ' + 'image-create-via-import ' + '--import-method web-download ' + '--uri fake_uri --disk-format qcow2') + self.assertEqual('error: Must provide --container-format when ' + 'using --uri.', e.message) + def test_do_image_list(self): input = { 'limit': None, @@ -447,6 +489,491 @@ class ShellV2Test(testtools.TestCase): utils.print_dict.assert_called_once_with({ 'id': 'pass', 'name': 'IMG-01', 'myprop': 'myval'}) + # NOTE(rosmaita): have to explicitly set to None the declared but unused + # arguments (the configparser does that for us normally) + base_args = {'name': 'Mortimer', + 'disk_format': 'raw', + 'container_format': 'bare', + 'progress': False, + 'file': None, + 'uri': None, + 'import_method': None} + + import_info_response = {'import-methods': { + 'type': 'array', + 'description': 'Import methods available.', + 'value': ['glance-direct', 'web-download']}} + + def _mock_utils_exit(self, msg): + sys.exit(msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('os.access') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_no_method_with_file_and_stdin( + self, mock_stdin, mock_access, mock_utils_exit): + expected_msg = ('You cannot use both --file and stdin with the ' + 'glance-direct import method.') + my_args = self.base_args.copy() + my_args['file'] = 'some.file' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: False + mock_access.return_value = True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_no_method_passing_uri( + self, mock_stdin, mock_utils_exit): + expected_msg = ('You cannot use --uri without specifying an import ' + 'method.') + my_args = self.base_args.copy() + my_args['uri'] = 'http://example.com/whatever' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_glance_direct_no_data( + self, mock_stdin, mock_utils_exit): + expected_msg = ('You must specify a --file or provide data via stdin ' + 'for the glance-direct import method.') + my_args = self.base_args.copy() + my_args['import_method'] = 'glance-direct' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_glance_direct_with_uri( + self, mock_stdin, mock_utils_exit): + expected_msg = ('You cannot specify a --uri with the glance-direct ' + 'import method.') + my_args = self.base_args.copy() + my_args['import_method'] = 'glance-direct' + my_args['uri'] = 'https://example.com/some/stuff' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('os.access') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_glance_direct_with_file_and_uri( + self, mock_stdin, mock_access, mock_utils_exit): + expected_msg = ('You cannot specify a --uri with the glance-direct ' + 'import method.') + my_args = self.base_args.copy() + my_args['import_method'] = 'glance-direct' + my_args['uri'] = 'https://example.com/some/stuff' + my_args['file'] = 'my.browncow' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_access.return_value = True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_glance_direct_with_data_and_uri( + self, mock_stdin, mock_utils_exit): + expected_msg = ('You cannot specify a --uri with the glance-direct ' + 'import method.') + my_args = self.base_args.copy() + my_args['import_method'] = 'glance-direct' + my_args['uri'] = 'https://example.com/some/stuff' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: False + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_web_download_no_uri( + self, mock_stdin, mock_utils_exit): + expected_msg = ('URI is required for web-download import method. ' + 'Please use \'--uri <uri>\'.') + my_args = self.base_args.copy() + my_args['import_method'] = 'web-download' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_web_download_no_uri_with_file( + self, mock_stdin, mock_utils_exit): + expected_msg = ('URI is required for web-download import method. ' + 'Please use \'--uri <uri>\'.') + my_args = self.base_args.copy() + my_args['import_method'] = 'web-download' + my_args['file'] = 'my.browncow' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_web_download_no_uri_with_data( + self, mock_stdin, mock_utils_exit): + expected_msg = ('URI is required for web-download import method. ' + 'Please use \'--uri <uri>\'.') + my_args = self.base_args.copy() + my_args['import_method'] = 'web-download' + my_args['file'] = 'my.browncow' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: False + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_web_download_with_data_and_uri( + self, mock_stdin, mock_utils_exit): + expected_msg = ('You cannot pass data via stdin with the web-download ' + 'import method.') + my_args = self.base_args.copy() + my_args['import_method'] = 'web-download' + my_args['uri'] = 'https://example.com/some/stuff' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: False + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_web_download_with_file_and_uri( + self, mock_stdin, mock_utils_exit): + expected_msg = ('You cannot specify a --file with the web-download ' + 'import method.') + my_args = self.base_args.copy() + my_args['import_method'] = 'web-download' + my_args['uri'] = 'https://example.com/some/stuff' + my_args['file'] = 'my.browncow' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_bad_method( + self, mock_stdin, mock_utils_exit): + expected_msg = ('Import method \'swift-party-time\' is not valid ' + 'for this cloud. Valid values can be retrieved with ' + 'import-info command.') + my_args = self.base_args.copy() + my_args['import_method'] = 'swift-party-time' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_utils_exit.side_effect = self._mock_utils_exit + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_no_method_with_data_and_method_NA( + self, mock_stdin, mock_utils_exit): + expected_msg = ('Import method \'glance-direct\' is not valid ' + 'for this cloud. Valid values can be retrieved with ' + 'import-info command.') + args = self._make_args(self.base_args) + # need to fake some data, or this is "just like" a + # create-image-record-only call + mock_stdin.isatty = lambda: False + mock_utils_exit.side_effect = self._mock_utils_exit + my_import_info_response = deepcopy(self.import_info_response) + my_import_info_response['import-methods']['value'] = ['web-download'] + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = my_import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + @mock.patch('sys.stdin', autospec=True) + def test_neg_image_create_via_import_good_method_not_available( + self, mock_stdin, mock_utils_exit): + """Make sure the good method names aren't hard coded somewhere""" + expected_msg = ('Import method \'glance-direct\' is not valid for ' + 'this cloud. Valid values can be retrieved with ' + 'import-info command.') + my_args = self.base_args.copy() + my_args['import_method'] = 'glance-direct' + args = self._make_args(my_args) + mock_stdin.isatty = lambda: True + mock_utils_exit.side_effect = self._mock_utils_exit + my_import_info_response = deepcopy(self.import_info_response) + my_import_info_response['import-methods']['value'] = ['bad-bad-method'] + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = my_import_info_response + try: + test_shell.do_image_create_via_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.v2.shell.do_image_import') + @mock.patch('glanceclient.v2.shell.do_image_stage') + @mock.patch('sys.stdin', autospec=True) + def test_image_create_via_import_no_method_with_stdin( + self, mock_stdin, mock_do_stage, mock_do_import): + """Backward compat -> handle this like a glance-direct""" + mock_stdin.isatty = lambda: False + self.mock_get_data_file.return_value = six.StringIO() + args = self._make_args(self.base_args) + with mock.patch.object(self.gc.images, 'create') as mocked_create: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + + ignore_fields = ['self', 'access', 'schema'] + expect_image = dict([(field, field) for field in + ignore_fields]) + expect_image['id'] = 'via-stdin' + expect_image['name'] = 'Mortimer' + expect_image['disk_format'] = 'raw' + expect_image['container_format'] = 'bare' + mocked_create.return_value = expect_image + mocked_get.return_value = expect_image + mocked_info.return_value = self.import_info_response + + test_shell.do_image_create_via_import(self.gc, args) + mocked_create.assert_called_once() + mock_do_stage.assert_called_once() + mock_do_import.assert_called_once() + mocked_get.assert_called_with('via-stdin') + utils.print_dict.assert_called_with({ + 'id': 'via-stdin', 'name': 'Mortimer', + 'disk_format': 'raw', 'container_format': 'bare'}) + + @mock.patch('glanceclient.v2.shell.do_image_import') + @mock.patch('glanceclient.v2.shell.do_image_stage') + @mock.patch('os.access') + @mock.patch('sys.stdin', autospec=True) + def test_image_create_via_import_no_method_passing_file( + self, mock_stdin, mock_access, mock_do_stage, mock_do_import): + """Backward compat -> handle this like a glance-direct""" + mock_stdin.isatty = lambda: True + self.mock_get_data_file.return_value = six.StringIO() + mock_access.return_value = True + my_args = self.base_args.copy() + my_args['file'] = 'fake-image-file.browncow' + args = self._make_args(my_args) + with mock.patch.object(self.gc.images, 'create') as mocked_create: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + + ignore_fields = ['self', 'access', 'schema'] + expect_image = dict([(field, field) for field in + ignore_fields]) + expect_image['id'] = 'via-file' + expect_image['name'] = 'Mortimer' + expect_image['disk_format'] = 'raw' + expect_image['container_format'] = 'bare' + mocked_create.return_value = expect_image + mocked_get.return_value = expect_image + mocked_info.return_value = self.import_info_response + + test_shell.do_image_create_via_import(self.gc, args) + mocked_create.assert_called_once() + mock_do_stage.assert_called_once() + mock_do_import.assert_called_once() + mocked_get.assert_called_with('via-file') + utils.print_dict.assert_called_with({ + 'id': 'via-file', 'name': 'Mortimer', + 'disk_format': 'raw', 'container_format': 'bare'}) + + @mock.patch('glanceclient.v2.shell.do_image_import') + @mock.patch('glanceclient.v2.shell.do_image_stage') + @mock.patch('sys.stdin', autospec=True) + def test_do_image_create_via_import_with_no_method_no_data( + self, mock_stdin, mock_do_image_stage, mock_do_image_import): + """Create an image record without calling do_stage or do_import""" + img_create_args = {'name': 'IMG-11', + 'os_architecture': 'powerpc', + 'id': 'watch-out-for-ossn-0075', + 'progress': False} + client_args = {'import_method': None, + 'file': None, + 'uri': None} + temp_args = img_create_args.copy() + temp_args.update(client_args) + args = self._make_args(temp_args) + with mock.patch.object(self.gc.images, 'create') as mocked_create: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + + ignore_fields = ['self', 'access', 'schema'] + expect_image = dict([(field, field) for field in + ignore_fields]) + expect_image['name'] = 'IMG-11' + expect_image['id'] = 'watch-out-for-ossn-0075' + expect_image['os_architecture'] = 'powerpc' + mocked_create.return_value = expect_image + mocked_get.return_value = expect_image + mocked_info.return_value = self.import_info_response + mock_stdin.isatty = lambda: True + + test_shell.do_image_create_via_import(self.gc, args) + mocked_create.assert_called_once_with(**img_create_args) + mocked_get.assert_called_with('watch-out-for-ossn-0075') + mock_do_image_stage.assert_not_called() + mock_do_image_import.assert_not_called() + utils.print_dict.assert_called_with({ + 'name': 'IMG-11', 'os_architecture': 'powerpc', + 'id': 'watch-out-for-ossn-0075'}) + + @mock.patch('glanceclient.v2.shell.do_image_import') + @mock.patch('glanceclient.v2.shell.do_image_stage') + @mock.patch('sys.stdin', autospec=True) + def test_do_image_create_via_import_with_web_download( + self, mock_stdin, mock_do_image_stage, mock_do_image_import): + temp_args = {'name': 'IMG-01', + 'disk_format': 'vhd', + 'container_format': 'bare', + 'uri': 'http://example.com/image.qcow', + 'import_method': 'web-download', + 'progress': False} + args = self._make_args(temp_args) + with mock.patch.object(self.gc.images, 'create') as mocked_create: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + + ignore_fields = ['self', 'access', 'schema'] + expect_image = dict([(field, field) for field in + ignore_fields]) + expect_image['id'] = 'pass' + expect_image['name'] = 'IMG-01' + expect_image['disk_format'] = 'vhd' + expect_image['container_format'] = 'bare' + expect_image['status'] = 'queued' + mocked_create.return_value = expect_image + mocked_get.return_value = expect_image + mocked_info.return_value = self.import_info_response + mock_stdin.isatty = lambda: True + + test_shell.do_image_create_via_import(self.gc, args) + mock_do_image_stage.assert_not_called() + mock_do_image_import.assert_called_once() + mocked_create.assert_called_once_with(**temp_args) + mocked_get.assert_called_with('pass') + utils.print_dict.assert_called_with({ + 'id': 'pass', 'name': 'IMG-01', 'disk_format': 'vhd', + 'container_format': 'bare', 'status': 'queued'}) + def test_do_image_update_no_user_props(self): args = self._make_args({'id': 'pass', 'name': 'IMG-01', 'disk_format': 'vhd', @@ -577,6 +1104,204 @@ class ShellV2Test(testtools.TestCase): test_shell.do_image_upload(self.gc, args) mocked_upload.assert_called_once_with('IMG-01', 'testfile', 1024) + @mock.patch('glanceclient.common.utils.exit') + def test_neg_image_import_not_available(self, mock_utils_exit): + expected_msg = 'Target Glance does not support Image Import workflow' + mock_utils_exit.side_effect = self._mock_utils_exit + args = self._make_args( + {'id': 'IMG-01', 'import_method': 'smarty-pants', 'uri': None}) + with mock.patch.object(self.gc.images, 'import') as mocked_import: + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.side_effect = exc.HTTPNotFound + try: + test_shell.do_image_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + mocked_import.assert_not_called() + + @mock.patch('glanceclient.common.utils.exit') + def test_neg_image_import_bad_method(self, mock_utils_exit): + expected_msg = ('Import method \'smarty-pants\' is not valid for this ' + 'cloud. Valid values can be retrieved with ' + 'import-info command.') + mock_utils_exit.side_effect = self._mock_utils_exit + args = self._make_args( + {'id': 'IMG-01', 'import_method': 'smarty-pants', 'uri': None}) + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + def test_neg_image_import_no_methods_configured(self, mock_utils_exit): + expected_msg = ('Import method \'glance-direct\' is not valid for ' + 'this cloud. Valid values can be retrieved with ' + 'import-info command.') + mock_utils_exit.side_effect = self._mock_utils_exit + args = self._make_args( + {'id': 'IMG-01', 'import_method': 'glance-direct', 'uri': None}) + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_info.return_value = {"import-methods": {"value": []}} + try: + test_shell.do_image_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + def test_neg_image_import_glance_direct_image_not_uploading_status( + self, mock_utils_exit): + expected_msg = ('The \'glance-direct\' import method can only be ' + 'applied to an image in status \'uploading\'') + mock_utils_exit.side_effect = self._mock_utils_exit + args = self._make_args( + {'id': 'IMG-01', 'import_method': 'glance-direct', 'uri': None}) + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + mocked_get.return_value = {'status': 'queued', + 'container_format': 'bare', + 'disk_format': 'raw'} + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + def test_neg_image_import_web_download_image_not_queued_status( + self, mock_utils_exit): + expected_msg = ('The \'web-download\' import method can only be ' + 'applied to an image in status \'queued\'') + mock_utils_exit.side_effect = self._mock_utils_exit + args = self._make_args( + {'id': 'IMG-01', 'import_method': 'web-download', + 'uri': 'http://joes-image-shack.com/funky.qcow2'}) + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + mocked_get.return_value = {'status': 'uploading', + 'container_format': 'bare', + 'disk_format': 'raw'} + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + def test_neg_image_import_image_no_container_format( + self, mock_utils_exit): + expected_msg = ('The \'container_format\' and \'disk_format\' ' + 'properties must be set on an image before it can be ' + 'imported.') + mock_utils_exit.side_effect = self._mock_utils_exit + args = self._make_args( + {'id': 'IMG-01', 'import_method': 'web-download', + 'uri': 'http://joes-image-shack.com/funky.qcow2'}) + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + mocked_get.return_value = {'status': 'uploading', + 'disk_format': 'raw'} + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + @mock.patch('glanceclient.common.utils.exit') + def test_neg_image_import_image_no_disk_format( + self, mock_utils_exit): + expected_msg = ('The \'container_format\' and \'disk_format\' ' + 'properties must be set on an image before it can be ' + 'imported.') + mock_utils_exit.side_effect = self._mock_utils_exit + args = self._make_args( + {'id': 'IMG-01', 'import_method': 'web-download', + 'uri': 'http://joes-image-shack.com/funky.qcow2'}) + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + mocked_get.return_value = {'status': 'uploading', + 'container_format': 'bare'} + mocked_info.return_value = self.import_info_response + try: + test_shell.do_image_import(self.gc, args) + self.fail("utils.exit should have been called") + except SystemExit: + pass + mock_utils_exit.assert_called_once_with(expected_msg) + + def test_image_import_glance_direct(self): + args = self._make_args( + {'id': 'IMG-01', 'import_method': 'glance-direct', 'uri': None}) + with mock.patch.object(self.gc.images, 'image_import') as mock_import: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_get.return_value = {'status': 'uploading', + 'container_format': 'bare', + 'disk_format': 'raw'} + mocked_info.return_value = self.import_info_response + mock_import.return_value = None + test_shell.do_image_import(self.gc, args) + mock_import.assert_called_once_with( + 'IMG-01', 'glance-direct', None) + + def test_image_import_web_download(self): + args = self._make_args( + {'id': 'IMG-01', 'uri': 'http://example.com/image.qcow', + 'import_method': 'web-download'}) + with mock.patch.object(self.gc.images, 'image_import') as mock_import: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_get.return_value = {'status': 'queued', + 'container_format': 'bare', + 'disk_format': 'raw'} + mocked_info.return_value = self.import_info_response + mock_import.return_value = None + test_shell.do_image_import(self.gc, args) + mock_import.assert_called_once_with( + 'IMG-01', 'web-download', + 'http://example.com/image.qcow') + + @mock.patch('glanceclient.common.utils.print_image') + def test_image_import_no_print_image(self, mocked_utils_print_image): + args = self._make_args( + {'id': 'IMG-02', 'uri': None, 'import_method': 'glance-direct', + 'from_create': True}) + with mock.patch.object(self.gc.images, 'image_import') as mock_import: + with mock.patch.object(self.gc.images, 'get') as mocked_get: + with mock.patch.object(self.gc.images, + 'get_import_info') as mocked_info: + mocked_get.return_value = {'status': 'uploading', + 'container_format': 'bare', + 'disk_format': 'raw'} + mocked_info.return_value = self.import_info_response + mock_import.return_value = None + test_shell.do_image_import(self.gc, args) + mock_import.assert_called_once_with( + 'IMG-02', 'glance-direct', None) + mocked_utils_print_image.assert_not_called() + def test_image_download(self): args = self._make_args( {'id': 'IMG-01', 'file': 'test', 'progress': True}) @@ -680,10 +1405,13 @@ class ShellV2Test(testtools.TestCase): self.assert_exits_with_msg(func=test_shell.do_image_delete, func_args=args) + @mock.patch('sys.stdout', autospec=True) @mock.patch.object(utils, 'print_err') - def test_do_image_download_with_forbidden_id(self, mocked_print_err): + def test_do_image_download_with_forbidden_id(self, mocked_print_err, + mocked_stdout): args = self._make_args({'id': 'IMG-01', 'file': None, 'progress': False}) + mocked_stdout.isatty = lambda: False with mock.patch.object(self.gc.images, 'data') as mocked_data: mocked_data.side_effect = exc.HTTPForbidden try: @@ -695,10 +1423,12 @@ class ShellV2Test(testtools.TestCase): self.assertEqual(1, mocked_data.call_count) self.assertEqual(1, mocked_print_err.call_count) + @mock.patch('sys.stdout', autospec=True) @mock.patch.object(utils, 'print_err') - def test_do_image_download_with_500(self, mocked_print_err): + def test_do_image_download_with_500(self, mocked_print_err, mocked_stdout): args = self._make_args({'id': 'IMG-01', 'file': None, 'progress': False}) + mocked_stdout.isatty = lambda: False with mock.patch.object(self.gc.images, 'data') as mocked_data: mocked_data.side_effect = exc.HTTPInternalServerError try: diff --git a/glanceclient/v2/image_schema.py b/glanceclient/v2/image_schema.py index d4fdd69..1ff20bc 100644 --- a/glanceclient/v2/image_schema.py +++ b/glanceclient/v2/image_schema.py @@ -13,7 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. -_doc_url = "http://docs.openstack.org/user-guide/common/cli-manage-images.html" # noqa +_doc_url = "https://docs.openstack.org/python-glanceclient/latest/cli/property-keys.html" # noqa # NOTE(flaper87): Keep a copy of the current default schema so that # we can react on cases where there's no connection to an OpenStack # deployment. See #1481729 @@ -129,7 +129,8 @@ _BASE_SCHEMA = { }, "status": { "readOnly": True, - "enum": ["queued", "saving", "active", "killed", "deleted", + "enum": ["queued", "saving", "active", "killed", + "deleted", "uploading", "importing", "pending_delete", "deactivated"], "type": "string", "description": "Status of the image" diff --git a/glanceclient/v2/images.py b/glanceclient/v2/images.py index 29abc30..fbb67a9 100644 --- a/glanceclient/v2/images.py +++ b/glanceclient/v2/images.py @@ -125,7 +125,7 @@ class Controller(object): if limit: limit -= 1 if limit <= 0: - raise StopIteration + return try: next_url = body['next'] @@ -254,10 +254,16 @@ class Controller(object): return body, resp @utils.add_req_id_to_object() - def image_import(self, image_id, method='glance-direct'): + def image_import(self, image_id, method='glance-direct', uri=None): """Import Image via method.""" url = '/v2/images/%s/import' % image_id data = {'method': {'name': method}} + if uri: + if method == 'web-download': + data['method']['uri'] = uri + else: + raise exc.HTTPBadRequest('URI is only supported with method: ' + '"web-download"') resp, body = self.http_client.post(url, data=data) return body, resp diff --git a/glanceclient/v2/shell.py b/glanceclient/v2/shell.py index c9f1fe1..d837ba1 100644 --- a/glanceclient/v2/shell.py +++ b/glanceclient/v2/shell.py @@ -30,7 +30,7 @@ import os MEMBER_STATUS_VALUES = image_members.MEMBER_STATUS_VALUES IMAGE_SCHEMA = None -DATA_FIELDS = ('location', 'copy_from', 'file') +DATA_FIELDS = ('location', 'copy_from', 'file', 'uri') def get_image_schema(): @@ -102,12 +102,34 @@ def do_image_create(gc, args): 'passed to the client via stdin.')) @utils.arg('--progress', action='store_true', default=False, help=_('Show upload progress bar.')) -@utils.arg('--import-method', metavar='<METHOD>', default='glance-direct', +@utils.arg('--import-method', metavar='<METHOD>', + default=utils.env('OS_IMAGE_IMPORT_METHOD', default=None), help=_('Import method used for Image Import workflow. ' - 'Valid values can be retrieved with import-info command.')) + 'Valid values can be retrieved with import-info command. ' + 'Defaults to env[OS_IMAGE_IMPORT_METHOD] or if that is ' + 'undefined uses \'glance-direct\' if data is provided using ' + '--file or stdin. Otherwise, simply creates an image ' + 'record if no import-method and no data is supplied')) +@utils.arg('--uri', metavar='<IMAGE_URL>', default=None, + help=_('URI to download the external image.')) @utils.on_data_require_fields(DATA_FIELDS) def do_image_create_via_import(gc, args): - """EXPERIMENTAL: Create a new image via image import.""" + """EXPERIMENTAL: Create a new image via image import. + + Use the interoperable image import workflow to create an image. This + command is designed to be backward compatible with the current image-create + command, so its behavior is as follows: + + * If an import-method is specified (either on the command line or through + the OS_IMAGE_IMPORT_METHOD environment variable, then you must provide a + data source appropriate to that method (for example, --file for + glance-direct, or --uri for web-download). + * If no import-method is specified AND you provide either a --file or + data to stdin, the command will assume you are using the 'glance-direct' + import-method and will act accordingly. + * If no import-method is specified and no data is supplied via --file or + stdin, the command will simply create an image record in 'queued' status. + """ schema = gc.schemas.get("image") _args = [(x[0].replace('-', '_'), x[1]) for x in vars(args).items()] fields = dict(filter(lambda x: x[1] is not None and @@ -121,23 +143,60 @@ def do_image_create_via_import(gc, args): fields[key] = value file_name = fields.pop('file', None) - if file_name is not None and os.access(file_name, os.R_OK) is False: - utils.exit("File %s does not exist or user does not have read " - "privileges to it" % file_name) + using_stdin = not sys.stdin.isatty() + + # special processing for backward compatibility with image-create + if args.import_method is None and (file_name or using_stdin): + args.import_method = 'glance-direct' + + # determine whether the requested import method is valid import_methods = gc.images.get_import_info().get('import-methods') - if file_name and (not import_methods or - 'glance-direct' not in import_methods.get('value')): - utils.exit("No suitable import method available for direct upload, " - "please use image-create instead.") + if args.import_method and args.import_method not in import_methods.get( + 'value'): + utils.exit("Import method '%s' is not valid for this cloud. " + "Valid values can be retrieved with import-info command." % + args.import_method) + + # make sure we have all and only correct inputs for the requested method + if args.import_method is None: + if args.uri: + utils.exit("You cannot use --uri without specifying an import " + "method.") + if args.import_method == 'glance-direct': + if args.uri: + utils.exit("You cannot specify a --uri with the glance-direct " + "import method.") + if file_name is not None and os.access(file_name, os.R_OK) is False: + utils.exit("File %s does not exist or user does not have read " + "privileges to it." % file_name) + if file_name is not None and using_stdin: + utils.exit("You cannot use both --file and stdin with the " + "glance-direct import method.") + if not file_name and not using_stdin: + utils.exit("You must specify a --file or provide data via stdin " + "for the glance-direct import method.") + if args.import_method == 'web-download': + if not args.uri: + utils.exit("URI is required for web-download import method. " + "Please use '--uri <uri>'.") + if file_name: + utils.exit("You cannot specify a --file with the web-download " + "import method.") + if using_stdin: + utils.exit("You cannot pass data via stdin with the web-download " + "import method.") + + # process image = gc.images.create(**fields) try: - if utils.get_data_file(args) is not None: - args.id = image['id'] - args.size = None - do_image_stage(gc, args) + args.id = image['id'] + if args.import_method: + if utils.get_data_file(args) is not None: + args.size = None + do_image_stage(gc, args) args.from_create = True do_image_import(gc, args) - image = gc.images.get(args.id) + image = gc.images.get(args.id) finally: utils.print_image(image) @@ -418,18 +477,59 @@ def do_image_stage(gc, args): 'Valid values can be retrieved with import-info command ' 'and the default "glance-direct" is used with ' '"image-stage".')) +@utils.arg('--uri', metavar='<IMAGE_URL>', default=None, + help=_('URI to download the external image.')) @utils.arg('id', metavar='<IMAGE_ID>', help=_('ID of image to import.')) def do_image_import(gc, args): """Initiate the image import taskflow.""" + + if getattr(args, 'from_create', False): + # this command is being called "internally" so we can skip + # validation -- just do the import and get out of here + gc.images.image_import(args.id, args.import_method, args.uri) + return + + # do input validation try: - gc.images.image_import(args.id, args.import_method) + import_methods = gc.images.get_import_info().get('import-methods') except exc.HTTPNotFound: utils.exit('Target Glance does not support Image Import workflow') - else: - if not getattr(args, 'from_create', False): - image = gc.images.get(args.id) - utils.print_image(image) + + if args.import_method not in import_methods.get('value'): + utils.exit("Import method '%s' is not valid for this cloud. " + "Valid values can be retrieved with import-info command." % + args.import_method) + + if args.import_method == 'web-download' and not args.uri: + utils.exit("Provide URI for web-download import method.") + if args.uri and args.import_method != 'web-download': + utils.exit("Import method should be 'web-download' if URI is " + "provided.") + + # check image properties + image = gc.images.get(args.id) + container_format = image.get('container_format') + disk_format = image.get('disk_format') + if not (container_format and disk_format): + utils.exit("The 'container_format' and 'disk_format' properties " + "must be set on an image before it can be imported.") + + image_status = image.get('status') + if args.import_method == 'glance-direct': + if image_status != 'uploading': + utils.exit("The 'glance-direct' import method can only be applied " + "to an image in status 'uploading'") + if args.import_method == 'web-download': + if image_status != 'queued': + utils.exit("The 'web-download' import method can only be applied " + "to an image in status 'queued'") + + # finally, do the import + gc.images.image_import(args.id, args.import_method, args.uri) + + image = gc.images.get(args.id) + utils.print_image(image) @utils.arg('id', metavar='<IMAGE_ID>', nargs='+', diff --git a/lower-constraints.txt b/lower-constraints.txt new file mode 100644 index 0000000..a02f7fb --- /dev/null +++ b/lower-constraints.txt @@ -0,0 +1,81 @@ +alabaster==0.7.10 +appdirs==1.3.0 +asn1crypto==0.23.0 +Babel==2.3.4 +cffi==1.7.0 +cliff==2.8.0 +cmd2==0.8.0 +coverage==4.0 +cryptography==2.1 +debtcollector==1.2.0 +docutils==0.11 +dulwich==0.15.0 +extras==1.0.0 +fasteners==0.7.0 +fixtures==3.0.0 +flake8==2.5.5 +future==0.16.0 +hacking==0.12.0 +idna==2.6 +imagesize==0.7.1 +iso8601==0.1.11 +Jinja2==2.10 +jsonpatch==1.16 +jsonpointer==1.13 +jsonschema==2.6.0 +keystoneauth1==3.6.2 +linecache2==1.0.0 +MarkupSafe==1.0 +mccabe==0.2.1 +mock==2.0.0 +monotonic==0.6 +msgpack-python==0.4.0 +netaddr==0.7.18 +netifaces==0.10.4 +openstackdocstheme==1.18.1 +ordereddict==1.1 +os-client-config==1.28.0 +os-testr==1.0.0 +oslo.concurrency==3.25.0 +oslo.config==5.2.0 +oslo.context==2.19.2 +oslo.i18n==3.15.3 +oslo.log==3.36.0 +oslo.serialization==2.18.0 +oslo.utils==3.33.0 +paramiko==2.0.0 +pbr==2.0.0 +pep8==1.5.7 +prettytable==0.7.1 +pyasn1==0.1.8 +pycparser==2.18 +pyflakes==0.8.1 +Pygments==2.2.0 +pyinotify==0.9.6 +pyOpenSSL==17.1.0 +pyparsing==2.1.0 +pyperclip==1.5.27 +python-dateutil==2.5.3 +python-mimeparse==1.6.0 +python-subunit==1.0.0 +pytz==2013.6 +PyYAML==3.12 +reno==2.5.0 +requests-mock==1.2.0 +requests==2.14.2 +requestsexceptions==1.2.0 +rfc3986==0.3.1 +six==1.10.0 +snowballstemmer==1.2.1 +Sphinx==1.6.2 +sphinxcontrib-websupport==1.0.1 +stestr==2.0.0 +stevedore==1.20.0 +tempest==17.1.0 +testscenarios==0.4 +testtools==2.2.0 +traceback2==1.4.0 +unittest2==1.1.0 +urllib3==1.21.1 +warlock==1.2.0 +wrapt==1.7.0 diff --git a/releasenotes/notes/http-headers-per-rfc-8187-aafa3199f863be81.yaml b/releasenotes/notes/http-headers-per-rfc-8187-aafa3199f863be81.yaml new file mode 100644 index 0000000..ab21b3c --- /dev/null +++ b/releasenotes/notes/http-headers-per-rfc-8187-aafa3199f863be81.yaml @@ -0,0 +1,14 @@ +--- +fixes: + - | + Bug 1766235_: Handle HTTP headers per RFC 8187 + + Previously the glanceclient encoded HTTP headers as UTF-8 + bytes. According to `RFC 8187`_, however, headers should be + encoded as 7-bit ASCII. The glanceclient now sends all headers + as 7-bit ASCII. It handles unicode strings by percent-encoding_ + them before sending them in headers. + + .. _1766235: https://code.launchpad.net/bugs/1766235 + .. _RFC 8187: https://tools.ietf.org/html/rfc8187 + .. _percent-encoding: https://tools.ietf.org/html/rfc3986#section-2.1 diff --git a/releasenotes/notes/rocky-2.11.0-ba936fd5e969198d.yaml b/releasenotes/notes/rocky-2.11.0-ba936fd5e969198d.yaml new file mode 100644 index 0000000..67409f3 --- /dev/null +++ b/releasenotes/notes/rocky-2.11.0-ba936fd5e969198d.yaml @@ -0,0 +1,12 @@ +--- +issues: + - | + Help texts for some properties has possibly outdated links. Please refer + to the documentation of the deployment while we try to find a way how to + document these references in a way that they do not point user to false + information. +fixes: + - | + * Bug 1762044_: Sync schema with glance-api service + + .. _1762044: https://code.launchpad.net/bugs/1762044 diff --git a/requirements.txt b/requirements.txt index 68abc02..25b670a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,11 +3,11 @@ # process, which may cause wedges in the gate later. pbr!=2.1.0,>=2.0.0 # Apache-2.0 PrettyTable<0.8,>=0.7.1 # BSD -keystoneauth1>=3.4.0 # Apache-2.0 +keystoneauth1>=3.6.2 # Apache-2.0 requests>=2.14.2 # Apache-2.0 warlock<2,>=1.2.0 # Apache-2.0 six>=1.10.0 # MIT oslo.utils>=3.33.0 # Apache-2.0 oslo.i18n>=3.15.3 # Apache-2.0 wrapt>=1.7.0 # BSD License -pyOpenSSL>=16.2.0 # Apache-2.0 +pyOpenSSL>=17.1.0 # Apache-2.0 @@ -33,21 +33,5 @@ setup-hooks = console_scripts = glance = glanceclient.shell:main -[build_sphinx] -builders = html,man -all-files = 1 -warning-is-error = 1 -source-dir = doc/source -build-dir = doc/build - -[upload_sphinx] -upload-dir = doc/build/html - [wheel] universal = 1 - -[pbr] -autodoc_index_modules = True -autodoc_exclude_modules = - glanceclient.tests.* -api_doc_dir = reference/api diff --git a/test-requirements.txt b/test-requirements.txt index 8bec3aa..0424393 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -5,14 +5,10 @@ hacking!=0.13.0,<0.14,>=0.12.0 # Apache-2.0 coverage!=4.4,>=4.0 # Apache-2.0 mock>=2.0.0 # BSD -ordereddict>=1.1 # MIT os-client-config>=1.28.0 # Apache-2.0 -openstackdocstheme>=1.18.1 # Apache-2.0 -reno>=2.5.0 # Apache-2.0 -sphinx!=1.6.6,>=1.6.2 # BSD -testrepository>=0.0.18 # Apache-2.0/BSD +stestr>=2.0.0 # Apache-2.0 testtools>=2.2.0 # MIT testscenarios>=0.4 # Apache-2.0/BSD fixtures>=3.0.0 # Apache-2.0/BSD -requests-mock>=1.1.0 # Apache-2.0 +requests-mock>=1.2.0 # Apache-2.0 tempest>=17.1.0 # Apache-2.0 diff --git a/tools/fix_ca_bundle.sh b/tools/fix_ca_bundle.sh index 8e3dba2..cd35d8d 100755 --- a/tools/fix_ca_bundle.sh +++ b/tools/fix_ca_bundle.sh @@ -6,10 +6,12 @@ # assumptions: # - devstack is running # - the devstack tls-proxy service is running +# - the environment var OS_TESTENV_NAME is set in tox.ini (defaults +# to 'functional' # # This code based on a function in devstack lib/tls function set_ca_bundle { - local python_cmd='.tox/functional/bin/python' + local python_cmd=".tox/${OS_TESTENV_NAME:-functional}/bin/python" local capath=$($python_cmd -c $'try:\n from requests import certs\n print (certs.where())\nexcept ImportError: pass') # of course, each distro keeps the CA store in a different location local fedora_CA='/etc/pki/tls/certs/ca-bundle.crt' @@ -9,18 +9,19 @@ install_command = pip install {opts} {packages} setenv = VIRTUAL_ENV={envdir} OS_STDOUT_NOCAPTURE=False OS_STDERR_NOCAPTURE=False - PYTHONHASHSEED=0 deps = -c{env:UPPER_CONSTRAINTS_FILE:https://git.openstack.org/cgit/openstack/requirements/plain/upper-constraints.txt} -r{toxinidir}/requirements.txt -r{toxinidir}/test-requirements.txt -commands = python setup.py testr --testr-args='{posargs}' +commands = stestr run --slowest {posargs} [testenv:pep8] +basepython = python3 commands = flake8 [testenv:venv] +basepython = python3 commands = {posargs} [pbr] @@ -30,23 +31,47 @@ warnerror = True # See glanceclient/tests/functional/README.rst # for information on running the functional tests. setenv = - OS_TEST_PATH = ./glanceclient/tests/functional + OS_TEST_PATH = ./glanceclient/tests/functional/v2 + OS_TESTENV_NAME = {envname} whitelist_externals = bash commands = bash tools/fix_ca_bundle.sh - python setup.py testr --testr-args='{posargs}' + stestr run --slowest {posargs} + +[testenv:functional-v1] +# TODO(rosmaita): remove this testenv at the beginning +# of the 'S' cycle +setenv = + OS_TEST_PATH = ./glanceclient/tests/functional/v1 + OS_TESTENV_NAME = {envname} +whitelist_externals = + bash +commands = + bash tools/fix_ca_bundle.sh + stestr run --slowest {posargs} [testenv:cover] -commands = python setup.py testr --coverage --testr-args='{posargs}' - coverage report +basepython = python3 +setenv = + PYTHON=coverage run --source glanceclient --parallel-mode +commands = + stestr run {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml [testenv:docs] -commands= - python setup.py build_sphinx +basepython = python3 +deps = -r{toxinidir}/doc/requirements.txt +commands = + sphinx-build -W -b html doc/source doc/build/html [testenv:releasenotes] -commands = sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html +basepython = python3 +deps = -r{toxinidir}/doc/requirements.txt +commands = + sphinx-build -a -E -W -d releasenotes/build/doctrees -b html releasenotes/source releasenotes/build/html [flake8] ignore = F403,F812,F821 @@ -55,3 +80,10 @@ exclude = .venv*,.tox,dist,*egg,build,.git,doc,*lib/python*,.update-venv [hacking] import_exceptions = six.moves,glanceclient._i18n + +[testenv:lower-constraints] +basepython = python3 +deps = + -c{toxinidir}/lower-constraints.txt + -r{toxinidir}/test-requirements.txt + -r{toxinidir}/requirements.txt |