Initial workflow implementation of tripleo-common

This patch adds basic implementation for tripleo-common
as defined in the spec[1].  The PlanManager class provides
the workflow for the REST API or CLI interaction.

[1] http://bit.ly/1O5n9JV

Co-Authored-By: Florian Fuchs <flfuchs@redhat.com>
Change-Id: I2be06d7282f9ca44b91d1db22efc0e7ad0096400
This commit is contained in:
Ryan Brady 2015-10-01 10:27:51 -04:00 committed by Tzu-Mainn Chen
parent 1c6c3eab2c
commit 3340c7ea7a
17 changed files with 1554 additions and 0 deletions

View File

View File

@ -0,0 +1,28 @@
# Copyright 2015 Red Hat, Inc.
# 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.
# TRIPLEO_META_USAGE_KEY is inserted into metadata for containers created in
# Swift via SwiftPlanStorageBackend to identify them from other containers
TRIPLEO_META_USAGE_KEY = 'x-container-meta-usage-tripleo'
# OBJECT_META_KEY_PREFIX is used to prefix Swift metadata keys per object
# in SwiftPlanStorageBackend
OBJECT_META_KEY_PREFIX = 'x-object-meta-'
# The following keys are used when identifying metadata from the capabilities
# map file
ROOT_TEMPLATE_META = {'file-type': 'root-template'}
ROOT_ENVIRONMENT_META = {'file-type': 'root-environment', 'enabled': 'True'}
ENVIRONMENT_META = {'file-type': 'environment'}

View File

@ -0,0 +1,88 @@
# Copyright 2015 Red Hat, Inc.
# 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 logging
import six
from six import reraise as raise_
import sys
from tripleo_common.core.i18n import _
from tripleo_common.core.i18n import _LE
_FATAL_EXCEPTION_FORMAT_ERRORS = False
LOG = logging.getLogger(__name__)
@six.python_2_unicode_compatible
class TripleoCommonException(Exception):
"""Base Tripleo-Common Exception.
To correctly use this class, inherit from it and define a 'msg_fmt'
property. That msg_fmt will get printf'd with the keyword arguments
provided to the constructor.
"""
message = _("An unknown exception occurred.")
def __init__(self, **kwargs):
self.kwargs = kwargs
try:
self.message = self.msg_fmt % kwargs
except KeyError:
exc_info = sys.exc_info()
# kwargs doesn't match a variable in the message
# log the issue and the kwargs
LOG.exception(_LE('Exception in string format operation'))
for name, value in six.iteritems(kwargs):
LOG.error(_LE("%(name)s: %(value)s"),
{'name': name, 'value': value}) # noqa
if _FATAL_EXCEPTION_FORMAT_ERRORS:
raise_(exc_info[0], exc_info[1], exc_info[2])
def __str__(self):
return self.message
def __deepcopy__(self, memo):
return self.__class__(**self.kwargs)
class StackInUseError(TripleoCommonException):
msg_fmt = _("Cannot delete a plan that has an associated stack.")
class PlanDoesNotExistError(TripleoCommonException):
msg_fmt = _("A plan with the name %(name) does not exist.")
class PlanAlreadyExistsError(TripleoCommonException):
msg_fmt = _("A plan with the name %(name) already exists.")
class TooManyRootTemplatesError(TripleoCommonException):
msg_fmt = _("There can only be up to one root template in a given plan.")
class HeatValidationFailedError(TripleoCommonException):
msg_fmt = _("The plan failed to validate via the Heat service.")
class MappingFileNotFoundError(TripleoCommonException):
msg_fmt = _("The capabilities_map.yaml file was not found in the root"
" of the plan.")
class TooManyCapabilitiesMapFilesError(TripleoCommonException):
msg_fmt = _("There can only be up to one root template in a given plan.")

View File

@ -0,0 +1,35 @@
# Copyright 2015 Red Hat, Inc.
# Copyright 2014 IBM Corp.
# 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.
# http://docs.openstack.org/developer/oslo.i18n/usage.html
import oslo_i18n as i18n
_translators = i18n.TranslatorFactory(domain='tripleo')
# The primary translation function using the well-known name "_"
_ = _translators.primary
# Translators for log levels.
#
# The abbreviated names are meant to reflect the usual use of a short
# name like '_'. The "L" is for "log" and the other letter comes from
# the level.
_LI = _translators.log_info
_LW = _translators.log_warning
_LE = _translators.log_error
_LC = _translators.log_critical

View File

@ -0,0 +1,27 @@
# Copyright 2015 Red Hat, Inc.
# 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 datetime
class Plan(object):
def __init__(self, name):
self.name = name
self.files = {}
self.metadata = {}
def created_date(self):
return datetime.datetime.fromtimestamp(
float(self.metadata['x-timestamp']))

241
tripleo_common/core/plan.py Normal file
View File

@ -0,0 +1,241 @@
# Copyright 2015 Red Hat, Inc.
# 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 logging
import yaml
from heatclient import exc as heatexceptions
import six
from swiftclient import exceptions as swiftexceptions
from tripleo_common.core import exception
from tripleo_common.utils import meta
from tripleo_common.utils import templates
LOG = logging.getLogger(__name__)
class PlanManager(object):
def __init__(self, plan_storage_backend, heatclient):
# TODO(rbrady) add code to create a storage backend based on config
# so the API can send a string representing a backend type and any
# client objects needed by the backend type
self.plan_store = plan_storage_backend
self.heatclient = heatclient
def create_plan(self, plan_name, plan_files):
"""Creates a plan to store templates
Creates a plan by creating a container matching plan_name, and
import given plan_files into it. The plan files is a dictionary
where the keys are filenames and the values are file contents.
:param plan_name: The name of the plan to use as the container name
:type plan_name: str
:param plan_files: The files to import into the container.
:type plan_files: dict
"""
# create container with versioning
try:
self.plan_store.create(plan_name)
except Exception:
LOG.exception("Error creating plan.")
raise
plan_files = meta.add_file_metadata(plan_files)
return self.update_plan(plan_name, plan_files)
def delete_plan(self, plan_name):
"""Deletes a plan and associated files
Deletes a plan by deleting the container matching plan_name. It
will not delete the plan if a stack exists with the same name.
Raises StackInUseError if a stack with the same name as plan_name
exists.
:param plan_name: The name of the container to delete
:type plan_name: str
"""
# heat throws HTTPNotFound if the stack is not found
try:
stack = self.heatclient.stacks.get(plan_name)
if stack is not None:
raise exception.StackInUseError(name=plan_name)
except heatexceptions.HTTPNotFound:
try:
self.plan_store.delete(plan_name)
except swiftexceptions.ClientException as ce:
LOG.exception("Swift error deleting plan.")
if ce.http_status == 404:
six.raise_from(exception.PlanDoesNotExistError(), ce)
except Exception:
LOG.exception("Error deleting plan.")
raise
def delete_temporary_environment(self, plan_name):
"""Deletes the temporary environment files
The temporary environment is the combination of deployment parameters
and selected environment information
:param plan_name: The name of the plan to use as the container name
:type plan_name: str
"""
plan = self.get_plan(plan_name)
for item in {k: v for (k, v) in plan.files.items() if
v.get('meta', {}).get('file-type') == 'temp-environment'}:
self.plan_store.delete_file(plan_name, item)
def get_plan(self, plan_name):
"""Retrieves the Heat templates and environment file
Retrieves the files from the container matching plan_name.
:param plan_name: The name of the plan to retrieve files for.
:type plan_name: str
:rtype dict
"""
try:
return self.plan_store.get(plan_name)
except Exception:
LOG.exception("Error retrieving plan.")
raise
def get_plan_list(self):
"""Gets a list of containers that store plans
Gets a list of containers that contain metadata with the key of
X-Container-Meta-Usage-Tripleo and value or 'plan'.
:return: a list of strings containing plan names
"""
try:
return self.plan_store.list()
except Exception:
LOG.exception("Error retrieving plan list.")
raise
def get_deployment_parameters(self, plan_name):
"""Determine available deployment parameters
:param plan_name: The name of the plan and container name
"""
plan = self.get_plan(plan_name)
template, environment, files = templates.process_plan_data(plan.files)
try:
params = self.heatclient.stacks.validate(
template=template,
files=files,
environment=environment,
show_nested=True)
except heatexceptions.HTTPBadRequest as exc:
six.raise_from(exception.HeatValidationFailedError(), exc)
return params
def update_deployment_parameters(self, plan_name, deployment_parameters):
"""Update the deployment parameters
:param plan_name: The name of the plan and container name
:type plan_name: str
:param deployment_parameters: dictionary of deployment parameters
:type deployment_parameters: dict
"""
plan = self.get_plan(plan_name)
deployment_params_file = 'environments/deployment_parameters.yaml'
# Make sure the dict has the expected environment file format.
if not deployment_parameters.get('parameter_defaults'):
deployment_parameters = {
'parameter_defaults': deployment_parameters
}
# pop the deployment params temporary environment file from the plan
# so it's not included in the validation call. If the stack is valid
# the deployment params temporary environment file is overwritten
if deployment_params_file in plan.files:
plan.files.pop(deployment_params_file)
# Update deployment params and validate through heat API.
template, environment, files = templates.process_plan_data(plan.files)
environment = templates.deep_update(environment, deployment_parameters)
try:
self.heatclient.stacks.validate(
template=template,
files=files,
environment=environment,
show_nested=True)
except heatexceptions.HTTPBadRequest as exc:
six.raise_from(exception.HeatValidationFailedError(), exc)
env = yaml.dump(deployment_parameters, default_flow_style=False)
plan.files[deployment_params_file] = {
'contents': env,
'meta': {
'file-type': 'temp-environment',
}
}
self.update_plan(plan_name, plan.files)
def update_plan(self, plan_name, plan_files):
"""Updates files in a plan container
:param plan_name: The name of the plan to use as the container name
:type plan_name: str
:param plan_files: The files to import into the container.
:type plan_files: dict
"""
try:
self.plan_store.update(plan_name, plan_files)
except Exception:
LOG.exception("Error updating plan.")
raise
return self.get_plan(plan_name)
def validate_plan(self, plan_name):
"""Validate Plan
This private method provides validations to ensure a plan
meets the proper criteria before allowed to persist in storage.
:param plan_files: The files to import into the container.
:type plan_files: dict
:returns boolean
"""
plan = self.get_plan(plan_name)
# there can only be up to one root-template file in metadata
rt = {k: v for (k, v) in plan.files.items()
if v.get('meta', {}).get('file-type') == 'root-template'}
if len(rt) > 1:
raise exception.TooManyRootTemplatesError()
# the plan needs to be validated with heat to ensure it conforms
template, environment, files = templates.process_plan_data(plan.files)
try:
self.heatclient.stacks.validate(
template=template,
files=files,
environment=environment,
show_nested=True)
except heatexceptions.HTTPBadRequest as exc:
LOG.exception("Error validating the plan.")
six.raise_from(exception.HeatValidationFailedError(), exc)
# no validation issues found
return True

