AWS Management & Governance Blog

Introducing TypeScript support for building AWS CloudFormation resource types

If you’ve authored private resource types to extend the AWS CloudFormation registry, you might have used Java, Python, or Go, which, until now, were our officially supported languages.

In this blog post, we will show you how to create a private resource type using TypeScript, the latest addition to our growing list of officially supported languages. The example resource type used in this post, a New Relic monitor that checks the state of your web application is available on GitHub, so you can download the code and try it right away.

We will be using the AWS CloudFormation CLI, an open source tool that helps author private resource types by providing scaffolding code, a testing framework, and a packaging and registration tool.  The CloudFormation CLI uses a language plugin system to support multiple languages. When we first released the CloudFormation CLI, we provided a plugin for Java. That was followed by support for Go and Python, which were written by internal teams. The TypeScript plugin was written by Eduardo de Moura Rodrigues, a member of the open source community. It is now officially supported by the CloudFormation team. Thank you, Eduardo!

Prerequisites

To use the TypeScript plugin with the example used in this post, you will need the following:

Installation

  1. Use pip to add TypeScript support for the CloudFormation CLI.
    pip3 install cloudformation-cli-typescript-plugin
  2. Run the cfn init command to validate that the TypeScript plugin is recognized by the CloudFormation CLI.
    cfn init --artifact-type RESOURCE --type-name Example::Monitoring::Website
  3. The wizard prompts you to select a language plugin. Choose TypeScript:
    Select a language for code generation:
    [1] java
    [2] typescript
    (enter an integer):
    >> 2
    

    Now that you have confirmed the TypeScript plugin installation is properly installed, use CTRL+C to cancel the project initialization wizard.

Walkthrough of a sample TypeScript project

In this walkthrough, you create a resource type that provisions a New Relic ping monitor that is used to verify that a web application is online.

Create a directory and clone the repository that contains this example.

mkdir aws-cloudformation-samples 
cd aws-cloudformation-samples
git clone https://github.com/aws-cloudformation/aws-cloudformation-samples.git
cd aws-cloudformation-samples/resource-types/typescript-example-website-monitor

Add dependencies

Although you won’t be making AWS API calls in this example, it’s a good practice to have a development dependency on the AWS SDK for JavaScript.

  1. In your IDE, open the project’s package.json file.
  2. (Optional) Edit the description field.
  3. In the development dependencies section, change the AWS SDK to your preferred semantic version.
    "devDependencies": {
        "@types/node": "^12.0.0",
        "typescript": "^4.0.0",
        "aws-sdk": "^2.656.0"
    }
  4. You are now ready to install the dependencies and generate the lock file.
    npm install --optionalNote: You can reduce the package size by not including the AWS SDK, because it’s already installed in the resource provider runtime. If you choose this option, use npm install --no-optional to install the dependencies instead. For information about how to add dependencies, see the NPM documentation.

Resource model

Now that you’ve cloned the project, let’s take a look at the model to get familiar with the resource type.

  1. In your IDE, open example-monitoring-website.json.
  2. This schema defines a resource, Example::Monitoring::Website, that provisions a monitoring solution using New Relic to ping a website and check if it’s available. The resource contains ten properties, five of which can be set by users: ApiKey, EndpointRegion, Name, Uri, and Frequency. The other properties are read-only (meaning users can’t set them) and are assigned during resource creation. The Name property is listed  in the createOnlyProperties section, because, if changed, it will cause a new resource to be created. The Id property serves as the primary identifier for the resource when it is provisioned.
    {
        "typeName": "Example::Monitoring::Website",
        "description": "During the creation of a simple website you may want to provision a third-party website monitor, which has a public API.",
        "properties": {
            "ApiKey": {
                "description": "API Key that allows using the REST API on the monitors of an account.",
                "type": "string"
            },
            "EndpointRegion": {
                "description": "The region from the account, which will influence the endpoint to be called: https://synthetics.newrelic.com/synthetics/api (US - default) or https://synthetics.eu.newrelic.com/synthetics/api (EU).",
                "type": "string"
            },
            "Name": {
                "description": "The friendly name of the website monitor.",
                "type": "string"
            },
            "Uri": {
                "description": "The URI of your website that will be monitored.",
                "type": "string"
            },
            "Frequency": {
                "description": "The frequency interval for the monitoring check (in minutes). Default is 5 minutes.",
                "type": "integer"
            },
            ...
        },
        "required": [
            "ApiKey",
            "Name",
            "Uri"
        ],
        "createOnlyProperties": [
            "/properties/Name"
        ],
        "readOnlyProperties": [
            "/properties/Id",
            "/properties/Kind",
            "/properties/Locations",
            "/properties/Status",
            "/properties/SlaThreshold"
        ],
        "primaryIdentifier": [
            "/properties/Id"
        ],
        "additionalIdentifiers": [
            [ "/properties/Name" ]
        ],
        ...
    }
    
  3. Use cfn generate to validate the schema and update the auto-generated files in the resource type package to reflect any change you made to the resource type schema.
    cfn generate

    A message is returned indicating it has generated files for the resource type:

    Generated files for Example::Monitoring::Website

