Add API to Create/Update/Delete Images in Glance

Co-Authored-By: Matt Borland <matt.borland@hpe.com>

Change-Id: I44360da3c30856dd875ab6b30c024b29dc4de4c7
Partially-Implements: blueprint angularize-images-table
This commit is contained in:
Rajat Vig 2015-10-19 00:45:15 -07:00 committed by Matt Borland
parent ebec8331de
commit ae6d42cbbd
5 changed files with 539 additions and 11 deletions

View File

@ -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])

View File

@ -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

View File

@ -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");
});
});
})();

View File

@ -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': {}})

View File

@ -0,0 +1,5 @@
---
prelude: >
Added new JS REST features for Glance
features:
- Image create/update/delete calls (post/patch/delete)