Better support for metadata in Compute service

Metadata for both Servers and Images as seen from the Compute
service is less than optimal. This approach uses a mixin class
and allows both the Server and Image resources to leverage the
same code, as they both work with metadata in the same way.

Change-Id: I1d6b1f4c840d1127257244472243e4fa170baa8d
Closes-Bug: #1467732
This commit is contained in:
Brian Curtin 2015-11-06 18:15:56 -06:00
parent 004bad9f1f
commit 5f69213a78
8 changed files with 645 additions and 2 deletions

View File

@ -188,6 +188,102 @@ class Proxy(proxy.BaseProxy):
img = _image.ImageDetail if details else _image.Image
return self._list(img, paginated=True, **query)
def _get_base_resource(self, res, base):
# Metadata calls for Image and Server can work for both those
# resources but also ImageDetail and ServerDetail. If we get
# either class, use it, otherwise create an instance of the base.
if isinstance(res, base):
return res
else:
return base({"id": res})
def get_image_metadata(self, image, key=None):
"""Return a dictionary of metadata for an image
:param server: Either the id of an image or a
:class:`~openstack.compute.v2.image.Image` or
:class:`~openstack.compute.v2.image.ImageDetail`
instance.
:param key: An optional key to retrieve from the image's metadata.
When no ``key`` is specified, all metadata is retrieved.
:returns: A dictionary of the image's metadata. All keys and values
are Unicode text.
:rtype: dict
"""
res = self._get_base_resource(image, _image.Image)
return res.get_metadata(self.session, key)
def create_image_metadata(self, image, **metadata):
"""Create metadata for an image
:param server: Either the id of an image or a
:class:`~openstack.compute.v2.image.Image` or
:class:`~openstack.compute.v2.image.ImageDetail`
instance.
:param kwargs metadata: Key/value pairs to be added as metadata
on the image. All keys and values
are stored as Unicode.
:returns: A dictionary of the metadata that was created on the image.
All keys and values are Unicode text.
:rtype: dict
"""
res = self._get_base_resource(image, _image.Image)
return res.create_metadata(self.session, **metadata)
def replace_image_metadata(self, image, **metadata):
"""Replace metadata for an image
:param server: Either the id of a image or a
:class:`~openstack.compute.v2.image.Image` or
:class:`~openstack.compute.v2.image.ImageDetail`
instance.
:param kwargs metadata: Key/value pairs to be added as metadata
on the image. Any other existing metadata
is removed. All keys and values are stored
as Unicode.
:returns: A dictionary of the metadata for the image. All keys and
values are Unicode text.
:rtype: dict
"""
res = self._get_base_resource(image, _image.Image)
return res.replace_metadata(self.session, **metadata)
def update_image_metadata(self, image, **metadata):
"""Update metadata for an image
:param server: Either the id of an image or a
:class:`~openstack.compute.v2.image.Image` or
:class:`~openstack.compute.v2.image.ImageDetail`
instance.
:param kwargs metadata: Key/value pairs to be updated in the image's
metadata. No other metadata is modified
by this call. All keys and values are stored
as Unicode.
:returns: A dictionary of the metadata for the image. All keys and
values are Unicode text.
:rtype: dict
"""
res = self._get_base_resource(image, _image.Image)
return res.update_metadata(self.session, **metadata)
def delete_image_metadata(self, image, key):
"""Delete metadata for an image
:param server: Either the id of an image or a
:class:`~openstack.compute.v2.image.Image` or
:class:`~openstack.compute.v2.image.ImageDetail`
instance.
:param key: The key to delete
:rtype: ``None``
"""
res = self._get_base_resource(image, _image.Image)
return res.delete_metadata(self.session, key)
def create_keypair(self, **attrs):
"""Create a new keypair from attributes
@ -583,3 +679,90 @@ class Proxy(proxy.BaseProxy):
"""
return self._list(availability_zone.AvailabilityZone,
paginated=False, **query)
def get_server_metadata(self, server, key=None):
"""Return a dictionary of metadata for a server
:param server: Either the id of a server or a
:class:`~openstack.compute.v2.server.Server` or
:class:`~openstack.compute.v2.server.ServerDetail`
instance.
:param key: An optional key to retrieve from the server's metadata.
When no ``key`` is specified, all metadata is retrieved.
:returns: A dictionary of the server's metadata. All keys and values
are Unicode text.
:rtype: dict
"""
res = self._get_base_resource(server, _server.Server)
return res.get_metadata(self.session, key)
def create_server_metadata(self, server, **metadata):
"""Create metadata for a server
:param server: Either the id of a server or a
:class:`~openstack.compute.v2.server.Server` or
:class:`~openstack.compute.v2.server.ServerDetail`
instance.
:param kwargs metadata: Key/value pairs to be added as metadata
on the server. All keys and values
are stored as Unicode.
:returns: A dictionary of the metadata that was created on the server.
All keys and values are Unicode text.
:rtype: dict
"""
res = self._get_base_resource(server, _server.Server)
return res.create_metadata(self.session, **metadata)
def replace_server_metadata(self, server, **metadata):
"""Replace metadata for a server
:param server: Either the id of a server or a
:class:`~openstack.compute.v2.server.Server` or
:class:`~openstack.compute.v2.server.ServerDetail`
instance.
:param kwargs metadata: Key/value pairs to be added as metadata
on the server. Any other existing metadata
is removed. All keys and values are stored
as Unicode.
:returns: A dictionary of the metadata for the server. All keys and
values are Unicode text.
:rtype: dict
"""
res = self._get_base_resource(server, _server.Server)
return res.replace_metadata(self.session, **metadata)
def update_server_metadata(self, server, **metadata):
"""Update metadata for a server
:param server: Either the id of a server or a
:class:`~openstack.compute.v2.server.Server` or
:class:`~openstack.compute.v2.server.ServerDetail`
instance.
:param kwargs metadata: Key/value pairs to be updated in the server's
metadata. No other metadata is modified
by this call. All keys and values are stored
as Unicode.
:returns: A dictionary of the metadata for the server. All keys and
values are Unicode text.
:rtype: dict
"""
res = self._get_base_resource(server, _server.Server)
return res.update_metadata(self.session, **metadata)
def delete_server_metadata(self, server, key):
"""Delete metadata for a server
:param server: Either the id of a server or a
:class:`~openstack.compute.v2.server.Server` or
:class:`~openstack.compute.v2.server.ServerDetail`
instance.
:param key: The key to delete
:rtype: ``None``
"""
res = self._get_base_resource(server, _server.Server)
return res.delete_metadata(self.session, key)

View File

@ -11,10 +11,11 @@
# under the License.
from openstack.compute import compute_service
from openstack.compute.v2 import metadata
from openstack import resource
class Image(resource.Resource):
class Image(resource.Resource, metadata.MetadataMixin):
resource_key = 'image'
resources_key = 'images'
base_path = '/images'

View File

@ -0,0 +1,143 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import six
from openstack import utils
class MetadataMixin(object):
def _metadata(self, method, key=None, clear=False, delete=False,
**metadata):
for k, v in metadata.items():
if not isinstance(v, six.string_types):
raise ValueError("The value for %s (%s) must be "
"a text string" % (k, v))
# If we're in a ServerDetail, we need to pop the "detail" portion
# of the URL off and then everything else will work the same.
pos = self.base_path.find("detail")
if pos != -1:
base = self.base_path[:pos]
else:
base = self.base_path
if key is not None:
url = utils.urljoin(base, self.id, "metadata", key)
else:
url = utils.urljoin(base, self.id, "metadata")
kwargs = {"endpoint_filter": self.service}
if metadata or clear:
# 'meta' is the key for singlular modifications.
# 'metadata' is the key for mass modifications.
key = "meta" if key is not None else "metadata"
kwargs["json"] = {key: metadata}
headers = {"Accept": ""} if delete else {}
response = method(url, headers=headers, **kwargs)
# DELETE doesn't return a JSON body while everything else does.
return response.json() if not delete else None
def get_metadata(self, session, key=None):
"""Retrieve metadata
:param session: The session to use for this request.
:param key: If specified, retrieve metadata only for this key.
If not specified, or ``None`` (the default),
retrieve all available metadata.
:returns: A dictionary of the requested metadata. All keys and values
are Unicode text.
:rtype: dict
"""
result = self._metadata(session.get, key=key)
return result["metadata"] if key is None else result["meta"]
def create_metadata(self, session, **metadata):
"""Create metadata
NOTE: One PUT call will be made for each key/value pair specified.
:param session: The session to use for this request.
:param kwargs metadata: key/value metadata pairs to be created on
this server instance. All keys and values
are stored as Unicode.
:returns: A dictionary of the metadata that was created. All keys and
values are Unicode text.
:rtype: dict
"""
results = {}
# A PUT to /metadata will entirely replace any existing metadata,
# so we need to PUT each individual key/value to /metadata/key
# in order to preserve anything existing and only add new keys.
for key, value in metadata.items():
result = self._metadata(session.put, key=key, **{key: value})
results[key] = result["meta"][key]
return results
def replace_metadata(self, session, **metadata):
"""Replace metadata
This call will replace any existing metadata with the key/value pairs
given here.
:param session: The session to use for this request.
:param kwargs metadata: key/value metadata pairs to be created on
this server instance. Any other existing
metadata is removed.
When metadata is not set, it is effectively
cleared out, replacing the metadata
with nothing.
All keys and values are stored as Unicode.
:returns: A dictionary of the metadata after being replaced.
All keys and values are Unicode text.
:rtype: dict
"""
# A PUT with empty metadata will clear anything out.
clear = True if not metadata else False
result = self._metadata(session.put, clear=clear, **metadata)
return result["metadata"]
def update_metadata(self, session, **metadata):
"""Update metadata
This call will replace only the metadata with the same keys
given here. Metadata with other keys will not be modified.
:param session: The session to use for this request.
:param kwargs metadata: key/value metadata pairs to be update on
this server instance. All keys and values
are stored as Unicode.
:returns: A dictionary of the metadata after being updated.
All keys and values are Unicode text.
:rtype: dict
"""
result = self._metadata(session.post, **metadata)
return result["metadata"]
def delete_metadata(self, session, key):
"""Delete metadata
:param session: The session to use for this request.
:param string key: The key to delete.
:rtype: ``None``
"""
self._metadata(session.delete, key=key, delete=True)

