DEV Community

Kenta Goto for AWS Heroes

Posted on

3

Use Cases for Lazy in AWS CDK

What is Lazy

AWS CDK has a useful but not well-known feature called Lazy.

Lazy is a feature (class) for lazy evaluation.

For example, when specifying a value for a certain property of a Construct, you can use it in cases where you don't want to determine the value at the time of specification, but want to determine (resolve) the value when synthesis (synth) is completed.

Lazy evaluation is mainly achieved using the following methods:

  • Lazy.any()
  • Lazy.list()
  • Lazy.number()
  • Lazy.string()

AWS CDK Lazy Documentation

Specifically, you would write code like the following:

let actualValue: number;

new AutoScalingGroup(this, 'Group', {
  desiredCapacity: Lazy.number({
    produce() {
      return actualValue;
    },
  }),
});

actualValue = 10;
Enter fullscreen mode Exit fullscreen mode

Instead of directly specifying a value for the desiredCapacity (number type) of AutoScalingGroup, we specify the Lazy.number method (its return value).

This Lazy.number method is passed an object with a produce method that returns a number type value.

The predefined actualValue is returned by the produce method, and the value 10 is set to actualValue after the creation of the AutoScalingGroup instance, in this example on the last line.

As a result, although the value of actualValue is undetermined at the time of calling Lazy.number specified for desiredCapacity, this desiredCapacity will ultimately be set to the value 10 that was set to actualValue.

In this way, Lazy is useful in cases where you want to determine (resolve) values later rather than at the time of calling or definition.

This was just a simple example to explain Lazy, but let me give some examples of how it can actually be used when developing with CDK.


Practical Examples of Lazy

1. Used with add methods

The first example is a case commonly seen in the internal implementation of Constructs in CDK contributions.

For example, suppose you call a Construct called SomeConstruct within a Construct called MyConstruct.

The props of that SomeConstruct have an array property (someArray), and you prepare a property (myArray) in the props (MyConstructProps) of MyConstruct to pass to it.

export interface MyConstructProps {
  readonly myArray: string[];
}

export class MyConstruct extends Construct {
  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);

    new SomeConstruct(this, 'SomeConstruct', {
      someArray: props.myArray,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

However, in cases like this, you may have seen Constructs that provide methods like addFoo that add values to array properties.

By having this method, you can not only pass values to the props when creating a Construct instance, but also set values by calling this method later.

This expands the ways Constructs can be used in some cases.

export class MyConstruct extends Construct {
  private readonly myArray: string[];

  // ...
  // ...

  public addMyArray(value: string): void {
    this.myArray.push(value);
  }
}
Enter fullscreen mode Exit fullscreen mode

However, earlier we were passing props.myArray to the SomeConstruct called within MyConstruct, so whether you call the add method or not, only the value passed in props will be passed.

new SomeConstruct(this, 'SomeConstruct', {
  someArray: props.myArray,
});
Enter fullscreen mode Exit fullscreen mode

This is exactly where Lazy comes in handy.

By adding values to the internal property myArray through the addFoo method and passing it to SomeConstruct through Lazy, you can pass the value after calling the addFoo method rather than the value at the time of Construct creation (props value).

export interface MyConstructProps {
  readonly myArray: string[];
}

export class MyConstruct extends Construct {
  private readonly myArray: string[];

  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);

    // Set the value passed in props to myArray
    this.myArray = props.myArray;

    new SomeConstruct(this, 'SomeConstruct', {
      // The value after calling the add method is passed
      someArray: Lazy.list({
        produce: () => this.myArray,
      }),
    });
  }

  public addMyArray(value: string): void {
    this.myArray.push(value);
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Reversing reference order and circular dependencies

For the second example, Lazy can reverse the reference order between Constructs or enable circular dependencies in some cases.

As shown in the example below, suppose you have a Construct called MyFunction that contains a Lambda function and a Construct called MyApi that contains an API Gateway.

MyFunction requires arnForExecuteApi, which should be the ARN returned by MyApi in this CDK code.

On the other hand, MyApi requires func, which should be the Lambda function returned by MyFunction in this CDK code.

const fn = new MyFunction(this, 'MyFunction', {
  // Want the API's ARN (but this fn is required by the API)
  arnForExecuteApi: api.arnForExecuteApi, // `api` is still undefined, so this will error
});

const api = new MyApi(this, 'MyApi', {
  // Want the Function (but this API's ARN is required by the Function)
  func: fn.function,
});
Enter fullscreen mode Exit fullscreen mode

Even in cases where they reference each other like this, Lazy can reverse the reference order and create circular dependencies.

let arnForExecuteApi: string;

const fn = new MyFunction(this, 'MyFunction', {
  // Want the API's ARN (but this fn is required by the API)
  arnForExecuteApi: Lazy.string({
    produce: () => arnForExecuteApi,
  }),
});

const api = new MyApi(this, 'MyApi', {
  // Want the Function (but this API's ARN is required by the Function)
  func: fn.function,
});

arnForExecuteApi = api.arnForExecuteApi;
Enter fullscreen mode Exit fullscreen mode

However, as a note of caution, anything that creates circular dependencies in the CloudFormation template will result in errors and cannot be resolved.

A better way to think about this is that there are cases where you can have circular dependencies in CDK Constructs without creating circular dependencies in the CloudFormation template.

In the above example, the arnForExecuteApi of MyFunction (i.e., the API's ARN) was intended to be used in Lambda environment variables, and the func of MyApi was intended to be used as an API Gateway method.

In other words, while it's a circular reference as CDK code, it doesn't become a circular reference as a CloudFormation template because the relationship is AWS::ApiGateway::Method -> AWS::Lambda::Function -> AWS::ApiGateway::RestApi, so it doesn't error and can be realized.


3. Consolidating IAM PolicyStatements

The third example is similar to the first one, but this one targets IAM statements rather than array properties.

First, please look at the following example:

export class ... {
  // ...
  // ...
  public grantInvokeFromVpcEndpointsOnly(vpcEndpoint: ec2.IVpcEndpoint): void {
    this.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.AnyPrincipal()],
        actions: ['execute-api:Invoke'],
        resources: ['execute-api:/*'],
        effect: iam.Effect.DENY,
        conditions: {
          StringNotEquals: {
            'aws:SourceVpce': vpcEndpoint,
          },
        },
      }),
    );
    this.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.AnyPrincipal()],
        actions: ['execute-api:Invoke'],
        resources: ['execute-api:/*'],
        effect: iam.Effect.ALLOW,
      }),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The above is a code example assuming an API Gateway Construct, which is a method for "allowing connections only from VPC endpoints".

On the other hand, there may be cases where you want to connect to API Gateway from multiple VPC endpoints. In that case, you would call the grantInvokeFromVpcEndpointsOnly method multiple times.

api.grantInvokeFromVpcEndpointsOnly(vpcEndpoint1);
api.grantInvokeFromVpcEndpointsOnly(vpcEndpoint2);
Enter fullscreen mode Exit fullscreen mode

However, the important DENY part of the IAM statement generated here looks like this. This is a statement that denies endpoints "other than" the specified endpoint:

{
  principals: [new iam.AnyPrincipal()],
  actions: ['execute-api:Invoke'],
  resources: ['execute-api:/*'],
  effect: iam.Effect.DENY,
  conditions: {
  StringNotEquals: {
      'aws:SourceVpce': vpcEndpoint,
  },
}
Enter fullscreen mode Exit fullscreen mode

When this method is called twice as in the previous example, the first call generates a statement that denies everything except vpcEndpoint1, and the second call generates a statement that denies everything except vpcEndpoint2.

This means that in this case, connections from both vpcEndpoint1 and vpcEndpoint2 would be denied.

api.grantInvokeFromVpcEndpointsOnly(vpcEndpoint1); // vpcEndpoint2 is denied
api.grantInvokeFromVpcEndpointsOnly(vpcEndpoint2); // vpcEndpoint1 is denied
Enter fullscreen mode Exit fullscreen mode

For example, you could change the method's argument to an array of endpoints and pass them all together to combine them into a single conditions, but even then, if the method is called multiple times, IAM statements that deny each other would be generated, which would somewhat reduce usability.

So, let's use Lazy to handle such cases as well. (The next example has been changed to use an array argument as mentioned earlier.)

export class ... {
  // ...
  // ...
  private _allowedVpcEndpoints: Set<ec2.IVpcEndpoint> = new Set();

  public grantInvokeFromVpcEndpointsOnly(vpcEndpoints: ec2.IVpcEndpoint[]): void {
    vpcEndpoints.forEach(endpoint => this._allowedVpcEndpoints.add(endpoint));

    const endpoints = Lazy.list({
      produce: () => {
        return Array.from(this._allowedVpcEndpoints).map(endpoint => endpoint.vpcEndpointId);
      },
    });

    this.addToResourcePolicy(new iam.PolicyStatement({
      principals: [new iam.AnyPrincipal()],
      actions: ['execute-api:Invoke'],
      resources: ['execute-api:/*'],
      effect: iam.Effect.DENY,
      conditions: {
        StringNotEquals: {
          'aws:SourceVpce': endpoints,
        },
      },
    }));
    this.addToResourcePolicy(new iam.PolicyStatement({
      principals: [new iam.AnyPrincipal()],
      actions: ['execute-api:Invoke'],
      resources: ['execute-api:/*'],
      effect: iam.Effect.ALLOW,
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

First, we create a _allowedVpcEndpoints variable of type Set<ec2.IVpcEndpoint> and add endpoints to it each time the relevant method is called.

We use the Set type because it eliminates duplicates when the same thing is passed.

  private _allowedVpcEndpoints: Set<ec2.IVpcEndpoint> = new Set();

  public grantInvokeFromVpcEndpointsOnly(vpcEndpoints: ec2.IVpcEndpoint[]): void {
    vpcEndpoints.forEach(endpoint => this._allowedVpcEndpoints.add(endpoint));
Enter fullscreen mode Exit fullscreen mode

Then, we convert the value of _allowedVpcEndpoints to an array of vpcEndpointIds within the Lazy.list method. After that, the conditions of PolicyStatement receives endpoints, which is deferred by Lazy, meaning an array of endpoints after the method has been called multiple times.

This way, even if the relevant method is called multiple times, all endpoint arrays are consolidated into the conditions of a single IAM statement.

const endpoints = Lazy.list({
  produce: () => {
    return Array.from(this._allowedVpcEndpoints).map((endpoint) => endpoint.vpcEndpointId);
  },
});

this.addToResourcePolicy(
  new iam.PolicyStatement({
    principals: [new iam.AnyPrincipal()],
    actions: ['execute-api:Invoke'],
    resources: ['execute-api:/*'],
    effect: iam.Effect.DENY,
    conditions: {
      StringNotEquals: {
        'aws:SourceVpce': endpoints,
      },
    },
  }),
);
Enter fullscreen mode Exit fullscreen mode

Reference PR that actually addressed this with Lazy

Actually, this third example is based on content I commented on when reviewing a PR submitted to AWS CDK, which I wrote up as an output.

AWS CDK PR #32719

How PolicyStatements are consolidated

Here, let me talk about something slightly off-topic from the main subject of Lazy.

Looking closely at the previous code, when the relevant method is called multiple times, a PolicyStatement should be generated each time and passed to addToResourcePolicy each time.

However, in the final CloudFormation template that is generated, only one Deny Statement is generated, and multiple endpoints are consolidated in its Condition.

"Condition": {
  "StringNotEquals": {
   "aws:SourceVpce": [
    {
     "Ref": "Vpc1VpcEndpoint1A8BD3278"
    },
    {
     "Ref": "Vpc2VpcEndpoint2898EEC0D"
    }
   ]
  }
},
Enter fullscreen mode Exit fullscreen mode

The reason for this is that PolicyStatement in the PolicyDocument of the aws-iam module automatically consolidates duplicate or consolidatable statements during synthesis.

In this example, calling the relevant method twice generates two PolicyStatements, but both have endpoints that are deferred by Lazy passed to their conditions. Since both statements' endpoints ultimately resolve to the same value, they are consolidated into one Statement during synthesis.

CDK internal code for consolidation processing

By the way, as for where and how this IAM statement consolidation is performed in CDK's internal code, it's processed separately for regular policies and token policies in the final stage of synthesis in CDK. (What tokens are will be explained later.)

Consolidation of regular policy statements is performed in the resolve method of the PolicyDocument class, and this method ultimately calls the mergeStatements function to run the consolidation processing.

On the other hand, consolidation of token policy statements is performed in the postProcess method of the PostProcessPolicyDocument class.

This PostProcessPolicyDocument is generated at the end of the resolve method of the former PolicyDocument class and is executed collectively around the end of the synthesis processing.

Since the endpoints passed by Lazy in this case are tokens, the consolidation processing runs through the latter PostProcessPolicyDocument.

export class PostProcessPolicyDocument implements cdk.IPostProcessor {
  // ...
  // ...
  public postProcess(input: any, _context: cdk.IResolveContext): any {
    // ...
    // ...
    for (const statement of input.Statement) {
      const jsonStatement = JSON.stringify(statement);
      if (!jsonStatements.has(jsonStatement)) {
        uniqueStatements.push(statement);
        jsonStatements.add(jsonStatement);
      }
    }
Enter fullscreen mode Exit fullscreen mode

Validation

When validating properties used in lazy evaluation with Lazy, you need to be careful.

This is because when doing validation in CDK, you probably validate directly using if statements within the constructor.

For example, in the example from section 1 above, suppose you want to throw an error if the value passed to myArray is empty. In such a case, you might first write code like the following:

constructor(scope: Construct, id: string, props: MyConstructProps) {
  super(scope, id);

  this.myArray = props.myArray;

  if (this.myArray.length === 0) {
    throw new Error('myArray must not be empty');
  }
}
Enter fullscreen mode Exit fullscreen mode

On the other hand, even if you don't pass any values from props myArray, passing them later with the add method is a normal pattern. However, even in such normal patterns, validation code like the above will execute before the add method is called, resulting in an error.

In such cases, you use the addValidation method provided in the Node class.

This is a method for deferred execution of validation. In other words, it's validation that runs after the add method is called.

constructor(scope: Construct, id: string, props: MyConstructProps) {
  super(scope, id);

  this.myArray = props.myArray;

  // Pass an object of IValidation interface type with a validate method to addValidation
  this.node.addValidation({ validate: () => this.validateMyArray() });
}

// For readability, the actual validation content is written in a separate method
private validateMyArray(): string[] {
  const errors: string[] = [];
  if (this.myArray.length === 0) {
    errors.push('myArray must not be empty');
  }
  return errors;
}
Enter fullscreen mode Exit fullscreen mode

By the way, addValidation is executed in the validate phase of CDK's application lifecycle. The lifecycle is divided into 4 phases, and the validate phase is the 3rd one.

validate


Tokens

Token validation

Values returned by Lazy become values of a type called tokens.

Since tokens are special format values for values that are resolved later, directly comparing values returned by Lazy for validation may cause unexpected behavior.

Therefore, when validating, you need to confirm that the value to be compared is not a token before performing the comparison (i.e., you basically cannot validate tokens). Specifically, you can use the Token.isUnresolved method to determine whether something is a token.

if (!cdk.Token.isUnresolved(props.lifecycleDays) && props.lifecycleDays > 400) {
  throw new Error('Lifecycle days must be 400 days or less');
}
Enter fullscreen mode Exit fullscreen mode

For more details about tokens, please see the official documentation.

AWS CDK Tokens Documentation

Token resolution

Basically, tokens are automatically resolved internally by CDK at synthesis time, but you can also resolve values yourself at any timing.

To do this, you use the resolve method of the Stack class. This allows you to get the original value from the special format token.

export class MyConstruct extends Construct {
  constructor(scope: Construct, id: string, props: MyConstructProps) {
    super(scope, id);

    // ...
    // ...

    Stack.of(this).resolve(myToken);
  }
}
Enter fullscreen mode Exit fullscreen mode

However, there are probably very few cases where you would resolve tokens yourself and do something with them when writing CDK code. In CDK contributions, it's occasionally used, so knowing about it might be helpful at some point.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.