New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for cf and codepipeline parameters file to cf deploy #5443
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"type": "feature", | ||
"category": "``cloudformation``", | ||
"description": "CloudFormation ``deploy`` command now supports various JSON file formats as an input for ``--parameter-overrides`` option." | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should probably link the GitHub issue that it fixes in the description. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,13 +11,16 @@ | |
# ANY KIND, either express or implied. See the License for the specific | ||
# language governing permissions and limitations under the License. | ||
|
||
import functools | ||
import json | ||
import os | ||
import sys | ||
import logging | ||
|
||
from botocore.client import Config | ||
|
||
from awscli.compat import compat_open | ||
from awscli.customizations.exceptions import ParamValidationError | ||
from awscli.customizations.cloudformation import exceptions | ||
from awscli.customizations.cloudformation.deployer import Deployer | ||
from awscli.customizations.s3uploader import S3Uploader | ||
|
@@ -107,12 +110,7 @@ class DeployCommand(BasicCommand): | |
'name': PARAMETER_OVERRIDE_CMD, | ||
'action': 'store', | ||
'required': False, | ||
'schema': { | ||
'type': 'array', | ||
'items': { | ||
'type': 'string' | ||
} | ||
}, | ||
'nargs': '+', | ||
'default': [], | ||
'help_text': ( | ||
'A list of parameter structures that specify input parameters' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just a reminder we need doc updates for these new formats. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We also should add a changelog entry for this. |
||
|
@@ -121,7 +119,7 @@ class DeployCommand(BasicCommand): | |
' existing value. For new stacks, you must specify' | ||
' parameters that don\'t have a default value.' | ||
' Syntax: ParameterKey1=ParameterValue1' | ||
' ParameterKey2=ParameterValue2 ...' | ||
' ParameterKey2=ParameterValue2 ... or JSON file (see Examples)' | ||
) | ||
}, | ||
{ | ||
|
@@ -255,10 +253,9 @@ def _run_main(self, parsed_args, parsed_globals): | |
template_str = handle.read() | ||
|
||
stack_name = parsed_args.stack_name | ||
parameter_overrides = self.parse_key_value_arg( | ||
parsed_args.parameter_overrides, | ||
self.PARAMETER_OVERRIDE_CMD) | ||
|
||
parameter_overrides = self.parse_parameter_overrides( | ||
parsed_args.parameter_overrides | ||
) | ||
tags_dict = self.parse_key_value_arg(parsed_args.tags, self.TAGS_CMD) | ||
tags = [{"Key": key, "Value": value} | ||
for key, value in tags_dict.items()] | ||
|
@@ -359,6 +356,79 @@ def merge_parameters(self, template_dict, parameter_overrides): | |
|
||
return parameter_values | ||
|
||
def _validate_cf_params(self, param): | ||
if 'ParameterKey' in param and 'ParameterValue' in param: | ||
if len(param.keys()) > 2: | ||
raise ParamValidationError( | ||
'CloudFormation like parameter JSON should have format ' | ||
'[{"ParameterKey": "string", "ParameterValue": "string"}]') | ||
return True | ||
|
||
def _cf_param_parser(self, data): | ||
# Parse parameter_overrides if they were given in | ||
# CloudFormation params file format | ||
# [{ | ||
# "ParameterKey": "string", | ||
# "ParameterValue": "string", | ||
# }] | ||
try: | ||
return { | ||
param['ParameterKey']: param['ParameterValue'] | ||
for param in data if self._validate_cf_params(param) | ||
} | ||
except (KeyError, TypeError): | ||
return None | ||
|
||
def _codepipeline_param_parser(self, data): | ||
# Parse parameter_overrides if they were given in | ||
# CodePipeline params file format | ||
# { | ||
# "Parameters": { | ||
# "ParameterKey": "ParameterValue" | ||
# } | ||
# } | ||
if isinstance(data, dict): | ||
return data.get('Parameters', None) | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Left this off of my last review... Should we validate that it does not have any of the top-level keys like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's discussible, if we take only Parameters key and just ignore the rest it'll allow customers to reuse the same files without charging There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah that's fair given this change is more of about expanding compatibility. |
||
def _parse_input_as_json(self, arg_value): | ||
# In case of inline json input it'll be list where json string | ||
# will be the first element | ||
if arg_value: | ||
if isinstance(arg_value, str): | ||
return json.loads(arg_value) | ||
Comment on lines
+428
to
+429
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then is this the case where it got loaded from a file? If so, it might be good to include that in the comment. |
||
try: | ||
return json.loads(arg_value[0]) | ||
except json.JSONDecodeError: | ||
return None | ||
|
||
def parse_parameter_overrides(self, arg_value): | ||
data = self._parse_input_as_json(arg_value) | ||
if data is not None: | ||
parsers = [ | ||
self._cf_param_parser, | ||
self._codepipeline_param_parser, | ||
|
||
] | ||
result = functools.reduce( | ||
lambda a, parser: a or parser(data), | ||
parsers, None | ||
) | ||
# In case it was in deploy command format | ||
# but was in file | ||
if result is None: | ||
return self.parse_key_value_arg( | ||
data, | ||
self.PARAMETER_OVERRIDE_CMD | ||
) | ||
return result | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So I'm not entire sure we should be relying on the old parsing behavior as the final attempt and not provide any helpful error message after it. Specifically playing around with this I got some really weird looking errors. In this example, I accidentally misspelled
Then even in the test case for just a generally bad structure, this message is confusing:
In general, it might make sense to abstract this logic a bit into classes/this format: class BaseParameterOverrideParser:
def can_parse(self, data):
# Returns true/false if it can parse
def parse(self, data):
# Return the properly formatted parameter dictionary
# Example parser
class CodePipelineParameterOverrideParser(BaseParameterOverrideParser):
def can_parse(self, data):
return isinstance(data, dict) and 'Parameters' in data
def parse(self, data):
return data['Parameters']
# In the DeployCommand class
def parse_parameter_overrides(self, arg_value):
# After checking if it is json:
parsers = [
CfnParameterOverrideParser(), # maybe find better names here
CodePipelineParameterOverrideParser(),
StringEqualsParameterOverrideParser()
]
for parser in parsers:
if parser.can_parse(data):
return parser.parse(data)
raise InvalidParameterOverrideFormat('Show helpful information about the accepted formats') The advantages with this approach is that:
|
||
else: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm wondering if we should allow users to be able to send these new formats through the command line as JSON? For example, this should probably work:
Generally, the expectation for CLI usage is that if it can be specified as a file, it can also be specified directly to the command line. While I expect most people to be using the file, I do not think we should be limiting the ability to pass the new formats in directly to the command line as users may be confused by this inconsistency and they may not necessarily want to write out a file in order to run a command. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure anyone will do it but to be consistent we need to allow it, I think |
||
# In case it was in deploy command format | ||
# and was input via command line | ||
return self.parse_key_value_arg( | ||
arg_value, | ||
self.PARAMETER_OVERRIDE_CMD | ||
) | ||
|
||
def parse_key_value_arg(self, arg_value, argname): | ||
""" | ||
Converts arguments that are passed as list of "Key=Value" strings | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,3 +4,40 @@ Following command deploys template named ``template.json`` to a stack named | |
|
||
aws cloudformation deploy --template-file /path_to_template/template.json --stack-name my-new-stack --parameter-overrides Key1=Value1 Key2=Value2 --tags Key1=Value1 Key2=Value2 | ||
|
||
or the same command using parameters from JSON file ``parameters.json``:: | ||
|
||
aws cloudformation deploy --template-file /path_to_template/template.json --stack-name my-new-stack --parameter-overrides file://path_to_parameters/parameters.json --tags Key1=Value1 Key2=Value2 | ||
|
||
Supported JSON syntax | ||
~~~~~~~~~~~~~~~~~~~~~ | ||
|
||
Original format:: | ||
|
||
[ | ||
"Key1=Value1", | ||
"Key2=Value2" | ||
] | ||
|
||
CloudFormation like format:: | ||
|
||
[ | ||
{ | ||
"ParameterKey": "Key1", | ||
"ParameterValue": "Value1", | ||
}, | ||
{ | ||
"ParameterKey": "Key2", | ||
"ParameterValue": "Value2", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if this is valid JSON with the trailing commas. |
||
}, | ||
] | ||
|
||
Note: Only ParameterKey and ParameterValue are expected keys, command will through an exception if receives unexpected keys (e.g. UsePreviousValue or ResolvedValue). | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another thing I noticed when I generated the docs we should use the reStructuredText note e.g. ( |
||
|
||
CodePipeline like format:: | ||
|
||
[ | ||
"Parameters": { | ||
"Key1": "Value1", | ||
"Key2": "Value2" | ||
} | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -60,3 +60,122 @@ def test_does_return_zero_exit_code_on_empty_changeset(self): | |
def test_does_return_non_zero_exit_code_on_empty_changeset(self): | ||
self.command += ' --fail-on-empty-changeset' | ||
self.run_cmd(self.command, expected_rc=255) | ||
|
||
|
||
class TestDeployCommandParameterOverrides(TestDeployCommand): | ||
def setUp(self): | ||
super(TestDeployCommandParameterOverrides, self).setUp() | ||
template = '''{ | ||
"AWSTemplateFormatVersion": "2010-09-09", | ||
"Parameters": { | ||
"Key1": { | ||
"Type": "String" | ||
}, | ||
"Key2": { | ||
"Type": "String" | ||
} | ||
} | ||
}''' | ||
Comment on lines
+70
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For any time we are writing a JSON document out to a file, it may make sense to expose a template = {
"AWSTemplateFormatVersion": "2010-09-09",
"Parameters": {
"Key1": {
"Type": "String"
},
"Key2": {
"Type": "String"
}
}
}
path = self.create_json_file('template.json', template)
... Specifically when the template or parameters is in the form of python dictionary it is a bit easier to work with (e.g. you don't have to worry about getting JSON syntax correctly) and expand on it (e.g you want to add or remove keys from some default JSON document). |
||
path = self.files.create_file('template.json', template) | ||
self.command = ( | ||
'cloudformation deploy --template-file %s ' | ||
'--stack-name Stack ' | ||
) % path | ||
|
||
def _assert_parameters_parsed(self): | ||
self.assertEqual( | ||
self.operations_called[1][1]['Parameters'], | ||
[ | ||
{'ParameterKey': 'Key1', 'ParameterValue': 'Value1'}, | ||
{'ParameterKey': 'Key2', 'ParameterValue': 'Value2'} | ||
] | ||
) | ||
|
||
def test_parameter_overrides_shorthand(self): | ||
self.command += ' --parameter-overrides Key1=Value1 Key2=Value2' | ||
self.run_cmd(self.command) | ||
self._assert_parameters_parsed() | ||
|
||
def test_parameter_overrides_from_inline_original_json(self): | ||
original_like_json = '["Key1=Value1","Key2=Value2"]' | ||
path = self.files.create_file('param.json', original_like_json) | ||
self.command += ' --parameter-overrides file://%s' % path | ||
self.run_cmd(self.command) | ||
self._assert_parameters_parsed() | ||
|
||
def test_parameter_overrides_from_inline_cf_like_json(self): | ||
cf_like_json = ('[{"ParameterKey":"Key1",' | ||
'"ParameterValue":"Value1"},' | ||
'{"ParameterKey":"Key2",' | ||
'"ParameterValue":"Value2"}]') | ||
self.command += ' --parameter-overrides %s' % cf_like_json | ||
self.run_cmd(self.command) | ||
self._assert_parameters_parsed() | ||
|
||
def test_parameter_overrides_from_cf_like_json_file(self): | ||
cf_like_json = ''' | ||
[ | ||
{"ParameterKey": "Key1", "ParameterValue": "Value1"}, | ||
{"ParameterKey": "Key2", "ParameterValue": "Value2"} | ||
] | ||
''' | ||
path = self.files.create_file('param.json', cf_like_json) | ||
self.command += ' --parameter-overrides file://%s' % path | ||
self.run_cmd(self.command) | ||
self._assert_parameters_parsed() | ||
|
||
def test_parameter_overrides_from_invalid_cf_like_json_file(self): | ||
cf_like_json = ''' | ||
[ | ||
{"ParameterKey": "Key1", | ||
"ParameterValue": "Value1", | ||
"RedundantKey": "RedundantValue" | ||
}, | ||
{"ParameterKey": "Key2", "ParameterValue": "Value2"} | ||
] | ||
''' | ||
path = self.files.create_file('param.json', cf_like_json) | ||
self.command += ' --parameter-overrides file://%s' % path | ||
_, err, _ = self.run_cmd(self.command, expected_rc=252) | ||
self.assertTrue('CloudFormation like parameter JSON should have format' | ||
in err) | ||
|
||
def test_parameter_overrides_from_inline_codepipeline_like_json(self): | ||
codepipeline_like_json = ('{"Parameters":{"Key1":"Value1",' | ||
'"Key2":"Value2"}}') | ||
self.command += ' --parameter-overrides %s' % codepipeline_like_json | ||
self.run_cmd(self.command) | ||
self._assert_parameters_parsed() | ||
|
||
def test_parameter_overrides_from_codepipeline_like_json_file(self): | ||
codepipeline_like_json = ''' | ||
{ | ||
"Parameters": | ||
{"Key1": "Value1", | ||
"Key2": "Value2"} | ||
} | ||
''' | ||
path = self.files.create_file('param.json', codepipeline_like_json) | ||
self.command += ' --parameter-overrides file://%s' % path | ||
self.run_cmd(self.command) | ||
self._assert_parameters_parsed() | ||
|
||
def test_parameter_overrides_from_original_json_file(self): | ||
original_like_json = ''' | ||
[ | ||
"Key1=Value1", | ||
"Key2=Value2" | ||
] | ||
''' | ||
path = self.files.create_file('param.json', original_like_json) | ||
self.command += ' --parameter-overrides file://%s' % path | ||
self.run_cmd(self.command, expected_rc=0) | ||
self._assert_parameters_parsed() | ||
|
||
def test_parameter_overrides_from_random_invalid_json(self): | ||
cf_like_json = '{"SomeKey":[{"RedundantKey": "RedundantValue"}]}' | ||
path = self.files.create_file('param.json', cf_like_json) | ||
self.command += ' --parameter-overrides file://%s' % path | ||
_, err, _ = self.run_cmd(self.command, expected_rc=255) | ||
self.assertTrue("['SomeKey'] value passed to --parameter-overrides" | ||
in err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's change this to an enhancement. Typically,
feature
means we will do a minor version bump and I'm not sure if this update warrants one.