From e85a80a8ad8b333cdf51965a92764dcd29323d1d Mon Sep 17 00:00:00 2001 From: Jason Dunsmore Date: Thu, 11 Jul 2013 16:26:06 -0500 Subject: [PATCH] Add resource_data table for free-form key/value data. This can be used to store arbitrary resource-specific data, such as SSH keys. Blueprint resource-data. Change-Id: Ic148bd131f528a676904c8f37fd6deb936917152 --- heat/db/sqlalchemy/api.py | 48 +++++++++ .../versions/021_resource_data.py | 45 +++++++++ heat/db/sqlalchemy/models.py | 22 ++++- heat/engine/resource.py | 2 + heat/tests/test_sqlalchemy_api.py | 99 +++++++++++++++++++ 5 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 heat/db/sqlalchemy/migrate_repo/versions/021_resource_data.py create mode 100644 heat/tests/test_sqlalchemy_api.py diff --git a/heat/db/sqlalchemy/api.py b/heat/db/sqlalchemy/api.py index 02941bf7..98e85c8a 100644 --- a/heat/db/sqlalchemy/api.py +++ b/heat/db/sqlalchemy/api.py @@ -97,6 +97,54 @@ def resource_get_all(context): return results +def resource_data_get(resource, key): + """Lookup value of resource's data by key.""" + data_lst = filter(lambda x: x.key == key, resource.data) + if not data_lst: + return None + assert len(data_lst) == 1 + data = data_lst[0] + + if data.redact: + return crypt.decrypt(data.value) + else: + return data.value + + +def resource_data_set(resource, key, value, redact=False): + """Save resource's key/value pair to database.""" + if redact: + value = crypt.encrypt(value) + data_lst = filter(lambda x: x.key == key, resource.data) + + if data_lst: # Key exists in db, so check value & replace if necessary + assert len(data_lst) == 1 + resource_data = data_lst[0] + + # If the new value is the same, do nothing + if value == resource_data.value: + return None + + # Otherwise, delete the old value + for i, d in enumerate(resource.data): + if d.key == key: + index = i + del(resource.data[index]) + + else: # Build a new key/value + resource_data = models.ResourceData() + resource_data.key = key + resource_data.resource_id = resource.id + resource_data.redact = True + + resource_data.value = value + resource.data.append(resource_data) + + # Save to new key/value pair to database + rs = model_query(resource.context, models.Resource).get(resource.id) + rs.update_and_save({'data': resource.data}) + + def resource_create(context, values): resource_ref = models.Resource() resource_ref.update(values) diff --git a/heat/db/sqlalchemy/migrate_repo/versions/021_resource_data.py b/heat/db/sqlalchemy/migrate_repo/versions/021_resource_data.py new file mode 100644 index 00000000..8fe68378 --- /dev/null +++ b/heat/db/sqlalchemy/migrate_repo/versions/021_resource_data.py @@ -0,0 +1,45 @@ +# 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 sqlalchemy + + +def upgrade(migrate_engine): + meta = sqlalchemy.MetaData() + meta.bind = migrate_engine + + resource_data = sqlalchemy.Table( + 'resource_data', meta, + sqlalchemy.Column('id', + sqlalchemy.Integer, + primary_key=True, + nullable=False), + sqlalchemy.Column('created_at', sqlalchemy.DateTime), + sqlalchemy.Column('updated_at', sqlalchemy.DateTime), + sqlalchemy.Column('key', sqlalchemy.String(255)), + sqlalchemy.Column('value', sqlalchemy.Text), + sqlalchemy.Column('redact', sqlalchemy.Boolean), + sqlalchemy.Column('resource_id', + sqlalchemy.String(36), + sqlalchemy.ForeignKey('resource.id'), + nullable=False) + ) + sqlalchemy.Table('resource', meta, autoload=True) + resource_data.create() + + +def downgrade(migrate_engine): + meta = sqlalchemy.MetaData() + meta.bind = migrate_engine + + resource_data = sqlalchemy.Table('resource_data', meta, autoload=True) + resource_data.drop() diff --git a/heat/db/sqlalchemy/models.py b/heat/db/sqlalchemy/models.py index 780795a2..6ee060fb 100644 --- a/heat/db/sqlalchemy/models.py +++ b/heat/db/sqlalchemy/models.py @@ -214,12 +214,30 @@ class Event(BASE, HeatBase): resource_properties = sqlalchemy.Column(sqlalchemy.PickleType) +class ResourceData(BASE, HeatBase): + """Key/value store of arbitrary, resource-specific data.""" + + __tablename__ = 'resource_data' + + id = sqlalchemy.Column('id', + sqlalchemy.Integer, + primary_key=True, + nullable=False) + key = sqlalchemy.Column('key', sqlalchemy.String) + value = sqlalchemy.Column('value', sqlalchemy.String) + redact = sqlalchemy.Column('redact', sqlalchemy.Boolean) + resource_id = sqlalchemy.Column('resource_id', + sqlalchemy.String, + sqlalchemy.ForeignKey('resource.id'), + nullable=False) + + class Resource(BASE, HeatBase): """Represents a resource created by the heat engine.""" __tablename__ = 'resource' - id = sqlalchemy.Column(sqlalchemy.Integer, + id = sqlalchemy.Column(sqlalchemy.String, primary_key=True, default=uuidutils.generate_uuid) action = sqlalchemy.Column('action', sqlalchemy.String) @@ -234,6 +252,8 @@ class Resource(BASE, HeatBase): sqlalchemy.ForeignKey('stack.id'), nullable=False) stack = relationship(Stack, backref=backref('resources')) + data = relationship(ResourceData, backref=backref('resources', + lazy='joined')) class WatchRule(BASE, HeatBase): diff --git a/heat/engine/resource.py b/heat/engine/resource.py index 264f0c73..9a90f6e6 100644 --- a/heat/engine/resource.py +++ b/heat/engine/resource.py @@ -179,12 +179,14 @@ class Resource(object): self.status = resource.status self.status_reason = resource.status_reason self.id = resource.id + self.data = resource.data else: self.resource_id = None self.action = None self.status = None self.status_reason = '' self.id = None + self.data = [] def __eq__(self, other): '''Allow == comparison of two resources.''' diff --git a/heat/tests/test_sqlalchemy_api.py b/heat/tests/test_sqlalchemy_api.py new file mode 100644 index 00000000..5782430d --- /dev/null +++ b/heat/tests/test_sqlalchemy_api.py @@ -0,0 +1,99 @@ +# 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 heat.db.sqlalchemy import api as db_api +from heat.engine import environment +from heat.tests.v1_1 import fakes +from heat.engine.resource import Resource +from heat.common import template_format +from heat.engine import parser +from heat.openstack.common import uuidutils +from heat.tests.common import HeatTestCase +from heat.tests.utils import setup_dummy_db + + +wp_template = ''' +{ + "AWSTemplateFormatVersion" : "2010-09-09", + "Description" : "WordPress", + "Parameters" : { + "KeyName" : { + "Description" : "KeyName", + "Type" : "String", + "Default" : "test" + } + }, + "Resources" : { + "WebServer": { + "Type": "AWS::EC2::Instance", + "Properties": { + "ImageId" : "F17-x86_64-gold", + "InstanceType" : "m1.large", + "KeyName" : "test", + "UserData" : "wordpress" + } + } + } +} +''' + + +class MyResource(Resource): + properties_schema = { + 'ServerName': {'Type': 'String', 'Required': True}, + 'Flavor': {'Type': 'String', 'Required': True}, + 'ImageName': {'Type': 'String', 'Required': True}, + 'UserData': {'Type': 'String'}, + 'PublicKey': {'Type': 'String'} + } + + @property + def my_secret(self): + return db_api.resource_data_get(self, 'my_secret') + + @my_secret.setter + def my_secret(self, my_secret): + db_api.resource_data_set(self, 'my_secret', my_secret, True) + + +class SqlAlchemyTest(HeatTestCase): + def setUp(self): + super(SqlAlchemyTest, self).setUp() + self.fc = fakes.FakeClient() + setup_dummy_db() + + def _setup_test_stack(self, stack_name): + t = template_format.parse(wp_template) + template = parser.Template(t) + stack = parser.Stack(None, stack_name, template, + environment.Environment({'KeyName': 'test'}), + stack_id=uuidutils.generate_uuid()) + return (t, stack) + + def test_encryption(self): + stack_name = 'test_encryption' + (t, stack) = self._setup_test_stack(stack_name) + cs = MyResource('cs_encryption', + t['Resources']['WebServer'], + stack) + + # This gives the fake cloud server an id and created_time attribute + cs._store_or_update(cs.CREATE, cs.IN_PROGRESS, 'test_store') + + cs.my_secret = 'fake secret' + rs = db_api.resource_get_by_name_and_stack(None, + 'cs_encryption', + stack.id) + encrypted_key = rs.data[0]['value'] + self.assertNotEqual(encrypted_key, "fake secret") + decrypted_key = cs.my_secret + self.assertEqual(decrypted_key, "fake secret") -- 2.45.2