-
Notifications
You must be signed in to change notification settings - Fork 4.3k
Add local module support to the cloudformation package command #9124
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
base: develop
Are you sure you want to change the base?
Conversation
Includes a basic test case for unit testing.
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #9124 +/- ##
============================================
+ Coverage 0.08% 92.13% +92.05%
============================================
Files 210 228 +18
Lines 16984 19365 +2381
============================================
+ Hits 14 17842 +17828
+ Misses 16970 1523 -15447 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
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.
Solid start. I have some corrections and some questions.
I was thinking about how to refer to values from a template and noticed that that is a place where the leaky abstraction might be a downside (most of it is true for the overrides too, but it feels more expected there): If you want to reference a resource in a module you would use e.g.
Having an "outputs" or "outputs" equivalent could solve this. e.g Parent template Modules:
Content:
Source: ./basic-module.yaml
Properties:
Name: foo
Outputs:
ExampleOutput:
Value: !GetAtt Content.BucketArn basic-module.yaml Parameters:
Name:
Type: String
Resources:
Bucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Ref Name
Outputs:
BucketArn: !GetAtt Bucket.Arn Output from the package command Resources:
ContentBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: foo
Outputs:
ExampleOutput:
Value: !GetAtt ContentBucket.Arn |
Agreed with Ben on this one. You could still allow for an override capability that allows for a missing output that may be needed but having to understand the module resource names can get frustrating and prevents flexible in changing them. There are going to be limitations in this approach that are similar to modules. Adding mappings/conditions into a module will limit its usage because it cannot rely on another resource from the parent template or other module. |
I don't see any reason not to add Outputs. It's definitely a more predictable way to reference resources in the module. I still want the Overrides section and the ability to reference things in the module directly if you know the name, but that can be considered a more advanced use case. If you're consuming a 3rd party module, the risk is higher than if you're referencing your own local modules. |
Implemented here: 160be7c |
loader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, | ||
_dict_constructor) | ||
loader.add_multi_constructor("!", intrinsics_multi_constructor) | ||
return yaml.compose(yamlstr, loader) |
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.
This will fail on JSON files. While YAML can parse JSON there are some limitations that require the JSON parsers.
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.
Just for compose, or for yaml_parse as well?
if DEFAULT in param: | ||
# Use the default value of the Parameter | ||
return param[DEFAULT] | ||
msg = f"{name} does not have a Default and is not a Property" |
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.
I might be missing the point of having a Parameter with a Default for defining a list. Package doesn't support a parameter so it isn't changeable with that process? Is this for sub-moduling?
Parameters:
List:
Type: CommaDelimitedList
Default: A,B,C
Modules:
Content:
Source: ./foreach-module.yaml
ForEach: !Ref List
Properties:
Name: !Sub my-bucket-$Identifier
If I can override it from a parent module or template then it would create some caveats on how the Outputs work. As in we can no longer guarantee that a particular value exists?
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.
For the first question, since this is all resolved client-side, we have to have either a default value for the parameter, or the parent template/module needs to specify it with a Property.
For the second, are you talking about conditional resources? Not sure how that will work with outputs that depend on a resource that is not actually emitted. Need to test it.
This PR adds basic client-side multi-file support to CloudFormation by changing the behavior of the
aws cloudformation package
command.Modules are imported into the parent template or parent module via a new
Modules
section. This is a departure from registry modules, which are configured as resources. Since local modules can not only emit multiple Resources, in addition to unresolved Conditions and Mappings, configuring them as a “resource” feels unintuitive.Modules are a superset of CloudFormation templates. They support
Parameters
,Resources
,Conditions
, andOutputs
.A sample module:
An example of using the module above:
The packaged output of the above template:
Parameters and ParameterSchema
Parameters
are configured with theProperties
attribute in the parent, and act much like normal parameters, except it is possible to pass in objects and lists to a module. The newParameterSchema
section provides a way to define the structure and validation rules for parameters, especially for complex types like objects and arrays.The parameter schema type system is based on JSON Schema with CloudFormation-specific extensions:
String
: Text valuesNumber
: Numeric valuesBoolean
: True/false valuesObject
: Key-value mapsArray
: Ordered listsOutputs
The
Outputs
of a module are reference-able in the parent by usingGetAtt
orSub
in the parent template, with the combination of the module name and Output name. Module outputs can be scalars, lists, or objects.Overrides
The
Overrides
attribute allows the consumer to override content from the module that is included in the parent template, if they know the internal structure of the module. Instead of forcing a module author to anticipate every possible use case with numerous parameters, the author can focus on basic use cases and allow the consumer to get creative if they want to change certain elements of the output. Overrides only apply to the Resources emitted by the module. UsingOverrides
carries a risk of breaking changes when a module author changes things, so it will generally be safer to rely onParameters
andReferences
, but the overrides provide a flexible and easy-to-use escape hatch that can solve some tricky design challenges associated with a declarative language like YAML. We do not plan to advertise this as a best practice, but we do think that the escape hatch is necessary, just as it is for CDK.Complex objects are merged when an override is specified, so it’s possible to do things like add statements to a policy without actually overwriting the entire thing. We could consider replacing the Override keyword with more specific words like Replace, Append, Merge, or Delete, since currently, it’s not possible to remove elements from a list.
Constants
The new
Constants
section is a simple key-value list of strings or objects that are referred to later withFn::Sub
orRef
. This feature reduces copy-paste of repeated elements within a module.Before any processing of the template happens, all instances of constants in
!Sub
strings are replaced, including in subsequent constant declarations. Constant references are prefixed byConst``::
. Constants are supported not just in modules, but in the parent template as well. For constants that are objects, they are referenced with!Ref Const::name
.Module Sources
After constants are processed, the
Modules
section is processed. Module source files are specified using a path that is relative to the parent template or module. The path is not relative to where thepackage
command is being run. So, if a module is in the same directory as the parent, it is simply referred to asmodule.yaml
or./module.yaml
. Modules can also reference an HTTPS URL or an S3 URI as the source location.Modules can contain other modules, with no enforced maximum limit on nesting. Modules are not allowed to refer to themselves directly or in cycles. Module A can’t import Module A, and it can’t import Module B if that module imports Module A.
Transforms
The
Transform
section of the module, if there is one, is emitted into the parent, merging with existing transforms if necessary. Modules don’t do anything special with transforms, since these need to run on the server.ForEach and Fn::ForEach
Modules support a basic form of looping/foreach by either using the familiar
Fn::ForEach
syntax, or with a shorthand by adding aForEach
attribute to the module configuration. Special variables$Identifier
and$Index
can be used to refer to the value and list index. With the shorthand, or if you don't put the Identifier in the logical id, logical ids are auto-incremented by adding an integer starting at zero. Since this is a client-side-only feature, list values must be fully resolved scalars, not values that need to be resolved at deploy time.Assuming the module itself simply creates a bucket, the output would be:
It’s also possible to refer to elements within a
ForEach
using syntax like!GetAtt Content[0].Arn
for a single element, or!GetAtt Content[*].Arn
, which resolves to a list of all of theArn
outputs from that module. Single elements can also be referenced by the key:Content[A].Arn
. If usingFn::ForEach
, in this case,Content
refers to the loop name, not theOutputKey
.ForEach with Complex Objects
When ForEach receives a list of objects (such as from
Fn::Flatten
, described later in the doc), each object must have an Identifier property. In this case:$Identifier
refers to the value of the Identifier property$Value
provides access to the entire objectThis allows direct property access without requiring additional lookups:
ForEach with Resources
The ForEach attribute can also be applied to a Resource within a module. It works in an almost identical way to modules and can be considered a shorthand for the
Fn::ForEach
intrinsic function. It adds the ability to reference maps in addition to simple CSV lists, can be combined withFn::Flatten
, and gives you access to the enhancedGetAtt
functionality to refer to resource properties.Conditions
When a module is processed, the first thing that happens is parsing of the
Conditions
within the module. Any Resources, Modules, or Outputs marked with a false condition are removed, and any property nodes with conditions are processed. Any values of!Ref AWS::NoValue
are removed. Any unresolved conditions (for example, a condition that references a parameter in the parent template, or something likeAWS::Region
) are emitted into the parent template, prefixed with the module name.Mappings
The Mappings section of a module is processed, and if possible, any
Fn::FindInMap
functions are fully resolved. If they cannot be fully resolved, the mapping is emitted into the parent template with the module name as a prefix.References
Much of the value of module output is in the smart handling of
Ref
,Fn::GetAtt
, andFn::Sub
. For the most part, we want these to “just work” in the way that an author would expect them to. Since the logical IDs in the module result in a concatenation of the module name and the ID specified inside the module, we have to modify self-references within the module. And we have to fix up any references to parameter values, so that in the final rendered output, they match up with actual IDs and property names.From the parent, the author has two options for referring to module properties. If they know the structure of the module, they can use the predicted final name for resources, in which case we leave the strings alone. For example, in a module that creates a bucket, if the logical id within the module is
Bucket
and the name of the module in the parent isContent
, the author could write!Ref ContentBucket
or!GetAtt ContentBucket.BucketName
. The second, safer way is for the module author to specify an output reference calledName
that refs the bucket name, so the parent author could write!GetAtt Content.Name
. Module authors are encouraged to provideOutputs
that provide access to all needed values, but there will be cases when they cannot predict everything a consumer needs. ForSub
strings, if we can fully resolve the value, we get rid of theFn::Sub
and simply write the string to the output.Intrinsics
As a part of this design, we are adding support for intrinsic functions that do not exist for normal CloudFormation templates, and can only be processed by the
package
command. We also augment the behavior of some intrinsics. These functions are fully resolved locally and won’t need server-side support. (If we decide to add server-side support via S3 module Sources, the depth of support is an open question. We could still limit these intrinsics to the “packaging phase” in the API and not allow them after the packaged template is submitted.)Fn::GetAtt (Enhanced)
Fn::GetAtt
has added capabilities in modules. It is possible to refer to elements of a module that has been replicated withFn::ForEach
. For example,!GetAtt Content[0].Arn
references theArn
Output value from the first element of a ForEach calledContent
. You can also use the Identifier keys for the foreach, for example!GetAtt Content[A].Arn
.Content[*].Arn
returns a list of all of theArn
Output values.Fn::GetAtt
can also reference Parameters that hold Mappings. This is very similar toFn::FindInMap
functionality.!GetAtt MapName[*]
- Returns a list of keys in the map!GetAtt MapName[Key]
- Returns the entire object at that key!GetAtt MapName[Key].Attribute
- Returns the attribute of the object at that keyAn alternative valid syntax is to use dots instead of brackets.
!GetAtt MapName.*
!GetAtt MapName.Key
!GetAtt MapName.Key.Attribute
Fn::Merge (New)
Fn::Merge
merges the contents of multiple objects. This is useful for things like tagging, where each module might want to add a set of tags, in addition to any tags defined by the parent module. This pattern is ubiquitous in Terraform modules.Example merge usage:
Fn::InsertFile (New)
Fn::InsertFile
inserts the contents of a local file directly into the template as a string. This is a convenience that makes it easier to embed code such as lambda functions into a template. This function only makes sense client-side (or for the server-side use case where a user uploads an entire zip file and we package it for them).Fn::Invoke (New)
Fn::Invoke
allows modules to be treated sort of like functions. A module can be created with onlyParameters
andOutputs
, and by usingInvoke
later, you can get different outputs. An example use case is a module that creates standard resource names, accepting things likeEnv
andApp
andName
as parameters, and returning a concatenated string. The utility of this function is in treating snippets of code that are smaller than resources, like modules. An invoke-able module basically just has Parameters and Outputs, and the output conditionally differs depending on the inputs. How Fn::Invoke works with modulesFn::Flatten (New)
The Fn::Flatten function transforms complex nested data structures into simpler, flattened collections that can be processed with ForEach. It accepts a source map or list and provides options for data manipulation. It is very common in Terraform modules, especially with the
foreach
function, to flatten structures into a list of properties to iterate over when creating multiple resources of the same type.Key features include:
This capability simplifies module structure by eliminating the need for cascading module references to handle nested iterations.
Fully resolving intrinsics
Fn::Select
is collapsed if we can fully resolve the return value as a string. We do the same forFn::Join
andFn::FindInMap.
Metadata
When processing modules, the package command adds metadata to the template for metrics gathering, so that we know how many customers are using the feature. (NOTE: We will add more relevant data to this section)
Each rendered resource has an added Metadata property to indicate where it originated. This can be useful for tooling such as IDE integration, and for troubleshooting in the console. (NOTE: The format is being designed separately, to be compatible with CDK)
Packages
In order to reference a collection of modules, you can add a
Packages
section to the template. Packages are zip files containing modules. This allows a group of related modules to be packaged up and versioned as a unit. Modules within the package can refer to other modules in the package with a local path. This functionality will serve as the basis for a more robust dependency management system in the future.Packages are referenced by using an alias to the package name that starts with
$
.Appendix A - Module Spec
How to test this PR
Check out my branch and install the aws cli into a local Python environment.
Examples
See the test templates in this PR at
tests/unit/customizations/cloudformation/modules/
, a draft PR with a full serverless webapp here: aws-cloudformation/aws-cloudformation-templates#457, and a draft PR with a CodePipeline and VPC: aws-cloudformation/aws-cloudformation-templates#463