Add action for editing instance metadata

This adds the Update Metadata action to the instances table to allow
managing the metadata on an instance. This is very similar to the
Update Metadata actions for images, flavors, etc.

Implements: blueprint edit-server-metadata
Change-Id: Ia09a05f5cd93898ec9d64ac7af1e6baf07e71757
This commit is contained in:
Justin Pomeroy 2015-11-03 13:59:11 -06:00
parent 1178757445
commit 79971c627b
11 changed files with 210 additions and 7 deletions

View File

@ -720,6 +720,14 @@ def server_unlock(request, instance_id):
novaclient(request).servers.unlock(instance_id)
def server_metadata_update(request, instance_id, metadata):
novaclient(request).servers.set_meta(instance_id, metadata)
def server_metadata_delete(request, instance_id, keys):
novaclient(request).servers.delete_meta(instance_id, keys)
def tenant_quota_get(request, tenant_id):
return base.QuotaSet(novaclient(request).quotas.get(tenant_id))

View File

@ -199,7 +199,7 @@ class Servers(generic.View):
class Server(generic.View):
"""API for retrieving a single server
"""
url_regex = r'nova/servers/(?P<server_id>.+|default)$'
url_regex = r'nova/servers/(?P<server_id>[^/]+|default)$'
@rest_utils.ajax()
def get(self, request, server_id):
@ -210,6 +210,35 @@ class Server(generic.View):
return api.nova.server_get(request, server_id).to_dict()
@urls.register
class ServerMetadata(generic.View):
"""API for server metadata.
"""
url_regex = r'nova/servers/(?P<server_id>[^/]+|default)/metadata$'
@rest_utils.ajax()
def get(self, request, server_id):
"""Get a specific server's metadata
http://localhost/api/nova/servers/1/metadata
"""
return api.nova.server_get(request,
server_id).to_dict().get('metadata')
@rest_utils.ajax()
def patch(self, request, server_id):
"""Update metadata items for a server
http://localhost/api/nova/servers/1/metadata
"""
updated = request.DATA['updated']
removed = request.DATA['removed']
if updated:
api.nova.server_metadata_update(request, server_id, updated)
if removed:
api.nova.server_metadata_delete(request, server_id, removed)
@urls.register
class Extensions(generic.View):
"""API for nova extensions.

View File

@ -719,6 +719,29 @@ class SimpleDisassociateIP(policy.PolicyTargetMixin, tables.Action):
return shortcuts.redirect(request.get_full_path())
class UpdateMetadata(policy.PolicyTargetMixin, tables.LinkAction):
name = "update_metadata"
verbose_name = _("Update Metadata")
ajax = False
icon = "pencil"
attrs = {"ng-controller": "MetadataModalHelperController as modal"}
policy_rules = (("compute", "compute:update_instance_metadata"),)
def __init__(self, attrs=None, **kwargs):
kwargs['preempt'] = True
super(UpdateMetadata, self).__init__(attrs, **kwargs)
def get_link_url(self, datum):
instance_id = self.table.get_object_id(datum)
self.attrs['ng-click'] = (
"modal.openMetadataModal('instance', '%s', true)" % instance_id)
return "javascript:void(0);"
def allowed(self, request, instance=None):
return (instance and
instance.status.lower() != 'error')
def instance_fault_to_friendly_message(instance):
fault = getattr(instance, 'fault', {})
message = fault.get('message', _("Unknown"))
@ -1177,7 +1200,7 @@ class InstancesTable(tables.DataTable):
row_actions = (StartInstance, ConfirmResize, RevertResize,
CreateSnapshot, SimpleAssociateIP, AssociateIP,
SimpleDisassociateIP, AttachInterface,
DetachInterface, EditInstance,
DetachInterface, EditInstance, UpdateMetadata,
DecryptInstancePassword, EditInstanceSecurityGroups,
ConsoleLink, LogLink, TogglePause, ToggleSuspend,
ToggleShelve, ResizeLink, LockInstance, UnlockInstance,

View File

@ -51,7 +51,8 @@
return {
aggregate: nova.getAggregateExtraSpecs,
flavor: nova.getFlavorExtraSpecs,
image: glance.getImageProps
image: glance.getImageProps,
instance: nova.getInstanceMetadata
}[resource](id);
}
@ -67,7 +68,8 @@
return {
aggregate: nova.editAggregateExtraSpecs,
flavor: nova.editFlavorExtraSpecs,
image: glance.editImageProps
image: glance.editImageProps,
instance: nova.editInstanceMetadata
}[resource](id, updated, removed);
}
@ -81,7 +83,8 @@
resource_type: {
aggregate: 'OS::Nova::Aggregate',
flavor: 'OS::Nova::Flavor',
image: 'OS::Glance::Image'
image: 'OS::Glance::Image',
instance: 'OS::Nova::Instance'
}[resource]
}, false);
}

View File

@ -23,7 +23,9 @@
var nova = {getAggregateExtraSpecs: function() {},
getFlavorExtraSpecs: function() {},
editAggregateExtraSpecs: function() {},
editFlavorExtraSpecs: function() {} };
editFlavorExtraSpecs: function() {},
getInstanceMetadata: function() {},
editInstanceMetadata: function() {} };
var glance = {getImageProps: function() {},
editImageProps: function() {},
@ -102,6 +104,26 @@
.toHaveBeenCalledWith({ resource_type: 'OS::Glance::Image' }, false);
});
it('should get instance metadata', function() {
var expected = 'instance metadata';
spyOn(nova, 'getInstanceMetadata').and.returnValue(expected);
var actual = metadataService.getMetadata('instance', '1');
expect(actual).toBe(expected);
});
it('should edit instance metadata', function() {
spyOn(nova, 'editInstanceMetadata');
metadataService.editMetadata('instance', '1', 'updated', ['removed']);
expect(nova.editInstanceMetadata).toHaveBeenCalledWith('1', 'updated', ['removed']);
});
it('should get instance namespace', function() {
spyOn(glance, 'getNamespaces');
metadataService.getNamespaces('instance');
expect(glance.getNamespaces)
.toHaveBeenCalledWith({ resource_type: 'OS::Nova::Instance' }, false);
});
});
})();

View File

@ -3,6 +3,7 @@
<span translate ng-if="modal.resourceType==='aggregate'">Update Aggregate Metadata</span>
<span translate ng-if="modal.resourceType==='flavor'">Update Flavor Metadata</span>
<span translate ng-if="modal.resourceType==='image'">Update Image Metadata</span>
<span translate ng-if="modal.resourceType==='instance'">Update Instance Metadata</span>
</h3>
</div>
<div class="modal-body">

View File

@ -46,7 +46,9 @@
editFlavorExtraSpecs: editFlavorExtraSpecs,
getAggregateExtraSpecs: getAggregateExtraSpecs,
editAggregateExtraSpecs: editAggregateExtraSpecs,
getServices: getServices
getServices: getServices,
getInstanceMetadata: getInstanceMetadata,
editInstanceMetadata: editInstanceMetadata
};
return service;
@ -368,6 +370,40 @@
toastService.add('error', gettext('Unable to edit the aggregate extra specs.'));
});
}
/**
* @name horizon.app.core.openstack-service-api.nova.getInstanceMetadata
* @description
* Get a single instance's metadata by ID.
* @param {string} id
* Specifies the id of the instance to request the metadata.
*/
function getInstanceMetadata(id) {
return apiService.get('/api/nova/servers/' + id + '/metadata')
.error(function () {
toastService.add('error', gettext('Unable to retrieve instance metadata.'));
});
}
/**
* @name horizon.openstack-service-api.nova.editInstanceMetadata
* @description
* Update a single instance's metadata by ID.
* @param {string} id
* @param {object} updated New metadata.
* @param {[]} removed Names of removed metadata items.
*/
function editInstanceMetadata(id, updated, removed) {
return apiService.patch(
'/api/nova/servers/' + id + '/metadata',
{
updated: updated,
removed: removed
}
).error(function () {
toastService.add('error', gettext('Unable to edit instance metadata.'));
});
}
}
}());

View File

@ -247,6 +247,28 @@
"testInput": [
42, {a: '1', b: '2'}, ['c', 'd']
]
},
{
"func": "getInstanceMetadata",
"method": "get",
"path": "/api/nova/servers/42/metadata",
"error": "Unable to retrieve instance metadata.",
"testInput": [
42
]
},
{
"func": "editInstanceMetadata",
"method": "patch",
"path": "/api/nova/servers/42/metadata",
"data": {
"updated": {a: '1', b: '2'},
"removed": ['c', 'd']
},
"error": "Unable to edit instance metadata.",
"testInput": [
42, {a: '1', b: '2'}, ['c', 'd']
]
}
];

View File

@ -153,6 +153,35 @@ class NovaRestTestCase(test.TestCase):
self.assertStatusCode(response, 200)
nc.server_get.assert_called_once_with(request, "1")
#
# Server Metadata
#
@mock.patch.object(nova.api, 'nova')
def test_server_get_metadata(self, nc):
request = self.mock_rest_request()
meta = {'foo': 'bar'}
nc.server_get.return_value.to_dict.return_value.get.return_value = meta
response = nova.ServerMetadata().get(request, "1")
self.assertStatusCode(response, 200)
nc.server_get.assert_called_once_with(request, "1")
@mock.patch.object(nova.api, 'nova')
def test_server_edit_metadata(self, nc):
request = self.mock_rest_request(
body='{"updated": {"a": "1", "b": "2"}, "removed": ["c", "d"]}'
)
response = nova.ServerMetadata().patch(request, '1')
self.assertStatusCode(response, 204)
self.assertEqual(response.content, b'')
nc.server_metadata_update.assert_called_once_with(
request, '1', {'a': '1', 'b': '2'}
)
nc.server_metadata_delete.assert_called_once_with(
request, '1', ['c', 'd']
)
#
# Extensions
#

View File

@ -212,6 +212,34 @@ class ComputeApiTests(test.APITestCase):
ret_val = api.nova.server_get(self.request, server.id)
self.assertIsInstance(ret_val, api.nova.Server)
def test_server_metadata_update(self):
server = self.servers.first()
metadata = {'foo': 'bar'}
novaclient = self.stub_novaclient()
novaclient.servers = self.mox.CreateMockAnything()
novaclient.servers.set_meta(server.id, metadata)
self.mox.ReplayAll()
ret_val = api.nova.server_metadata_update(self.request,
server.id,
metadata)
self.assertIsNone(ret_val)
def test_server_metadata_delete(self):
server = self.servers.first()
keys = ['a', 'b']
novaclient = self.stub_novaclient()
novaclient.servers = self.mox.CreateMockAnything()
novaclient.servers.delete_meta(server.id, keys)
self.mox.ReplayAll()
ret_val = api.nova.server_metadata_delete(self.request,
server.id,
keys)
self.assertIsNone(ret_val)
def _test_absolute_limits(self, values, expected_results):
limits = self.mox.CreateMockAnything()
limits.absolute = []

View File

@ -0,0 +1,2 @@
features:
- Instance metadata can be updated (https://blueprints.launchpad.net/horizon/+spec/edit-server-metadata)