Skip to content
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 local module support to the cloudformation package command #9124

Draft
wants to merge 64 commits into
base: develop
Choose a base branch
from

Conversation

ericzbeard
Copy link

@ericzbeard ericzbeard commented Dec 6, 2024

This PR adds the ability to write local CloudFormation modules, which are YAML files that contain Resources. These modules can be included in a parent template in a new Modules section. Modules are configured by a combination of formal Parameters (which coincide with the Properties defined in the parent) and an Overrides attribute, which allows users full control over the rendered output. This is a port from the functionality that exists in the Rain CLI. See the snippets below for a basic example.

Parent template

Modules:
  Content:
    Source: ./basic-module.yaml
    Properties:
      Name: foo
    Overrides:
      Bucket:
        Properties:
          OverrideMe: def

basic-module.yaml

Parameters:
  Name:
    Type: String
Resources:
  Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref Name
      OverrideMe: abc

Output from the package command

Resources:
  ContentBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: foo
      OverrideMe: def

Design Decisions

  1. Modules can be imported into a template (or into a module itself) from a new Modules section. In Rain and in registry modules, this section does not exist. Instead, the imported content is configured in a resource and the source of the module is embedded in the Type attribute. The benefit of using a Modules section is that it will be more intuitive if we decide to allow injecting other sections other than Resources, like Outputs.

  2. Logical IDs are a concatenation of the module name and the logical id defined in the module. Unlike registry modules, users do not have the option of referring to the resources with a dot between the names.

  3. The Overrides section is not considered an escape hatch of last resort. It is an encouraged way to configure modules without requiring the module author to anticipate every possible need. IaC abstractions are leaky by nature and we expect users to understand the internals of any module they import. Deciding how many actual Inputs to include in a module is highly opinionated and left up to authors. One caveat here is that Overrides are fragile, since they will break if the module author changes any of the logical IDs in the module. See item 5, References, as a way to mitigate this.

  4. This functionality has been added to the aws cloudformation package command. We could have created a new dedicated command for module packaging, like synth. This is an easily reversible decision. The code is ported from the Rain pkg command, which was originally written to mimic the behavior of aws cloudformation package, so it seems like the right place to put it. We removed the --s3-bucket requirement from the command, waiting to check for that parameter when we see that you need to upload something to S3.

  5. Modules can define Outputs, which are key-value pairs that can be referenced by the parent. This gives module authors a formal way to pass values to the consumer without them needing to rely on knowing the internal structure of the module, which will be fragile if the module author changes any of the logical IDs.

  6. Instead of using Fn::ForEach as a way of implementing loops, we added a Map attribute to a local module resource. The module output is repeated for each element in the map, substituting $MapValue and $MapIndex in module Properties. This can only be used with lists that can be resolved at build time.

  7. The PR also adds a new Constants section to templates and modules. This feature is not absolutely necessary for an implementation of modules, but it serves a similar purpose of significantly reducing copy-paste when authoring CloudFormation.

Additional features to add on this PR or in the future

[ ] - Download modules from S3
[ ] - ForEach support within a module and in the parent template
[ ] - Module package support like in Rain, with an alias to a zip file
[ ] - SAM Globals or some sort of "Aspects" that allow the user to apply patterns to many resources at once.
[ ] - A way to apply a set of tags to all resources in a module
[ ] - Prevent cycles when Module A -> B -> A

How to test this PR

Check out my branch and install the aws cli into a local Python environment.

git clone [email protected]:ericzbeard/aws-cli.git
git fetch origin cfn-modules
git checkout cfn-modules
python3 -m venv .env # Or whatever you use for python environments
source .env/bin/activate
pip install -e .
aws cloudformation package \
    --template-file tests/unit/customizations/cloudformation/modules/type-template.yaml

Examples

See the test templates in this PR at tests/unit/customizations/cloudformation/modules/ and also a draft PR with a full serverless webapp here: aws-cloudformation/aws-cloudformation-templates#457

@ericzbeard ericzbeard changed the title Add local module support to the package command Add local module support to the cloudformation package command Dec 6, 2024
@codecov-commenter
Copy link

codecov-commenter commented Dec 6, 2024

Codecov Report

Attention: Patch coverage is 0% with 929 lines in your changes missing coverage. Please review.

Project coverage is 0.07%. Comparing base (96f96b8) to head (bd652a0).
Report is 35 commits behind head on develop.

Files with missing lines Patch % Lines
awscli/customizations/cloudformation/modules.py 0.00% 493 Missing ⚠️
awscli/customizations/cloudformation/parse_sub.py 0.00% 85 Missing ⚠️
...wscli/customizations/cloudformation/module_maps.py 0.00% 83 Missing ⚠️
...customizations/cloudformation/module_conditions.py 0.00% 77 Missing ⚠️
.../customizations/cloudformation/module_constants.py 0.00% 61 Missing ⚠️
...customizations/cloudformation/artifact_exporter.py 0.00% 33 Missing ⚠️
...wscli/customizations/cloudformation/module_read.py 0.00% 33 Missing ⚠️
...scli/customizations/cloudformation/module_merge.py 0.00% 26 Missing ⚠️
...li/customizations/cloudformation/module_visitor.py 0.00% 19 Missing ⚠️
awscli/customizations/cloudformation/package.py 0.00% 8 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##           develop   #9124      +/-   ##
==========================================
- Coverage     0.08%   0.07%   -0.01%     
==========================================
  Files          210     218       +8     
  Lines        16984   17904     +920     
==========================================
  Hits            14      14              
- Misses       16970   17890     +920     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link

@kddejong kddejong left a 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.

overrides = resource_overrides[attr_name]
resource[attr_name] = merge_props(original, overrides)

def resolve(self, k, v, d, n):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like there are better flows here. You pass in k,v but also the dict and parent k that holds k, v? From what I can tell the big reason for that is to switch an object using an intrinsic function back into a value. You are taking advantage of the fact that dict/list are sent by reference. It feels like an approach that does a return instead of relying on a reference would read better.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean something like d[n] = self.resolve(k, v)? Maybe that is better. But I am hesitant to change it, this was the trickiest piece of code to get right.

@benbridts
Copy link

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. !GetAtt ContentBucket.Arn, which means:

  • the module author can't change any resource name in the template
  • (not a problem now, but possibly when extending to allow conditions) there is no way to have to different resources cover the same generic module. Imagine e.g a ApiGateway module that creates an HTTP or REST api, depending on a parameter/condition.
  • Even if users are not using any overrides, they would need to know the resources (and names) created by the template - I thing the same argument as why this is not a problem with overrides can be applied here, but if you see overrides as a more advanced use case, it might not.

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

@kddejong
Copy link

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.

@ericzbeard
Copy link
Author

Having an "outputs" or "outputs" equivalent could solve this.

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.

@ericzbeard
Copy link
Author

Having an "outputs" or "outputs" equivalent could solve this.

Implemented here: 160be7c

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants