Skip to content

Commit

Permalink
feat(aspects): priority-ordered aspect invocation (#32097)
Browse files Browse the repository at this point in the history
Closes #21341

This feature was designed in [RFC648](aws/aws-cdk-rfcs#651)

### Reason for this change

The current algorithm for invoking aspects (see invokeAspects in [synthesis.ts](https://github.com/aws/aws-cdk/blob/8b495f9ec157c0b00674715f62b1bbcabf2096ac/packages/aws-cdk-lib/core/lib/private/synthesis.ts#L217)) does not handle all use cases — specifically, when an Aspect adds a new node to the Construct tree and when Aspects are applied out of order.

### Description of changes

This PR introduces a priority-based ordering system for aspects in the CDK to allow users to control the order in which aspects are applied on the construct tree. This PR also adds a stabilization loop for invoking aspects that can be enabled via the feature flag `@aws-cdk/core:aspectStabilization` - the stabilization loop ensures that newly added Aspects to the construct tree are visited and nested Aspects are invoked.

### Description of how you validated changes

Plenty of unit tests - see `aspects.test.ts`.

### 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*
  • Loading branch information
sumupitchayan authored Nov 29, 2024
1 parent 0e79874 commit 8ccdff4
Show file tree
Hide file tree
Showing 8 changed files with 1,359 additions and 22 deletions.
142 changes: 142 additions & 0 deletions packages/aws-cdk-lib/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1629,6 +1629,148 @@ scenarios are (non-exhaustive list):
valid)
- Warn if the user is using a deprecated API

## Aspects

[Aspects](https://docs.aws.amazon.com/cdk/v2/guide/aspects.html) is a feature in CDK that allows you to apply operations or transformations across all
constructs in a construct tree. Common use cases include tagging resources, enforcing encryption on S3 Buckets, or applying specific security or
compliance rules to all resources in a stack.

Conceptually, there are two types of Aspects:

- **Read-only aspects** scan the construct tree but do not make changes to the tree. Common use cases of read-only aspects include performing validations
(for example, enforcing that all S3 Buckets have versioning enabled) and logging (for example, collecting information about all deployed resources for
audits or compliance).
- **Mutating aspects** either (1.) add new nodes or (2.) mutate existing nodes of the tree in-place. One commonly used mutating Aspect is adding Tags to
resources. An example of an Aspect that adds a node is one that automatically adds a security group to every EC2 instance in the construct tree if
no default is specified.

Here is a simple example of creating and applying an Aspect on a Stack to enable versioning on all S3 Buckets:

```ts
import { IAspect, IConstruct, Tags, Stack } from 'aws-cdk-lib';
class EnableBucketVersioning implements IAspect {
visit(node: IConstruct) {
if (node instanceof CfnBucket) {
node.versioningConfiguration = {
status: 'Enabled'
};
}
}
}
const app = new App();
const stack = new MyStack(app, 'MyStack');
// Apply the aspect to enable versioning on all S3 Buckets
Aspects.of(stack).add(new EnableBucketVersioning());
```

### Aspect Stabilization

The modern behavior is that Aspects automatically run on newly added nodes to the construct tree. This is controlled by the
flag `@aws-cdk/core:aspectStabilization`, which is default for new projects (since version 2.172.0).

The old behavior of Aspects (without stabilization) was that Aspect invocation runs once on the entire construct
tree. This meant that nested Aspects (Aspects that create new Aspects) are not invoked and nodes created by Aspects at a higher level of the construct tree are not visited.

To enable the stabilization behavior for older versions, use this feature by putting the following into your `cdk.context.json`:

```json
{
"@aws-cdk/core:aspectStabilization": true
}
```

### Aspect Priorities

Users can specify the order in which Aspects are applied on a construct by using the optional priority parameter when applying an Aspect. Priority
values must be non-negative integers, where a higher number means the Aspect will be applied later, and a lower number means it will be applied sooner.

By default, newly created nodes always inherit aspects. Priorities are mainly for ordering between mutating aspects on the construct tree.

CDK provides standard priority values for mutating and readonly aspects to help ensure consistency across different construct libraries:

```ts
/**
* Default Priority values for Aspects.
*/
export class AspectPriority {
/**
* Suggested priority for Aspects that mutate the construct tree.
*/
static readonly MUTATING: number = 200;
/**
* Suggested priority for Aspects that only read the construct tree.
*/
static readonly READONLY: number = 1000;
/**
* Default priority for Aspects that are applied without a priority.
*/
static readonly DEFAULT: number = 500;
}
```

If no priority is provided, the default value will be 500. This ensures that aspects without a specified priority run after mutating aspects but before
any readonly aspects.

Correctly applying Aspects with priority values ensures that mutating aspects (such as adding tags or resources) run before validation aspects. This allows users to avoid misconfigurations and ensure that the final
construct tree is fully validated before being synthesized.

### Applying Aspects with Priority

```ts
import { Aspects, Stack, IAspect, Tags } from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';
class MyAspect implements IAspect {
visit(node: IConstruct) {
// Modifies a resource in some way
}
}
class ValidationAspect implements IAspect {
visit(node: IConstruct) {
// Perform some readonly validation on the cosntruct tree
}
}
const stack = new Stack();
Aspects.of(stack).add(new MyAspect(), { priority: AspectPriority.MUTATING } ); // Run first (mutating aspects)
Aspects.of(stack).add(new ValidationAspect(), { priority: AspectPriority.READONLY } ); // Run later (readonly aspects)
```

### Inspecting applied aspects and changing priorities

We also give customers the ability to view all of their applied aspects and override the priority on these aspects.
The `AspectApplication` class represents an Aspect that is applied to a node of the construct tree with a priority.

Users can access AspectApplications on a node by calling `applied` from the Aspects class as follows:

```ts
const app = new App();
const stack = new MyStack(app, 'MyStack');
Aspects.of(stack).add(new MyAspect());
let aspectApplications: AspectApplication[] = Aspects.of(root).applied;
for (const aspectApplication of aspectApplications) {
// The aspect we are applying
console.log(aspectApplication.aspect);
// The construct we are applying the aspect to
console.log(aspectApplication.construct);
// The priority it was applied with
console.log(aspectApplication.priority);
// Change the priority
aspectApplication.priority = 700;
}
```

### Acknowledging Warnings

If you would like to run with `--strict` mode enabled (warnings will throw
Expand Down
108 changes: 100 additions & 8 deletions packages/aws-cdk-lib/core/lib/aspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,39 @@ export interface IAspect {
visit(node: IConstruct): void;
}

/**
* Default Priority values for Aspects.
*/
export class AspectPriority {
/**
* Suggested priority for Aspects that mutate the construct tree.
*/
static readonly MUTATING: number = 200;

/**
* Suggested priority for Aspects that only read the construct tree.
*/
static readonly READONLY: number = 1000;

/**
* Default priority for Aspects that are applied without a priority.
*/
static readonly DEFAULT: number = 500;
}

/**
* Options when Applying an Aspect.
*/
export interface AspectOptions {
/**
* The priority value to apply on an Aspect.
* Priority must be a non-negative integer.
*
* @default - AspectPriority.DEFAULT
*/
readonly priority?: number;
}

/**
* Aspects can be applied to CDK tree scopes and can operate on the tree before
* synthesis.
Expand All @@ -24,7 +57,7 @@ export class Aspects {
public static of(scope: IConstruct): Aspects {
let aspects = (scope as any)[ASPECTS_SYMBOL];
if (!aspects) {
aspects = new Aspects();
aspects = new Aspects(scope);

Object.defineProperty(scope, ASPECTS_SYMBOL, {
value: aspects,
Expand All @@ -35,24 +68,83 @@ export class Aspects {
return aspects;
}

private readonly _aspects: IAspect[];
private readonly _scope: IConstruct;
private readonly _appliedAspects: AspectApplication[];

private constructor() {
this._aspects = [];
private constructor(scope: IConstruct) {
this._appliedAspects = [];
this._scope = scope;
}

/**
* Adds an aspect to apply this scope before synthesis.
* @param aspect The aspect to add.
* @param options Options to apply on this aspect.
*/
public add(aspect: IAspect) {
this._aspects.push(aspect);
public add(aspect: IAspect, options?: AspectOptions) {
this._appliedAspects.push(new AspectApplication(this._scope, aspect, options?.priority ?? AspectPriority.DEFAULT));
}

/**
* The list of aspects which were directly applied on this scope.
*/
public get all(): IAspect[] {
return [...this._aspects];
return this._appliedAspects.map(application => application.aspect);
}

/**
* The list of aspects with priority which were directly applied on this scope.
*
* Also returns inherited Aspects of this node.
*/
public get applied(): AspectApplication[] {
return [...this._appliedAspects];
}
}

/**
* Object respresenting an Aspect application. Stores the Aspect, the pointer to the construct it was applied
* to, and the priority value of that Aspect.
*/
export class AspectApplication {
/**
* The construct that the Aspect was applied to.
*/
public readonly construct: IConstruct;

/**
* The Aspect that was applied.
*/
public readonly aspect: IAspect;

/**
* The priority value of this Aspect. Must be non-negative integer.
*/
private _priority: number;

/**
* Initializes AspectApplication object
*/
public constructor(construct: IConstruct, aspect: IAspect, priority: number) {
this.construct = construct;
this.aspect = aspect;
this._priority = priority;
}

/**
* Gets the priority value.
*/
public get priority(): number {
return this._priority;
}
}

/**
* Sets the priority value.
*/
public set priority(priority: number) {
if (priority < 0) {
throw new Error('Priority must be a non-negative number');
}
this._priority = priority;
}
}
Loading

0 comments on commit 8ccdff4

Please sign in to comment.