Resource handlers

Now that you have defined the resource type schema, you can start writing the code for the event handlers.

In our example resource, we implement the create, update, and delete operation handlers and leave the read handler to return static data only. To simplify the development, all event handler code is in a single file, handlers.ts, located in the src/ folder.  CloudFormation events are routed using a @handlerEvent decorator.

Implement the create handler

CloudFormation invokes this handler when the resource is initially created during stack create operations.

  1. In your IDE, open the handlers.ts file, located in the src/ folder.
  2. Find the create method within the Resource class. It is important to create a new instance of the model, because the desired state is immutable.
    const model = new ResourceModel(request.desiredResourceState);

    Because Id is a read-only property, it can’t be set during create or update operations.

    if (model.id) {
        throw new exceptions.InvalidRequest('Read only property [Id] cannot be provided by the user.');
    }
    

    Set or fall back to the default values for each property.

    model.frequency = model.frequency || Integer(5);
    model.endpointRegion = model.endpointRegion || 'US';
    model.kind = Resource.DEFAULT_MONITOR_KIND;
    model.status = Resource.DEFAULT_MONITOR_STATUS;
    model.locations = Resource.DEFAULT_MONITOR_LOCATIONS;
    model.slaThreshold = Resource.DEFAULT_MONITOR_SLA_THRESHOLD;

    Use the New Relic API to create a New Relic synthetics monitor.

    const apiKey = model.apiKey;
    const apiEndpoint = ApiEndpoints[model.endpointRegion as EndpointRegions];
    const createResponse: Response = await fetch(`${apiEndpoint}/v3/monitors`, {
        method: 'POST',
        headers: { ...Resource.DEFAULT_HEADERS, 'Api-Key': apiKey },
        body: JSON.stringify({
            name: model.name,
            uri: model.uri,
            type: model.kind,
            frequency: model.frequency,
            status: model.status,
            locations: model.locations,
            slaThreshold: model.slaThreshold
        } as Monitor)
    });
    await this.checkResponse(createResponse, logger, request.logicalResourceIdentifier);
    

    Use the address from the location header to retrieve the ID of the newly created monitor and store it in your resource model.

    const locationUrl = createResponse.headers.get('location');
    if (!locationUrl) {
        throw new exceptions.NotFound(this.typeName, request.logicalResourceIdentifier);
    }
    const response: Response = await fetch(locationUrl, {
        method: 'GET',
        headers: { ...Resource.DEFAULT_HEADERS, 'Api-Key': apiKey }
    });
    const monitor: Monitor = await this.checkResponse(response, logger, request.logicalResourceIdentifier);
    model.id = monitor.id;
    

    By setting progress.status to success, you signal to CloudFormation that the operation is complete.

    progress.status = OperationStatus.Success;

Implement the read handler

CloudFormation invokes this handler as part of a stack update operation when detailed information about the resource’s current state is required.

  1. In your IDE, open the handlers.ts file located in the src/ folder.
  2. Find the read method in the Resource class.  The handler code returns the static values and the resource’s unique identifier.
    model.kind = Resource.DEFAULT_MONITOR_KIND; 
    model.status = Resource.DEFAULT_MONITOR_STATUS; 
    model.locations = Resource.DEFAULT_MONITOR_LOCATIONS; 
    model.slaThreshold = Resource.DEFAULT_MONITOR_SLA_THRESHOLD;

Implement the update handler