View File

@ -0,0 +1,135 @@
# Copyright 2015 Red Hat, Inc.
# 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 tripleo_common.core import constants
from tripleo_common.core import exception
from tripleo_common.core.models import Plan
from tripleo_common.utils import meta
default_container_headers = {
'X-Versions-Location': 'versions',
constants.TRIPLEO_META_USAGE_KEY: 'plan'
}
class SwiftPlanStorageBackend(object):
def __init__(self, swiftclient):
self.swiftclient = swiftclient
def create(self, plan_name):
"""Creates a plan to store files
Creates a plan by creating a Swift container matching plan_name, and
given files into it.
:param plan_name: The name of the plan
:type plan_name: str
:param plan_files: names and contents of files to store
:type plan_files: dict
"""
if plan_name not in self.list():
self.swiftclient.put_container(
plan_name,
headers=default_container_headers
)
else:
raise exception.PlanAlreadyExistsError()
def delete(self, plan_name):
"""Deletes a plan and associated files
Deletes a plan by deleting the Swift container matching plan_name.
:param plan_name: The name of the plan
:type plan_name: str
"""
# delete files from plan
for data in self.swiftclient.get_container(plan_name)[1]:
self.swiftclient.delete_object(plan_name, data['name'])
# delete plan container
self.swiftclient.delete_container(plan_name)
def delete_file(self, plan_name, filepath):
"""Deletes a file for a given filepath from a plan container
:param plan_name: The name of the plan
:type plan_name: str
:param filepath: The path of the file to be deleted
"""
self.swiftclient.delete_object(plan_name, filepath)
def get(self, plan_name):
"""Retrieves the files for a given container name
Retrieves the files from the Swift container matching plan_name.
:param plan_name: The name of the plan
:type plan_name: str
:return: a list of files
:rtype list
"""
plan = Plan(plan_name)
container = self.swiftclient.get_container(plan_name)
plan.metadata = container[0]
for data in container[1]:
filename = data['name']
plan_obj = self.swiftclient.get_object(plan_name, filename)
plan.files[filename] = {}
plan.files[filename]['contents'] = plan_obj[1]
meta_info = {k: v for (k, v) in plan_obj[0].items()
if constants.OBJECT_META_KEY_PREFIX in k}
if len(meta_info) > 0:
plan.files[filename]['meta'] = \
meta.remove_key_prefix(meta_info)
return plan
def list(self):
"""Gets a list of containers that store plans
Gets a list of containers that contain metadata with the key of
X-Container-Meta-Usage-Tripleo and value or 'plan'.
:return: a list of strings containing plan names
"""
plan_list = []
for item in self.swiftclient.get_account()[1]:
container = self.swiftclient.get_container(item['name'])[0]
if constants.TRIPLEO_META_USAGE_KEY in container.keys():
plan_list.append(item['name'])
return plan_list
def update(self, plan_name, plan_files):
"""Updates a plan by updating the files in container
Updates a plan by updating the files in the Swift container
matching plan_name.
:param plan_name: The name of the plan
:type plan_name: str
:param plan_files: names and contents of files to store
:type plan_files: dict
"""
for filename, details in plan_files.items():
custom_headers = {}
if 'meta' in details:
custom_headers = meta.add_key_prefix(details['meta'])
self.swiftclient.put_object(
plan_name,
filename,
details['contents'],
headers=custom_headers
)

View File

View File

@ -0,0 +1,50 @@
# Copyright 2015 Red Hat, Inc.
# 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 datetime
import time
from tripleo_common.core.models import Plan
from tripleo_common.tests import base
class ModelTest(base.TestCase):
def setUp(self):
super(ModelTest, self).setUp()
self.timestamp = time.time()
def test_plan(self):
plan = Plan('overcloud')
plan.metadata = {
'x-container-meta-usage-tripleo': 'plan',
'accept-ranges': 'bytes',
'x-storage-policy': 'Policy-0',
'connection': 'keep-alive',
'x-timestamp': self.timestamp,
'x-trans-id': 'tx1f41a9d34a2a437d8f8dd-00565dd486',
'content-type': 'application/json; charset=utf-8',
'x-versions-location': 'versions'
}
plan.files = {
'some-name.yaml': {
'contents': "some fake contents",
'meta': {'file-type': 'environment'}
},
}
expected_date = datetime.datetime.fromtimestamp(
float(self.timestamp))
self.assertEqual(expected_date, plan.created_date(), "Date mismatch")

View File

