Database API and engine changes for stack tags

Allow tagging of stacks with simple string tags.

blueprint stack-tags

Change-Id: I65e1e8e87515595edae332c2ff7e0e82ded409ce
This commit is contained in:
Jason Dunsmore 2014-12-24 12:34:32 -06:00
parent ecb1b1eb04
commit 9817ed77ef
12 changed files with 267 additions and 16 deletions

View File

@ -31,6 +31,7 @@ basic_keys = (
rpc_api.STACK_OWNER,
rpc_api.STACK_PARENT,
rpc_api.STACK_USER_PROJECT_ID,
rpc_api.STACK_TAGS,
)

View File

@ -76,6 +76,18 @@ def resource_data_delete(resource, key):
return IMPL.resource_data_delete(resource, key)
def stack_tags_set(context, stack_id, tags):
return IMPL.stack_tags_set(context, stack_id, tags)
def stack_tags_delete(context, stack_id):
return IMPL.stack_tags_delete(context, stack_id)
def stack_tags_get(context, stack_id):
return IMPL.stack_tags_get(context, stack_id)
def resource_get(context, resource_id):
return IMPL.resource_get(context, resource_id)

View File

@ -200,6 +200,36 @@ def resource_data_get(resource, key):
return result.value
def stack_tags_set(context, stack_id, tags):
session = get_session()
with session.begin():
stack_tags_delete(context, stack_id)
result = []
for tag in tags:
stack_tag = models.StackTag()
stack_tag.tag = tag
stack_tag.stack_id = stack_id
stack_tag.save(session=session)
result.append(stack_tag)
return result or None
def stack_tags_delete(context, stack_id):
session = get_session()
with session.begin():
result = stack_tags_get(context, stack_id)
if result:
for tag in result:
tag.delete()
def stack_tags_get(context, stack_id):
result = (model_query(context, models.StackTag)
.filter_by(stack_id=stack_id)
.all())
return result or None
def _encrypt(value):
if value is not None:
return crypt.encrypt(value.encode('utf-8'))

View File

@ -15,6 +15,7 @@ import collections
from oslo_log import log as logging
from oslo_utils import timeutils
import six
from heat.common.i18n import _
from heat.common.i18n import _LE
@ -62,6 +63,25 @@ def extract_args(params):
raise ValueError(_('Invalid adopt data: %s') % exc)
kwargs[rpc_api.PARAM_ADOPT_STACK_DATA] = adopt_data
tags = params.get(rpc_api.PARAM_TAGS)
if tags:
if not isinstance(tags, list):
raise ValueError(_('Invalid tags, not a list: %s') % tags)
for tag in tags:
if not isinstance(tag, six.string_types):
raise ValueError(_('Invalid tag, "%s" is not a string') % tag)
if len(tag) > 80:
raise ValueError(_('Invalid tag, "%s" is longer than 80 '
'characters') % tag)
# Comma is not allowed as per the API WG tagging guidelines
if ',' in tag:
raise ValueError(_('Invalid tag, "%s" contains a comma') % tag)
kwargs[rpc_api.PARAM_TAGS] = tags
return kwargs
@ -105,6 +125,7 @@ def format_stack(stack, preview=False):
rpc_api.STACK_OWNER: stack.username,
rpc_api.STACK_PARENT: stack.owner_id,
rpc_api.STACK_USER_PROJECT_ID: stack.stack_user_project_id,
rpc_api.STACK_TAGS: stack.tags,
}
if not preview:

View File

@ -44,6 +44,7 @@ from heat.engine import update
from heat.objects import resource as resource_objects
from heat.objects import snapshot as snapshot_object
from heat.objects import stack as stack_object
from heat.objects import stack_tag as stack_tag_object
from heat.objects import user_creds as ucreds_object
from heat.rpc import api as rpc_api
@ -83,7 +84,7 @@ class Stack(collections.Mapping):
user_creds_id=None, tenant_id=None,
use_stored_context=False, username=None,
nested_depth=0, strict_validate=True, convergence=False,
current_traversal=None):
current_traversal=None, tags=None):
'''
Initialise from a context, name, Template object and (optionally)
Environment object. The database ID may also be initialised, if the
@ -126,6 +127,7 @@ class Stack(collections.Mapping):
self.strict_validate = strict_validate
self.convergence = convergence
self.current_traversal = current_traversal
self.tags = tags
if use_stored_context:
self.context = self.stored_context()
@ -371,6 +373,9 @@ class Stack(collections.Mapping):
use_stored_context=False):
template = tmpl.Template.load(
context, stack.raw_template_id, stack.raw_template)
tags = None
if stack.tags:
tags = [t.tag for t in stack.tags]
return cls(context, stack.name, template,
stack_id=stack.id,
action=stack.action, status=stack.status,
@ -386,7 +391,7 @@ class Stack(collections.Mapping):
user_creds_id=stack.user_creds_id, tenant_id=stack.tenant,
use_stored_context=use_stored_context,
username=stack.username, convergence=stack.convergence,
current_traversal=stack.current_traversal)
current_traversal=stack.current_traversal, tags=tags)
def get_kwargs_for_cloning(self, keep_status=False, only_db=False):
"""Get common kwargs for calling Stack() for cloning.
@ -465,6 +470,9 @@ class Stack(collections.Mapping):
self.id = new_s.id
self.created_time = new_s.created_at
if self.tags:
stack_tag_object.StackTagList.set(self.context, self.id, self.tags)
self._set_param_stackid()
return self.id
@ -920,6 +928,13 @@ class Stack(collections.Mapping):
self.timeout_mins = newstack.timeout_mins
self._set_param_stackid()
self.tags = newstack.tags
if newstack.tags:
stack_tag_object.StackTagList.set(self.context, self.id,
newstack.tags)
else:
stack_tag_object.StackTagList.delete(self.context, self.id)
try:
updater.start(timeout=self.timeout_secs())
yield

View File

@ -57,7 +57,7 @@ class Stack(
'current_deps': heat_fields.JsonField(),
'prev_raw_template_id': fields.IntegerField(),
'prev_raw_template': fields.ObjectField('RawTemplate'),
'tag': fields.ObjectField('StackTag'),
'tags': fields.ObjectField('StackTagList'),
}
@staticmethod
@ -67,13 +67,12 @@ class Stack(
stack['raw_template'] = (
raw_template.RawTemplate.get_by_id(
context, db_stack['raw_template_id']))
elif field == 'tag':
elif field == 'tags':
if db_stack.get(field) is not None:
stack['tag'] = stack_tag.StackTag.get_obj(
db_stack.get(field)
)
stack['tags'] = stack_tag.StackTagList.get(
context, db_stack['id'])
else:
stack['tag'] = None
stack['tags'] = None
else:
stack[field] = db_stack.__dict__.get(field)
stack._context = context

View File

@ -19,6 +19,8 @@ StackTag object
from oslo_versionedobjects import base
from oslo_versionedobjects import fields
from heat.db import api as db_api
class StackTag(base.VersionedObject,
base.VersionedObjectDictCompat,
@ -32,7 +34,11 @@ class StackTag(base.VersionedObject,
}
@staticmethod
def _from_db_object(tag, db_tag):
def _from_db_object(context, tag, db_tag):
"""Method to help with migration to objects.
Converts a database entity to a formal object.
"""
if db_tag is None:
return None
for field in tag.fields:
@ -42,5 +48,32 @@ class StackTag(base.VersionedObject,
@classmethod
def get_obj(cls, context, tag):
tag_obj = cls._from_db_object(cls(context), tag)
return tag_obj
return cls._from_db_object(cls(context), tag)
class StackTagList(base.VersionedObject,
base.ObjectListBase):
fields = {
'objects': fields.ListOfObjectsField('StackTag'),
}
def __init__(self, *args, **kwargs):
self._changed_fields = set()
super(StackTagList, self).__init__()
@classmethod
def get(cls, context, stack_id):
db_tags = db_api.stack_tags_get(context, stack_id)
if db_tags:
return base.obj_make_list(context, cls(), StackTag, db_tags)
@classmethod
def set(cls, context, stack_id, tags):
db_tags = db_api.stack_tags_set(context, stack_id, tags)
if db_tags:
return base.obj_make_list(context, cls(), StackTag, db_tags)
@classmethod
def delete(cls, context, stack_id):
db_api.stack_tags_delete(context, stack_id)

View File

@ -18,12 +18,12 @@ PARAM_KEYS = (
PARAM_TIMEOUT, PARAM_DISABLE_ROLLBACK, PARAM_ADOPT_STACK_DATA,
PARAM_SHOW_DELETED, PARAM_SHOW_NESTED, PARAM_EXISTING,
PARAM_CLEAR_PARAMETERS, PARAM_GLOBAL_TENANT, PARAM_LIMIT,
PARAM_NESTED_DEPTH,
PARAM_NESTED_DEPTH, PARAM_TAGS
) = (
'timeout_mins', 'disable_rollback', 'adopt_stack_data',
'show_deleted', 'show_nested', 'existing',
'clear_parameters', 'global_tenant', 'limit',
'nested_depth',
'nested_depth', 'tags'
)
STACK_KEYS = (
@ -34,7 +34,7 @@ STACK_KEYS = (
STACK_PARAMETERS, STACK_OUTPUTS, STACK_ACTION,
STACK_STATUS, STACK_STATUS_DATA, STACK_CAPABILITIES,
STACK_DISABLE_ROLLBACK, STACK_TIMEOUT, STACK_OWNER,
STACK_PARENT, STACK_USER_PROJECT_ID
STACK_PARENT, STACK_USER_PROJECT_ID, STACK_TAGS
) = (
'stack_name', 'stack_identity',
'creation_time', 'updated_time', 'deletion_time',
@ -43,7 +43,7 @@ STACK_KEYS = (
'parameters', 'outputs', 'stack_action',
'stack_status', 'stack_status_reason', 'capabilities',
'disable_rollback', 'timeout_mins', 'stack_owner',
'parent', 'stack_user_project_id'
'parent', 'stack_user_project_id', 'tags'
)
STACK_OUTPUT_KEYS = (

View File

@ -1324,6 +1324,38 @@ class DBAPIUserCredsTest(common.HeatTestCase):
self.assertIn(exp_msg, six.text_type(err))
class DBAPIStackTagTest(common.HeatTestCase):
def setUp(self):
super(DBAPIStackTagTest, self).setUp()
self.ctx = utils.dummy_context()
self.template = create_raw_template(self.ctx)
self.user_creds = create_user_creds(self.ctx)
self.stack = create_stack(self.ctx, self.template, self.user_creds)
def test_stack_tags_set(self):
tags = db_api.stack_tags_set(self.ctx, self.stack.id, ['tag1', 'tag2'])
self.assertEqual(self.stack.id, tags[0].stack_id)
self.assertEqual('tag1', tags[0].tag)
tags = db_api.stack_tags_set(self.ctx, self.stack.id, [])
self.assertIsNone(tags)
def test_stack_tags_get(self):
db_api.stack_tags_set(self.ctx, self.stack.id, ['tag1', 'tag2'])
tags = db_api.stack_tags_get(self.ctx, self.stack.id)
self.assertEqual(self.stack.id, tags[0].stack_id)
self.assertEqual('tag1', tags[0].tag)
tags = db_api.stack_tags_get(self.ctx, UUID1)
self.assertIsNone(tags)
def test_stack_tags_delete(self):
db_api.stack_tags_set(self.ctx, self.stack.id, ['tag1', 'tag2'])
db_api.stack_tags_delete(self.ctx, self.stack.id)
tags = db_api.stack_tags_get(self.ctx, self.stack.id)
self.assertIsNone(tags)
class DBAPIStackTest(common.HeatTestCase):
def setUp(self):
super(DBAPIStackTest, self).setUp()

View File

@ -304,6 +304,7 @@ class FormatTest(common.HeatTestCase):
'stack_user_project_id': None,
'template_description': 'No description',
'timeout_mins': None,
'tags': None,
'parameters': {
'AWS::Region': 'ap-southeast-1',
'AWS::StackId': aws_id,
@ -1056,3 +1057,36 @@ class TestExtractArgs(common.HeatTestCase):
def test_disable_rollback_extract_bad(self):
self.assertRaises(ValueError, api.extract_args,
{'disable_rollback': 'bad'})
def test_tags_extract(self):
p = {'tags': ["tag1", "tag2"]}
args = api.extract_args(p)
self.assertEqual(['tag1', 'tag2'], args['tags'])
def test_tags_extract_not_present(self):
args = api.extract_args({})
self.assertNotIn('tags', args)
def test_tags_extract_not_map(self):
p = {'tags': {"foo": "bar"}}
exc = self.assertRaises(ValueError, api.extract_args, p)
self.assertIn('Invalid tags, not a list: ', six.text_type(exc))
def test_tags_extract_not_string(self):
p = {'tags': ["tag1", 2]}
exc = self.assertRaises(ValueError, api.extract_args, p)
self.assertIn('Invalid tag, "2" is not a string', six.text_type(exc))
def test_tags_extract_over_limit(self):
p = {'tags': ["tag1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}
exc = self.assertRaises(ValueError, api.extract_args, p)
self.assertIn('Invalid tag, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" is longer '
'than 80 characters', six.text_type(exc))
def test_tags_extract_comma(self):
p = {'tags': ["tag1", 'tag2,']}
exc = self.assertRaises(ValueError, api.extract_args, p)
self.assertIn('Invalid tag, "tag2," contains a comma',
six.text_type(exc))

View File

@ -32,6 +32,7 @@ from heat.engine import scheduler
from heat.engine import stack
from heat.engine import template
from heat.objects import stack as stack_object
from heat.objects import stack_tag as stack_tag_object
from heat.objects import user_creds as ucreds_object
from heat.tests import common
from heat.tests import fakes
@ -290,7 +291,8 @@ class StackTest(common.HeatTestCase):
use_stored_context=False,
username=mox.IgnoreArg(),
convergence=False,
current_traversal=None)
current_traversal=None,
tags=mox.IgnoreArg())
self.m.ReplayAll()
stack.Stack.load(self.ctx, stack_id=self.stack.id,
@ -1124,6 +1126,35 @@ class StackTest(common.HeatTestCase):
ctx_expected['auth_token'] = None
self.assertEqual(ctx_expected, self.stack.stored_context().to_dict())
def test_load_reads_tags(self):
self.stack = stack.Stack(self.ctx, 'stack_tags', self.tmpl)
self.stack.store()
stack_id = self.stack.id
test_stack = stack.Stack.load(self.ctx, stack_id=stack_id)
self.assertIsNone(test_stack.tags)
self.stack = stack.Stack(self.ctx, 'stack_name', self.tmpl,
tags=['tag1', 'tag2'])
self.stack.store()
stack_id = self.stack.id
test_stack = stack.Stack.load(self.ctx, stack_id=stack_id)
self.assertEqual(['tag1', 'tag2'], test_stack.tags)
def test_store_saves_tags(self):
self.stack = stack.Stack(self.ctx, 'tags_stack', self.tmpl)
self.stack.store()
db_tags = stack_tag_object.StackTagList.get(self.stack.context,
self.stack.id)
self.assertIsNone(db_tags)
self.stack = stack.Stack(self.ctx, 'tags_stack', self.tmpl,
tags=['tag1', 'tag2'])
self.stack.store()
db_tags = stack_tag_object.StackTagList.get(self.stack.context,
self.stack.id)
self.assertEqual('tag1', db_tags[0].tag)
self.assertEqual('tag2', db_tags[1].tag)
def test_store_saves_creds(self):
"""
A user_creds entry is created on first stack store

View File

@ -187,6 +187,49 @@ class StackUpdateTest(common.HeatTestCase):
self.stack.state)
self.assertEqual(True, self.stack.disable_rollback)
def test_update_tags(self):
tmpl = {'HeatTemplateFormatVersion': '2012-12-12',
'Description': 'ATemplate',
'Resources': {'AResource': {'Type': 'GenericResourceType'}}}
self.stack = stack.Stack(self.ctx, 'update_test_stack',
template.Template(tmpl),
tags=['tag1', 'tag2'])
self.stack.store()
self.stack.create()
self.assertEqual((stack.Stack.CREATE, stack.Stack.COMPLETE),
self.stack.state)
self.assertEqual(['tag1', 'tag2'], self.stack.tags)
updated_stack = stack.Stack(self.ctx, 'updated_stack',
template.Template(tmpl),
tags=['tag3', 'tag4'])
self.stack.update(updated_stack)
self.assertEqual((stack.Stack.UPDATE, stack.Stack.COMPLETE),
self.stack.state)
self.assertEqual(['tag3', 'tag4'], self.stack.tags)
def test_update_tags_remove_all(self):
tmpl = {'HeatTemplateFormatVersion': '2012-12-12',
'Description': 'ATemplate',
'Resources': {'AResource': {'Type': 'GenericResourceType'}}}
self.stack = stack.Stack(self.ctx, 'update_test_stack',
template.Template(tmpl),
tags=['tag1', 'tag2'])
self.stack.store()
self.stack.create()
self.assertEqual((stack.Stack.CREATE, stack.Stack.COMPLETE),
self.stack.state)
self.assertEqual(['tag1', 'tag2'], self.stack.tags)
updated_stack = stack.Stack(self.ctx, 'updated_stack',
template.Template(tmpl))
self.stack.update(updated_stack)
self.assertEqual((stack.Stack.UPDATE, stack.Stack.COMPLETE),
self.stack.state)
self.assertEqual(None, self.stack.tags)
def test_update_modify_ok_replace(self):
tmpl = {'HeatTemplateFormatVersion': '2012-12-12',
'Resources': {'AResource': {'Type': 'ResourceWithPropsType',