Implement v2 API image tags

* Add image_tags table and new db methods to manage it
* Add appropriate API resource classes
* Implements bp api-v2-image-tags

Change-Id: I5f3748b15239de8da000e7b3ff537c1cfc8e2f0d
This commit is contained in:
Brian Waldon 2012-05-03 20:10:50 -07:00
parent 5e85329ed1
commit 793bb61005
11 changed files with 476 additions and 19 deletions

View File

@ -0,0 +1,73 @@
# Copyright 2012 OpenStack, LLC
# All Rights Reserved.
#
# 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 json
import webob.exc
from glance.api.v2 import base
from glance.common import exception
from glance.common import wsgi
import glance.registry.db.api
class Controller(base.Controller):
def __init__(self, conf, db=None):
super(Controller, self).__init__(conf)
self.db_api = db or glance.registry.db.api
self.db_api.configure_db(conf)
@staticmethod
def _build_tag(image_tag):
return {
'value': image_tag['value'],
'image_id': image_tag['image_id'],
}
def index(self, req, image_id):
tags = self.db_api.image_tag_get_all(req.context, image_id)
return [self._build_tag(t) for t in tags]
def update(self, req, image_id, tag_value):
self.db_api.image_tag_create(req.context, image_id, tag_value)
def delete(self, req, image_id, tag_value):
try:
self.db_api.image_tag_delete(req.context, image_id, tag_value)
except exception.NotFound:
raise webob.exc.HTTPNotFound()
class ResponseSerializer(wsgi.JSONResponseSerializer):
@staticmethod
def _format_tag(tag):
return tag['value']
def index(self, response, tags):
response.content_type = 'application/json'
response.body = json.dumps([self._format_tag(t) for t in tags])
def update(self, response, result):
response.status_int = 204
def delete(self, response, result):
response.status_int = 204
def create_resource(conf):
"""Images resource factory method"""
serializer = ResponseSerializer()
controller = Controller(conf)
return wsgi.Resource(controller, serializer=serializer)

View File

@ -20,6 +20,7 @@ import logging
import routes
from glance.api.v2 import image_data
from glance.api.v2 import image_tags
from glance.api.v2 import images
from glance.api.v2 import root
from glance.api.v2 import schemas
@ -85,4 +86,18 @@ class API(wsgi.Router):
action='upload',
conditions={'method': ['PUT']})
image_tags_resource = image_tags.create_resource(conf)
mapper.connect('/images/{image_id}/tags',
controller=image_tags_resource,
action='index',
conditions={'method': ['GET']})
mapper.connect('/images/{image_id}/tags/{tag_value}',
controller=image_tags_resource,
action='update',
conditions={'method': ['PUT']})
mapper.connect('/images/{image_id}/tags/{tag_value}',
controller=image_tags_resource,
action='delete',
conditions={'method': ['DELETE']})
super(API, self).__init__(mapper)

View File

@ -768,3 +768,36 @@ def can_show_deleted(context):
if not hasattr(context, 'get'):
return False
return context.get('deleted', False)
def image_tag_create(context, image_id, value):
"""Create an image tag."""
session = get_session()
tag_ref = models.ImageTag(image_id=image_id, value=value)
tag_ref.save(session=session)
return tag_ref
def image_tag_delete(context, image_id, value):
"""Delete an image tag."""
session = get_session()
query = session.query(models.ImageTag).\
filter_by(image_id=image_id).\
filter_by(value=value).\
filter_by(deleted=False)
try:
tag_ref = query.one()
except exc.NoResultFound:
raise exception.NotFound()
tag_ref.delete(session=session)
def image_tag_get_all(context, image_id):
"""Get a list of tags for a specific image."""
session = get_session()
tags = session.query(models.ImageTag).\
filter_by(image_id=image_id).\
filter_by(deleted=False).\
all()
return tags

View File

@ -0,0 +1,54 @@
# Copyright 2012 OpenStack, LLC
# All Rights Reserved.
#
# 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.
from sqlalchemy import schema
from glance.registry.db.migrate_repo import schema as glance_schema
def define_image_tags_table(meta):
# Load the images table so the foreign key can be set up properly
schema.Table('images', meta, autoload=True)
image_tags = schema.Table('image_tags', meta,
schema.Column('id', glance_schema.Integer(),
primary_key=True, nullable=False),
schema.Column('image_id', glance_schema.String(36),
schema.ForeignKey('images.id'), nullable=False),
schema.Column('value', glance_schema.String(255), nullable=False),
mysql_engine='InnoDB')
schema.Index('ix_image_tags_image_id',
image_tags.c.image_id)
schema.Index('ix_image_tags_image_id_tag_value',
image_tags.c.image_id,
image_tags.c.value)
return image_tags
def upgrade(migrate_engine):
meta = schema.MetaData()
meta.bind = migrate_engine
tables = [define_image_tags_table(meta)]
glance_schema.create_tables(tables)
def downgrade(migrate_engine):
meta = schema.MetaData()
meta.bind = migrate_engine
tables = [define_image_tags_table(meta)]
glance_schema.drop_tables(tables)