View File

@ -13,11 +13,12 @@
from openstack.compute import compute_service
from openstack.compute.v2 import flavor
from openstack.compute.v2 import image
from openstack.compute.v2 import metadata
from openstack import resource
from openstack import utils
class Server(resource.Resource):
class Server(resource.Resource, metadata.MetadataMixin):
resource_key = 'server'
resources_key = 'servers'
base_path = '/servers'

View File

@ -52,3 +52,43 @@ class TestImage(base.BaseFunctionalTest):
self.assertIn('metadata', image)
self.assertIn('progress', image)
self.assertIn('status', image)
def test_image_metadata(self):
image = self._get_non_test_image()
sot = self.conn.compute.get_image(image.id)
# Start by clearing out any other metadata.
self.assertDictEqual(self.conn.compute.replace_image_metadata(sot),
{})
# Insert first and last name metadata
meta = {"first": "Matthew", "last": "Dellavedova"}
self.assertDictEqual(
self.conn.compute.create_image_metadata(sot, **meta), meta)
# Update only the first name
short = {"first": "Matt", "last": "Dellavedova"}
self.assertDictEqual(
self.conn.compute.update_image_metadata(sot,
first=short["first"]),
short)
# Get all metadata and then only the last name
self.assertDictEqual(self.conn.compute.get_image_metadata(sot),
short)
self.assertDictEqual(
self.conn.compute.get_image_metadata(sot, "last"),
{"last": short["last"]})
# Replace everything with just a nickname
nick = {"nickname": "Delly"}
self.assertDictEqual(
self.conn.compute.replace_image_metadata(sot, **nick),
nick)
self.assertDictEqual(self.conn.compute.get_image_metadata(sot),
nick)
# Delete the only remaining key, make sure we're empty
self.assertIsNone(
self.conn.compute.delete_image_metadata(sot, "nickname"))
self.assertDictEqual(self.conn.compute.get_image_metadata(sot), {})

View File

