diff --git a/doc/source/user/proxies/image_v2.rst b/doc/source/user/proxies/image_v2.rst index 97c3cc65d..295e72cf4 100644 --- a/doc/source/user/proxies/image_v2.rst +++ b/doc/source/user/proxies/image_v2.rst @@ -28,6 +28,7 @@ Image Operations .. automethod:: openstack.image.v2._proxy.Proxy.images .. automethod:: openstack.image.v2._proxy.Proxy.deactivate_image .. automethod:: openstack.image.v2._proxy.Proxy.reactivate_image + .. automethod:: openstack.image.v2._proxy.Proxy.stage_image .. automethod:: openstack.image.v2._proxy.Proxy.add_tag .. automethod:: openstack.image.v2._proxy.Proxy.remove_tag diff --git a/openstack/image/v2/_proxy.py b/openstack/image/v2/_proxy.py index 04965b372..c033d0dc2 100644 --- a/openstack/image/v2/_proxy.py +++ b/openstack/image/v2/_proxy.py @@ -64,6 +64,36 @@ class Proxy(_base_proxy.BaseImageProxy): image.import_image(self, method=method, uri=uri) + def stage_image(self, image, filename=None, data=None): + """Stage binary image data + + :param image: The value can be the ID of a image or a + :class:`~openstack.image.v2.image.Image` instance. + :param filename: Optional name of the file to read data from. + :param data: Optional data to be uploaded as an image. + + :returns: The results of image creation + :rtype: :class:`~openstack.image.v2.image.Image` + """ + image = self._get_resource(_image.Image, image) + + if 'queued' != image.status: + raise exceptions.SDKException('Image stage is only possible for ' + 'images in the queued state.' + ' Current state is {status}' + .format(status=image.status)) + + if filename: + image.data = open(filename, 'rb') + elif data: + image.data = data + image.stage(self) + + # Stage does not return content, but updates the object + image.fetch(self) + + return image + def upload_image(self, container_format=None, disk_format=None, data=None, **attrs): """Create and upload a new image from attributes diff --git a/openstack/image/v2/image.py b/openstack/image/v2/image.py index cc6a3236c..84d4b79aa 100644 --- a/openstack/image/v2/image.py +++ b/openstack/image/v2/image.py @@ -262,6 +262,16 @@ class Image(resource.Resource, resource.TagMixin): headers={"Content-Type": "application/octet-stream", "Accept": ""}) + def stage(self, session): + """Stage binary image data into an existing image""" + url = utils.urljoin(self.base_path, self.id, 'stage') + response = session.put( + url, data=self.data, + headers={"Content-Type": "application/octet-stream", + "Accept": ""}) + self._translate_response(response, has_body=False) + return self + def import_image(self, session, method='glance-direct', uri=None): """Import Image via interoperable image import process""" url = utils.urljoin(self.base_path, self.id, 'import') @@ -269,6 +279,8 @@ class Image(resource.Resource, resource.TagMixin): if uri: if method == 'web-download': json['method']['uri'] = uri + elif method == 'glance-direct': + json['method']['uri'] = uri else: raise exceptions.InvalidRequest('URI is only supported with ' 'method: "web-download"') diff --git a/openstack/tests/unit/image/v2/test_image.py b/openstack/tests/unit/image/v2/test_image.py index 281c89f4c..9e2c91040 100644 --- a/openstack/tests/unit/image/v2/test_image.py +++ b/openstack/tests/unit/image/v2/test_image.py @@ -92,6 +92,7 @@ class FakeResponse(object): def __init__(self, response, status_code=200, headers=None, reason=None): self.body = response self.content = response + self.text = response self.status_code = status_code headers = headers if headers else {'content-type': 'application/json'} self.headers = requests.structures.CaseInsensitiveDict(headers) @@ -115,7 +116,7 @@ class TestImage(base.TestCase): self.sess.post = mock.Mock(return_value=self.resp) self.sess.put = mock.Mock(return_value=FakeResponse({})) self.sess.delete = mock.Mock(return_value=FakeResponse({})) - self.sess.fetch = mock.Mock(return_value=FakeResponse({})) + self.sess.get = mock.Mock(return_value=FakeResponse({})) self.sess.default_microversion = None self.sess.retriable_status_codes = None self.sess.log = _log.setup_logging('openstack') @@ -259,11 +260,12 @@ class TestImage(base.TestCase): def test_import_image_with_uri_not_web_download(self): sot = image.Image(**EXAMPLE) - self.assertRaises(exceptions.InvalidRequest, - sot.import_image, - self.sess, - "glance-direct", - "such-a-good-uri") + + sot.import_image(self.sess, "glance-direct") + self.sess.post.assert_called_with( + 'images/IDENTIFIER/import', + json={"method": {"name": "glance-direct"}} + ) def test_upload(self): sot = image.Image(**EXAMPLE) @@ -275,6 +277,22 @@ class TestImage(base.TestCase): "application/octet-stream", "Accept": ""}) + def test_stage(self): + sot = image.Image(**EXAMPLE) + + self.assertIsNotNone(sot.stage(self.sess)) + self.sess.put.assert_called_with('images/IDENTIFIER/stage', + data=sot.data, + headers={"Content-Type": + "application/octet-stream", + "Accept": ""}) + + def test_stage_error(self): + sot = image.Image(**EXAMPLE) + + self.sess.put.return_value = FakeResponse("dummy", status_code=400) + self.assertRaises(exceptions.SDKException, sot.stage, self.sess) + def test_download_checksum_match(self): sot = image.Image(**EXAMPLE) diff --git a/openstack/tests/unit/image/v2/test_proxy.py b/openstack/tests/unit/image/v2/test_proxy.py index 617d55f70..025e7069f 100644 --- a/openstack/tests/unit/image/v2/test_proxy.py +++ b/openstack/tests/unit/image/v2/test_proxy.py @@ -94,6 +94,38 @@ class TestImageProxy(test_proxy_base.TestProxyBase): 'chunk_size': 1, 'stream': True}) + @mock.patch("openstack.image.v2.image.Image.fetch") + def test_image_stage(self, mock_fetch): + img = image.Image(id="id", status="queued") + img.stage = mock.Mock() + + self.proxy.stage_image(image=img) + mock_fetch.assert_called() + img.stage.assert_called_with(self.proxy) + + @mock.patch("openstack.image.v2.image.Image.fetch") + def test_image_stage_with_data(self, mock_fetch): + img = image.Image(id="id", status="queued") + img.stage = mock.Mock() + mock_fetch.return_value = img + + rv = self.proxy.stage_image(image=img, data="data") + + img.stage.assert_called_with(self.proxy) + mock_fetch.assert_called() + self.assertEqual(rv.data, "data") + + def test_image_stage_wrong_status(self): + img = image.Image(id="id", status="active") + img.stage = mock.Mock() + + self.assertRaises( + exceptions.SDKException, + self.proxy.stage_image, + img, + "data" + ) + def test_image_delete(self): self.verify_delete(self.proxy.delete_image, image.Image, False) diff --git a/releasenotes/notes/add-image-stage-1dbc3844a042fd26.yaml b/releasenotes/notes/add-image-stage-1dbc3844a042fd26.yaml new file mode 100644 index 000000000..bde7506df --- /dev/null +++ b/releasenotes/notes/add-image-stage-1dbc3844a042fd26.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + Add support for staging image data.