CloudFormation invokes this handler when the resource is updated as part of a stack update operation.

  1. In your IDE, open the handlers.ts file located in the src/ folder.
  2. Find the update method in the Resource class.

Name is a create only property, which means that it shouldn’t be updated. If the Name property does not match the name stored in your model, the exceptions object throws an exception.

The exceptions object returns a ProgressEvent to CloudFormation, with an OperationsStatus of FAILED and a HandlerErrorCode. In this case, an error code of NotUpdatable is returned, which causes CloudFormation to roll back the stack and advise the user of this error condition.

if (model.name !== name) { 
  logger.log(this.typeName, `[NEW ${model.name}] [${request.logicalResourceIdentifier}] does not match identifier from saved resource [OLD ${name}].`); 
  throw new exceptions.NotUpdatable('Create only property [Name] cannot be updated.'); 
}

After the Name property is validated, the synthetics monitor is updated by calling the endpoint using the Id.

const response: Response = await fetch(`${apiEndpoint}/v3/monitors/${id}`, {
    method: 'PUT',
    headers: { ...Resource.DEFAULT_HEADERS, 'Api-Key': apiKey },
    body: JSON.stringify({
    name,
    uri: model.uri,
    type: model.kind,
    frequency: model.frequency,
    status: model.status,
    locations: model.locations,
    slaThreshold: model.slaThreshold
    } as Monitor)
});
await this.checkResponse(response, logger, id);

For convenience, you can use the success method to create a successful ProgressEvent object, which signals to CloudFormation that your update operation was completed successfully.

const progress = ProgressEvent.success<ProgressEvent<ResourceModel>>(model);

Implement the delete handler

CloudFormation invokes this handler when the resource is deleted, either when the resource is deleted from the stack as part of a stack update operation or when the stack itself is deleted.

  1. In your IDE, open the handlers.ts file, located in the src/ folder.
  2. Find the delete method within the Resource class.

The Id property is the primary identifier, so it cannot be left empty.  If left empty, our code throws an exception using the NotFound handler error code.

if (!model.id) { 
  throw new exceptions.NotFound(this.typeName, request.logicalResourceIdentifier); 
}

If you have an Id value in your model, our code deletes the monitor using the New Relic API.

const response: Response = await fetch(`${apiEndpoint}/v3/monitors/${model.id}`, { 
  method: 'DELETE', 
  headers: { ...Resource.DEFAULT_HEADERS, 'Api-Key': model.apiKey }
  }); 
await this.checkResponse(response, logger, model.id);

Test the resource type

Use the AWS SAM CLI to test that your resource will work as expected after you submit it to the CloudFormation registry. To do this, define tests for AWS SAM to run against your create, update, delete, and read handlers. Testing with AWS SAM requires Docker, so be sure that it’s running on your computer.

Use npm to build your code.

npm run build

This command invokes the TypeScript compiler with the configuration specified in tsconfig.json.

You need your New Relic account and API key for the following steps.

Test the create handler

  1. Open the create.json file located in the sam-tests/ folder and replace the placeholders with your API key and endpoint region (either US or EU):
    {
        "action": "CREATE",
        "request": {
            "desiredResourceState": {
                "ApiKey": "<MY_API_KEY_HERE>",
                "EndpointRegion": "<MY_REGION_HERE>",
                "Name": "MyWebsiteMonitor",
                "Uri": "https://aws.amazon.com"
            },
            ...
        },
        ...
    }
    
  2. Invoke the SAM function (from the resource package root directory):
    sam local invoke TestEntrypoint --event sam-tests/create.json 

    You might be wondering what the TestEntryPoint is. The TypeScript plugin has two entry points: production and test. The production entry point is used when CloudFormation invokes the handler code. The test entry point has less overhead and is better suited for local testing.  Because we’re running local tests, we specify the TestEntryPoint. After the resource provisioning is complete, the test returns a response with a status of SUCCESS. For example:

    {
    	"callbackDelaySeconds": 0,
    	"resourceModel": {
        	"Id":"MY_RESOURCE_ID",
        	"ApiKey": "MY_API_KEY",
        	"EndpointRegion": "MY_REGION",
        	"Name": "MyWebsiteMonitor",
        	"Uri": "https://aws.amazon.com",
        	"Frequency": 10
    	},
    	"status": "SUCCESS"
    }
    