View File

@ -131,6 +131,15 @@ class ImageProperty(BASE, ModelBase):
value = Column(Text)
class ImageTag(BASE, ModelBase):
"""Represents an image tag in the datastore"""
__tablename__ = 'image_tags'
id = Column(Integer, primary_key=True, nullable=False)
image_id = Column(String(36), ForeignKey('images.id'), nullable=False)
value = Column(String(255), nullable=False)
class ImageMember(BASE, ModelBase):
"""Represents an image members in the datastore"""
__tablename__ = 'image_members'

View File

@ -53,14 +53,19 @@ def runs_sql(func):
@functools.wraps(func)
def wrapped(*a, **kwargs):
test_obj = a[0]
orig_sql_connection = test_obj.registry_server.sql_connection
orig_reg_sql_connection = test_obj.registry_server.sql_connection
orig_api_sql_connection = test_obj.api_server.sql_connection
try:
if orig_sql_connection.startswith('sqlite'):
if orig_reg_sql_connection.startswith('sqlite'):
test_obj.registry_server.sql_connection =\
"sqlite:///tests.sqlite"
if orig_api_sql_connection.startswith('sqlite'):
test_obj.api_server.sql_connection =\
"sqlite:///tests.sqlite"
func(*a, **kwargs)
finally:
test_obj.registry_server.sql_connection = orig_sql_connection
test_obj.registry_server.sql_connection = orig_reg_sql_connection
test_obj.api_server.sql_connection = orig_api_sql_connection
return wrapped
@ -212,6 +217,11 @@ class ApiServer(Server):
self.policy_file = policy_file
self.policy_default_rule = 'default'
self.server_control_options = '--capture-output'
default_sql_connection = 'sqlite:///'
self.sql_connection = os.environ.get('GLANCE_TEST_SQL_CONNECTION',
default_sql_connection)
self.conf_base = """[DEFAULT]
verbose = %(verbose)s
debug = %(debug)s
@ -248,6 +258,7 @@ image_cache_dir = %(image_cache_dir)s
image_cache_driver = %(image_cache_driver)s
policy_file = %(policy_file)s
policy_default_rule = %(policy_default_rule)s
sql_connection = %(sql_connection)s
[paste_deploy]
flavor = %(deployment_flavor)s
"""
@ -448,6 +459,7 @@ class FunctionalTest(unittest.TestCase):
# and recreate it, which ensures that we have no side-effects
# from the tests
self._reset_database(self.registry_server.sql_connection)
self._reset_database(self.api_server.sql_connection)
def set_policy_rules(self, rules):
fap = open(self.policy_file, 'w')

View File