@ -41,6 +41,7 @@ class TestServer(base.BaseFunctionalTest):
args = {}
sot = cls.conn.compute.create_server(
name=cls.NAME, flavor=flavor, image=image.id, **args)
cls.conn.compute.wait_for_server(sot)
assert isinstance(sot, server.Server)
cls.assertIs(cls.NAME, sot.name)
cls.server = sot
@ -66,3 +67,42 @@ class TestServer(base.BaseFunctionalTest):
def test_list(self):
names = [o.name for o in self.conn.compute.servers()]
self.assertIn(self.NAME, names)
def test_server_metadata(self):
sot = self.conn.compute.get_server(self.server.id)
# Start by clearing out any other metadata.
self.assertDictEqual(self.conn.compute.replace_server_metadata(sot),
{})
# Insert first and last name metadata
meta = {"first": "Matthew", "last": "Dellavedova"}
self.assertDictEqual(
self.conn.compute.create_server_metadata(sot, **meta), meta)
# Update only the first name
short = {"first": "Matt", "last": "Dellavedova"}
self.assertDictEqual(
self.conn.compute.update_server_metadata(sot,
first=short["first"]),
short)
# Get all metadata and then only the last name
self.assertDictEqual(self.conn.compute.get_server_metadata(sot),
short)
self.assertDictEqual(
self.conn.compute.get_server_metadata(sot, "last"),
{"last": short["last"]})
# Replace everything with just a nickname
nick = {"nickname": "Delly"}
self.assertDictEqual(
self.conn.compute.replace_server_metadata(sot, **nick),
nick)
self.assertDictEqual(self.conn.compute.get_server_metadata(sot),
nick)
# Delete the only remaining key, make sure we're empty
self.assertIsNone(
self.conn.compute.delete_server_metadata(sot, "nickname"))
self.assertDictEqual(self.conn.compute.get_server_metadata(sot), {})

View File

@ -0,0 +1,176 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
import testtools
from openstack.compute.v2 import server
IDENTIFIER = 'IDENTIFIER'
# NOTE: The implementation for metadata is done via a mixin class that both
# the server and image resources inherit from. Currently this test class
# uses the Server resource to test it. Ideally it would be parameterized
# to run with both Server and Image when the tooling for subtests starts
# working.
class TestMetadata(testtools.TestCase):
def setUp(self):
super(TestMetadata, self).setUp()
self.metadata_result = {"metadata": {"go": "cubs", "boo": "sox"}}
self.meta_result = {"meta": {"oh": "yeah"}}
def test_get_all_metadata_Server(self):
self._test_get_all_metadata(server.Server({"id": IDENTIFIER}))
def test_get_all_metadata_ServerDetail(self):
# This is tested explicitly so we know ServerDetail items are
# properly having /detail stripped out of their base_path.
self._test_get_all_metadata(server.ServerDetail({"id": IDENTIFIER}))
def _test_get_all_metadata(self, sot):
response = mock.Mock()
response.json.return_value = self.metadata_result
sess = mock.Mock()
sess.get.return_value = response
result = sot.get_metadata(sess)
self.assertEqual(result, self.metadata_result["metadata"])
sess.get.assert_called_once_with("servers/IDENTIFIER/metadata",
headers={},
endpoint_filter=sot.service)
def test_get_one_metadata(self):
response = mock.Mock()
response.json.return_value = self.meta_result
sess = mock.Mock()
sess.get.return_value = response
sot = server.Server({"id": IDENTIFIER})
key = "lol"
result = sot.get_metadata(sess, key)
self.assertEqual(result, self.meta_result["meta"])
sess.get.assert_called_once_with("servers/IDENTIFIER/metadata/" + key,
headers={},
endpoint_filter=sot.service)
def test_create_metadata_bad_type(self):
sess = mock.Mock()
sess.put = mock.Mock()
sot = server.Server({"id": IDENTIFIER})
self.assertRaises(ValueError,
sot.create_metadata, sess, some_key=True)
def test_create_metadata(self):
metadata = {"first": "1", "second": "2"}
responses = []
for key, value in metadata.items():
response = mock.Mock()
response.json.return_value = {"meta": {key: value}}
responses.append(response)
sess = mock.Mock()
sess.put.side_effect = responses
sot = server.Server({"id": IDENTIFIER})
result = sot.create_metadata(sess, **metadata)
self.assertEqual(result, dict([(k, v) for k, v in metadata.items()]))
# assert_called_with depends on sequence, which doesn't work nicely
# with all of the dictionaries we're working with here. Build up
# our own list of calls and check that they've happend
calls = []
for key in metadata.keys():
calls.append(mock.call("servers/IDENTIFIER/metadata/" + key,
endpoint_filter=sot.service,
headers={},
json={"meta": {key: metadata[key]}}))
sess.put.assert_has_calls(calls, any_order=True)
def test_replace_metadata(self):
response = mock.Mock()
response.json.return_value = self.metadata_result
sess = mock.Mock()
sess.put.return_value = response
sot = server.Server({"id": IDENTIFIER})
new_meta = {"lol": "rofl"}
result = sot.replace_metadata(sess, **new_meta)
self.assertEqual(result, self.metadata_result["metadata"])
sess.put.assert_called_once_with("servers/IDENTIFIER/metadata",
endpoint_filter=sot.service,
headers={},
json={"metadata": new_meta})
def test_replace_metadata_clear(self):
empty = {}
response = mock.Mock()
response.json.return_value = {"metadata": empty}
sess = mock.Mock()
sess.put.return_value = response
sot = server.Server({"id": IDENTIFIER})
result = sot.replace_metadata(sess)
self.assertEqual(result, empty)
sess.put.assert_called_once_with("servers/IDENTIFIER/metadata",
endpoint_filter=sot.service,
headers={},
json={"metadata": empty})
def test_update_metadata(self):
response = mock.Mock()
response.json.return_value = self.metadata_result
sess = mock.Mock()
sess.post.return_value = response
sot = server.Server({"id": IDENTIFIER})
updated_meta = {"lol": "rofl"}
result = sot.update_metadata(sess, **updated_meta)
self.assertEqual(result, self.metadata_result["metadata"])
sess.post.assert_called_once_with("servers/IDENTIFIER/metadata",
endpoint_filter=sot.service,
headers={},
json={"metadata": updated_meta})
def test_delete_metadata(self):
sess = mock.Mock()
sess.delete.return_value = None
sot = server.Server({"id": IDENTIFIER})
key = "hey"
result = sot.delete_metadata(sess, key)
self.assertIsNone(result)
sess.delete.assert_called_once_with(
"servers/IDENTIFIER/metadata/" + key,
headers={"Accept": ""},
endpoint_filter=sot.service)

View File

@ -223,6 +223,13 @@ class TestComputeProxy(test_proxy_base.TestProxyBase):
def test_server_update(self):
self.verify_update(self.proxy.update_server, server.Server)
def test_server_wait_for(self):
value = server.Server(attrs={'id': '1234'})
self.verify_wait_for_status(
self.proxy.wait_for_server,
method_args=[value],
expected_args=[value, 'ACTIVE', ['ERROR'], 2, 120])
def test_server_resize(self):
self._verify("openstack.compute.v2.server.Server.resize",
self.proxy.resize_server,
@ -277,3 +284,55 @@ class TestComputeProxy(test_proxy_base.TestProxyBase):
def test_availability_zones(self):
self.verify_list(self.proxy.availability_zones, az.AvailabilityZone,
paginated=False)
def test_get_all_server_metadata(self):
self._verify2("openstack.compute.v2.server.Server.get_metadata",
self.proxy.get_server_metadata,
expected_result={},
method_args=["value"],
expected_args=[self.session, None])
def test_get_one_server_metadata(self):
self._verify2("openstack.compute.v2.server.Server.get_metadata",
self.proxy.get_server_metadata,
expected_result={},
method_args=["value"],
method_kwargs={"key": "key"},
expected_args=[self.session, "key"])
def test_create_server_metadata(self):
kwargs = {"a": "1", "b": "2"}
self._verify2("openstack.compute.v2.server.Server.create_metadata",
self.proxy.create_server_metadata,
expected_result={},
method_args=["value"],
method_kwargs=kwargs,
expected_args=[self.session],
expected_kwargs=kwargs)
def test_replace_server_metadata(self):
kwargs = {"a": "1", "b": "2"}
self._verify2("openstack.compute.v2.server.Server.replace_metadata",
self.proxy.replace_server_metadata,
expected_result={},
method_args=["value"],
method_kwargs=kwargs,
expected_args=[self.session],
expected_kwargs=kwargs)
def test_update_server_metadata(self):
kwargs = {"a": "1", "b": "2"}
self._verify2("openstack.compute.v2.server.Server.update_metadata",
self.proxy.update_server_metadata,
expected_result={},
method_args=["value"],
method_kwargs=kwargs,
expected_args=[self.session],
expected_kwargs=kwargs)
def test_delete_server_metadata(self):
self._verify2("openstack.compute.v2.server.Server.delete_metadata",
self.proxy.delete_server_metadata,
expected_result=None,
method_args=["value", "key"],
expected_args=[self.session, "key"])