Make a note of the Id that is returned. You will need it to test the create, read, and delete handlers.

Test the read handler

  1. Open the read.json file located in the sam-tests/and replace the placeholder with the Id for your resource:
    {
        "action": "READ",
        "request": {
            "desiredResourceState": {
                "Id": "<MY_RESOURCE_ID_HERE>"
            },
            ...
        },
        ...
    }
    
  2. Use the following command to invoke the SAM function from the resource package root directory:
    sam local invoke TestEntrypoint --event sam-tests/read.json

    After the resource data has been retrieved, the test returns a response with a status of SUCCESS.

Test the update handler

  1. Open the update.json file located in the sam-tests/ folder and replace the placeholders with the Id, API key, and endpoint region (either US or EU) of your resource:
    {
        "action": "UPDATE",
        "request": {
            "desiredResourceState": {
                "Id": "<MY_RESOURCE_ID_HERE>",
                "ApiKey": "<MY_API_KEY_HERE>",
                "EndpointRegion": "<MY_REGION_HERE>",
                "Name": "MyWebsiteMonitor",
                "Uri": "https://aws.amazon.com",
                "Frequency": 30
            },
            "previousResourceState": {
                "Id": "<MY_RESOURCE_ID_HERE>",
                "ApiKey": "<MY_API_KEY_HERE>",
                "EndpointRegion": "<MY_REGION_HERE>",
                "Name": "MyWebsiteMonitor",
                "Uri": "https://aws.amazon.com",
                "Frequency": 10
            },
            ...
        },
        ...
    }
    
  2. Use the following command to invoke the SAM function from the resource package root directory:
    sam local invoke TestEntrypoint --event sam-tests/update.json

    After the resource has been updated, the test returns a response with a status of SUCCESS.

Test the delete handler

  1. Open the delete.json file located in the sam-tests/ folder and replace the placeholders with the Id, API key, and endpoint region (either US or EU) of your resource:
    {
        "action": "DELETE",
        "request": {
            "desiredResourceState": {
                "Id": "<MY_RESOURCE_ID_HERE>",
                "ApiKey": "<MY_API_KEY_HERE>",
                "EndpointRegion": "<MY_REGION_HERE>"
            },
           ...
        },
        ...
    }
  2. Use the following command to invoke the SAM function from the resource package root directory:
    sam local invoke TestEntrypoint --event sam-tests/delete.json

    After the resource has been deleted, the test returns a response with a status of SUCCESS. For example:

    {
        "callbackDelaySeconds": 0,
        "status": "SUCCESS"
    }

About contract tests

Resource contract tests are used to validate user input before passing it to the resource handlers. These tests verify that the resource schema you’ve defined catches property values that will fail when passed to the underlying APIs called from your resource handlers. For example, in the Example::Monitoring::Website resource type schema (in the example-monitoring-website.json file), we specified regex patterns for the Uri property and set the maximum length of Name to 50 characters. Contract tests are used to stress and validate those input definitions.

 

Run the contract tests

Before you start the contract tests, open the overrides.json file and replace the placeholders with your API key and endpoint region (US or EU).

{
    "CREATE": {
        "/ApiKey": "<MY_API_KEY_HERE>",
        "/EndpointRegion": "<MY_REGION_HERE>"
    }
}

To run resource contract tests, you’ll need two shell sessions.

In one of those shell sessions, create a local endpoint that emulates AWS Lambda.

sam local start-lambda

From your second shell session, run cfn test to run the resource contract tests.

cfn test

The session that is running the SAM command will display information about the status of your tests.  Due to the mechanism by which the API Key credentials are stored and retrieved in the read handler, it is expected that the contract_delete_read and contract_read_without_create tests will fail, and can be ignored.

 

Submit your resource type to the CloudFormation registry

To use your resource type, you must register it in the CloudFormation registry.

In a terminal, run the cfn submit command to register the resource type in your default region and set this version as the default.

cfn submit -v --set-default

The CloudFormation CLI validates the resource type schema, packages and uploads your resource provider code, and then submits it to the CloudFormation registry. A successful invocation should look similar to the following output.