@ -0,0 +1,103 @@
# Copyright 2012 OpenStack, LLC
# All Rights Reserved.
#
# 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 json
import requests
from glance.tests import functional
class TestImageTags(functional.FunctionalTest):
def setUp(self):
super(TestImageTags, self).setUp()
self.cleanup()
self.api_server.deployment_flavor = 'noauth'
self.start_servers(**self.__dict__.copy())
# Create an image for our tests
path = 'http://0.0.0.0:%d/v2/images' % self.api_port
headers = self._headers({'Content-Type': 'application/json'})
data = json.dumps({'name': 'image-1'})
response = requests.post(path, headers=headers, data=data)
self.assertEqual(200, response.status_code)
self.image_url = response.headers['Location']
def _url(self, path):
return '%s%s' % (self.image_url, path)
def _headers(self, custom_headers=None):
base_headers = {
'X-Identity-Status': 'Confirmed',
'X-Auth-Token': '932c5c84-02ac-4fe5-a9ba-620af0e2bb96',
'X-User-Id': 'f9a41d13-0c13-47e9-bee2-ce4e8bfe958e',
'X-Tenant-Id': '38b7149a-b564-48dd-a0a5-aa7e643368c0',
'X-Roles': 'member',
}
base_headers.update(custom_headers or {})
return base_headers
@functional.runs_sql
def test_image_tag_lifecycle(self):
# List of image tags should be empty
path = self._url('/tags')
response = requests.get(path, headers=self._headers())
self.assertEqual(200, response.status_code)
tags = json.loads(response.text)
self.assertEqual([], tags)
# Create a tag
path = self._url('/tags/sniff')
response = requests.put(path, headers=self._headers())
self.assertEqual(204, response.status_code)
# List should now have an entry
path = self._url('/tags')
response = requests.get(path, headers=self._headers())
self.assertEqual(200, response.status_code)
tags = json.loads(response.text)
self.assertEqual(['sniff'], tags)
# Create a more complex tag
path = self._url('/tags/someone%40example.com')
response = requests.put(path, headers=self._headers())
self.assertEqual(204, response.status_code)
# List should reflect our new tag
path = self._url('/tags')
response = requests.get(path, headers=self._headers())
self.assertEqual(200, response.status_code)
tags = json.loads(response.text)
self.assertEqual(['sniff', 'someone@example.com'], tags)
# The tag should be deletable
path = self._url('/tags/someone%40example.com')
response = requests.delete(path, headers=self._headers())
self.assertEqual(204, response.status_code)
# List should reflect the deletion
path = self._url('/tags')
response = requests.get(path, headers=self._headers())
self.assertEqual(200, response.status_code)
tags = json.loads(response.text)
self.assertEqual(['sniff'], tags)
# Deleting the same tag should return a 404
path = self._url('/tags/someone%40example.com')
response = requests.delete(path, headers=self._headers())
self.assertEqual(404, response.status_code)
self.stop_servers()

View File

@ -44,6 +44,7 @@ class TestImages(functional.FunctionalTest):
base_headers.update(custom_headers or {})
return base_headers
@functional.runs_sql
def test_image_lifecycle(self):
# Image list should be empty
path = self._url('/v2/images')

View File

@ -48,6 +48,26 @@ CONF = {'sql_connection': 'sqlite://',
'debug': False}
class BaseDBTestCase(base.IsolatedUnitTest):
def setUp(self):
super(BaseDBTestCase, self).setUp()
conf = test_utils.TestConfigOpts(CONF)
self.adm_context = context.RequestContext(is_admin=True)
self.context = context.RequestContext(is_admin=False)
db_api.configure_db(conf)
self.destroy_fixtures()
self.create_fixtures()
def create_fixtures(self):
pass
def destroy_fixtures(self):
# Easiest to just drop the models and re-create them...
db_models.unregister_models(db_api._ENGINE)
db_models.register_models(db_api._ENGINE)
def build_fixtures(t1, t2):
return [
{'id': UUID1,
@ -84,17 +104,7 @@ def build_fixtures(t1, t2):
'properties': {}}]
class TestRegistryDb(base.IsolatedUnitTest):
def setUp(self):
"""Establish a clean test environment"""
super(TestRegistryDb, self).setUp()
conf = test_utils.TestConfigOpts(CONF)
self.adm_context = context.RequestContext(is_admin=True)
self.context = context.RequestContext(is_admin=False)
db_api.configure_db(conf)
self.destroy_fixtures()
self.create_fixtures()
class TestRegistryDb(BaseDBTestCase):
def create_fixtures(self):
self.fixtures = self.build_fixtures()
@ -106,11 +116,6 @@ class TestRegistryDb(base.IsolatedUnitTest):
t2 = t1 + datetime.timedelta(microseconds=1)
return build_fixtures(t1, t2)
def destroy_fixtures(self):
# Easiest to just drop the models and re-create them...
db_models.unregister_models(db_api._ENGINE)
db_models.register_models(db_api._ENGINE)
def test_image_get(self):
image = db_api.image_get(self.context, UUID1)
self.assertEquals(image['id'], self.fixtures[0]['id'])
@ -160,6 +165,48 @@ class TestRegistryDb(base.IsolatedUnitTest):
self.assertEquals(len(images), 0)
class TestDBImageTags(BaseDBTestCase):
def create_fixtures(self):
fixtures = [
{'id': UUID1, 'status': 'queued'},
{'id': UUID2, 'status': 'queued'},
]
for fixture in fixtures:
db_api.image_create(self.adm_context, fixture)
def test_image_tag_create(self):
tag_ref = db_api.image_tag_create(self.context, UUID1, 'snap')
self.assertEqual(UUID1, tag_ref.image_id)
self.assertEqual('snap', tag_ref.value)
def test_image_tag_get_all(self):
db_api.image_tag_create(self.context, UUID1, 'snap')
db_api.image_tag_create(self.context, UUID1, 'snarf')
db_api.image_tag_create(self.context, UUID2, 'snarf')
# Check the tags for the first image
tag_refs = db_api.image_tag_get_all(self.context, UUID1)
tags = [(t.image_id, t.value) for t in tag_refs]
expected = [(UUID1, 'snap'), (UUID1, 'snarf')]
self.assertEqual(expected, tags)
# Check the tags for the second image
tag_refs = db_api.image_tag_get_all(self.context, UUID2)
tags = [(t.image_id, t.value) for t in tag_refs]
expected = [(UUID2, 'snarf')]
self.assertEqual(expected, tags)
def test_image_tag_get_all_no_tags(self):
self.assertEqual([], db_api.image_tag_get_all(self.context, UUID1))
def test_image_tag_delete(self):
db_api.image_tag_create(self.context, UUID1, 'snap')
db_api.image_tag_delete(self.context, UUID1, 'snap')
self.assertRaises(exception.NotFound, db_api.image_tag_delete,
self.context, UUID1, 'snap')
class TestRegistryDbWithSameTime(TestRegistryDb):
def build_fixtures(self):

