cdklabs / decdk

Define AWS CDK applications declaratively
https://aws.amazon.com/cdk
Apache License 2.0
61 stars 2 forks source link

deCDK - Declarative CDK

experimental

Define AWS CDK applications declaratively.

This tool reads CloudFormation-like JSON/YAML templates which can contain both normal CloudFormation resources (AWS::S3::Bucket) and also reference AWS CDK resources (aws-cdk-lib.aws_s3.Bucket).

Getting Started

Install the AWS CDK CLI and the decdk tool:

npm i -g aws-cdk decdk

This is optional (but highly recommended): You can use decdk-schema to generate a JSON schema and use it for IDE completion and validation:

decdk-schema > cdk.schema.json

Okay, we are ready to begin with a simple example. Create a file called hello.json:

{
  "$schema": "./cdk.schema.json",
  "Resources": {
    "MyQueue": {
      "Type": "aws-cdk-lib.aws_sqs.Queue",
      "Properties": {
        "fifo": true
      }
    }
  }
}

Now, you can use it as a CDK app (you'll need to npm install -g aws-cdk):

$ cdk -a "decdk hello.json" synth
Resources:
  MyQueueE6CA6235:
    Type: AWS::SQS::Queue
    Properties:
      FifoQueue: true
    Metadata:
      aws:cdk:path: hello2/MyQueue/Resource

As you can see, the deCDK has the same semantics as a CloudFormation template. It contains a section for "Resources", where each resource is defined by a type and a set of properties. deCDK allows using constructs from AWS Construct Library in templates by identifying the class name (in this case aws-cdk-lib.aws_sqs.Queue).

When deCDK processes a template, it identifies these special resources and under-the-hood, it instantiates an object of that type, passing in the properties to the object's constructor. All CDK constructs have a uniform signature, so this is actually straightforward.

Development

Examples/Tests

When you build this module, it will produce a cdk.schema.json file at the root, which is referenced by the examples in the examples directory. This directory includes working examples of deCDK templates for various areas. We also snapshot-test those to ensure there are no unwanted regressions.

Running local code

You can execute decdk using the current TypeScript code, without having to build the project. Provide this as your app argument to cdk:

cdk -a "ts-node src/decdk.ts template.json" synth

Features

CloudFormation Resources

If deCDK doesn't identify a resource type as a CDK resource, it will just pass it through to the resulting output. This means that any existing CloudFormation/SAM resources (such as AWS::SQS::Queue) can be used as-is.

The decdk JSON schema will simply pass through any resources that have a type that includes ::, so don't expect any validation of raw CloudFormation resource properties.

Declaring constructs using method calls

In the Getting Started section you saw how to declare a construct using Properties, which are mapped to the construct's "Props" interface. This is the most common use case, but there are other ways of declaring constructs, using method calls.

Static factory methods

The CDK construct library offers many static factory methods to create constructs. Usually these are import methods, to incorporate existing constructs into your CDK application (e.g., aws-cdk-lib.aws_efs.AccessPoint.fromAccessPointId) . The declarative version of these methods uses the property Call:

{
  "Resources": {
    "MyAccessPoint": {
      "Call": {
        "aws-cdk-lib.aws_efs.AccessPoint.fromAccessPointId": "some-access-point-id"
      }
    }
  }
}

There are a number of things to notice in this example. First, there is no Type declaration. Remember that when you declare a construct using its properties, the type is required, since the set of properties alone is not enough to uniquely determine which construct you are trying to declare. But the return type of a method call is unambiguous and can be inferred by deCDK. Nonetheless, if you still want to declare the type, as a form of documentation, it's perfectly valid to do so. Just keep in mind that the declared type must match the method's return type. Otherwise, deCDK will stop synthesis and give you an error message.

The second point to notice is that the method name used here is actually its fully qualified name (FQN). Again, this has to do with being unambiguous. A method name, by itself, may refer to different methods, from different classes. The FQN, on the other hand, is unique.

The third point is related to the arguments passed to this method. The signature of fromAccessPointId has three parameters: scope, id and accessPointId, but we are only passing the third one: the access point ID. The first two are inferred by deCDK; for the scope, deCDK uses a stack that it creates during the processing of the template to which all constructs are added; for the ID, it uses the logical ID of the construct being declared ("MyAccessPoint" in this case).

If you need to provide these arguments explicitly, you can do it using the new (CDK specific) intrinsic function CDK::Args and pseudo parameter CDK::Scope. Rewriting the example above, but with all arguments being explicitly defined, it becomes:

{
  "Resources": {
    "MyAccessPoint": {
      "Call": {
        "aws-cdk-lib.aws_efs.AccessPoint.fromAccessPointId": {
          "CDK::Args": [
            {
              "Ref": "CDK::Scope"
            },
            "MyAccessPoint",
            "some-access-point-id"
          ]
        }
      }
    }
  }
}

CDK::Args is an intrinsic function that just returns the array passed to it. But it signals to deCDK to turn off its inference mechanism and just use the explicitly provided arguments. CDK::Scope is a reference to the internal stack.

And finally, look at the list of arguments in the first example. In the general case, you have to pass an array, in the same order as the declared parameters. But in cases where it's legal to pass a single argument (either because the other arguments were inferred or because there is only one required parameter), the list syntax may be omitted and the argument passed directly.

Instance factory methods

In addition to the static factory methods, there are also instance factory methods that produce constructs. For example, you can create a Lambda Alias by calling the method addAlias on an instance of a Function construct. The declarative way to call this method is:

Resources:
  Alias:
    Call:
      - MyFunction
      - addAlias: live

  MyFunction:
    Type: aws-cdk-lib.aws_lambda.Function
    Properties:
    # ... List of properties

The syntax is very similar to the static method case, with two differences:

  1. The value of Call must be an array, whose first element is the logical ID of the construct that is receiving this call
  2. You use the method's simple name, rather than its FQN.

You can also call instance methods in nested properties:

Resources:
  AllowFromEverywhere:
    Call:
      - Listener.connections # of type aws-cdk-lib.aws_ec2.Connections
      - allowDefaultPortFromAnyIpv4: Open to the world

CDK classes that are not constructs

In a CDK application, many of the objects you create and manipulate are not constructs; they are not added directly to a stack, and they don't correspond to a CloudFormation resource. For instance, to create an aws-cdk-lib.aws_cloudwatch.Dashboard, which is a construct, the CDK construct library provides the interface aws-cdk-lib.aws_cloudwatch.IWidget, with many non-construct implementations (AlarmStatusWidget, TextWidget etc). These classes abstract certain concepts and improve the ergonomics of the API.

If the class has a public constructor with one required parameter that is a props interface (e.g., TextWidget), you can pass them via Properties:

{
  "Resources": {
    "TitleWidget": {
      "Type": "aws-cdk-lib.aws_cloudwatch.TextWidget",
      "Properties": {
        "markdown": "# Operational Metrics"
      }
    },
    "ServiceDashboard": {
      "Type": "aws-cdk-lib.aws_cloudwatch.Dashboard",
      "Properties": {
        "widgets": [
          [
            {
              "Ref": "TitleWidget"
            }
          ]
        ]
      }
    }
  }
}

If no parameters are required, Properties can be omitted completely.

Work in progress: it is possible to support public constructors with two or more required parameters, but it requires careful design of the syntax. Still under investigation.

Notice the use of the Ref intrinsic function, that works basically the same way as in raw CloudFormation resources, although there are some nuances to this function in the context of deCDK, that are explained in References.

Non-construct objects can also be created with method calls:

Resources:
  BusinessLogic:
    Call:
      aws-cdk-lib.aws_lambda.Code.fromInline: "exports.handler = function(event, ctx, cb) { return cb(null, \"hi\"); }"

Inline calls

So far, we have seen how to create non-construct instances at the top level, with associated logical IDs, just like you would do for constructs. This is useful in cases where you want to create an instance to be reused later. More often than not, however, there is no need for re-use. A simpler alternative for these cases is to declare the instance in-line:

Resources:
  MyBucket:
    Type: aws-cdk-lib.aws_s3.Bucket

  MyLambda:
    Type: aws-cdk-lib.aws_lambda.Function
    Properties:
      runtime: NODEJS_16_X
      code:
        aws-cdk-lib.aws_lambda.Code.fromBucket: # inline call
          - Ref: MyBucket
          - handler.zip
      # ... other properties

The syntax in this case is almost the same as for declaring the object at the top level. The difference is that the context makes it clear that this is a method call. So there is no Call property wrapping it. The example above also shows the use of an enum-like class (aws-cdk-lib.aws_lambda.Runtime) for the runtime property. These are classes that have at least one static method. If the class also has static properties, the property names can be used to reference the value. In this example, the string "NODEJS_16_X" is interpreted as a property name (aws-cdk-lib.aws_lambda.Runtime.NODEJS_16_X). The same logic applies to proper enums, such as FunctionUrlAuthType:

Resources:
  MyFunction:
    Type: aws-cdk-lib.aws_lambda.Function
    Properties:
    # ... List of properties

  FunctionUrl:
    Call:
      - MyFunction
      - addFunctionUrl:
          authType: AWS_IAM # from enum FunctionUrlAuthType

References

The intrinsic function Ref can be used to reference any logical ID (the keys in the Resources section), with two exceptions:

Resources:
  ConfigureAsyncInvokeStatement:
    Call:
      - Alias # of type aws-cdk-lib.aws_lambda.Alias
      - configureAsyncInvoke:
          retryAttempts: 2

In this case, ConfigureAsyncInvokeStatement doesn't refer to anything. It is only necessary to keep the syntax for statements consistent with the other types of method calls.

Resources:
  Bucket:
    Type: "AWS::S3::Bucket"

  MyFunction:
    Type: "aws-cdk-lib.aws_lambda.Function",
    Properties:
      code:
        Ref: "Bucket" # an aws_s3.Bucket is expected here
      # ... other properties

Work in progress: We are investigating the possibility of supporting the second case.

Notably, CDK constructs, that is, types that extend cdk.Construct or interfaces that extend cdk.IConstruct, can be referenced from anywhere, even from CloudFormation resource declarations. In this case a reference to the underlying L1 CloudFormation resource is returned.

Resources:
  CdkBucket:
    Type: "aws-cdk-lib.aws_s3.CfnBucket"

  CloudFormationFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket:
          Ref: CdkBucket
        S3Key: mycode.zip
      # ... other properties

Polymorphism

Due to the decoupled nature of AWS, The AWS Construct Library highly utilizes polymorphism to expose rich APIs to users. In many cases, APIs would accept an interface of some kind, and various AWS services provide an implementation for that interface. deCDK is able to find all concrete implementation of an interface or an abstract class and offer the user an enum-like experience. The following example shows how this approach can be used to define AWS Lambda events:

{
  "Description": "A template creates a lambda function which can be invoked by an sns topic",
  "Resources": {
    "MyTopic": {
      "Type": "aws-cdk-lib.aws_sns.Topic"
    },
    "Table": {
      "Type": "aws-cdk-lib.aws_dynamodb.Table",
      "Properties": {
        "partitionKey": {
          "name": "ID",
          "type": "STRING"
        },
        "stream": "NEW_AND_OLD_IMAGES"
      }
    },
    "HelloWorldFunction": {
      "Type": "aws-cdk-lib.aws_lambda.Function",
      "Properties": {
        "handler": "app.hello_handler",
        "runtime": "PYTHON_3_6",
        "code": {
          "aws-cdk-lib.aws_lambda.Code.fromAsset": "examples/lambda-handler"
        },
        "environment": {
          "Param": "f"
        },
        "events": [
          {
            "aws-cdk-lib.aws_lambda_event_sources.DynamoEventSource": [
              {
                "Ref": "Table"
              },
              {
                "startingPosition": "TRIM_HORIZON"
              }
            ]
          },
          {
            "aws-cdk-lib.aws_lambda_event_sources.ApiEventSource": [
              "GET",
              "/hello"
            ]
          },
          {
            "aws-cdk-lib.aws_lambda_event_sources.ApiEventSource": [
              "POST",
              "/hello"
            ]
          },
          {
            "aws-cdk-lib.aws_lambda_event_sources.SnsEventSource": {
              "Ref": "MyTopic"
            }
          }
        ]
      },
      "Overrides": [
        {
          "ChildConstructPath": "ServiceRole",
          "Update": {
            "Path": "Properties.Description",
            "Value": "This value has been overridden"
          }
        }
      ]
    }
  }
}

The keys in the "events" array are all fully qualified names of classes in the AWS Construct Library. The declaration is Array<IEventSource>. When deCDK deconstructs the objects in this array, it will create objects of these types and pass them in as IEventSource objects.

Fn::GetAtt and CDK::GetProp

CloudFormation provides the Fn::GetAtt intrinsic function to access the value of resource attributes. With deCDK, this function works the same way, whether it's being referenced from a CloudFormation resource or from a CDK construct:

Resources:
  SourceBucket:
    Type: AWS::S3::Bucket

  DestinationBucket:
    Type: aws-cdk-lib.aws_s3.Bucket

  BucketDeployment:
    Type: aws-cdk-lib.aws_s3_deployment.BucketDeployment
    Properties:
      destinationBucket:
        Ref: DestinationBucket
      sources:
        - aws-cdk-lib.aws_s3_deployment.Source.jsonData:
            - 'my/config.json'
            - website_url:
                "Fn::GetAtt":
                  - SourceBucket # reference to a CloudFormation resource
                  - WebsiteUrl

CDK constructs can also be the target of Fn::GetAtt, in which case the attribute being referenced belongs to the construct's default child. For example, suppose you want to export the WebsiteUrl property of the DestinationBucket construct defined in the example above:

Resources:
# ... Same resources as in the previous example

Outputs:
  BucketUrl:
    Description: Website URL of the destination bucket
    Value:
      "Fn::GetAtt":
        - DestinationBucket # reference to a CDK construct
        - WebsiteUrl # but this is the name of a property of its underlying CFN resource 

To reference a property of a CDK construct itself, however, you have to use the new intrinsic function CDK::GetProp:

Resources:
  MyHandler:
    Type: aws-cdk-lib.aws_lambda.Function
    Properties:
    # ... List of properties

Outputs:
  HelloWorldApi:
    Description: API Gateway endpoint URL for Prod stage for Hello World function
    Value:
      CDK::GetProp:
        - MyHandler
        - runtime.name # nested properties are also allowed

CDK::GetProp also allows you to reference an element of an array, by its index:

Resources:
  MyVpc:
    Type: aws-cdk-lib.aws_ec2.Vpc
Outputs:
  SubnetOneAz:
    Value:
      CDK::GetProp: MyVpc.publicSubnets.0.availabilityZone

CDK::GetProp cannot be used with a CloudFormation resource as a target. This will always result in an error.

To sum up, the behavior is:

Intrinsic function Target is a CDK construct Target is a CFN resource
CDK::GetProp Returns the value of the property Error!
Fn::GetAtt Returns the value of the attribute of the underlying CFN resource (inferred from the defaultChild property) Returns the value of the attribute