@ -0,0 +1,338 @@
# Copyright 2015 Red Hat, Inc.
# 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 mock
from heatclient import exc as heatexceptions
from swiftclient import exceptions as swiftexceptions
from tripleo_common.core import exception
from tripleo_common.core import models
from tripleo_common.core import plan
from tripleo_common.tests import base
class PlanManagerTest(base.TestCase):
def setUp(self):
super(PlanManagerTest, self).setUp()
self.heatclient = mock.MagicMock()
self.plan_store = mock.MagicMock()
self.plan_name = "overcloud"
self.stack = mock.MagicMock(
id='123',
status='CREATE_COMPLETE',
stack_name=self.plan_name
)
self.expected_plan = models.Plan(self.plan_name)
self.expected_plan.metadata = {
'x-container-meta-usage-tripleo': 'plan',
}
self.expected_plan.files = {
'some-environment.yaml': {
'contents': "some fake contents",
'meta': {'file-type': 'environment'}
},
'some-root-environment.yaml': {
'contents': "parameters:\n"
" one: uno\n"
" obj:\n"
" two: due\n"
" three: tre\n",
'meta': {
'file-type': 'root-environment',
'enabled': 'True'
}
},
'some-root-template.yaml': {
'contents': "some fake contents",
'meta': {'file-type': 'root-template'}
},
'some-template.yaml': {
'contents': "some fake contents",
},
}
def test_create_plan(self):
self.plan_store.create = mock.MagicMock()
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
plan_mgr.create_plan(self.plan_name, self.expected_plan.files)
self.plan_store.create.assert_called_with(self.plan_name)
# calls the Exception handling in create_plan
with mock.patch('tripleo_common.core.plan.LOG') as log_mock:
self.plan_store.create = mock.Mock(side_effect=ValueError())
self.assertRaises(ValueError,
plan_mgr.create_plan,
self.plan_name, self.expected_plan.files)
log_mock.exception.assert_called_with("Error creating plan.")
def test_delete_plan(self):
# test that stack exists
self.heatclient.stacks.get = mock.MagicMock(return_value=self.stack)
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
self.assertRaises(exception.StackInUseError,
plan_mgr.delete_plan,
self.plan_name)
self.heatclient.stacks.get.assert_called_with(self.plan_name)
# test that stack doesn't exist yet
self.plan_store.delete = mock.MagicMock()
self.heatclient.stacks.get = mock.Mock(
side_effect=heatexceptions.HTTPNotFound)
plan_mgr.delete_plan(self.plan_name)
self.plan_store.delete.assert_called_with(self.plan_name)
# set side effect of swiftexceptions.ClientException
with mock.patch('tripleo_common.core.plan.LOG') as log_mock:
self.plan_store.delete = mock.Mock(
side_effect=swiftexceptions.ClientException(
"test-error", http_status=404))
self.assertRaises(exception.PlanDoesNotExistError,
plan_mgr.delete_plan,
self.plan_name)
log_mock.exception.assert_called_with('Swift error deleting plan.')
# set side effect of random Exception
self.heatclient.stacks.get = mock.Mock(
side_effect=ValueError())
self.assertRaises(ValueError,
plan_mgr.delete_plan,
self.plan_name)
def test_delete_temporary_environment(self):
self.expected_plan.files = {
'some-name.yaml': {
'contents': "some fake contents",
'meta': {'file-type': 'temp-environment'}
},
}
self.plan_store.get = mock.MagicMock(return_value=self.expected_plan)
self.plan_store.delete_file = mock.MagicMock()
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
plan_mgr.delete_temporary_environment(self.plan_name)
self.plan_store.delete_file.assert_called_with(
self.plan_name, 'some-name.yaml')
def test_get_plan(self):
self.expected_plan.files = {
'some-name.yaml': {
'contents': "some fake contents",
'meta': {'file-type': 'environment'}
},
}
self.plan_store.get = mock.MagicMock(return_value=self.expected_plan)
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
self.assertEqual(self.expected_plan,
plan_mgr.get_plan(self.plan_name),
"Plan mismatch")
self.plan_store.get.assert_called_with(self.plan_name)
with mock.patch('tripleo_common.core.plan.LOG') as log_mock:
self.plan_store.get = mock.Mock(side_effect=ValueError())
self.assertRaises(ValueError, plan_mgr.get_plan, 'overcloud')
log_mock.exception.assert_called_with("Error retrieving plan.")
def test_get_plan_list(self):
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
self.plan_store.list = mock.MagicMock(return_value=['overcloud'])
self.assertEqual(['overcloud'], plan_mgr.get_plan_list(),
"get_plan_list failed")
with mock.patch('tripleo_common.core.plan.LOG') as log_mock:
self.plan_store.list = mock.Mock(side_effect=ValueError())
self.assertRaises(ValueError, plan_mgr.get_plan_list)
log_mock.exception.assert_called_with(
"Error retrieving plan list.")
def test_get_deployment_parameters(self):
self.plan_store.get = mock.MagicMock(return_value=self.expected_plan)
# calls templates.process_plan_data(plan.files)
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
with mock.patch('tripleo_common.utils.templates') as templates:
templates.process_plan_data.return_value = (
"some fake contents", {
'parameters': {
'obj': {
'two': 'due',
'three': 'tre'
},
'one': 'uno'
}
}, {'some-template.yaml': 'some fake contents'})
self.heatclient.stacks.validate = mock.MagicMock(
return_value={
'parameters': {
'obj': {
'two': 'due',
'three': 'tre'
},
'one': 'uno'
}
})
self.assertEqual({
'parameters': {
'obj': {
'two': 'due',
'three': 'tre'
},
'one': 'uno'
}
},
plan_mgr.get_deployment_parameters(self.plan_name),
"Bad params")
self.heatclient.stacks.validate.assert_called_with(
template="some fake contents",
files={'some-template.yaml': 'some fake contents'},
environment={
'parameters': {
'obj': {
'two': 'due',
'three': 'tre'
},
'one': 'uno'
}
},
show_nested=True
)
# set side effect of heatexceptions.HTTPBadRequest on validate
self.heatclient.stacks.validate = mock.Mock(
side_effect=heatexceptions.HTTPBadRequest
)
self.assertRaises(exception.HeatValidationFailedError,
plan_mgr.get_deployment_parameters,
self.plan_name)
def test_update_deployment_parameters(self):
self.plan_store.get = mock.MagicMock(return_value=self.expected_plan)
# calls templates.process_plan_data(plan.files)
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
self.expected_plan.files['environments/deployment_parameters.yaml'] = {
'contents':
"parameters:\n"
" one: uno\n"
" obj:\n"
" two: due\n"
" three: tre\n",
'meta': {'file-type': 'temp-environment'}
}
with mock.patch('tripleo_common.utils.templates') as templates:
templates.process_plan_data.return_value = (
"some fake contents", {
'parameters': {
'obj': {
'two': 'due',
'three': 'tre'
},
'one': 'uno'
}
}, {'some-template.yaml': 'some fake contents'})
self.heatclient.stacks.validate = mock.MagicMock()
plan_mgr.validate_plan(self.plan_name)
self.heatclient.stacks.validate.assert_called_with(
template="some fake contents",
files={'some-template.yaml': 'some fake contents'},
environment={
'parameters': {
'obj': {
'two': 'due',
'three': 'tre'
},
'one': 'uno'
}
},
show_nested=True
)
# set side effect of heatexceptions.HTTPBadRequest on validate
self.heatclient.stacks.validate = mock.Mock(
side_effect=heatexceptions.HTTPBadRequest
)
self.assertRaises(exception.HeatValidationFailedError,
plan_mgr.get_deployment_parameters,
self.plan_name)
# calls self.heatclient.stacks.validate
# set side effect on validate of heatexceptions.HTTPBadRequest
# calls six.raise_from(exception.HeatValidationFailedError(), exc)
# assert params match
pass
def test_update_plan(self):
self.plan_store.update = mock.MagicMock()
self.plan_store.get = mock.MagicMock(return_value=self.expected_plan)
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
self.assertEqual(self.expected_plan,
plan_mgr.update_plan(
self.plan_name, self.expected_plan.files),
"Plan mismatch")
self.plan_store.get.assert_called_with(self.plan_name)
with mock.patch('tripleo_common.core.plan.LOG') as log_mock:
self.plan_store.get = mock.Mock(side_effect=ValueError())
self.assertRaises(ValueError, plan_mgr.update_plan,
self.plan_name, self.expected_plan.files)
log_mock.exception.assert_called_with("Error retrieving plan.")
def test_validate_plan(self):
# calls self.get_plan(plan_name)
self.plan_store.get = mock.MagicMock(return_value=self.expected_plan)
# test 2 root-templates to get exception.TooManyRootTemplatesError
self.expected_plan.files['another-root-template.yaml'] = {
'contents': "some fake contents",
'meta': {'file-type': 'root-template'}
}
plan_mgr = plan.PlanManager(self.plan_store, self.heatclient)
self.assertRaises(exception.TooManyRootTemplatesError,
plan_mgr.validate_plan,
self.plan_name)
del(self.expected_plan.files['another-root-template.yaml'])
# calls templates.process_plan_data(plan.files) (mock and assert_call)
with mock.patch('tripleo_common.utils.templates') as templates:
templates.process_plan_data = mock.MagicMock(return_value=(
"some fake contents", {
'parameters': {
'obj': {
'two': 'due',
'three': 'tre'
},
'one': 'uno'
}
}, {'some-template.yaml': 'some fake contents'}))
self.heatclient.stacks.validate = mock.MagicMock()
plan_mgr.validate_plan(self.plan_name)
self.heatclient.stacks.validate.assert_called_with(
template="some fake contents",
files={'some-template.yaml': 'some fake contents'},
environment={
'parameters': {
'obj': {
'two': 'due',
'three': 'tre'
},
'one': 'uno'
}
},
show_nested=True
)
# set side effect of heatexceptions.HTTPBadRequest on validate
self.heatclient.stacks.validate = mock.Mock(
side_effect=heatexceptions.HTTPBadRequest
)
with mock.patch('tripleo_common.core.plan.LOG') as log_mock:
self.assertRaises(exception.HeatValidationFailedError,
plan_mgr.validate_plan,
self.plan_name)
log_mock.exception.assert_called_with(
"Error validating the plan.")

View File

@ -0,0 +1,193 @@
# Copyright 2015 Red Hat, Inc.
# 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 mock
from tripleo_common.core import exception
from tripleo_common.core.models import Plan
from tripleo_common.core.plan_storage import default_container_headers
from tripleo_common.core.plan_storage import SwiftPlanStorageBackend
from tripleo_common.tests import base
PLAN_DATA = {
'/path/to/overcloud.yaml': {
'contents': "heat_template_version: 2015-04-30\n\n"
"resources:\n"
"\n"
" HorizonSecret:\n"
" type: OS::Heat::RandomString\n"
" properties:\n"
" length: 10\n"
"\n"
" Controller:\n"
" type: OS::Heat::ResourceGroup\n"
" depends_on: Networks\n"
" properties:\n"
" count: {get_param: ControllerCount}\n",
'meta': {'file-type': 'root-template'},
},
'/path/to/environment.yaml': {
'contents': "parameters:\n"
" one: uno\n"
" obj:\n"
" two: due\n"
" three: tre\n",
'meta': {'file-type': 'root-environment'},
},
'/path/to/network-isolation.json': {
'contents': '{"parameters": {"one": "one"}}',
'meta': {'file-type': 'environment', 'order': 1},
},
'/path/to/ceph-storage-env.yaml': {
'contents': "parameters:\n"
" obj:\n"
" two: dos,\n"
" three: three",
'meta': {'file-type': 'environment', 'order': 2},
},
'/path/to/poc-custom-env.yaml': {
'contents': "parameters:\n"
" obj:\n"
" two: two\n"
" some::resource: /path/to/somefile.yaml",
'meta': {'file-type': 'environment', 'order': 0}
},
'/path/to/somefile.yaml': {'contents': "description: lorem ipsum"}
}
class PlanStorageTest(base.TestCase):
def setUp(self):
super(PlanStorageTest, self).setUp()
self.swiftclient = mock.MagicMock()
self.plan_store = SwiftPlanStorageBackend(self.swiftclient)
self.plan_name = "overcloud"
def test_create(self):
# create a plan
self.plan_store.list = mock.MagicMock(return_value=['test1', 'test2'])
self.swiftclient.put_container = mock.MagicMock()
self.plan_store.create(self.plan_name)
self.swiftclient.put_container.assert_called_with(
self.plan_name,
headers=default_container_headers
)
# attempt to create a 2nd plan should fail
self.plan_store.list = mock.MagicMock(return_value=['overcloud'])
self.assertRaises(exception.PlanAlreadyExistsError,
self.plan_store.create,
self.plan_name)
def test_delete(self):
self.swiftclient.get_container = mock.MagicMock(
return_value=({}, [
{'name': 'some-name.yaml'},
{'name': 'some-other-name.yaml'},
{'name': 'yet-some-other-name.yaml'},
{'name': 'finally-another-name.yaml'}
])
)
self.swiftclient.delete_object = mock.MagicMock()
self.plan_store.delete(self.plan_name)
mock_calls = [
mock.call('overcloud', 'some-name.yaml'),
mock.call('overcloud', 'some-other-name.yaml'),
mock.call('overcloud', 'yet-some-other-name.yaml'),
mock.call('overcloud', 'finally-another-name.yaml')
]
self.swiftclient.delete_object.assert_has_calls(
mock_calls, any_order=True)
def test_delete_file(self):
self.swiftclient.delete_object = mock.MagicMock()
filepath = '/a/random/path/to/file.yaml'
self.plan_store.delete_file(self.plan_name, filepath)
self.swiftclient.delete_object.assert_called_with(
self.plan_name, filepath)
def test_get(self):
metadata = {
'x-container-meta-usage-tripleo': 'plan',
'accept-ranges': 'bytes',
'x-storage-policy': 'Policy-0',
'connection': 'keep-alive',
'x-timestamp': '1447161410.72641',
'x-trans-id': 'tx1f41a9d34a2a437d8f8dd-00565dd486',
'content-type': 'application/json; charset=utf-8',
'x-versions-location': 'versions'
}
expected_plan = Plan(self.plan_name)
expected_plan.metadata = metadata
expected_plan.files = {
'some-name.yaml': {
'contents': "some fake contents",
'meta': {'file-type': 'environment'}
},
}
self.swiftclient.get_container = mock.MagicMock(return_value=(
metadata, [
{'name': 'some-name.yaml'},
])
)
self.swiftclient.get_object = mock.MagicMock(return_value=(
{'x-object-meta-file-type': 'environment'}, "some fake contents"
))
self.assertEqual(expected_plan.name,
self.plan_store.get(self.plan_name).name)
self.swiftclient.get_container.assert_called_with(self.plan_name)
self.swiftclient.get_object.assert_called_with(
'overcloud', 'some-name.yaml')
def test_list(self):
self.swiftclient.get_account = mock.MagicMock(
return_value=({}, [
{
'count': 1,
'bytes': 55,
'name': 'overcloud'
},
])
)
self.swiftclient.get_container = mock.MagicMock(
return_value=({
'x-container-meta-usage-tripleo': 'plan',
}, [])
)
self.assertEqual(['overcloud'], self.plan_store.list())
self.swiftclient.get_container.assert_called_with('overcloud')
def test_update(self):
expected_plan = Plan(self.plan_name)
expected_plan.metadata = {
'x-container-meta-usage-tripleo': 'plan',
'accept-ranges': 'bytes',
'x-storage-policy': 'Policy-0',
}
expected_plan.files = {
'some-name.yaml': {
'contents': "some fake contents",
'meta': {'file-type': 'environment'}
},
}
self.swiftclient.put_object = mock.MagicMock()
self.plan_store.update(self.plan_name, expected_plan.files)
self.swiftclient.put_object.assert_called_with(
self.plan_name,
'some-name.yaml',
"some fake contents",
headers={'x-object-meta-file-type': 'environment'}
)

View File

View File

@ -0,0 +1,165 @@
# Copyright 2015 Red Hat, Inc.
# 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 tripleo_common.core import exception
from tripleo_common.tests import base
from tripleo_common.utils.meta import add_file_metadata
MAPPING_YAML_CONTENTS = """root_template: /path/to/overcloud.yaml
root_environment: /path/to/environment.yaml
topics:
- title: Fake Single Environment Group Configuration
description:
environment_groups:
- title:
description: Random fake string of text
environments:
- file: /path/to/network-isolation.json
title: Default Configuration
description:
- title: Fake Multiple Environment Group Configuration
description:
environment_groups:
- title: Random Fake 1
description: Random fake string of text
environments:
- file: /path/to/ceph-storage-env.yaml
title: Fake1
description: Random fake string of text
- title: Random Fake 2
description:
environments:
- file: /path/to/poc-custom-env.yaml
title: Fake2
description:
"""
PLAN_DATA = {
'/path/to/overcloud.yaml': {
'contents': 'heat_template_version: 2015-04-30',
'meta': {'file-type': 'root-template'},
},
'/path/to/environment.yaml': {
'contents': "parameters:\n"
" one: uno\n"
" obj:\n"
" two: due\n"
" three: tre\n",
'meta': {'file-type': 'root-environment', 'enabled': 'True'},
},
'/path/to/network-isolation.json': {
'contents': '{"parameters": {"one": "one"}}',
'meta': {'file-type': 'environment'},
},
'/path/to/ceph-storage-env.yaml': {
'contents': "parameters:\n"
" obj:\n"
" two: dos,\n"
" three: three",
'meta': {'file-type': 'environment'},
},
'/path/to/poc-custom-env.yaml': {
'contents': "parameters:\n"
" obj:\n"
" two: two\n"
" some::resource: /path/to/somefile.yaml",
'meta': {'file-type': 'environment'}
},
'/path/to/somefile.yaml': {'contents': "description: lorem ipsum"},
'capabilities-map.yaml': {
'contents': MAPPING_YAML_CONTENTS,
'meta': {'file-type': 'capabilities-map'},
},
}
PLAN_DATA_NO_META = {
'/path/to/overcloud.yaml': {
'contents': 'heat_template_version: 2015-04-30',
},
'/path/to/environment.yaml': {
'contents': "parameters:\n"
" one: uno\n"
" obj:\n"
" two: due\n"
" three: tre\n",
},
'/path/to/network-isolation.json': {
'contents': '{"parameters": {"one": "one"}}',
},
'/path/to/ceph-storage-env.yaml': {
'contents': "parameters:\n"
" obj:\n"
" two: dos,\n"
" three: three",
},
'/path/to/poc-custom-env.yaml': {
'contents': "parameters:\n"
" obj:\n"
" two: two\n"
" some::resource: /path/to/somefile.yaml",
},
'/path/to/somefile.yaml': {'contents': "description: lorem ipsum"},
'capabilities-map.yaml': {
'contents': MAPPING_YAML_CONTENTS,
'meta': {'file-type': 'capabilities-map'},
},
}
class UtilsMetaTest(base.TestCase):
def test_add_file_metadata(self):
# tests case where files have no metadata yet
plan_files_with_metadata = add_file_metadata(PLAN_DATA_NO_META)
self.assertEqual(
PLAN_DATA,
plan_files_with_metadata,
"Metadata not added properly"
)
# tests case where files already have a metadata dict per file
for k, v in PLAN_DATA.items():
if 'meta' in v:
v.update({'enabled': 'True'})
else:
v['meta'] = {'enabled': 'True'}
for k, v in PLAN_DATA_NO_META.items():
if 'meta' in v:
v.update({'enabled': 'True'})
else:
v['meta'] = {'enabled': 'True'}
plan_files_with_metadata = add_file_metadata(PLAN_DATA_NO_META)
self.assertEqual(
PLAN_DATA,
plan_files_with_metadata,
"Metadata not added properly"
)
# test to ensure having more than one capabilities-map file
# results in an exception
PLAN_DATA_NO_META.update({
'capabilities-map2.yaml': {
'contents': MAPPING_YAML_CONTENTS,
'meta': {'file-type': 'capabilities-map'}
}
})
self.assertRaises(exception.TooManyCapabilitiesMapFilesError,
add_file_metadata,
PLAN_DATA_NO_META)

