-
Notifications
You must be signed in to change notification settings - Fork 103
RFC 814: CDK Mixins #824
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: main
Are you sure you want to change the base?
RFC 814: CDK Mixins #824
Conversation
| todo: will fail if encountering construct it cannot deal with. use to ensure now surprises | ||
|
|
||
| ```ts | ||
| Mixins.of(scope, selector).mustApply(new EncryptionAtRest()); |
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 felt that it should be the other way around the .apply() will fail encountered a construct that's not supported, and another .mayApply() function that only maybe will apply.
This feeling come from as a customer expecting apply() should apply for everything and if it's not possible throw an error
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.
will fail encountered a construct that's not supported
I don't think that's what we're proposing. I think we're saying: "Must have applied to >=1 construct"
It might still track to have mustApply be the default...
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.
If the behavior is really "all selected constructs must support this", then basically every Mixins.of() call needs a selector.
Bucket already doesn't support CfnBucketPropsMixin... is that the experience we want?
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 was based on some feedback. When using EncryptionAtRest mixin, I want to make sure the mixin really is applied to all constructs that I expect it to be applied to. So for this example you are right: It is pointless without selector.
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.
3 options:
- must apply to everything
- must apply to at least one
- apply to only supported (maybe none)
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.
mustApply only makes sense with selector.
other two are okay without selector.
fully optional case will warn if not applied
Is this just a naming question? What are the 3 cases called?
| // The default is to apply to all constructs in the scope | ||
| Mixins.of( | ||
| scope, | ||
| ConstructSelector.all() // supports depth-first and breadth-first |
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.
What does depth-first and breadth-first mean here?
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.
Should we even get to specifying/picking traversal order?
(Probably we then also need preorder/postorder 😉 )
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.
Was thinking that all() has a bunch of options for people who really care.
| return new CfnResourceSelector(); | ||
| } | ||
|
|
||
| static resourcesOfType(type: string | Function): ConstructSelector { |
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.
What about resourcesOfTypes(type: string[] | Function[])?
EDIT: I feel a bit stupid now is those function coming out of the box from us to the customers or that's an example of how a customer using mixin 😅
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.
sure we can make that work
| #### Mixins declare which constructs they support | ||
|
|
||
| Mixins typically target specific resource constructs. | ||
| When applied to a construct tree, the mixing will check if a given construct is supported. | ||
| Only when supported applies, otherwise skip. | ||
|
|
||
| ```ts | ||
| bucketAccessLogsMixin.supports(bucket); // true | ||
| bucketAccessLogsMixin.supports(queue); // false | ||
| ``` |
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.
Is there a way for users to know when they can safely remove an unused mixin?
IMixin.supports() is a runtime check so compilers/transpilers won't show anything at compile time (or via LSP feedback with things like yellow squiggly underlines in your editor of choice).
It seems like the current proposed Mixins.mustApply() is the way to detect this, so mixin application being optional sounds like an intended property. I suspect this is because L2 constructs may conditionally create an L1 construct so it's not possible to guarantee a mixin will always be used.
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.
Also planning to emit a warning for mixins that didn't apply to anything.
| // Alternative: select by CloudFormation resource type name | ||
| Mixins.of( | ||
| scope, | ||
| ConstructSelector.resourcesOfType("AWS::S3::Bucket") | ||
| ).apply(new EncryptionAtRest()); | ||
|
|
||
| // Apply to constructs matching a pattern | ||
| Mixins.of( | ||
| scope, | ||
| ConstructSelector.byId(/.*-prod-.*/) | ||
| ).apply(new ProductionSecurityMixin()); |
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.
If there's an L2 construct which internally generates multiple L1 constructs of the same type, would users have to know how the L1 construct IDs are being generated internally to select the right one?
Or would it be better for users to extend a mixin class and override the supports() function to do more complicated matching (e.g. type + value checks).
text/0814-cdk-mixins.md
Outdated
| class EnableVersioning implements IMixin { | ||
| supports(construct: IConstruct): boolean { | ||
| return construct instanceof s3.CfnBucket; | ||
| } | ||
|
|
||
| applyTo(bucket: IConstruct): IConstruct { | ||
| bucket.versioningConfiguration = { | ||
| status: "Enabled" | ||
| }; | ||
| return bucket; | ||
| } | ||
| } |
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 a mixin to support both L1 and L2 constructs, do we need something like:
class EnableVersioning implements IMixin {
supports(construct: IConstruct): boolean {
return construct instanceof s3.CfnBucket || construct instanceof s3.Bucket;
}
applyTo(bucket: IConstruct): IConstruct {
if (construct instanceof s3.CfnBucket) {
// ...
} else if (construct instanceof s3.Bucket) {
// ...
}
}
}What if the user-defined portions of mixins instead always operate on L1 constructs and any L2 constructs are automatically decomposed to their L1 constructs?
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.
Hi @commiterate, L2s already contain their L1s, so this already works!
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.
Sorry I didn't make myself clear enough. I'm aware that L2s do have their L1s exposed via IConstruct.node.children so the question is whether IMixin.supports/applyTo should only receive L1s (i.e. have intermediary logic which automatically decomposes higher level constructs into L1s) or should higher level constructs be passed in and the CDK should only provide a utility decomposition function they can call within these mixin methods?
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.
Excellent question @commiterate! Currently I have this goal split up into multiple places:
- The mixin itself should only support L1s.
IMixin.supportswill cause the the mixin to only be applied to the L1, even when given an L2. - I envisioned the default selector for constructs to be one that picks the current construct if it's an L1 or picks
IConstruct.node.childrenif that's an L1. This makes it however slightly awkward to apply mixins to L3s, so not sure. - Constructs implementing
.with()should either use the "magic selector" or simply pass their default resource
For mixin implementation, to recommendation would very strongly be to apply to L1s whenever possible.
| // calls can be chained | ||
| const bucket = new s3.CfnBucket(scope, "MyBucket"); | ||
| Mixins.of(bucket) | ||
| .apply(new EncryptionAtRest()) |
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.
Are we doing variadic functions?
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 think we want to. Downside is we cannot have optional props ever here.
text/0814-cdk-mixins.md
Outdated
| class EnableVersioning implements IMixin { | ||
| supports(construct: IConstruct): boolean { | ||
| return construct instanceof s3.CfnBucket; | ||
| } | ||
|
|
||
| applyTo(bucket: IConstruct): IConstruct { | ||
| bucket.versioningConfiguration = { | ||
| status: "Enabled" | ||
| }; | ||
| return bucket; | ||
| } | ||
| } |
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.
Hi @commiterate, L2s already contain their L1s, so this already works!
text/0814-cdk-mixins.md
Outdated
| // Apply to all resources of a specific type | ||
| Mixins.of( | ||
| scope, | ||
| ConstructSelector.resourcesOfType(s3.CfnBucket) |
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.
Does this require passing a class? Because that won't work in jsii.
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.
resourcesOfType(res: string | { readonly CFN_RESOURCE_TYPE_NAME: string })
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.
ConstructSelector.resourcesOfType(s3.CfnBucket.CFN_RESOURCE_TYPE_NAME)| // This is what `.with()` is using | ||
| Mixins.of( | ||
| bucket, | ||
| ConstructSelector.cfnResource() // provided CfnResource or a CfnResource default child |
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.
Not sure what this does/adds over supports() ?
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.
Use case is that I might want some buckets to be encrypted, but not others.
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.
Potential future: ConstructSelector.css("....") a CSS style syntax
select based on tags, or construct context values
For now provides an interfaces to turn a construct tree into an ordered list of constructs
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.
What's the point if with really?
// with on whole tree, must apply once
// fun.with(new PermissionsBoundaryMixin())
// Mixins.of(func).applyAtLeastOnce(new PermissionsBoundaryMixin());
// or
// typed, on L1 resource (same or default child) only, must applyThere 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.
Could ConstructSelector be a predicate. Input probably needs to be current construct plus context (root path etc)
| // The default is to apply to all constructs in the scope | ||
| Mixins.of( | ||
| scope, | ||
| ConstructSelector.all() // supports depth-first and breadth-first |
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.
Should we even get to specifying/picking traversal order?
(Probably we then also need preorder/postorder 😉 )
| CDK Mixins provide a new, advanced way to add functionality to through composable abstractions. | ||
| Unlike traditional L2 constructs that bundle all features together, Mixins allow you to pick and choose exactly the capabilities you need for constructs. | ||
|
|
||
| #### Key Benefits |
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.
We need some discussion of what they do and don't do.
Looks like:
- It can't automatically set required arguments.
- It doesn't do defaults.
Might be worth discussing up front?
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.
CfnFunctionProps.code is a problem as well because it is required and cannot be a mixin
| bucket.versioningConfiguration = { | ||
| status: "Enabled" | ||
| }; | ||
| return bucket; |
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.
Why does applyTo() return something? I was hoping to find the answer to that later on in the doc but didn't?
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 was the idea:
declare const bucket: CfnBucket;
const bucketWithPolicy: CfnBucket & HasResourcePolicy = new TypedMixin().applyTo(bucket);(Doesn't effect typing in with() or apply())
Application behavior of multiple .apply() vs. Tree and Selectors. Can we even return a changed construct?
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.
.nowGetValue(); ?
|
|
||
| These treadmills are unsustainable given AWS's pace of innovation (2,000+ features annually vs. 5 new CDK modules per year). | ||
|
|
||
| ### Why should we _not_ do this? |
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.
Something somewhere needs to discuss that L1 mixins allow desyncing the L1 state from the L2 state. We need to acknowledge that and formulate some thoughts around it.
"Don't do that if it hurts", "we will get to that later", ... something.
| The solution has four key components: | ||
|
|
||
| 1. **Mixin Interface**: A `.with(mixin)` method that allows composing functionality | ||
| 2. **Resource Traits**: Common interfaces (like `IEncryptable`) that enable cross-service abstractions |
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.
Could be discussed up top where you discuss new EncryptionAtRest, that it is supported by this interface.
| 3. **Addressable Resources**: Shared interfaces between L1s and L2s for interoperability | ||
| 4. **Automatic Generation**: Mixins generated from AWS service specifications | ||
|
|
||
| The implementation uses TypeScript's type system to ensure type safety while maintaining runtime flexibility. |
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.
If you say TypeScript you must say jsii 😉
| **Why Mixins Are Better**: Mixins provide the sophisticated features of enhanced L1s | ||
| while enabling composition and cross-service patterns that enhanced L1s cannot achieve. | ||
|
|
||
| #### 2. Modular L2 Redesign |
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.
Not too sure if there was something specific in mind for this but I'll just interpret this as any breaking L2 changes.
#657 (comment) proposes such a change so I'll just use this thread (or the closest substitute we can get with GitHub PR comments) to discuss that proposal and how this RFC addresses it.
The linked proposal essentially wants L2 constructs to be split into 2 parts:
- A "dumb/plain" dictionary/map of L1 constructs.
- Wiring/currying/overlay functions which operate on L1 constructs.
L2 constructs today are fairly opaque since they complect both of these concerns without exposing much of the underlying details. The child L1 constructs aren't exactly that convenient to access (need to use IConstruct.node.children and do a bunch of if...else with instanceof to restore type information).
For 1, the general idea is possible but the implementation detail of using a TS interface (i.e. just a plain dictionary/map) isn't really possible since the CDK is heavily class based which is inherited from constructs. Passing immutable maps like in Clojure is out of the question.
For 2, it's essentially saying to extract all of the L2 wiring logic outside of the L2 construct (e.g. into functions, mixins, or whatever is decided upon).
If we assume something like the current class-based mixin design is accepted, that means we'd have something like this for AWS::Lambda::Function:
import { Construct, IConstruct } from "constructs";
import { IMixin, Resource } from "@aws-cdk/core";
import { aws_iam } from "@aws-cdk";
// L1. Auto-generated from Cfn resource definitions.
export class CfnFunctionProps {
// ...
}
export class CfnFunction extends Construct {
// Resource attributes. Same as today.
public attr*: type;
// Constructor. Same as today.
constructor(scope: Construct, id: string, props: CfnFunctionProps) {
// ...
}
}
// L2. Manually written.
export interface FunctionExecutionRoleMixinProps {
function: CfnFunction;
role: aws_iam.CfnRole;
}
// I don't really know why this is a class. This can just be a function.
export class FunctionExecutionRoleMixin implements IMixin<FunctionExecutionRoleMixinProps> {
static apply(props: FunctionExecutionRoleMixinProps): FunctionExecutionRoleMixinProps {
// Add the Lambda-managed basic execution role policy.
props.function.managedPolicyArns = (props.function.managedPolicyArns ?? []) + [aws_iam.ManagedPolicy.fromAwsManagedPolicyName("AWSLambdaBasicExecutionRole").managedPolicyArn];
return props;
}
}
export interface FunctionComponents {
function: CfnFunction;
role: aws_iam.CfnRole;
}
// Same as today.
export interface FunctionProps {
// ...
}
export class Function extends Resource {
// L1 components.
public readonly components: FunctionComponents;
// Existing properties for backwards compatibility.
// Constructor.
constructor(scope: Construct, id: String, props: FunctionProps) {
this.components = {
function: new CfnFunction(scope, "function", {
// Wire in values from `props: FunctionProps`.
}),
role: props.role ?? new aws_iam.CfnRole(scope, "role", {
// Default function execution role if absent.
})
};
// Apply the function mixin. This is doing mutation in place, may not want this for the final design.
FunctionExecutionRoleMixin.apply({
function: this.components.function;
role: this.components.role;
});
}
// Existing methods for backwards compatibility.
//
// All of their logic (e.g. adding IAM policies for event sources) are
// extracted into mixins. The methods just apply the mixin for users.
}The L2 construct basically becomes a composition of L1s and default mixin applications. If users don't like the L2 construct, they're free to apply mixins directly to L1s they created or extract the L1s from the L2 and apply them.
|
|
||
| ### README: CDK Mixins | ||
|
|
||
| CDK Mixins provide a new, advanced way to add functionality to through composable abstractions. |
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.
| CDK Mixins provide a new, advanced way to add functionality to through composable abstractions. | |
| CDK Mixins provide a new, advanced way to add functionality through composable abstractions. |
| // Access day-one AWS features with abstractions | ||
| const bucketWithLatestFeature = new s3.CfnBucket(scope, "LatestBucket") | ||
| .with(new CfnBucketPropsMixin({ | ||
| // New CloudFormation property available immediately |
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.
ah i was wondering before reading this section that - why would user ever want to use auto-generated mixins on L1s
and here ru saying that once the feature is available in service spec, u can use the CfnXpropsMixin to set up new properties dynamically here, instead of waiting for the L1 property is available in CDK version after we release like this
my understanding correct?
text/0814-cdk-mixins.md
Outdated
| .with(new EncryptionAtRest({ algorithm: "AES256" })) | ||
| .with(new EncryptionAtRest({ algorithm: "aws:kms" })); // KMS wins | ||
|
|
||
| // Mixins could detect and react to conflicts, but this is generally not encouraged |
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.
why?
| .with(new SecurityCompliance()); | ||
| ``` | ||
|
|
||
| #### Mixins and Aspects |
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.
should we also talk abt its comparision to property injections? i know they are applied in different ways and serving mostly different purpose but isnt there overlap use cases?
### Reason for this change This PR implements the foundational infrastructure for the CDK Mixins framework, introducing a composable abstraction system for applying functionality to CDK constructs. It is based on the _current_ state of the [RFC](aws/aws-cdk-rfcs#824). While the RFC is not yet approved and finalized, this PR aims to implement it including all its flaws so we can move forward with other implementing depending on this. We will update the package as the RFC evolves. ### Description of changes **Core Framework:** - Implemented `IMixin` interface and `Mixin` base class for creating composable abstractions - Added `Mixins.of()` API for applying mixins to constructs with `apply()` and `mustApply()` methods - Created `ConstructSelector` for filtering constructs by type, ID pattern, or CloudFormation resource type - Added comprehensive error handling and validation support - Added `.with()` augmentation to constructs for fluent mixin application **Testing:** - Comprehensive unit tests for core framework, selectors, and all built-in mixins - Integration tests demonstrating real-world usage patterns - Property manipulation utility tests including edge cases **Documentation:** - Updated README with usage examples, API reference, and best practices - Added Rosetta fixture for documentation code examples ### Description of how you validated changes - All new code is covered by unit tests - Integration tests validate end-to-end functionality - Rosetta fixture ensures documentation examples are valid ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) --- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
### Reason for this change aws/aws-cdk-rfcs#824 CDK Mixins are composable, reusable abstractions that can be applied to any construct (L1, L2 or custom). They are breaking down the traditional barriers between construct levels, allowing customers to mix and match sophisticated features without being locked into specific implementations. ### Description of changes This PR makes the package public so it can be released. It also implements some small changes based on RFC feedback. Main functional changes are: - Removing `validate()` in favor of just throwing errors - Making `.with()`, `.apply()` and `.mustApply()` variadic ### Describe any new or updated permissions being added n/a ### Description of how you validated changes Unit tests. ### Checklist - [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md) ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
This is a request for comments about CDK Mixins: Composable Abstractions for AWS Resources. See #814 for additional details.
APIs are signed off by @rix0r.
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license