Skip to content

Commit

Permalink
add raw json/yaml template support (#530)
Browse files Browse the repository at this point in the history
* add raw json/yaml template support

Fixes #444

* fix diff; fix transform; remove build action hack

* add raw template tests

* additional updates for raw template support

* update diff action to support regular & raw blueprints without
  conditions
* fix validation & test of class / template path use in Config
* move raw blueprint specific function from util to blueprint module
* cleanup class selection in stack.py

* Handle mutual exclusion validation better

* Add functional test for template_path

* Remove dependency on troposphere template

* Make RawTemplateBlueprint inherit from object.

* cleanup pylint/pydocstyle messages in raw blueprint
  • Loading branch information
troyready authored and phobologic committed Feb 11, 2018
1 parent b9242f6 commit 4d0fad8
Show file tree
Hide file tree
Showing 13 changed files with 499 additions and 16 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
- assertRenderedBlueprint always dumps current results [GH-528]
- stacker now builds a DAG internally [GH-523]
- an unecessary DescribeStacks network call was removed [GH-529]
- support stack json/yaml templates [GH-530]
- logging output has been simplified and no longer uses ANSI escape sequences to clear the screen [GH-532]
- logging output is now colorized in `--interactive` mode if the terminal has a TTY [GH-532]


## 1.1.4 (2018-01-26)

- Add `blueprint.to_json` for standalone rendering [GH-459]
Expand Down
6 changes: 5 additions & 1 deletion docs/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,11 @@ A stack has the following keys:
The base name for the stack (note: the namespace from the environment
will be prepended to this)
**class_path:**
The python class path to the Blueprint to be used.
The python class path to the Blueprint to be used. Specify this or
``template_path`` for the stack.
**template_path:**
Path to raw CloudFormation template (JSON or YAML). Specify this or
``class_path`` for the stack.
**description:**
A short description to apply to the stack. This overwrites any description
provided in the Blueprint. See: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-description-structure.html
Expand Down
2 changes: 1 addition & 1 deletion stacker/actions/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ def _launch_stack(self, stack, **kwargs):
template = self._template(stack.blueprint)
tags = build_stack_tags(stack)
parameters = self.build_parameters(stack, provider_stack)
force_change_set = stack.blueprint.template.transform is not None
force_change_set = stack.blueprint.requires_change_set

if recreate:
logger.debug("Re-creating stack: %s", stack.fqn)
Expand Down
20 changes: 17 additions & 3 deletions stacker/actions/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import sys
from operator import attrgetter

import yaml

from .base import plan
from . import build
from .. import exceptions
Expand Down Expand Up @@ -144,7 +146,8 @@ def print_stack_changes(stack_name, new_stack, old_stack, new_params,
to_file = "new_%s" % (stack_name,)
lines = difflib.context_diff(
old_stack, new_stack,
fromfile=from_file, tofile=to_file)
fromfile=from_file, tofile=to_file,
n=7) # ensure at least a few lines of context are displayed afterward

template_changes = list(lines)
if not template_changes:
Expand Down Expand Up @@ -212,7 +215,7 @@ def _diff_stack(self, stack, **kwargs):

stack.resolve(self.context, self.provider)
# generate our own template & params
new_template = stack.blueprint.rendered
new_template = stack.blueprint.to_json()
parameters = self.build_parameters(stack)
new_params = dict()
for p in parameters:
Expand All @@ -225,7 +228,18 @@ def _diff_stack(self, stack, **kwargs):
self._print_new_stack(new_stack, parameters)
else:
# Diff our old & new stack/parameters
old_stack = self._normalize_json(old_template)
old_template = yaml.load(old_template)
if isinstance(old_template, str):
# YAML templates returned from CFN need parsing again
# "AWSTemplateFormatVersion: \"2010-09-09\"\nParam..."
# ->
# AWSTemplateFormatVersion: "2010-09-09"
old_template = yaml.load(old_template)
old_stack = self._normalize_json(
json.dumps(old_template,
sort_keys=True,
indent=4)
)
print_stack_changes(stack.name, new_stack, old_stack, new_params,
old_params)
return COMPLETE
Expand Down
5 changes: 5 additions & 0 deletions stacker/blueprints/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,11 @@ def set_template_description(self, description):
"""
self.template.add_description(description)

@property
def requires_change_set(self):
"""Returns true if the underlying template has transforms."""
return self.template.transform is not None

@property
def rendered(self):
if not self._rendered:
Expand Down
188 changes: 188 additions & 0 deletions stacker/blueprints/raw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Blueprint representing raw template module."""

import hashlib
import json

import yaml

from ..exceptions import MissingVariable, UnresolvedVariable


def get_template_params(template):
"""Parse a CFN template for defined parameters.
Args:
template (dict): Parsed CFN template.
Returns:
dict: Template parameters.
"""
params = {}

if 'Parameters' in template:
params = template['Parameters']
return params


def resolve_variable(var_name, var_def, provided_variable, blueprint_name):
"""Resolve a provided variable value against the variable definition.
This acts as a subset of resolve_variable logic in the base module, leaving
out everything that doesn't apply to CFN parameters.
Args:
var_name (str): The name of the defined variable on a blueprint.
var_def (dict): A dictionary representing the defined variables
attributes.
provided_variable (:class:`stacker.variables.Variable`): The variable
value provided to the blueprint.
blueprint_name (str): The name of the blueprint that the variable is
being applied to.
Returns:
object: The resolved variable string value.
Raises:
MissingVariable: Raised when a variable with no default is not
provided a value.
UnresolvedVariable: Raised when the provided variable is not already
resolved.
"""
if provided_variable:
if not provided_variable.resolved:
raise UnresolvedVariable(blueprint_name, provided_variable)

value = provided_variable.value
else:
# Variable value not provided, try using the default, if it exists
# in the definition
try:
value = var_def["Default"]
except KeyError:
raise MissingVariable(blueprint_name, var_name)

return value


class RawTemplateBlueprint(object):
"""Blueprint class for blueprints auto-generated from raw templates."""

def __init__(self, name, context, raw_template_path, mappings=None, # noqa pylint: disable=too-many-arguments
description=None): # pylint: disable=unused-argument
"""Initialize RawTemplateBlueprint object."""
self.name = name
self.context = context
self.mappings = mappings
self.resolved_variables = None
self.raw_template_path = raw_template_path
self._rendered = None
self._version = None

def to_json(self, variables=None): # pylint: disable=unused-argument
"""Return the template in JSON.
Args:
variables (dict):
Unused in this subclass (variables won't affect the template).
Returns:
str: the rendered CFN JSON template
"""
# load -> dumps will produce json from json or yaml templates
return json.dumps(self.to_dict(), sort_keys=True, indent=4)

def to_dict(self):
"""Return the template as a python dictionary.
Returns:
dict: the loaded template as a python dictionary
"""
return yaml.load(self.rendered)

def render_template(self):
"""Load template and generate its md5 hash."""
return (self.version, self.rendered)

def get_parameter_definitions(self):
"""Get the parameter definitions to submit to CloudFormation.
Returns:
dict: parameter definitions. Keys are parameter names, the values
are dicts containing key/values for various parameter
properties.
"""
return get_template_params(self.to_dict())

def resolve_variables(self, provided_variables):
"""Resolve the values of the blueprint variables.
This will resolve the values of the template parameters with values
from the env file, the config, and any lookups resolved.
Args:
provided_variables (list of :class:`stacker.variables.Variable`):
list of provided variables
"""
self.resolved_variables = {}
defined_variables = self.get_parameter_definitions()
variable_dict = dict((var.name, var) for var in provided_variables)
for var_name, var_def in defined_variables.iteritems():
value = resolve_variable(
var_name,
var_def,
variable_dict.get(var_name),
self.name
)
self.resolved_variables[var_name] = value

def get_parameter_values(self):
"""Return a dictionary of variables with `type` :class:`CFNType`.
Returns:
dict: variables that need to be submitted as CloudFormation
Parameters. Will be a dictionary of <parameter name>:
<parameter value>.
"""
return self.resolved_variables

def get_required_parameter_definitions(self): # noqa pylint: disable=invalid-name
"""Return all template parameters that do not have a default value.
Returns:
dict: dict of required CloudFormation Parameters for the blueprint.
Will be a dictionary of <parameter name>: <parameter
attributes>.
"""
required = {}
for i in list(self.get_parameter_definitions().items()):
if i[1].get('Default', None) is None:
required[i[0]] = i[1]
return required

@property
def requires_change_set(self):
"""Return True if the underlying template has transforms."""
return bool("Transform" in self.to_dict())

@property
def rendered(self):
"""Return (generating first if needed) rendered template."""
if not self._rendered:
with open(self.raw_template_path, 'r') as template:
self._rendered = template.read()
return self._rendered

@property
def version(self):
"""Return (generating first if needed) version hash."""
if not self._version:
self._version = hashlib.md5(self.rendered).hexdigest()[:8]
return self._version
23 changes: 22 additions & 1 deletion stacker/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,9 @@ class Hook(Model):
class Stack(Model):
name = StringType(required=True)

class_path = StringType(required=True)
class_path = StringType(serialize_when_none=False)

template_path = StringType(serialize_when_none=False)

description = StringType(serialize_when_none=False)

Expand All @@ -289,6 +291,25 @@ class Stack(Model):

tags = DictType(StringType, serialize_when_none=False)

def validate_class_path(self, data, value):
if value and data["template_path"]:
raise ValidationError(
"template_path cannot be present when "
"class_path is provided.")
self.validate_stack_source(data)

def validate_template_path(self, data, value):
if value and data["class_path"]:
raise ValidationError(
"class_path cannot be present when "
"template_path is provided.")
self.validate_stack_source(data)

def validate_stack_source(self, data):
if not (data["class_path"] or data["template_path"]):
raise ValidationError(
"class_path or template_path is required.")

def validate_parameters(self, data, value):
if value:
stack_name = data['name']
Expand Down
24 changes: 18 additions & 6 deletions stacker/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
deconstruct,
)