Validating your resource specification... 
Starting build. 
Creating example-monitoring-website-role-stack 
example-monitoring-website-role-stack already exists. 
Attempting to update example-monitoring-website-role-stack 
stack is up to date 
Creating CloudFormationManagedUploadInfrastructure 
CloudFormationManagedUploadInfrastructure already exists. 
Attempting to update CloudFormationManagedUploadInfrastructure 
stack is up to date 
Successfully submitted type. 
Waiting for registration with token '3c27b9e6-dca4-4892-ba4e-3c0example' to complete. 
Registration complete. 
Set default version to 'arn:aws:cloudformation:eu-central-1:123456789012:type/resource/Example-Monitoring-Website/00000001

Provision the resource type in a CloudFormation template

After you’ve successfully registered the resource type, create a stack that includes resources of that type.

  1. Store your New Relic API Key in Secrets Manager.
    aws secretsmanager create-secret \
    --name typescript-example/apikey  \
    --description "TyeScript Example Api Key" \
    --secret-string "<MY_API_KEY_HERE>"
  2. Save the following template code with the name example-stack.yml. Replace EndpointRegion with your own values (US or EU).
    AWSTemplateFormatVersion: '2010-09-09'
    Description: Website Monitoring stack
    Resources:
      MyWebsiteMonitor:
        Type: Example::Monitoring::Website
        Properties:
          ApiKey: "{{resolve:secretsmanager:typescript-example/apikey:SecretString}}"
          EndpointRegion: <MY_REGION_HERE>
          Name: MyWebsiteMonitor
          Uri: https://aws.amazon.com
          Frequency: 10
  3. Use the template to create a stack. Navigate to the folder in which you saved the example-stack.yml file, and create a stack named example-website-monitor.
    aws cloudformation create-stack \
    --template-body file://example-stack.yml \
    --stack-name example-website-monitor

    As CloudFormation creates the stack, it will invoke your resource type create handler to provision a resource of type Example::Monitoring::Websiteand a New Relic Synthetics monitor named MyWebsiteMonitor will be created.

    aws cloudformation describe-stacks \
    --stack-name example-website-monitor \
    --query "Stacks[0].StackStatus"

    "CREATE_COMPLETE"

    New Relic synthetic monitor

Next steps

Using the resource provider framework, you can perform the following actions:

Clean up resources

When you’re done experimenting with the resource type, perform these cleanup steps:

  1. Delete the example example-website-monitor stack.
    aws cloudformation delete-stack \
    --stack-name monitoring-website-test
    
  2. Delete the secret from Secrets Manager.
    aws secretsmanager delete-secret \
    --secret-id "typescript-example/apikey"
  3. Delete the execution role for the Example::Monitoring::Website resource type.
    aws cloudformation update-termination-protection \
    --stack-name example-monitoring-website-role-stack \
    --no-enable-termination-protection
    
    aws cloudformation delete-stack \
    --stack-name example-monitoring-website-role-stack
  4. Remove the Example::Monitoring::Website resource type from the AWS CloudFormation registry.
    aws cloudformation deregister-type \
    --type RESOURCE \
    --type-name "Example::Monitoring::Website"
    

Conclusion

In this blog post, we showed you how to develop, test, and register a resource type authored in TypeScript.

The CloudFormation CLI and the CloudFormation CLI TypeScript plugin are open source projects. If you’d like to inspect the source code, raise an issue, or contribute to the project, we encourage you to visit our CloudFormation CLI or CloudFormation CLI TypeScript plugin GitHub repositories.

If you’ve read this far and would like to dive into a more complex use case using the TypeScript language plugin, we encourage you to check out the org-formation GitHub project.

About the authors

Eduardo Rodrigues is a software engineer with more than 13 years experience. Throughout his career, he has had experience in a variety of different companies, which includes big tech, cloud infrastructure, financial industry and startups. In recent years, Eduardo has grown an interest in building serverless applications and managing the infrastructure used to run serverless. His current mission is to design and develop future-proof, cost-efficient, and maintainable software using the AWS cloud. You can find him on twitter at @EdMouraR.

 

Craig Lefkowitz is a Senior Developer Advocate for AWS CloudFormation. When not writing blogs or coding, Craig works with customers helping them adopt modern cloud development and operations practices through automation.  Prior to his current role, Craig worked as both an AWS Solutions Architect and AWS Professional Services consultant for enterprise customers, as well as, state and local governments. Craig can be reached directly through his Twitter account @CraigLefkowitz.