View File

@ -0,0 +1,88 @@
# Copyright 2015 Red Hat, Inc.
# 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 tripleo_common.tests import base
from tripleo_common.utils.templates import find_root_template
from tripleo_common.utils.templates import process_plan_data
PLAN_DATA = {
'/path/to/overcloud.yaml': {
'contents': 'heat_template_version: 2015-04-30',
'meta': {'file-type': 'root-template'},
},
'/path/to/environment.yaml': {
'contents': "parameters:\n"
" one: uno\n"
" obj:\n"
" two: due\n"
" three: tre\n",
'meta': {
'file-type': 'root-environment',
'enabled': 'True'
}
},
'/path/to/network-isolation.json': {
'contents': '{"parameters": {"one": "one"}}',
'meta': {'file-type': 'environment'},
},
'/path/to/ceph-storage-env.yaml': {
'contents': "parameters:\n"
" obj:\n"
" two: dos,\n"
" three: three",
'meta': {'file-type': 'environment'},
},
'/path/to/poc-custom-env.yaml': {
'contents': "parameters:\n"
" obj:\n"
" two: two\n"
" some::resource: /path/to/somefile.yaml",
'meta': {'file-type': 'environment'}
},
'/path/to/somefile.yaml': {'contents': "description: lorem ipsum"}
}
class UtilsTemplatesTest(base.TestCase):
def setUp(self):
super(UtilsTemplatesTest, self).setUp()
self.tpl, self.env, self.files = process_plan_data(PLAN_DATA)
print(self.files)
def test_find_root_template(self):
# delete the root_template from sample data
del PLAN_DATA['/path/to/overcloud.yaml']
# without root, should return {}
self.assertEqual({}, find_root_template(PLAN_DATA))
# add root_template back to sample data
root_template = {
'/path/to/overcloud.yaml': {
'contents': 'heat_template_version: 2015-04-30',
'meta': {'file-type': 'root-template'}}
}
PLAN_DATA.update(root_template)
self.assertEqual(root_template, find_root_template(PLAN_DATA))
def test_template_found(self):
self.assertEqual(self.tpl, 'heat_template_version: 2015-04-30')
def test_files_found(self):
self.assertEqual(self.files, {
'/path/to/somefile.yaml': 'description: lorem ipsum',
})