View File

@ -62,10 +62,18 @@ class FakeDB(object):
],
UUID2: [],
}
self.tags = {
UUID1: {
'ping': {'image_id': UUID1, 'value': 'ping'},
'pong': {'image_id': UUID1, 'value': 'pong'},
},
UUID2: [],
}
def reset(self):
self.images = {}
self.members = {}
self.tags = {}
def configure_db(*args, **kwargs):
pass
@ -145,6 +153,29 @@ class FakeDB(object):
LOG.info('Image %s updated to %s' % (image_id, str(image)))
return image
def image_tag_get_all(self, context, image_id):
return [
{'image_id': image_id, 'value': 'ping'},
{'image_id': image_id, 'value': 'pong'},
]
def image_tag_get(self, context, image_id, value):
try:
return self.tags[image_id][value]
except KeyError:
raise exception.NotFound()
def image_tag_create(self, context, image_id, value):
tag = {'image_id': image_id, 'value': value}
self.tags[image_id][value] = tag.copy()
return tag
def image_tag_delete(self, context, image_id, value):
try:
del self.tags[image_id][value]
except KeyError:
raise exception.NotFound()
class FakeStoreAPI(object):
def __init__(self):

View File

@ -0,0 +1,79 @@
# Copyright 2012 OpenStack, LLC
# All Rights Reserved.
#
# 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 json
import unittest
import webob
import glance.api.v2.image_tags
import glance.tests.unit.utils as test_utils
class TestImageTagsController(unittest.TestCase):
def setUp(self):
super(TestImageTagsController, self).setUp()
self.db = test_utils.FakeDB()
conf = {}
self.controller = glance.api.v2.image_tags.Controller(conf, self.db)
def test_list_tags(self):
request = test_utils.FakeRequest()
tags = self.controller.index(request, test_utils.UUID1)
expected = [
{'value': 'ping', 'image_id': test_utils.UUID1},
{'value': 'pong', 'image_id': test_utils.UUID1},
]
self.assertEqual(expected, tags)
def test_create_tag(self):
request = test_utils.FakeRequest()
self.controller.update(request, test_utils.UUID1, 'dink')
def test_delete_tag(self):
request = test_utils.FakeRequest()
self.controller.delete(request, test_utils.UUID1, 'ping')
def test_delete_tag_not_found(self):
request = test_utils.FakeRequest()
self.assertRaises(webob.exc.HTTPNotFound, self.controller.delete,
request, test_utils.UUID1, 'what')
class TestImagesSerializer(unittest.TestCase):
def setUp(self):
self.serializer = glance.api.v2.image_tags.ResponseSerializer()
def test_list_tags(self):
fixtures = [
{'value': 'ping', 'image_id': test_utils.UUID1},
{'value': 'pong', 'image_id': test_utils.UUID1},
]
expected = ['ping', 'pong']
response = webob.Response()
self.serializer.index(response, fixtures)
self.assertEqual(200, response.status_int)
self.assertEqual('application/json', response.content_type)
self.assertEqual(expected, json.loads(response.body))
def test_create_tag(self):
response = webob.Response()
self.serializer.update(response, None)
self.assertEqual(204, response.status_int)
def test_delete_tag(self):
response = webob.Response()
self.serializer.delete(response, None)
self.assertEqual(204, response.status_int)