From ae6d42cbbdee2a457c5adaad4ad399b56e68e06d Mon Sep 17 00:00:00 2001 From: Rajat Vig Date: Mon, 19 Oct 2015 00:45:15 -0700 Subject: [PATCH] Add API to Create/Update/Delete Images in Glance Co-Authored-By: Matt Borland Change-Id: I44360da3c30856dd875ab6b30c024b29dc4de4c7 Partially-Implements: blueprint angularize-images-table --- openstack_dashboard/api/rest/glance.py | 137 ++++++++++- .../openstack-service-api/glance.service.js | 136 +++++++++++ .../glance.service.spec.js | 58 ++++- .../test/api_tests/glance_rest_tests.py | 214 ++++++++++++++++++ ...ize-images-table-api-eb54206cc9ecc329.yaml | 5 + 5 files changed, 539 insertions(+), 11 deletions(-) create mode 100644 releasenotes/notes/angularize-images-table-api-eb54206cc9ecc329.yaml diff --git a/openstack_dashboard/api/rest/glance.py b/openstack_dashboard/api/rest/glance.py index f604f236ba..f9fb02aa4b 100644 --- a/openstack_dashboard/api/rest/glance.py +++ b/openstack_dashboard/api/rest/glance.py @@ -15,13 +15,13 @@ """ from six.moves import zip as izip + from django.views import generic from openstack_dashboard import api from openstack_dashboard.api.rest import utils as rest_utils from openstack_dashboard.api.rest import urls - CLIENT_KEYWORDS = {'resource_type', 'marker', 'sort_dir', 'sort_key', 'paginate'} @@ -52,6 +52,43 @@ class Image(generic.View): """ return api.glance.image_get(request, image_id).to_dict() + @rest_utils.ajax(data_required=True) + def patch(self, request, image_id): + """Update a specific image + + Update an Image using the parameters supplied in the POST + application/json object. The parameters are: + + :param name: (required) the name to give the image + :param description: (optional) description of the image + :param disk_format: (required) format of the image + :param kernel: (optional) kernel to use for the image + :param ramdisk: (optional) Ramdisk to use for the image + :param architecture: (optional) the Architecture of the image + :param min_disk: (optional) the minimum disk size for the image to boot with + :param min_ram: (optional) the minimum ram for the image to boot with + :param visibility: (required) takes 'public', 'shared', and 'private' + :param protected: (required) true if the image is protected + + Any parameters not listed above will be assigned as custom properties + for the image. + + http://localhost/api/glance/images/cc758c90-3d98-4ea1-af44-aab405c9c915 + + """ + meta = create_image_metadata(request.DATA) + meta['purge_props'] = False + + api.glance.image_update(request, image_id, **meta) + + @rest_utils.ajax() + def delete(self, request, image_id): + """Delete a specific image + + DELETE http://localhost/api/glance/images/cc758c90-3d98-4ea1-af44-aab405c9c915 + """ + api.glance.image_delete(request, image_id) + @urls.register class ImageProperties(generic.View): @@ -124,6 +161,46 @@ class Images(generic.View): 'has_prev_data': has_prev_data, } + @rest_utils.ajax(data_required=True) + def post(self, request): + """Create an Image. + + Create an Image using the parameters supplied in the POST + application/json object. The parameters are: + + :param name: the name to give the image + :param description: (optional) description of the image + :param source_type: (required) source type. current only 'url' is supported + :param image_url: (required) URL to get the image + :param disk_format: (required) format of the image + :param kernel: (optional) kernel to use for the image + :param ramdisk: (optional) Ramdisk to use for the image + :param architecture: (optional) the Architecture of the image + :param min_disk: (optional) the minimum disk size for the image to boot with + :param min_ram: (optional) the minimum ram for the image to boot with + :param visibility: (required) takes 'public', 'private', and 'shared' + :param protected: (required) true if the image is protected + :param import_data: (optional) true to copy the image data to the image service + or use it from the current location + + Any parameters not listed above will be assigned as custom properties + for the image. + + This returns the new image object on success. + """ + meta = create_image_metadata(request.DATA) + + if request.DATA.get('import_data'): + meta['copy_from'] = request.DATA.get('image_url') + else: + meta['location'] = request.DATA.get('image_url') + + image = api.glance.image_create(request, **meta) + return rest_utils.CreatedResponse( + '/api/glance/images/%s' % image.name, + image.to_dict() + ) + @urls.register class MetadefsNamespaces(generic.View): @@ -175,3 +252,61 @@ class MetadefsNamespaces(generic.View): return dict(izip(names, api.glance.metadefs_namespace_full_list( request, filters=filters, **kwargs ))) + +def create_image_metadata(data): + try: + """Use the given dict of image form data to generate the metadata used for + creating the image in glance. + """ + + meta = {'protected': data.get('protected'), + 'min_disk': data.get('min_disk', 0), + 'min_ram': data.get('min_ram', 0), + 'name': data.get('name'), + 'disk_format': data.get('disk_format'), + 'container_format': data.get('container_format'), + 'properties': {}} + + # 'description' and 'architecture' will be directly mapped + # into the .properties by the handle_unknown_properties function. + # 'kernel' and 'ramdisk' need to get specifically mapped for backwards + # compatibility. + if data.get('kernel'): + meta['properties']['kernel_id'] = data.get('kernel') + if data.get('ramdisk'): + meta['properties']['ramdisk_id'] = data.get('ramdisk') + handle_unknown_properties(data, meta) + handle_visibility(data.get('visibility'), meta) + + except KeyError as e: + raise rest_utils.AjaxError(400, 'missing required parameter ' + "'%s'" % e.args[0]) + + return meta + +def handle_unknown_properties(data, meta): + # The Glance API takes in both known and unknown fields. Unknown fields + # are assumed as metadata. To achieve this and continue to use the + # existing horizon api wrapper, we need this function. This way, the + # client REST mirrors the Glance API. + known_props = ['visibility', 'protected', 'disk_format', + 'container_format', 'min_disk', 'min_ram', 'name', + 'properties', 'kernel', 'ramdisk', + 'tags', 'import_data', 'source', + 'image_url', 'source_type'] + other_props = {k: v for (k, v) in data.items() if not k in known_props} + meta['properties'].update(other_props) + +def handle_visibility(visibility, meta): + # The following expects a 'visibility' parameter to be passed via + # the AJAX call, then translates this to a Glance API v1 is_public + # parameter. In the future, if the 'visibility' param is exposed on the + # glance API, you can check for version, e.g.: + # if float(api.glance.get_version()) < 2.0: + mapping_to_v1 = {'public': True, 'private': False, 'shared': False} + # note: presence of 'visibility' previously checked for in general call + try: + meta['is_public'] = mapping_to_v1[visibility] + except KeyError as e: + raise rest_utils.AjaxError(400, "invalid visibility option: %s" % e.args[0]) + diff --git a/openstack_dashboard/static/app/core/openstack-service-api/glance.service.js b/openstack_dashboard/static/app/core/openstack-service-api/glance.service.js index 470788bdff..79aac59935 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/glance.service.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/glance.service.js @@ -34,6 +34,9 @@ var service = { getVersion: getVersion, getImage: getImage, + createImage: createImage, + updateImage: updateImage, + deleteImage: deleteImage, getImageProps: getImageProps, editImageProps: editImageProps, getImages: getImages, @@ -45,6 +48,12 @@ /////////////// // Version + + /** + * @name horizon.app.core.openstack-service-api.glance.getVersion + * @description + * Get the version of the Glance API + */ function getVersion() { return apiService.get('/api/glance/version/') .error(function () { @@ -58,8 +67,10 @@ * @name horizon.app.core.openstack-service-api.glance.getImage * @description * Get a single image by ID + * * @param {string} id * Specifies the id of the image to request. + * */ function getImage(id) { return apiService.get('/api/glance/images/' + id) @@ -68,6 +79,131 @@ }); } + /** + * @name horizon.app.core.openstack-service-api.glance.createImage + * @description + * Create a new image. This returns the new image object on success. + * + * @param {object} image + * The image to create + * + * @param {string} image.name + * Name of the image. Required. + * + * @param {string} image.description + * Description of the image. Optional. + * + * @param {string} image.source_type + * Source Type for the image. Only 'url' is supported. Required. + * + * @param {string} image.disk_format + * Format of the image. Required. + * + * @param {string} image.kernel + * Kernel to use for the image. Optional. + * + * @param {string} image.ramdisk + * RamDisk to use for the image. Optional. + * + * @param {string} image.architecture + * Architecture the image. Optional. + * + * @param {string} image.min_disk + * The minimum disk size required to boot the image. Optional. + * + * @param {string} image.min_ram + * The minimum memory size required to boot the image. Optional. + * + * @param {boolean} image.visibility + * values of 'public', 'private', and 'shared' are valid. Required. + * + * @param {boolean} image.protected + * True if the image is protected, false otherwise. Required. + * + * @param {boolean} image.import_data + * True to import the image data to the image service otherwise + * image data will be used in its current location + * + * Any parameters not listed above will be assigned as custom properites. + * + */ + function createImage(image) { + return apiService.post('/api/glance/images/', image) + .error(function () { + toastService.add('error', gettext('Unable to create the image.')); + }); + } + + /** + * @name horizon.app.core.openstack-service-api.glance.getImage + * @description + * Update an existing image. + * + * @param {object} image + * The image to update + * + * @param {string} image.id + * ID of the image to update. Required. Read Only. + * + * @param {string} image.name + * Name of the image. Required. + * + * @param {string} image.description + * Description of the image. Optional. + * + * @param {string} image.disk_format + * Disk format of the image. Required. + * + * @param {string} image.kernel + * Kernel to use for the image. Optional. + * + * @param {string} image.ramdisk + * RamDisk to use for the image. Optional. + * + * @param {string} image.architecture + * Architecture the image. Optional. + * + * @param {string} image.min_disk + * The minimum disk size required to boot the image. Optional. + * + * @param {string} image.min_ram + * The minimum memory size required to boot the image. Optional. + * + * @param {boolean} image.visibility + * Values of 'public', 'private', and 'shared' are valid. Required. + * + * @param {boolean} image.protected + * True if the image is protected, false otherwise. Required. + * + * Any parameters not listed above will be assigned as custom properites. + */ + function updateImage(image) { + return apiService.patch('/api/glance/images/' + image.id + '/', image) + .error(function () { + toastService.add('error', gettext('Unable to update the image.')); + }); + } + + /** + * @name horizon.app.core.openstack-service-api.glance.deleteImage + * @description + * Deletes single Image by ID. + * + * @param {string} imageId + * The Id of the image to delete. + * + * @param {boolean} suppressError + * If passed in, this will not show the default error handling + * + */ + function deleteImage(imageId, suppressError) { + var promise = apiService.delete('/api/glance/images/' + imageId); + + return suppressError ? promise : promise.error(function() { + toastService.add('error', gettext('Unable to delete the image with id: ') + imageId); + }); + } + /** * @name horizon.app.core.openstack-service-api.glance.getImageProps * @description diff --git a/openstack_dashboard/static/app/core/openstack-service-api/glance.service.spec.js b/openstack_dashboard/static/app/core/openstack-service-api/glance.service.spec.js index a6a5c814c8..d21733bc0a 100644 --- a/openstack_dashboard/static/app/core/openstack-service-api/glance.service.spec.js +++ b/openstack_dashboard/static/app/core/openstack-service-api/glance.service.spec.js @@ -22,18 +22,17 @@ var apiService = {}; var toastService = {}; - beforeEach( - module('horizon.mock.openstack-service-api', - function($provide, initServices) { - testCall = initServices($provide, apiService, toastService); - }) - ); + beforeEach(function() { + module('horizon.mock.openstack-service-api', function($provide, initServices) { + testCall = initServices($provide, apiService, toastService); + }); - beforeEach(module('horizon.app.core.openstack-service-api')); + module('horizon.app.core.openstack-service-api'); - beforeEach(inject(['horizon.app.core.openstack-service-api.glance', function(glanceAPI) { - service = glanceAPI; - }])); + inject(['horizon.app.core.openstack-service-api.glance', function(glanceAPI) { + service = glanceAPI; + }]); + }); it('defines the service', function() { expect(service).toBeDefined(); @@ -55,6 +54,40 @@ 42 ] }, + { + "func": "deleteImage", + "method": "delete", + "path": "/api/glance/images/42", + "error": "Unable to delete the image with id: 42", + "testInput": [ + 42 + ] + }, + { + "func": "createImage", + "method": "post", + "path": "/api/glance/images/", + "data": { + name: '1' + }, + "error": "Unable to create the image.", + "testInput": [ + {name: '1'} + ] + }, + { + "func": "updateImage", + "method": "patch", + "path": "/api/glance/images/1/", + "data": { + id: '1', + name: '1' + }, + "error": "Unable to update the image.", + "testInput": [ + {name: '1', id: '1'} + ] + }, { "func": "getImageProps", "method": "get", @@ -137,6 +170,11 @@ expect(service.getNamespaces("whatever", true)).toBe("promise"); }); + it('supresses the error if instructed for deleteImage', function() { + spyOn(apiService, 'delete').and.returnValue("promise"); + expect(service.deleteImage("whatever", true)).toBe("promise"); + }); + }); })(); diff --git a/openstack_dashboard/test/api_tests/glance_rest_tests.py b/openstack_dashboard/test/api_tests/glance_rest_tests.py index a868df3564..49de1c67e8 100644 --- a/openstack_dashboard/test/api_tests/glance_rest_tests.py +++ b/openstack_dashboard/test/api_tests/glance_rest_tests.py @@ -63,6 +63,43 @@ class ImagesRestTestCase(test.TestCase): request, '1', ['c', 'd'], a='1', b='2' ) + @mock.patch.object(glance.api, 'glance') + def test_image_delete(self, gc): + request = self.mock_rest_request() + glance.Image().delete(request, "1") + gc.image_delete.assert_called_once_with(request, "1") + + @mock.patch.object(glance.api, 'glance') + def test_image_edit(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "container_format": "aki", + "visibility": "public", "protected": false, + "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + + metadata = {'name': 'Test', + 'disk_format': 'aki', + 'container_format': 'aki', + 'is_public': True, + 'protected': False, + 'min_disk': 10, + 'min_ram': 5, + 'properties': { + 'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel', + }, + 'purge_props': False} + + response = glance.Image().patch(request, "1") + self.assertStatusCode(response, 204) + self.assertEqual(response.content.decode('utf-8'), '') + gc.image_update.assert_called_once_with(request, '1', **metadata) + @mock.patch.object(glance.api, 'glance') def test_image_get_list_detailed(self, gc): kwargs = { @@ -88,6 +125,183 @@ class ImagesRestTestCase(test.TestCase): filters=filters, **kwargs) + @mock.patch.object(glance.api, 'glance') + def test_image_create_basic(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "import_data": false, + "visibility": "public", "container_format": "aki", + "protected": false, "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'aki', + 'container_format': 'aki', + 'is_public': True, + 'protected': False, + 'min_disk': 10, + 'min_ram': 5, + 'location': 'test.com', + 'properties': { + 'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel', + }} + + response = glance.Images().post(request) + self.assertStatusCode(response, 201) + self.assertEqual(response.content.decode('utf-8'), + '{"name": "testimage"}') + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_create_shared(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "import_data": false, + "visibility": "shared", "container_format": "aki", + "protected": false, "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'aki', + 'container_format': 'aki', + 'is_public': False, + 'protected': False, + 'min_disk': 10, + 'min_ram': 5, + 'location': 'test.com', + 'properties': { + 'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel', + }} + + response = glance.Images().post(request) + self.assertStatusCode(response, 201) + self.assertEqual(response.content.decode('utf-8'), + '{"name": "testimage"}') + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_create_private(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "import_data": false, + "visibility": "private", "container_format": "aki", + "protected": false, "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'aki', + 'container_format': 'aki', + 'is_public': False, + 'protected': False, + 'min_disk': 10, + 'min_ram': 5, + 'location': 'test.com', + 'properties': { + 'description': 'description', + 'architecture': 'testArch', + 'ramdisk_id': 10, + 'kernel_id': 'kernel', + }} + + response = glance.Images().post(request) + self.assertStatusCode(response, 201) + self.assertEqual(response.content.decode('utf-8'), + '{"name": "testimage"}') + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_create_bad_visibility(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "aki", "import_data": false, + "visibility": "verybad", "container_format": "aki", + "protected": false, "image_url": "test.com", + "source_type": "url", "architecture": "testArch", + "description": "description", "kernel": "kernel", + "min_disk": 10, "min_ram": 5, "ramdisk": 10 } + ''') + + response = glance.Images().post(request) + self.assertStatusCode(response, 400) + self.assertEqual(response.content.decode('utf-8'), + '"invalid visibility option: verybad"') + + @mock.patch.object(glance.api, 'glance') + def test_image_create_required(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "raw", "import_data": true, + "container_format": "docker", + "visibility": "public", "protected": false, + "source_type": "url", "image_url": "test.com" }''') + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'raw', + 'container_format': 'docker', + 'copy_from': 'test.com', + 'is_public': True, + 'protected': False, + 'min_disk': 0, + 'min_ram': 0, + 'properties': {} + } + response = glance.Images().post(request) + self.assertStatusCode(response, 201) + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + + @mock.patch.object(glance.api, 'glance') + def test_image_create_additional_props(self, gc): + request = self.mock_rest_request(body='''{"name": "Test", + "disk_format": "raw", "import_data": true, + "container_format": "docker", + "visibility": "public", "protected": false, + "arbitrary": "property", "another": "prop", + "source_type": "url", "image_url": "test.com" }''') + new = gc.image_create.return_value + new.to_dict.return_value = {'name': 'testimage'} + new.name = 'testimage' + + metadata = {'name': 'Test', + 'disk_format': 'raw', + 'container_format': 'docker', + 'copy_from': 'test.com', + 'is_public': True, + 'protected': False, + 'min_disk': 0, + 'min_ram': 0, + 'properties': {'arbitrary': 'property', 'another': 'prop'} + } + response = glance.Images().post(request) + self.assertStatusCode(response, 201) + self.assertEqual(response['location'], '/api/glance/images/testimage') + gc.image_create.assert_called_once_with(request, **metadata) + @mock.patch.object(glance.api, 'glance') def test_namespace_get_list(self, gc): request = self.mock_rest_request(**{'GET': {}}) diff --git a/releasenotes/notes/angularize-images-table-api-eb54206cc9ecc329.yaml b/releasenotes/notes/angularize-images-table-api-eb54206cc9ecc329.yaml new file mode 100644 index 0000000000..53b10474c3 --- /dev/null +++ b/releasenotes/notes/angularize-images-table-api-eb54206cc9ecc329.yaml @@ -0,0 +1,5 @@ +--- +prelude: > + Added new JS REST features for Glance +features: + - Image create/update/delete calls (post/patch/delete)