from .blueprints.raw import RawTemplateBlueprint
from .exceptions import FailedVariableLookup


Expand Down Expand Up @@ -103,17 +104,28 @@ def requires(self):
@property
def blueprint(self):
if not hasattr(self, "_blueprint"):
class_path = self.definition.class_path
blueprint_class = util.load_object_from_string(class_path)
if not hasattr(blueprint_class, "rendered"):
raise AttributeError("Stack class %s does not have a "
"\"rendered\" "
"attribute." % (class_path,))
kwargs = {}
blueprint_class = None
if self.definition.class_path:
class_path = self.definition.class_path
blueprint_class = util.load_object_from_string(class_path)
if not hasattr(blueprint_class, "rendered"):
raise AttributeError("Stack class %s does not have a "
"\"rendered\" "
"attribute." % (class_path,))
elif self.definition.template_path:
blueprint_class = RawTemplateBlueprint
kwargs["raw_template_path"] = self.definition.template_path
else:
raise AttributeError("Stack does not have a defined class or "
"template path.")

self._blueprint = blueprint_class(
name=self.name,
context=self.context,
mappings=self.mappings,
description=self.definition.description,
**kwargs
)
return self._blueprint

Expand Down
Loading

0 comments on commit 4d0fad8

Please sign in to comment.