View File

View File

@ -0,0 +1,85 @@
# Copyright 2015 Red Hat, Inc.
# 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 yaml
from tripleo_common.core import constants
from tripleo_common.core import exception
def add_key_prefix(source):
result = dict()
for keyname, value in source.items():
new_keyname = "%s%s" % (constants.OBJECT_META_KEY_PREFIX, keyname)
result[new_keyname] = value
return result
def remove_key_prefix(source):
result = dict()
for keyname, value in source.items():
new_keyname = keyname.replace(constants.OBJECT_META_KEY_PREFIX, '')
result[new_keyname] = value
return result
def add_file_metadata(plan_files):
cm = {k: v for (k, v) in plan_files.items()
if v.get('meta', {}).get('file-type') == 'capabilities-map'}
# if there is more than one capabilities-map file, throw an exception
# if there is a capabilities-map file, then process it and set metadata
# in files found
if len(cm) > 1:
raise exception.TooManyCapabilitiesMapFilesError()
if len(cm) == 1:
mapfile = yaml.load(list(cm.items())[0][1]['contents'])
# identify the root template
if mapfile['root_template']:
if plan_files[mapfile['root_template']]:
# if the file exists in the plan and has meta, update it
# otherwise add meta dict
if 'meta' in plan_files[mapfile['root_template']]:
plan_files[mapfile['root_template']]['meta'].update(
dict(constants.ROOT_TEMPLATE_META)
)
else:
plan_files[mapfile['root_template']]['meta'] =\
dict(constants.ROOT_TEMPLATE_META)
# identify all environments
for topic in mapfile['topics']:
for eg in topic['environment_groups']:
for env in eg['environments']:
if 'meta' in plan_files[env['file']]:
plan_files[env['file']]['meta'].update(
dict(constants.ENVIRONMENT_META)
)
else:
plan_files[env['file']]['meta'] =\
dict(constants.ENVIRONMENT_META)
# identify the root environment
if mapfile['root_environment']:
if plan_files[mapfile['root_environment']]:
# if the file exists in the plan and has meta, update it
# otherwise add meta dict
if 'meta' in plan_files[mapfile['root_environment']]:
plan_files[mapfile['root_environment']]['meta'].update(
dict(constants.ROOT_ENVIRONMENT_META)
)
else:
plan_files[mapfile['root_environment']]['meta'] =\
dict(constants.ROOT_ENVIRONMENT_META)
return plan_files

View File

@ -0,0 +1,81 @@
# Copyright 2015 Red Hat, Inc.
# 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 logging
import yaml
LOG = logging.getLogger(__name__)
def _get_dict_from_env_string(env_name, env_string):
"""Returns environment dict, either from yaml or json."""
if '.yaml' in env_name:
return yaml.load(env_string)
else:
return json.loads(env_string)
def deep_update(base, new):
for key, val in new.items():
if isinstance(val, dict):
tmp = deep_update(base.get(key, {}), val)
base[key] = tmp
else:
base[key] = val
return base
def process_plan_data(plan_data):
"""Processes the plan data."""
template = ''
environment = {}
env_items = []
temp_env_items = []
files = {}
for key, val in plan_data.items():
file_type = val.get('meta', {}).get('file-type')
enabled = val.get('meta', {}).get('enabled')
if not file_type:
files[key] = val['contents']
elif file_type == 'environment' and enabled:
env_items.append({'name': key,
'meta': val['meta'],
'contents': val['contents']})
elif file_type == 'temp-environment':
temp_env_items.append({'name': key,
'meta': val['meta'],
'contents': val['contents']})
elif file_type == 'root-template':
template = val['contents']
elif file_type == 'root-environment' and enabled:
environment = _get_dict_from_env_string(key, val['contents'])
# merge environment files
for item in env_items:
env_dict = _get_dict_from_env_string(item['name'], item['contents'])
environment = deep_update(environment, env_dict)
# merge the temporary environment files last
for item in temp_env_items:
env_dict = _get_dict_from_env_string(item['name'], item['contents'])
environment = deep_update(environment, env_dict)
return template, environment, files
def find_root_template(plan_files):
return {k: v for (k, v) in plan_files.items()
if v.get('meta', {}).get('file-type') == 'root-template'}