From 8787800692aa5445004963021e0c4daf2dcb2855 Mon Sep 17 00:00:00 2001 From: Dougal Matthews Date: Thu, 7 Aug 2014 08:26:11 +0100 Subject: [PATCH] SQLAlchemy Storage Driver This patch adds the local database driver for Tuskar template storage. It includes some better testing of the base API which is easier against a real driver. Change-Id: Icc14e9920a611ec24754a44d02178e62a327854b Implements: blueprint tripleo-juno-tuskar-template-storage --- etc/tuskar/tuskar.conf.sample | 10 +- .../versions/002_add_stored_file.py | 54 +++ tuskar/db/sqlalchemy/models.py | 27 ++ tuskar/storage/__init__.py | 3 +- tuskar/storage/drivers/sqlalchemy.py | 335 ++++++++++++++++++ .../tests/storage/drivers/test_sqlalchemy.py | 279 +++++++++++++++ tuskar/tests/storage/test_stores.py | 146 +++++++- 7 files changed, 837 insertions(+), 17 deletions(-) create mode 100644 tuskar/db/sqlalchemy/migrate_repo/versions/002_add_stored_file.py create mode 100644 tuskar/storage/drivers/sqlalchemy.py create mode 100644 tuskar/tests/storage/drivers/test_sqlalchemy.py diff --git a/etc/tuskar/tuskar.conf.sample b/etc/tuskar/tuskar.conf.sample index 408b18ee..8c0967e4 100644 --- a/etc/tuskar/tuskar.conf.sample +++ b/etc/tuskar/tuskar.conf.sample @@ -415,6 +415,14 @@ #matchmaker_heartbeat_ttl=600 +# +# Options defined in tuskar.storage.drivers.sqlalchemy +# + +# MySQL engine (string value) +#mysql_engine=InnoDB + + [database] # @@ -722,6 +730,6 @@ # Storage driver to store Deployment Plans and Heat # Orchestration Templates (string value) -#driver=mock.Mock +#driver=tuskar.storage.drivers.sqlalchemy.SQLAlchemyDriver diff --git a/tuskar/db/sqlalchemy/migrate_repo/versions/002_add_stored_file.py b/tuskar/db/sqlalchemy/migrate_repo/versions/002_add_stored_file.py new file mode 100644 index 00000000..2e8e5516 --- /dev/null +++ b/tuskar/db/sqlalchemy/migrate_repo/versions/002_add_stored_file.py @@ -0,0 +1,54 @@ +# +# 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 Column, DateTime, Integer, MetaData, String, Table + +from tuskar.openstack.common.gettextutils import _ # noqa +from tuskar.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) + +ENGINE = 'InnoDB' +CHARSET = 'utf8' + + +def upgrade(migrate_engine): + meta = MetaData() + meta.bind = migrate_engine + + stored_file = Table( + 'stored_file', + meta, + Column('uuid', String(length=36), primary_key=True, nullable=False), + Column('contents', String(), nullable=False), + Column('object_type', String(length=20), nullable=False), + Column('name', String(length=64), nullable=True), + Column('version', Integer(), nullable=True), + Column('created_at', DateTime), + Column('updated_at', DateTime), + mysql_engine=ENGINE, + mysql_charset=CHARSET, + ) + + try: + LOG.info(repr(stored_file)) + stored_file.create() + except Exception: + LOG.info(repr(stored_file)) + LOG.exception(_('Exception while creating table.')) + raise + + +def downgrade(migrate_engine): + raise NotImplementedError('Downgrade is unsupported.') diff --git a/tuskar/db/sqlalchemy/models.py b/tuskar/db/sqlalchemy/models.py index 1cf30c0d..5819f1f9 100644 --- a/tuskar/db/sqlalchemy/models.py +++ b/tuskar/db/sqlalchemy/models.py @@ -210,3 +210,30 @@ class Overcloud(Base): d['counts'] = count_dicts return d + + +class StoredFile(Base): + """Tuskar Stored File + + The StoredFile model is used by the tuskar.storage package and more + specifically for the SQLAlchemy storage driver. Simply put it is a + collection of text files with some metadata. + """ + + __tablename__ = "stored_file" + + #: UUID's are used as the unique identifier. + uuid = Column(String(length=36), primary_key=True) + + #: contents contains the full file contents as a string. + contents = Column(String(), nullable=False) + + #: Object type flags the type of file that this is, i.e. template or + #: environment file. + object_type = Column(String(length=20), nullable=False) + + #: Names provide a short human readable description of a file. + name = Column(String(length=64), nullable=True) + + #: Versions are an automatic incrementing count. + version = Column(Integer(), nullable=True) diff --git a/tuskar/storage/__init__.py b/tuskar/storage/__init__.py index 31cd6e2a..06c569b1 100644 --- a/tuskar/storage/__init__.py +++ b/tuskar/storage/__init__.py @@ -16,11 +16,10 @@ from oslo.config import cfg from tuskar.openstack.common import log as logging -# TODO(dmatthew): Switch this to the default driver when it is added. heat_opts = [ cfg.StrOpt( 'driver', - default='mock.Mock', + default='tuskar.storage.drivers.sqlalchemy.SQLAlchemyDriver', help=('Storage driver to store Deployment Plans and Heat ' 'Orchestration Templates') ) diff --git a/tuskar/storage/drivers/sqlalchemy.py b/tuskar/storage/drivers/sqlalchemy.py new file mode 100644 index 00000000..9ae2d6ad --- /dev/null +++ b/tuskar/storage/drivers/sqlalchemy.py @@ -0,0 +1,335 @@ +# -*- encoding: utf-8 -*- +# +# 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 __future__ import absolute_import + +from uuid import uuid4 + +from oslo.config import cfg +from sqlalchemy import func +from sqlalchemy.orm.exc import NoResultFound + +from tuskar.db.sqlalchemy.models import StoredFile +from tuskar.openstack.common.db.sqlalchemy import session as db_session +from tuskar.storage.drivers.base import BaseDriver +from tuskar.storage.exceptions import NameAlreadyUsed +from tuskar.storage.exceptions import UnknownName +from tuskar.storage.exceptions import UnknownUUID +from tuskar.storage.exceptions import UnknownVersion +from tuskar.storage.models import StoredFile as StorageModel + +sql_opts = [ + cfg.StrOpt('mysql_engine', + default='InnoDB', + help='MySQL engine') +] + +cfg.CONF.register_opts(sql_opts) + + +def get_session(): + return db_session.get_session(sqlite_fk=True) + + +class SQLAlchemyDriver(BaseDriver): + + def _generate_uuid(self): + return str(uuid4()) + + def _to_storage_model(self, store, result): + """Convert a result from SQLAlchemy into an instance of the common + model used in the tuskar.storage. + + :param store: Instance of the storage store + :type store: tuskat.storage.stores._BaseStore + + :param result: Instance of the SQLAlchemy model as returned by a query. + :type result: tuskar.db.sqlalchemy.models.StoredFile + + :return: Instance of the StoredFile class. + :rtype: tuskar.storage.models.StoredFile + """ + file_dict = result.as_dict() + file_dict.pop('object_type') + file_dict['store'] = store + return StorageModel(**file_dict) + + def _upsert(self, store, stored_file): + + session = get_session() + session.begin() + + try: + session.add(stored_file) + session.commit() + return self._to_storage_model(store, stored_file) + finally: + session.close() + + def _get_latest_version(self, store, name): + + session = get_session() + + try: + return session.query( + func.max(StoredFile.version) + ).filter_by( + object_type=store.object_type, name=name + ).scalar() + finally: + session.close() + + def _create(self, store, name, contents, version): + + stored_file = StoredFile( + uuid=self._generate_uuid(), + contents=contents, + object_type=store.object_type, + name=name, + version=version + ) + + return self._upsert(store, stored_file) + + def create(self, store, name, contents): + """Given the store, name and contents create a new file and return a + `StoredFile` instance representing it. + + Some of the stored items such as environment files do not have names. + When working with these, name must be passed explicitly as None. This + is why the name has a type of "str or None" below. + + :param store: The store class, used for routing the storage. + :type store: tuskar.storage.stores._BaseStore + + :param name: name of the object to store (optional) + :type name: str or None + + :param contents: String containing the file contents + :type contents: str + + :return: StoredFile instance containing the file metadata and contents + :rtype: tuskar.storage.models.StoredFile + """ + + if store.versioned: + version = 1 + else: + version = None + + if name is not None: + try: + self.retrieve_by_name(store, name) + msg = "A file with the name '{0}' already exists".format(name) + raise NameAlreadyUsed(msg) + except UnknownName: + pass + + return self._create(store, name, contents, version) + + def _retrieve(self, object_type, uuid): + + session = get_session() + try: + return session.query(StoredFile).filter_by( + uuid=uuid, + object_type=object_type + ).one() + except NoResultFound: + msg = "No results found for the UUID: {0}".format(uuid) + raise UnknownUUID(msg) + finally: + session.close() + + def retrieve(self, store, uuid): + """Returns the stored file for a given store that matches the provided + UUID. + + :param store: The store class, used for routing the storage. + :type store: tuskar.storage.stores._BaseStore + + :param uuid: UUID of the object to retrieve. + :type uuid: str + + :return: StoredFile instance containing the file metadata and contents + :rtype: tuskar.storage.models.StoredFile + + :raises: tuskar.storage.exceptions.UnknownUUID if the UUID can't be + found + """ + + stored_file = self._retrieve(store.object_type, uuid) + return self._to_storage_model(store, stored_file) + + def update(self, store, uuid, contents): + """Given the store, uuid, name and contents update the existing stored + file and return an instance of StoredFile that reflects the updates. + Either name and/or contents can be provided. If they are not then they + will remain unchanged. + + :param store: The store class, used for routing the storage. + :type store: tuskar.storage.stores._BaseStore + + :param uuid: UUID of the object to update. + :type uuid: str + + :param name: name of the object to store (optional) + :type name: str + + :param contents: String containing the file contents (optional) + :type contents: str + + :return: StoredFile instance containing the file metadata and contents + :rtype: tuskar.storage.models.StoredFile + + :raises: tuskar.storage.exceptions.UnknownUUID if the UUID can't be + found + """ + + stored_file = self._retrieve(store.object_type, uuid) + + stored_file.contents = contents + + if store.versioned: + version = self._get_latest_version(store, stored_file.name) + 1 + return self._create( + store, stored_file.name, stored_file.contents, version) + + return self._upsert(store, stored_file) + + def delete(self, store, uuid): + """Delete the stored file with the UUID under the given store. + + :param store: The store class, used for routing the storage. + :type store: tuskar.storage.stores._BaseStore + + :param uuid: UUID of the object to update. + :type uuid: str + + :return: Returns nothing on success. Exceptions are expected for errors + :rtype: None + + :raises: tuskar.storage.exceptions.UnknownUUID if the UUID can't be + found + """ + + session = get_session() + session.begin() + + stored_file = self._retrieve(store.object_type, uuid) + + try: + session.delete(stored_file) + session.commit() + finally: + session.close() + + def list(self, store, only_latest=False): + """Return a list of all the stored objects for a given store. + Optionally only_latest can be set to True to return only the most + recent version of each objects (grouped by name). + + :param store: The store class, used for routing the storage. + :type store: tuskar.storage.stores._BaseStore + + :param only_latest: If set to True only the latest versions of each + object will be returned. + :type only_latest: bool + + :return: List of StoredFile instances + :rtype: [tuskar.storage.models.StoredFile] + """ + + object_type = store.object_type + + session = get_session() + try: + files = session.query(StoredFile).filter_by( + object_type=object_type + ) + + if only_latest: + stmt = session.query( + StoredFile.uuid, + func.max(StoredFile.version).label("version") + ).subquery() + + files = files.filter( + StoredFile.version == stmt.c.version, + StoredFile.uuid == stmt.c.uuid + ) + + return [self._to_storage_model(store, file_) for file_ in files] + finally: + session.close() + + def retrieve_by_name(self, store, name, version=None): + """Returns the stored file for a given store that matches the provided + name and optionally version. + + :param store: The store class, used for routing the storage. + :type store: tuskar.storage.stores._BaseStore + + :param name: name of the object to retrieve. + :type name: str + + :param version: Version of the object to retrieve. If the version isn't + provided, the latest will be returned. + :type version: int + + :return: StoredFile instance containing the file metadata and contents + :rtype: tuskar.storage.models.StoredFile + + :raises: tuskar.storage.exceptions.UnknownName if the name can't be + found + :raises: tuskar.storage.exceptions.UnknownVersion if the version can't + be found + """ + + object_type = store.object_type + + session = get_session() + + try: + query = session.query(StoredFile).filter_by( + name=name, + object_type=object_type, + ) + if version is not None: + query = query.filter_by(version=version) + else: + query = query.filter_by( + version=self._get_latest_version(store, name) + ) + + stored_file = query.one() + return self._to_storage_model(store, stored_file) + except NoResultFound: + + name_query = session.query(StoredFile).filter_by( + name=name, + object_type=object_type, + ) + + if name_query.count() == 0: + msg = "No results found for the Name: {0}".format(name) + raise UnknownName(msg) + elif name_query.filter_by(version=version).count() == 0: + msg = "No results found for the Version: {0}".format(version) + raise UnknownVersion(msg) + + raise + + finally: + session.close() diff --git a/tuskar/tests/storage/drivers/test_sqlalchemy.py b/tuskar/tests/storage/drivers/test_sqlalchemy.py new file mode 100644 index 00000000..1d94fe92 --- /dev/null +++ b/tuskar/tests/storage/drivers/test_sqlalchemy.py @@ -0,0 +1,279 @@ +# -*- encoding: utf-8 -*- +# +# 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 functools import partial + +from mock import Mock +from mock import patch +from sqlalchemy.orm.exc import NoResultFound + +from tuskar.common import context as tuskar_context +from tuskar.storage.drivers.sqlalchemy import SQLAlchemyDriver +from tuskar.storage.exceptions import UnknownName +from tuskar.storage.exceptions import UnknownUUID +from tuskar.storage.exceptions import UnknownVersion +from tuskar.storage.stores import DeploymentPlanStore +from tuskar.storage.stores import TemplateStore +from tuskar.tests import base + + +class SQLAlchemyDriverTestCase(base.TestCase): + + def setUp(self): + super(SQLAlchemyDriverTestCase, self).setUp() + + self.context = tuskar_context.get_admin_context() + self.driver = SQLAlchemyDriver() + self.store = TemplateStore(self.driver) + + @patch('tuskar.storage.drivers.sqlalchemy.SQLAlchemyDriver._generate_uuid') + def test_create(self, mock_uuid): + + # Setup + expected_uuid = 'b4b85dc2-0b0a-48ed-a56c-e4d582fd1473' + mock_uuid.return_value = expected_uuid + + # Test + result = self.driver.create(self.store, "swift.yaml", "YAML") + + # Verify + self.assertEqual(result.uuid, expected_uuid) + self.assertEqual(result.version, 1) + + @patch('tuskar.storage.drivers.sqlalchemy.SQLAlchemyDriver._generate_uuid') + def test_create_no_versioning(self, mock_uuid): + + # Setup + store = DeploymentPlanStore(self.driver) + expected_uuid = 'b4b85dc2-0b0a-48ed-a56c-e4d582fd1473' + mock_uuid.return_value = expected_uuid + + # Test + result = self.driver.create(store, "swift.yaml", "YAML") + + # Verify + self.assertEqual(result.uuid, expected_uuid) + self.assertEqual(result.version, None) + + @patch('tuskar.storage.drivers.sqlalchemy.SQLAlchemyDriver._generate_uuid') + def test_retrieve(self, mock_uuid): + + # Setup + expected_uuid = 'b4b85dc2-0b0a-48ed-a56c-e4d582fd1473' + expected_name = "swift.yaml" + expected_contents = "YAML" + mock_uuid.return_value = expected_uuid + self.driver.create(self.store, expected_name, expected_contents) + + # Test + result = self.driver.retrieve(self.store, expected_uuid) + + # Verify + self.assertEqual(result.uuid, expected_uuid) + self.assertEqual(result.name, expected_name) + self.assertEqual(result.contents, expected_contents) + + def test_retrieve_invalid(self): + + # Setup + retrieve_call = partial( + self.driver.retrieve, + self.store, "uuid" + ) + + # Test & Verify + self.assertRaises(UnknownUUID, retrieve_call) + + def test_update(self): + + # Setup + expected_name = "swift.yaml" + original_contents = "YAML" + created = self.driver.create( + self.store, expected_name, original_contents) + + # Test + new_contents = "YAML2" + updated = self.driver.update(self.store, created.uuid, new_contents) + + # Verify + retrieved = self.driver.retrieve(self.store, created.uuid) + self.assertEqual(retrieved.uuid, created.uuid) + self.assertEqual(retrieved.name, expected_name) + + # Original and retrieved have not been updated + self.assertEqual(retrieved.contents, original_contents) + self.assertEqual(created.version, 1) + self.assertEqual(retrieved.version, 1) + + # Updated has a new version, and new contents + self.assertEqual(updated.contents, new_contents) + self.assertEqual(updated.version, 2) + + def test_update_no_versioning(self): + + # Setup + store = DeploymentPlanStore(self.driver) + expected_name = "swift.yaml" + original_contents = "YAML" + created = self.driver.create(store, expected_name, original_contents) + + # Test + new_contents = "YAML2" + updated = self.driver.update(store, created.uuid, new_contents) + + # Verify + self.assertEqual(updated.uuid, created.uuid) + self.assertEqual(updated.name, expected_name) + self.assertEqual("YAML2", updated.contents) + self.assertEqual(updated.version, None) + + def test_update_invalid_uuid(self): + + # Setup + update_call = partial(self.driver.update, self.store, "uuid", "YAML2") + + # Test & Verify + self.assertRaises(UnknownUUID, update_call) + + @patch('tuskar.storage.drivers.sqlalchemy.SQLAlchemyDriver._generate_uuid') + def test_delete(self, mock_uuid): + + # Setup + expected_uuid = 'b4b85dc2-0b0a-48ed-a56c-e4d582fd1473' + expected_name = "swift.yaml" + contents = "YAML" + mock_uuid.return_value = expected_uuid + self.driver.create(self.store, expected_name, contents) + + # Test + result = self.driver.delete(self.store, expected_uuid) + + # Verify + self.assertEqual(None, result) + retrieve_call = partial( + self.driver.retrieve, + self.store, expected_uuid + ) + self.assertRaises(UnknownUUID, retrieve_call) + + def test_delete_invalid(self): + self.assertRaises( + UnknownUUID, self.driver.delete, self.store, "uuid") + + def test_list(self): + + name = "swift.yaml" + template = self.driver.create(self.store, name, "YAML1") + + self.assertEqual(1, len(self.driver.list(self.store))) + + self.driver.update(self.store, template.uuid, "YAML2") + + self.assertEqual(2, len(self.driver.list(self.store))) + + def test_list_only_latest(self): + + name = "swift.yaml" + template = self.driver.create(self.store, name, "YAML1") + self.driver.update(self.store, template.uuid, "YAML2") + + listed = self.driver.list(self.store, only_latest=True) + + self.assertEqual(1, len(listed)) + + def test_retrieve_by_name(self): + + # Setup + create_result = self.driver.create(self.store, "name", "YAML") + self.driver.update(self.store, create_result.uuid, "YAML2") + + # Test + retrieved = self.driver.retrieve_by_name(self.store, "name") + + # Verify + self.assertNotEqual(create_result.uuid, retrieved.uuid) + self.assertEqual(retrieved.contents, "YAML2") + self.assertEqual(retrieved.version, 2) + + def test_retrieve_by_name_version(self): + + name = "swift.yaml" + + # Setup + first = self.driver.create(self.store, name, "YAML1") + second = self.driver.update(self.store, first.uuid, "YAML2") + third = self.driver.update(self.store, first.uuid, "YAML3") + + # Test + retrieved_first = self.driver.retrieve_by_name(self.store, name, 1) + retrieved_second = self.driver.retrieve_by_name(self.store, name, 2) + retrieved_third = self.driver.retrieve_by_name(self.store, name, 3) + + # Verify + + self.assertEqual(3, len(self.driver.list(self.store))) + + self.assertEqual(retrieved_first.uuid, first.uuid) + self.assertEqual(1, retrieved_first.version) + self.assertEqual("YAML1", retrieved_first.contents) + + self.assertEqual(retrieved_second.uuid, second.uuid) + self.assertEqual(2, retrieved_second.version) + self.assertEqual("YAML2", retrieved_second.contents) + + self.assertEqual(retrieved_third.uuid, third.uuid) + self.assertEqual(3, retrieved_third.version) + self.assertEqual("YAML3", retrieved_third.contents) + + def test_retrieve_by_name_invalid_name(self): + + retrieve_by_name_call = partial( + self.driver.retrieve_by_name, + self.store, "name" + ) + self.assertRaises(UnknownName, retrieve_by_name_call) + + def test_retrieve_by_name_invalid_version(self): + + self.driver.create(self.store, "name", "YAML") + + retrieve_by_name_call = partial( + self.driver.retrieve_by_name, + self.store, "name", 2 + ) + + self.assertRaises(UnknownVersion, retrieve_by_name_call) + + def test_retrieve_by_name_other_error(self): + """Verify that a NoResultFound exception is re-raised (and not + lost/squashed) if it isn't detected to be due to a missing name or + version that wasn't found. + """ + + self.driver.create(self.store, "name", "YAML") + + with patch('tuskar.storage.drivers.sqlalchemy.get_session') as mock: + + query_mock = Mock() + query_mock.query.side_effect = NoResultFound() + + mock.return_value = query_mock + + retrieve_by_name_call = partial( + self.driver.retrieve_by_name, + self.store, "name" + ) + + self.assertRaises(NoResultFound, retrieve_by_name_call) diff --git a/tuskar/tests/storage/test_stores.py b/tuskar/tests/storage/test_stores.py index c118f2c9..32836e75 100644 --- a/tuskar/tests/storage/test_stores.py +++ b/tuskar/tests/storage/test_stores.py @@ -17,6 +17,7 @@ from functools import partial from mock import Mock +from tuskar.storage.exceptions import NameAlreadyUsed from tuskar.storage.models import StoredFile from tuskar.storage.stores import _BaseStore from tuskar.storage.stores import _NamedStore @@ -177,10 +178,10 @@ class EnvironmentFileTests(TestCase): self.driver.create.assert_called_once_with(self.store, None, contents) -class DeploymentPlanTests(TestCase): +class DeploymentPlanMockedTests(TestCase): def setUp(self): - super(DeploymentPlanTests, self).setUp() + super(DeploymentPlanMockedTests, self).setUp() self.driver = Mock() @@ -230,10 +231,8 @@ class DeploymentPlanTests(TestCase): self.driver.create.return_value = self._stored_file(name, contents) - result = self.store.create( - name, 'Template UUID', 'Environment UUID') - self.driver.create.assert_called_once_with( - self.store, name, contents) + result = self.store.create(name, 'Template UUID', 'Environment UUID') + self.driver.create.assert_called_once_with(self.store, name, contents) self.assertEqual(result.name, name) @@ -250,15 +249,13 @@ class DeploymentPlanTests(TestCase): self.driver.create.return_value = self._stored_file(name, contents) self.template_store.create.return_value = Mock(uuid="UUID1") - result = self.store.create( - name, environment_uuid='Environment UUID') + result = self.store.create(name, environment_uuid='Environment UUID') self.template_store.create.assert_called_once_with( 'deployment_plan name', '') self.assertItemsEqual(self.environment_store.create.call_args_list, []) - self.driver.create.assert_called_once_with( - self.store, name, contents) + self.driver.create.assert_called_once_with(self.store, name, contents) self.assertEqual(result.name, name) self.template_store.retrieve.assert_called_once_with('UUID1') @@ -274,14 +271,12 @@ class DeploymentPlanTests(TestCase): self.driver.create.return_value = self._stored_file(name, contents) self.environment_store.create.return_value = Mock(uuid="UUID2") - result = self.store.create( - name, master_template_uuid='Template UUID') + result = self.store.create(name, master_template_uuid='Template UUID') self.environment_store.create.assert_called_once_with('') self.assertItemsEqual(self.template_store.create.call_args_list, []) - self.driver.create.assert_called_once_with( - self.store, name, contents) + self.driver.create.assert_called_once_with(self.store, name, contents) self.assertEqual(result.name, name) self.template_store.retrieve.assert_called_once_with('Template UUID') @@ -312,3 +307,126 @@ class DeploymentPlanTests(TestCase): # test & verify self.assertRaises(ValueError, update_call) self.assertItemsEqual(self.driver.update.call_args_list, []) + + +class DeploymentPlanTests(TestCase): + + def setUp(self): + super(DeploymentPlanTests, self).setUp() + + self.template_store = TemplateStore() + self.environment_store = EnvironmentFileStore() + self.store = DeploymentPlanStore( + template_store=self.template_store, + environment_store=self.environment_store + ) + + self._create_plan() + + def _create_plan(self): + + contents = "Template Contents" + self.template = self.template_store.create("Template", contents) + self.env = self.environment_store.create("Environment Contents") + + self.plan = self.store.create( + "Plan Name", self.template.uuid, self.env.uuid) + + def test_create(self): + + name = "deployment_plan name" + + result = self.store.create(name, self.template.uuid, self.env.uuid) + + self.assertEqual(result.name, name) + + def test_create_duplicate(self): + + # setup + name = "deployment_plan name" + self.store.create(name, self.template.uuid, self.env.uuid) + create_call = partial(self.store.create, name) + + # test & verify + self.assertRaises(NameAlreadyUsed, create_call) + + def test_create_no_template(self): + + name = "deployment_plan name" + result = self.store.create(name, environment_uuid=self.env.uuid) + self.assertEqual(result.name, name) + + def test_create_no_environment(self): + + name = "deployment_plan name" + template_uuid = self.template.uuid + result = self.store.create(name, master_template_uuid=template_uuid) + self.assertEqual(result.name, name) + + def test_retrieve(self): + + # test + retrieved = self.store.retrieve(self.plan.uuid) + + # verify + self.assertEqual(self.plan.uuid, retrieved.uuid) + self.assertEqual(self.template.uuid, retrieved.master_template.uuid) + self.assertEqual(self.env.uuid, retrieved.environment_file.uuid) + + def test_update_template(self): + + # setup + plan = self.store.create("plan") + + new_template = self.store._template_store.update( + plan.master_template.uuid, "NEW CONTENT") + + # test + updated = self.store.update( + plan.uuid, master_template_uuid=new_template.uuid) + + # verify + retrieved = self.store.retrieve(plan.uuid) + self.assertEqual(plan.uuid, retrieved.uuid) + self.assertEqual(updated.master_template.uuid, new_template.uuid) + + def test_update_environment(self): + + # setup + plan = self.store.create("plan") + + new_env = self.store._env_file_store.update( + plan.environment_file.uuid, "NEW CONTENT") + + # test + updated = self.store.update( + plan.uuid, environment_uuid=new_env.uuid) + + # verify + retrieved = self.store.retrieve(plan.uuid) + self.assertEqual(plan.uuid, retrieved.uuid) + self.assertEqual(updated.environment_file.uuid, new_env.uuid) + + def test_update_nothing(self): + + # setup + update_call = partial(self.store.update, self.plan.uuid) + + # test & verify + self.assertRaises(ValueError, update_call) + + def test_list(self): + + plans = self.store.list() + + self.assertEqual(1, len(plans)) + + plan, = plans + + self.assertEqual(plan.uuid, self.plan.uuid) + + def test_retrieve_by_name(self): + + plan = self.store.retrieve_by_name("Plan Name") + + self.assertEqual(plan.uuid, self.plan.uuid)