summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.zuul.yaml91
-rw-r--r--glanceclient/common/http.py9
-rw-r--r--glanceclient/tests/unit/v2/test_shell_v2.py336
-rw-r--r--glanceclient/v2/images.py19
-rw-r--r--glanceclient/v2/shell.py168
-rw-r--r--releasenotes/notes/2.17.0_Release-c67392be3b428d10.yaml35
-rw-r--r--releasenotes/notes/copy-existing-image-619b7e6bc3394446.yaml5
-rw-r--r--releasenotes/notes/del_from_store-2d807c3038283907.yaml4
-rw-r--r--releasenotes/notes/drop-py-2-7-f10417b8d1dd38fb.yaml6
-rw-r--r--releasenotes/notes/multi-store-import-45d05a6193ef2c04.yaml5
-rw-r--r--releasenotes/source/index.rst1
-rw-r--r--releasenotes/source/train.rst6
-rw-r--r--setup.cfg10
-rw-r--r--tox.ini18
14 files changed, 554 insertions, 159 deletions
diff --git a/.zuul.yaml b/.zuul.yaml
index 5a22fd3..c48d6f4 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -1,52 +1,4 @@
- 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/opendev.org/openstack/python-glanceclient
- irrelevant-files:
- - ^doc/.*$
- - ^releasenotes/.*$
- - ^.*\.rst$
- - ^(test-|)requirements.txt$
- - ^lower-constraints.txt$
- - ^setup.cfg$
- - ^tox.ini$
- - ^\.zuul\.yaml$
-
-- job:
name: glanceclient-dsvm-functional
parent: devstack-tox-functional
description: |
@@ -83,7 +35,6 @@
- ^lower-constraints.txt$
- ^setup.cfg$
- ^tox.ini$
- - ^\.zuul\.yaml$
- job:
name: glanceclient-tox-keystone-tips-base
@@ -94,20 +45,12 @@
- name: openstack/keystoneauth
- job:
- name: glanceclient-tox-py27-keystone-tips
+ name: glanceclient-tox-py3-keystone-tips
parent: glanceclient-tox-keystone-tips-base
description: |
- glanceclient py27 unit tests vs. keystone masters
+ glanceclient py3 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
+ tox_envlist: py3
- job:
name: glanceclient-tox-oslo-tips-base
@@ -119,20 +62,12 @@
- name: openstack/oslo.utils
- job:
- name: glanceclient-tox-py27-oslo-tips
+ name: glanceclient-tox-py3-oslo-tips
parent: glanceclient-tox-oslo-tips-base
description: |
- glanceclient py27 unit tests vs. oslo masters
+ glanceclient py3 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
+ tox_envlist: py3
- job:
name: glanceclient-dsvm-functional-py3
@@ -144,21 +79,17 @@
- project:
templates:
- check-requirements
- - lib-forward-testing
- lib-forward-testing-python3
- openstack-cover-jobs
- openstack-lower-constraints-jobs
- - openstack-python-jobs
- - openstack-python3-train-jobs
+ - openstack-python3-ussuri-jobs
- publish-openstack-docs-pti
- release-notes-jobs-python3
check:
jobs:
- - glanceclient-dsvm-functional-v1
- glanceclient-dsvm-functional
gate:
jobs:
- - glanceclient-dsvm-functional-v1
- glanceclient-dsvm-functional
periodic:
jobs:
@@ -174,13 +105,9 @@
# to define these jobs in the openstack/project-config repo.
# That would make us less agile in adjusting these tests, so we
# aren't doing that either.
- - glanceclient-tox-py27-keystone-tips:
- branches: master
- - glanceclient-tox-py35-keystone-tips:
- branches: master
- - glanceclient-tox-py27-oslo-tips:
+ - glanceclient-tox-py3-keystone-tips:
branches: master
- - glanceclient-tox-py35-oslo-tips:
+ - glanceclient-tox-py3-oslo-tips:
branches: master
experimental:
jobs:
diff --git a/glanceclient/common/http.py b/glanceclient/common/http.py
index 17d7cc7..78c4bc5 100644
--- a/glanceclient/common/http.py
+++ b/glanceclient/common/http.py
@@ -183,6 +183,15 @@ class HTTPClient(_BaseHTTPClient):
self.session.cert = (kwargs.get('cert_file'),
kwargs.get('key_file'))
+ def __del__(self):
+ if self.session:
+ try:
+ self.session.close()
+ except Exception as e:
+ LOG.exception(e)
+ finally:
+ self.session = None
+
@staticmethod
def parse_endpoint(endpoint):
return netutils.urlsplit(endpoint)
diff --git a/glanceclient/tests/unit/v2/test_shell_v2.py b/glanceclient/tests/unit/v2/test_shell_v2.py
index 84ef1f0..c43f606 100644
--- a/glanceclient/tests/unit/v2/test_shell_v2.py
+++ b/glanceclient/tests/unit/v2/test_shell_v2.py
@@ -95,7 +95,7 @@ class ShellV2Test(testtools.TestCase):
# dict directly, it throws an AttributeError.
class Args(object):
def __init__(self, entries):
- self.backend = None
+ self.store = None
self.__dict__.update(entries)
return Args(args)
@@ -782,9 +782,9 @@ class ShellV2Test(testtools.TestCase):
@mock.patch('glanceclient.common.utils.exit')
@mock.patch('os.access')
@mock.patch('sys.stdin', autospec=True)
- def test_neg_do_image_create_no_file_and_stdin_with_backend(
+ def test_neg_do_image_create_no_file_and_stdin_with_store(
self, mock_stdin, mock_access, mock_utils_exit):
- expected_msg = ('--backend option should only be provided with --file '
+ expected_msg = ('--store option should only be provided with --file '
'option or stdin.')
mock_utils_exit.side_effect = self._mock_utils_exit
mock_stdin.isatty = lambda: True
@@ -792,7 +792,7 @@ class ShellV2Test(testtools.TestCase):
args = self._make_args({'name': 'IMG-01',
'property': ['myprop=myval'],
'file': None,
- 'backend': 'file1',
+ 'store': 'file1',
'container_format': 'bare',
'disk_format': 'qcow2'})
@@ -805,16 +805,16 @@ class ShellV2Test(testtools.TestCase):
mock_utils_exit.assert_called_once_with(expected_msg)
@mock.patch('glanceclient.common.utils.exit')
- def test_neg_do_image_create_invalid_backend(
+ def test_neg_do_image_create_invalid_store(
self, mock_utils_exit):
- expected_msg = ("Backend 'dummy' is not valid for this cloud. "
+ expected_msg = ("Store 'dummy' is not valid for this cloud. "
"Valid values can be retrieved with stores-info "
"command.")
mock_utils_exit.side_effect = self._mock_utils_exit
args = self._make_args({'name': 'IMG-01',
'property': ['myprop=myval'],
'file': "somefile.txt",
- 'backend': 'dummy',
+ 'store': 'dummy',
'container_format': 'bare',
'disk_format': 'qcow2'})
@@ -842,7 +842,7 @@ class ShellV2Test(testtools.TestCase):
import_info_response = {'import-methods': {
'type': 'array',
'description': 'Import methods available.',
- 'value': ['glance-direct', 'web-download']}}
+ 'value': ['glance-direct', 'web-download', 'copy-image']}}
def _mock_utils_exit(self, msg):
sys.exit(msg)
@@ -871,15 +871,120 @@ class ShellV2Test(testtools.TestCase):
mock_utils_exit.assert_called_once_with(expected_msg)
@mock.patch('glanceclient.common.utils.exit')
+ def test_neg_image_create_via_import_copy_image(
+ self, mock_utils_exit):
+ expected_msg = ("Import method 'copy-image' cannot be used "
+ "while creating the image.")
+ mock_utils_exit.side_effect = self._mock_utils_exit
+ my_args = self.base_args.copy()
+ my_args.update(
+ {'id': 'IMG-01', 'import_method': 'copy-image'})
+ args = self._make_args(my_args)
+
+ 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')
+ def test_neg_image_create_via_import_stores_all_stores_specified(
+ self, mock_utils_exit):
+ expected_msg = ('Only one of --store, --stores and --all-stores can '
+ 'be provided')
+ mock_utils_exit.side_effect = self._mock_utils_exit
+ my_args = self.base_args.copy()
+ my_args.update(
+ {'id': 'IMG-01', 'import_method': 'glance-direct',
+ 'stores': 'file1,file2', 'os_all_stores': True,
+ 'file': 'some.mufile',
+ 'disk_format': 'raw',
+ 'container_format': 'bare',
+ })
+ args = self._make_args(my_args)
+
+ 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_stores_without_file(
+ self, mock_stdin, mock_utils_exit):
+ expected_msg = ('--stores option should only be provided with --file '
+ 'option or stdin for the glance-direct import method.')
+ mock_utils_exit.side_effect = self._mock_utils_exit
+ mock_stdin.isatty = lambda: True
+ my_args = self.base_args.copy()
+ my_args.update(
+ {'id': 'IMG-01', 'import_method': 'glance-direct',
+ 'stores': 'file1,file2',
+ 'disk_format': 'raw',
+ 'container_format': 'bare',
+ })
+ args = self._make_args(my_args)
+
+ with mock.patch.object(self.gc.images,
+ 'get_import_info') as mocked_info:
+ with mock.patch.object(self.gc.images,
+ 'get_stores_info') as mocked_stores_info:
+ mocked_stores_info.return_value = self.stores_info_response
+ 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_all_stores_without_file(
+ self, mock_stdin, mock_utils_exit):
+ expected_msg = ('--all-stores option should only be provided with '
+ '--file option or stdin for the glance-direct import '
+ 'method.')
+ mock_utils_exit.side_effect = self._mock_utils_exit
+ mock_stdin.isatty = lambda: True
+ my_args = self.base_args.copy()
+ my_args.update(
+ {'id': 'IMG-01', 'import_method': 'glance-direct',
+ 'os_all_stores': True,
+ 'disk_format': 'raw',
+ 'container_format': 'bare',
+ })
+ args = self._make_args(my_args)
+
+ 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_no_file_and_stdin_with_backend(
+ def test_neg_image_create_via_import_no_file_and_stdin_with_store(
self, mock_stdin, mock_access, mock_utils_exit):
- expected_msg = ('--backend option should only be provided with --file '
+ expected_msg = ('--store option should only be provided with --file '
'option or stdin for the glance-direct import method.')
my_args = self.base_args.copy()
my_args['import_method'] = 'glance-direct'
- my_args['backend'] = 'file1'
+ my_args['store'] = 'file1'
args = self._make_args(my_args)
mock_stdin.isatty = lambda: True
@@ -900,13 +1005,13 @@ class ShellV2Test(testtools.TestCase):
@mock.patch('glanceclient.common.utils.exit')
@mock.patch('sys.stdin', autospec=True)
- def test_neg_image_create_via_import_no_uri_with_backend(
+ def test_neg_image_create_via_import_no_uri_with_store(
self, mock_stdin, mock_utils_exit):
- expected_msg = ('--backend option should only be provided with --uri '
+ expected_msg = ('--store option should only be provided with --uri '
'option for the web-download import method.')
my_args = self.base_args.copy()
my_args['import_method'] = 'web-download'
- my_args['backend'] = 'file1'
+ my_args['store'] = 'file1'
args = self._make_args(my_args)
mock_utils_exit.side_effect = self._mock_utils_exit
with mock.patch.object(self.gc.images,
@@ -925,14 +1030,14 @@ class ShellV2Test(testtools.TestCase):
@mock.patch('glanceclient.common.utils.exit')
@mock.patch('os.access')
@mock.patch('sys.stdin', autospec=True)
- def test_neg_image_create_via_import_invalid_backend(
+ def test_neg_image_create_via_import_invalid_store(
self, mock_stdin, mock_access, mock_utils_exit):
- expected_msg = ("Backend 'dummy' is not valid for this cloud. "
+ expected_msg = ("Store 'dummy' is not valid for this cloud. "
"Valid values can be retrieved with stores-info"
" command.")
my_args = self.base_args.copy()
my_args['import_method'] = 'glance-direct'
- my_args['backend'] = 'dummy'
+ my_args['store'] = 'dummy'
args = self._make_args(my_args)
mock_stdin.isatty = lambda: True
@@ -1084,6 +1189,60 @@ class ShellV2Test(testtools.TestCase):
mock_utils_exit.assert_called_once_with(expected_msg)
@mock.patch('glanceclient.common.utils.exit')
+ def test_neg_image_create_via_import_stores_without_uri(
+ self, mock_utils_exit):
+ expected_msg = ('--stores option should only be provided with --uri '
+ 'option for the web-download import method.')
+ mock_utils_exit.side_effect = self._mock_utils_exit
+ my_args = self.base_args.copy()
+ my_args.update(
+ {'id': 'IMG-01', 'import_method': 'web-download',
+ 'stores': 'file1,file2',
+ 'disk_format': 'raw',
+ 'container_format': 'bare',
+ })
+ args = self._make_args(my_args)
+
+ with mock.patch.object(self.gc.images,
+ 'get_import_info') as mocked_info:
+ with mock.patch.object(self.gc.images,
+ 'get_stores_info') as mocked_stores_info:
+ mocked_stores_info.return_value = self.stores_info_response
+ 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')
+ def test_neg_image_create_via_import_all_stores_without_uri(
+ self, mock_utils_exit):
+ expected_msg = ('--all-stores option should only be provided with '
+ '--uri option for the web-download import '
+ 'method.')
+ mock_utils_exit.side_effect = self._mock_utils_exit
+ my_args = self.base_args.copy()
+ my_args.update(
+ {'id': 'IMG-01', 'import_method': 'web-download',
+ 'os_all_stores': True,
+ 'disk_format': 'raw',
+ 'container_format': 'bare',
+ })
+ args = self._make_args(my_args)
+
+ 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):
@@ -1576,15 +1735,15 @@ class ShellV2Test(testtools.TestCase):
backend=None)
@mock.patch('glanceclient.common.utils.exit')
- def test_image_upload_invalid_backend(self, mock_utils_exit):
- expected_msg = ("Backend 'dummy' is not valid for this cloud. "
+ def test_image_upload_invalid_store(self, mock_utils_exit):
+ expected_msg = ("Store 'dummy' is not valid for this cloud. "
"Valid values can be retrieved with stores-info "
"command.")
mock_utils_exit.side_effect = self._mock_utils_exit
args = self._make_args(
{'id': 'IMG-01', 'file': 'test', 'size': 1024, 'progress': False,
- 'backend': 'dummy'})
+ 'store': 'dummy'})
with mock.patch.object(self.gc.images,
'get_stores_info') as mock_stores_info:
@@ -1743,15 +1902,15 @@ class ShellV2Test(testtools.TestCase):
mock_utils_exit.assert_called_once_with(expected_msg)
@mock.patch('glanceclient.common.utils.exit')
- def test_image_import_invalid_backend(self, mock_utils_exit):
- expected_msg = ("Backend 'dummy' is not valid for this cloud. "
+ def test_image_import_invalid_store(self, mock_utils_exit):
+ expected_msg = ("Store 'dummy' is not valid for this cloud. "
"Valid values can be retrieved with stores-info "
"command.")
mock_utils_exit.side_effect = self._mock_utils_exit
args = self._make_args(
{'id': 'IMG-01', 'import_method': 'glance-direct', 'uri': None,
- 'backend': 'dummy'})
+ 'store': 'dummy'})
with mock.patch.object(self.gc.images, 'get') as mocked_get:
with mock.patch.object(self.gc.images,
@@ -1785,7 +1944,8 @@ class ShellV2Test(testtools.TestCase):
mock_import.return_value = None
test_shell.do_image_import(self.gc, args)
mock_import.assert_called_once_with(
- 'IMG-01', 'glance-direct', None, backend=None)
+ 'IMG-01', 'glance-direct', None, backend=None,
+ all_stores=None, allow_failure=True, stores=None)
def test_image_import_web_download(self):
args = self._make_args(
@@ -1803,7 +1963,9 @@ class ShellV2Test(testtools.TestCase):
test_shell.do_image_import(self.gc, args)
mock_import.assert_called_once_with(
'IMG-01', 'web-download',
- 'http://example.com/image.qcow', backend=None)
+ 'http://example.com/image.qcow',
+ all_stores=None, allow_failure=True,
+ backend=None, stores=None)
@mock.patch('glanceclient.common.utils.print_image')
def test_image_import_no_print_image(self, mocked_utils_print_image):
@@ -1821,9 +1983,108 @@ class ShellV2Test(testtools.TestCase):
mock_import.return_value = None
test_shell.do_image_import(self.gc, args)
mock_import.assert_called_once_with(
- 'IMG-02', 'glance-direct', None, backend=None)
+ 'IMG-02', 'glance-direct', None, stores=None,
+ all_stores=None, allow_failure=True, backend=None)
mocked_utils_print_image.assert_not_called()
+ @mock.patch('glanceclient.common.utils.print_image')
+ @mock.patch('glanceclient.v2.shell._validate_backend')
+ def test_image_import_multiple_stores(self, mocked_utils_print_image,
+ msvb):
+ args = self._make_args(
+ {'id': 'IMG-02', 'uri': None, 'import_method': 'glance-direct',
+ 'from_create': False, 'stores': 'site1,site2'})
+ 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, all_stores=None,
+ allow_failure=True, stores=['site1', 'site2'],
+ backend=None)
+
+ @mock.patch('glanceclient.common.utils.print_image')
+ @mock.patch('glanceclient.v2.shell._validate_backend')
+ def test_image_import_copy_image(self, mocked_utils_print_image,
+ msvb):
+ args = self._make_args(
+ {'id': 'IMG-02', 'uri': None, 'import_method': 'copy-image',
+ 'from_create': False, 'stores': 'file1,file2'})
+ 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': 'active',
+ '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', 'copy-image', None, all_stores=None,
+ allow_failure=True, stores=['file1', 'file2'],
+ backend=None)
+
+ @mock.patch('glanceclient.common.utils.exit')
+ def test_neg_image_import_copy_image_not_active(
+ self, mock_utils_exit):
+ expected_msg = ("The 'copy-image' import method can only be used on "
+ "an image with status 'active'.")
+ mock_utils_exit.side_effect = self._mock_utils_exit
+ args = self._make_args(
+ {'id': 'IMG-02', 'uri': None, 'import_method': 'copy-image',
+ 'disk_format': 'raw',
+ 'container_format': 'bare',
+ 'from_create': False, 'stores': 'file1,file2'})
+ with mock.patch.object(
+ self.gc.images,
+ 'get_stores_info') as mocked_stores_info:
+ 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_stores_info.return_value = self.stores_info_response
+ 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_stores_all_stores_not_specified(
+ self, mock_utils_exit):
+ expected_msg = ("Provide either --stores or --all-stores for "
+ "'copy-image' import method.")
+ mock_utils_exit.side_effect = self._mock_utils_exit
+ my_args = self.base_args.copy()
+ my_args.update(
+ {'id': 'IMG-01', 'import_method': 'copy-image',
+ 'disk_format': 'raw',
+ 'container_format': 'bare',
+ })
+ args = self._make_args(my_args)
+
+ 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)
+
def test_image_download(self):
args = self._make_args(
{'id': 'IMG-01', 'file': 'test', 'progress': True,
@@ -1905,6 +2166,29 @@ class ShellV2Test(testtools.TestCase):
mocked_utils_exit.assert_called_once_with()
@mock.patch.object(utils, 'exit')
+ def test_do_image_delete_from_store_not_found(self, mocked_utils_exit):
+ args = argparse.Namespace(id='image1', store='store1')
+ with mock.patch.object(self.gc.images,
+ 'delete_from_store') as mocked_delete:
+ mocked_delete.side_effect = exc.HTTPNotFound
+
+ test_shell.do_stores_delete(self.gc, args)
+
+ self.assertEqual(1, mocked_delete.call_count)
+ mocked_utils_exit.assert_called_once_with('Multi Backend support '
+ 'is not enabled or '
+ 'Image/store not found.')
+
+ def test_do_image_delete_from_store(self):
+ args = argparse.Namespace(id='image1', store='store1')
+ with mock.patch.object(self.gc.images,
+ 'delete_from_store') as mocked_delete:
+ test_shell.do_stores_delete(self.gc, args)
+
+ mocked_delete.assert_called_once_with('store1',
+ 'image1')
+
+ @mock.patch.object(utils, 'exit')
@mock.patch.object(utils, 'print_err')
def test_do_image_delete_with_forbidden_ids(self, mocked_print_err,
mocked_utils_exit):
diff --git a/glanceclient/v2/images.py b/glanceclient/v2/images.py
index 5252ee3..1e8e621 100644
--- a/glanceclient/v2/images.py
+++ b/glanceclient/v2/images.py
@@ -303,6 +303,14 @@ class Controller(object):
return body, resp
@utils.add_req_id_to_object()
+ def delete_from_store(self, store_id, image_id):
+ """Delete image data from specific store."""
+ url = ('/v2/stores/%(store)s/%(image)s' % {'store': store_id,
+ 'image': image_id})
+ resp, body = self.http_client.delete(url)
+ return body, resp
+
+ @utils.add_req_id_to_object()
def stage(self, image_id, image_data, image_size=None):
"""Upload the data to image staging.
@@ -318,13 +326,22 @@ class Controller(object):
@utils.add_req_id_to_object()
def image_import(self, image_id, method='glance-direct', uri=None,
- backend=None):
+ backend=None, stores=None, allow_failure=True,
+ all_stores=None):
"""Import Image via method."""
headers = {}
url = '/v2/images/%s/import' % image_id
data = {'method': {'name': method}}
+ if stores:
+ data['stores'] = stores
+ if allow_failure:
+ data['all_stores_must_succeed'] = 'false'
if backend is not None:
headers['x-image-meta-store'] = backend
+ if all_stores:
+ data['all_stores'] = 'true'
+ if allow_failure:
+ data['all_stores_must_succeed'] = 'false'
if uri:
if method == 'web-download':
diff --git a/glanceclient/v2/shell.py b/glanceclient/v2/shell.py
index 61e5ec3..b4dc811 100644
--- a/glanceclient/v2/shell.py
+++ b/glanceclient/v2/shell.py
@@ -71,8 +71,8 @@ def get_image_schema():
'passed to the client via stdin.'))
@utils.arg('--progress', action='store_true', default=False,
help=_('Show upload progress bar.'))
-@utils.arg('--backend', metavar='<STORE>',
- default=utils.env('OS_IMAGE_BACKEND', default=None),
+@utils.arg('--store', metavar='<STORE>',
+ default=utils.env('OS_IMAGE_STORE', default=None),
help='Backend store to upload image to.')
@utils.on_data_require_fields(DATA_FIELDS)
def do_image_create(gc, args):
@@ -90,12 +90,12 @@ def do_image_create(gc, args):
key, value = datum.split('=', 1)
fields[key] = value
- backend = args.backend
+ backend = args.store
file_name = fields.pop('file', None)
using_stdin = not sys.stdin.isatty()
- if args.backend and not (file_name or using_stdin):
- utils.exit("--backend option should only be provided with --file "
+ if args.store and not (file_name or using_stdin):
+ utils.exit("--store option should only be provided with --file "
"option or stdin.")
if backend:
@@ -108,7 +108,7 @@ def do_image_create(gc, args):
image = gc.images.create(**fields)
try:
if utils.get_data_file(args) is not None:
- backend = fields.get('backend', None)
+ backend = fields.get('store', None)
args.id = image['id']
args.size = None
do_image_upload(gc, args)
@@ -147,9 +147,31 @@ def do_image_create(gc, args):
'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.arg('--backend', metavar='<STORE>',
- default=utils.env('OS_IMAGE_BACKEND', default=None),
+@utils.arg('--store', metavar='<STORE>',
+ default=utils.env('OS_IMAGE_STORE', default=None),
help='Backend store to upload image to.')
+@utils.arg('--stores', metavar='<STORES>',
+ default=utils.env('OS_IMAGE_STORES', default=None),
+ help=_('Stores to upload image to if multi-stores import '
+ 'available. Comma separated list. Available stores can be '
+ 'listed with "stores-info" call.'))
+@utils.arg('--all-stores', type=strutils.bool_from_string,
+ metavar='[True|False]',
+ default=None,
+ dest='os_all_stores',
+ help=_('"all-stores" can be ued instead of "stores"-list to '
+ 'indicate that image should be imported into all available '
+ 'stores.'))
+@utils.arg('--allow-failure', type=strutils.bool_from_string,
+ metavar='[True|False]',
+ dest='os_allow_failure',
+ default=utils.env('OS_IMAGE_ALLOW_FAILURE', default=True),
+ help=_('Indicator if all stores listed (or available) must '
+ 'succeed. "True" by default meaning that we allow some '
+ 'stores to fail and the status can be monitored from the '
+ 'image metadata. If this is set to "False" the import will '
+ 'be reverted should any of the uploads fail. Only usable '
+ 'with "stores" or "all-stores".'))
@utils.on_data_require_fields(DATA_FIELDS)
def do_image_create_via_import(gc, args):
"""EXPERIMENTAL: Create a new image via image import.
@@ -188,6 +210,10 @@ def do_image_create_via_import(gc, args):
if args.import_method is None and (file_name or using_stdin):
args.import_method = 'glance-direct'
+ if args.import_method == 'copy-image':
+ utils.exit("Import method 'copy-image' cannot be used "
+ "while creating the image.")
+
# determine whether the requested import method is valid
import_methods = gc.images.get_import_info().get('import-methods')
if args.import_method and args.import_method not in import_methods.get(
@@ -198,9 +224,21 @@ def do_image_create_via_import(gc, args):
# determine if backend is valid
backend = None
- if args.backend:
- backend = args.backend
+ stores = getattr(args, "stores", None)
+ all_stores = getattr(args, "os_all_stores", None)
+
+ if (args.store and (stores or all_stores)) or (stores and all_stores):
+ utils.exit("Only one of --store, --stores and --all-stores can be "
+ "provided")
+ elif args.store:
+ backend = args.store
+ # determine if backend is valid
_validate_backend(backend, gc)
+ elif stores:
+ stores = str(stores).split(',')
+ for store in stores:
+ # determine if backend is valid
+ _validate_backend(store, gc)
# make sure we have all and only correct inputs for the requested method
if args.import_method is None:
@@ -209,8 +247,16 @@ def do_image_create_via_import(gc, args):
"method.")
if args.import_method == 'glance-direct':
if backend and not (file_name or using_stdin):
- utils.exit("--backend option should only be provided with --file "
+ utils.exit("--store option should only be provided with --file "
+ "option or stdin for the glance-direct import method.")
+ if stores and not (file_name or using_stdin):
+ utils.exit("--stores option should only be provided with --file "
"option or stdin for the glance-direct import method.")
+ if all_stores and not (file_name or using_stdin):
+ utils.exit("--all-stores option should only be provided with "
+ "--file option or stdin for the glance-direct import "
+ "method.")
+
if args.uri:
utils.exit("You cannot specify a --uri with the glance-direct "
"import method.")
@@ -225,8 +271,14 @@ def do_image_create_via_import(gc, args):
"for the glance-direct import method.")
if args.import_method == 'web-download':
if backend and not args.uri:
- utils.exit("--backend option should only be provided with --uri "
+ utils.exit("--store option should only be provided with --uri "
+ "option for the web-download import method.")
+ if stores and not args.uri:
+ utils.exit("--stores option should only be provided with --uri "
"option for the web-download import method.")
+ if all_stores and not args.uri:
+ utils.exit("--all-stores option should only be provided with "
+ "--uri option for the web-download import method.")
if not args.uri:
utils.exit("URI is required for web-download import method. "
"Please use '--uri <uri>'.")
@@ -246,6 +298,7 @@ def do_image_create_via_import(gc, args):
args.size = None
do_image_stage(gc, args)
args.from_create = True
+ args.stores = stores
do_image_import(gc, args)
image = gc.images.get(args.id)
finally:
@@ -267,7 +320,7 @@ def _validate_backend(backend, gc):
break
if not valid_backend:
- utils.exit("Backend '%s' is not valid for this cloud. Valid "
+ utils.exit("Store '%s' is not valid for this cloud. Valid "
"values can be retrieved with stores-info command." %
backend)
@@ -503,6 +556,24 @@ def do_stores_info(gc, args):
utils.print_dict(stores_info)
+@utils.arg('id', metavar='<IMAGE_ID>', help=_('ID of image to update.'))
+@utils.arg('--store', metavar='<STORE_ID>', required=True,
+ help=_('Store to delete image from.'))
+def do_stores_delete(gc, args):
+ """Delete image from specific store."""
+ try:
+ gc.images.delete_from_store(args.store, args.id)
+ except exc.HTTPNotFound:
+ utils.exit('Multi Backend support is not enabled or Image/store not '
+ 'found.')
+ except (exc.HTTPForbidden, exc.HTTPException) as e:
+ msg = ("Unable to delete image '%s' from store '%s'. (%s)" % (
+ args.id,
+ args.store,
+ e))
+ utils.exit(msg)
+
+
@utils.arg('--allow-md5-fallback', action='store_true',
default=utils.env('OS_IMAGE_ALLOW_MD5_FALLBACK', default=False),
help=_('If os_hash_algo and os_hash_value properties are available '
@@ -559,14 +630,14 @@ def do_image_download(gc, args):
help=_('Show upload progress bar.'))
@utils.arg('id', metavar='<IMAGE_ID>',
help=_('ID of image to upload data to.'))
-@utils.arg('--backend', metavar='<STORE>',
- default=utils.env('OS_IMAGE_BACKEND', default=None),
+@utils.arg('--store', metavar='<STORE>',
+ default=utils.env('OS_IMAGE_STORE', default=None),
help='Backend store to upload image to.')
def do_image_upload(gc, args):
"""Upload data for a specific image."""
backend = None
- if args.backend:
- backend = args.backend
+ if args.store:
+ backend = args.store
# determine if backend is valid
_validate_backend(backend, gc)
@@ -614,22 +685,59 @@ def do_image_stage(gc, args):
help=_('URI to download the external image.'))
@utils.arg('id', metavar='<IMAGE_ID>',
help=_('ID of image to import.'))
-@utils.arg('--backend', metavar='<STORE>',
- default=utils.env('OS_IMAGE_BACKEND', default=None),
+@utils.arg('--store', metavar='<STORE>',
+ default=utils.env('OS_IMAGE_STORE', default=None),
help='Backend store to upload image to.')
+@utils.arg('--stores', metavar='<STORES>',
+ default=utils.env('OS_IMAGE_STORES', default=None),
+ help='Stores to upload image to if multi-stores import available.')
+@utils.arg('--all-stores', type=strutils.bool_from_string,
+ metavar='[True|False]',
+ default=None,
+ dest='os_all_stores',
+ help=_('"all-stores" can be ued instead of "stores"-list to '
+ 'indicate that image should be imported all available '
+ 'stores.'))
+@utils.arg('--allow-failure', type=strutils.bool_from_string,
+ metavar='[True|False]',
+ dest='os_allow_failure',
+ default=utils.env('OS_IMAGE_ALLOW_FAILURE', default=True),
+ help=_('Indicator if all stores listed (or available) must '
+ 'succeed. "True" by default meaning that we allow some '
+ 'stores to fail and the status can be monitored from the '
+ 'image metadata. If this is set to "False" the import will '
+ 'be reverted should any of the uploads fail. Only usable '
+ 'with "stores" or "all-stores".'))
def do_image_import(gc, args):
"""Initiate the image import taskflow."""
- backend = None
- if args.backend:
- backend = args.backend
+ backend = getattr(args, "store", None)
+ stores = getattr(args, "stores", None)
+ all_stores = getattr(args, "os_all_stores", None)
+ allow_failure = getattr(args, "os_allow_failure", True)
+
+ if not getattr(args, 'from_create', False):
+ if (args.store and (stores or all_stores)) or (stores and all_stores):
+ utils.exit("Only one of --store, --stores and --all-stores can be "
+ "provided")
+ elif args.store:
+ backend = args.store
+ # determine if backend is valid
+ _validate_backend(backend, gc)
+ elif stores:
+ stores = str(stores).split(',')
+
# determine if backend is valid
- _validate_backend(backend, gc)
+ if stores:
+ for store in stores:
+ _validate_backend(store, gc)
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,
- backend=backend)
+ backend=backend,
+ stores=stores, all_stores=all_stores,
+ allow_failure=allow_failure)
return
# do input validation
@@ -649,6 +757,10 @@ def do_image_import(gc, args):
utils.exit("Import method should be 'web-download' if URI is "
"provided.")
+ if args.import_method == 'copy-image' and not (stores or all_stores):
+ utils.exit("Provide either --stores or --all-stores for "
+ "'copy-image' import method.")
+
# check image properties
image = gc.images.get(args.id)
container_format = image.get('container_format')
@@ -666,10 +778,16 @@ def do_image_import(gc, args):
if image_status != 'queued':
utils.exit("The 'web-download' import method can only be applied "
"to an image in status 'queued'")
+ if args.import_method == 'copy-image':
+ if image_status != 'active':
+ utils.exit("The 'copy-image' import method can only be used on "
+ "an image with status 'active'.")
# finally, do the import
gc.images.image_import(args.id, args.import_method, args.uri,
- backend=backend)
+ backend=backend,
+ stores=stores, all_stores=all_stores,
+ allow_failure=allow_failure)
image = gc.images.get(args.id)
utils.print_image(image)
diff --git a/releasenotes/notes/2.17.0_Release-c67392be3b428d10.yaml b/releasenotes/notes/2.17.0_Release-c67392be3b428d10.yaml
new file mode 100644
index 0000000..d52fcc0
--- /dev/null
+++ b/releasenotes/notes/2.17.0_Release-c67392be3b428d10.yaml
@@ -0,0 +1,35 @@
+---
+prelude: |
+ This version of python-glanceclient finalizes client-side support for
+ the Glance multiple stores feature. See the `Multi Store Support
+ <https://docs.openstack.org/glance/latest/admin/multistores.html>`_
+ section of the Glance documentation for more information.
+
+ Support for Glance multiple stores has been available on an EXPERIMENTAL
+ basis since release 2.12.0. For the Train release, the Image service
+ has finalized how API users interact with multiple stores. See the
+ "Upgrade Notes" section of this document for information about changes this
+ has necessitated in multistore support in the glanceclient.
+fixes:
+ - |
+ Bug 1822052_: HTTPClient: actually set a timeout for requests
+
+ .. _1822052: https://code.launchpad.net/bugs/1822052
+upgrade:
+ - |
+ The following Command Line Interface calls now take a ``--store``
+ option:
+
+ * ``glance image-create``
+ * ``glance image-create-via-import``
+ * ``glance image-upload``
+ * ``glance image-import``
+
+ The value for this option is a store identifier. The list of
+ available stores may be obtained from the ``glance stores-info``
+ command.
+
+ - |
+ The ``--backend`` option, available on some commands on an experimental
+ basis since release 2.12.0, is no longer available. Use ``--store``
+ instead.
diff --git a/releasenotes/notes/copy-existing-image-619b7e6bc3394446.yaml b/releasenotes/notes/copy-existing-image-619b7e6bc3394446.yaml
new file mode 100644
index 0000000..fc7833c
--- /dev/null
+++ b/releasenotes/notes/copy-existing-image-619b7e6bc3394446.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ Adds support for copy-image import method which will copy existing
+ images into multiple stores.
diff --git a/releasenotes/notes/del_from_store-2d807c3038283907.yaml b/releasenotes/notes/del_from_store-2d807c3038283907.yaml
new file mode 100644
index 0000000..7c330a8
--- /dev/null
+++ b/releasenotes/notes/del_from_store-2d807c3038283907.yaml
@@ -0,0 +1,4 @@
+---
+features:
+ - |
+ Support for deleting the image data from single store.
diff --git a/releasenotes/notes/drop-py-2-7-f10417b8d1dd38fb.yaml b/releasenotes/notes/drop-py-2-7-f10417b8d1dd38fb.yaml
new file mode 100644
index 0000000..8d2d16b
--- /dev/null
+++ b/releasenotes/notes/drop-py-2-7-f10417b8d1dd38fb.yaml
@@ -0,0 +1,6 @@
+---
+upgrade:
+ - |
+ Python 2.7 support has been dropped. Last release of python-glanceclient
+ to support py2.7 is OpenStack Train. The minimum version of Python now
+ supported by python-glanceclient is Python 3.6.
diff --git a/releasenotes/notes/multi-store-import-45d05a6193ef2c04.yaml b/releasenotes/notes/multi-store-import-45d05a6193ef2c04.yaml
new file mode 100644
index 0000000..8b483e3
--- /dev/null
+++ b/releasenotes/notes/multi-store-import-45d05a6193ef2c04.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ Adds support for multi-store import where user can import
+ image into multiple backend stores with single command.
diff --git a/releasenotes/source/index.rst b/releasenotes/source/index.rst
index a67164d..1567c44 100644
--- a/releasenotes/source/index.rst
+++ b/releasenotes/source/index.rst
@@ -6,6 +6,7 @@ glanceclient Release Notes
:maxdepth: 1
unreleased
+ train
stein
rocky
queens
diff --git a/releasenotes/source/train.rst b/releasenotes/source/train.rst
new file mode 100644
index 0000000..7fa1088
--- /dev/null
+++ b/releasenotes/source/train.rst
@@ -0,0 +1,6 @@
+===================================
+ Train Series Release Notes
+===================================
+
+.. release-notes::
+ :branch: stable/train
diff --git a/setup.cfg b/setup.cfg
index d72221c..6d2ec66 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -7,6 +7,7 @@ license = Apache License, Version 2.0
author = OpenStack
author-email = openstack-discuss@lists.openstack.org
home-page = https://docs.openstack.org/python-glanceclient/latest/
+python-requires = >=3.6
classifier =
Development Status :: 5 - Production/Stable
Environment :: Console
@@ -16,8 +17,6 @@ classifier =
License :: OSI Approved :: Apache Software License
Operating System :: POSIX :: Linux
Programming Language :: Python
- Programming Language :: Python :: 2
- Programming Language :: Python :: 2.7
Programming Language :: Python :: 3
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
@@ -26,13 +25,6 @@ classifier =
packages =
glanceclient
-[global]
-setup-hooks =
- pbr.hooks.setup_hook
-
[entry_points]
console_scripts =
glance = glanceclient.shell:main
-
-[wheel]
-universal = 1
diff --git a/tox.ini b/tox.ini
index 119042d..0cd66e0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,13 +1,11 @@
[tox]
-envlist = py27,py37,pep8
+envlist = py37,pep8
minversion = 2.0
skipsdist = True
[testenv]
usedevelop = True
-install_command = pip install {opts} {packages}
-setenv = VIRTUAL_ENV={envdir}
- OS_STDOUT_NOCAPTURE=False
+setenv = OS_STDOUT_NOCAPTURE=False
OS_STDERR_NOCAPTURE=False
deps =
@@ -39,18 +37,6 @@ commands =
bash tools/fix_ca_bundle.sh
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]
basepython = python3
setenv =