Skip to content
cdkd

cdkd Provider Development Guide

A provider is the class that makes the AWS calls for a resource type. cdkd has two kinds. An SDK provider is written by hand for one service and calls that service's SDK client directly. The Cloud Control provider is generic and handles the types nobody has written an SDK provider for.

Adding an SDK provider for a commonly used type makes deploys of that type faster, because the provider's calls return without polling. An SDK provider can also handle what the generic route cannot, such as retries for IAM propagation, or a type Cloud Control does not support.

This page walks through one small SDK provider from the first file to the pull request. Read Architecture first if you have not seen how the deploy engine calls a provider.

In this section

How a resource reaches your provider

ProviderRegistry chooses a provider for each resource on each deploy. Your SDK provider is chosen when the resource's type is registered to it and it handles every property the template sets.

How the provider registry routes a resourceProviderRegistry receives each resource in the template and picks one of three providers. Custom::* types go to the custom resource provider. A resource whose type is registered and whose every property is handled goes to the SDK provider. A resource with no SDK provider, or whose template sets a property the SDK provider does not handle, goes to the Cloud Control provider.Resource in the templateProviderRegistryCustom resourceproviderCustom::* typesSDK providerThe type is registered andevery property is handledCloud Control providerNo SDK provider, or thetemplate sets a property theSDK provider does not handleHow the provider registry routes a resourceProviderRegistry receives each resource in the template and picks one of three providers. Custom::* types go to the custom resource provider. A resource whose type is registered and whose every property is handled goes to the SDK provider. A resource with no SDK provider, or whose template sets a property the SDK provider does not handle, goes to the Cloud Control provider.Resource in the templateProviderRegistryCustomresourceproviderCustom::*typesSDKproviderThe type isregisteredand everyproperty ishandledCloudControlproviderNo SDKprovider, orthe templatesets aproperty theSDK providerdoes nothandle

Two consequences follow for a provider author:

  • Your provider only sees a resource whose top-level properties are all in the provider's handledProperties set. A template that sets any other property is sent through Cloud Control instead, so that the property is not silently dropped.
  • State records which route created each resource. A resource that state records as created through Cloud Control normally stays on Cloud Control after you add an SDK provider for its type.

Provisioning Layers has the full order of the decision.

Before you write code

Open the CloudFormation resource reference and the AWS SDK documentation for the type, and answer five questions:

  1. Does a provider already exist? Check src/provisioning/register-providers.ts, because a type is registered once.
  2. Which SDK client do you need, and which calls create, update, delete and describe the resource? Those calls are the body of the provider.
  3. What does CloudFormation use as the physical id, the value Ref returns? Your create must return the same thing.
  4. Which attributes does Fn::GetAtt expose? Your create and update return them.
  5. Which properties are marked "Update requires: Replacement"? An update to one of those must not be sent as an in-place change.

Provider Implementation Examples

The example is DocDBSubnetGroupProvider, which serves AWS::DocDB::DBSubnetGroup. It is one of the smallest providers in the tree, and it has every part a provider needs. The code below is taken from src/provisioning/providers/docdb-subnet-group-provider.ts, shortened where marked.

The walkthrough has eight steps. Steps 1 to 6 make a working provider. Step 7 adds two optional methods, and step 8 is the tests and the bookkeeping a pull request needs.

For a larger provider that manages sub-resources, read iam-role-provider.ts. It attaches managed policies and writes inline policies on create, diffs them on update, and removes them before it deletes the role.

1. Create the file and the client

Name the file src/provisioning/providers/{service}-{resource}-provider.ts, for example docdb-subnet-group-provider.ts or lambda-function-provider.ts. The class implements ResourceProvider and owns one SDK client:

import { DocDBClient /* and the commands */ } from '@aws-sdk/client-docdb';
import { getLogger } from '../../utils/logger.js';
import { ambientClientDefaults } from '../../utils/ambient-client-defaults.js';
import { ambientRegion } from '../../utils/stack-aws-scope.js';
import { assertRegionMatch } from '../region-check.js';
import type { CreateContext, ResourceProvider, UpdateContext } from '../../types/resource.js';

export class DocDBSubnetGroupProvider implements ResourceProvider {
  private docdbClient?: DocDBClient;
  private readonly providerRegion = ambientRegion();
  private logger = getLogger().child('DocDBSubnetGroupProvider');

  private getClient(): DocDBClient {
    if (!this.docdbClient) {
      this.docdbClient = new DocDBClient({
        ...ambientClientDefaults(),
        ...(this.providerRegion ? { region: this.providerRegion } : {}),
      });
    }
    return this.docdbClient;
  }
}

There are two ways to get a client:

  • getAwsClients() from src/utils/aws-clients.ts has shared clients for the most used services (.s3, .iam, .lambda and others). Use a shared client when one exists.
  • Otherwise build the client yourself, lazily, as the example does. Spread ambientClientDefaults() first, because that call carries the active profile and credentials.

Read the region through ambientRegion(), never from process.env.AWS_REGION. cdkd deploy runs stacks in several regions at once. The environment variable belongs to the whole process, while ambientRegion() follows the stack that is being deployed.

If the SDK package is not yet a dependency, add it with vp add @aws-sdk/client-<service>.

2. Declare the properties you handle

handledProperties maps each resource type to the set of top-level properties your provider sends to AWS:

handledProperties = new Map<string, ReadonlySet<string>>([
  [
    'AWS::DocDB::DBSubnetGroup',
    new Set([
      'DBSubnetGroupName',
      'DBSubnetGroupDescription',
      'SubnetIds',
      'Tags',
    ]),
  ],
]);

List a property only when create and update both send it to AWS. The registry reads this set to decide whether a resource can come to your provider. A unit test also compares the set with the type's CloudFormation schema. Step 8 comes back to it.

3. create

create receives the resolved properties, makes the resource, and returns its physical id and its attributes.

async create(
  logicalId: string,
  resourceType: string,
  properties: Record<string, unknown>,
  context?: CreateContext
): Promise<ResourceCreateResult> {
  this.logger.debug(safeMsg`Creating DocDB DBSubnetGroup ${logicalId}`);
  // A malformed Tags value is refused before any call.
  const tags = refuseMalformedDesiredTags(
    properties['Tags'],
    resourceType,
    logicalId
  );

  const dbSubnetGroupName =
    (properties['DBSubnetGroupName'] as string | undefined) ||
    generatedNameFor('AWS::DocDB::DBSubnetGroup', logicalId);

  try {
    await this.getCreateClient().send(
      new CreateDBSubnetGroupCommand({
        DBSubnetGroupName: dbSubnetGroupName,
        DBSubnetGroupDescription:
          (properties['DBSubnetGroupDescription'] as string) ||
          `Subnet group for ${logicalId}`,
        SubnetIds: properties['SubnetIds'] as string[],
        ...(tags.length > 0 && { Tags: tags }),
      })
    );

    return {
      physicalId: dbSubnetGroupName,
      attributes: { DBSubnetGroupName: dbSubnetGroupName },
    };
  } catch (error) {
    const cause = error instanceof Error ? error : undefined;
    throw this.wrapCaughtError(
      context?.maskSecrets,
      error,
      (text) =>
        new ProvisioningError(
          `Failed to create DocDB DBSubnetGroup ${logicalId}: ${text}`,
          resourceType,
          logicalId,
          dbSubnetGroupName,
          cause
        )
    );
  }
}

Five things in this method apply to every provider:

  • The name. When the template gives no name, generatedNameFor (src/provisioning/resource-name.ts) names the resource by the type's rule in FALLBACK_NAME_RULES (length, characters, case, any prefix). A Cloud Control create fills the name from the same rule, so the resource is named the same on either route; a new type gets a rule there, and the generated-name-route-parity and generated-name-limits unit tests list it. generateResourceName underneath builds {StackName}-{LogicalId}-{suffix}, where the 12-character suffix comes from the stack's instance id and the resource's name generation, and cuts the {StackName}-{LogicalId} part to fit the service's length limit. A stack deployed before the instance id existed keeps {StackName}-{LogicalId}. Call it inside the stack's withStackScope: the deploy and every replay open one, and a generated name derived before the deploy reads the stack's state throws. A scope with an instance id must also carry nameGenerations (built from the state record by nameGenerationsOf in src/state/name-generation.ts), or the name throws. A provider never picks a generation itself: the deploy binds a replacement's next generation with withNameGeneration around the create. A unit test that derives a generated name opens withStackScope({ stackName, stackInstanceId: undefined, nameGenerations: undefined }, ...) for a legacy name, or passes a map such as new Map() beside an id.

  • The physical id. Return what CloudFormation's Ref returns for the type. For a subnet group that is its name.

  • The attributes. Return each attribute a template can read with Fn::GetAtt.

  • The error. Put every AWS call inside a try, and rethrow a failure as ProvisioningError with the type, the logical id, the physical id and the original error as the cause. The user then sees which resource failed. The thrown message is saved in deployments/{runId}.jsonl, and AWS's text for an access denial names the caller's role, so the message must not quote AWS. Define wrapCaughtError as a private method of the provider: gen-update-wrap-coverage recognises a this.<method>(...) call that returns a ProvisioningError as a wrap. For an AWS-authored failure it puts the error's class and a --verbose pointer in the message, and sends AWS's text, masked, to the debug log. The body is a few lines; here wrapDocDBFailure in docdb-shared.ts holds it, and wrapCaughtError in rds-provider.ts is the same body inline:

    private wrapCaughtError(
      mask: ((text: string) => string) | undefined,
      error: unknown,
      build: (text: string) => ProvisioningError
    ): ProvisioningError {
      const m = maskerOrIdentity(mask);
      const failure = describeAwsFailure(error);
      if (!failure.redacted) return wrapMaskedAwsError(m, error, build);
      this.logger.debug(m(build(m(failure.detail)).message));
      const wrapped = build(`${m(failure.summary)}${relayedNameCollision(failure.detail)}`);
      return error instanceof Error ? markRedactedCause(wrapped) : wrapped;
    }
    
  • The log line. safeMsg keeps a value from the template on one line, so the value cannot forge a log row.

When an attribute's value can be missing, build the map with definedAttributes (src/provisioning/attribute-map.ts). It leaves the key out instead of storing an empty string.

You do not need a retry loop in create for transient errors. The deploy engine already retries a provider call that fails with one, such as an IAM role that has not propagated yet. If you do add a withRetry call of your own, it must pass an interrupt watch so that Ctrlc stops the wait. See A retry loop inside a provider must honor Ctrl-C.

If the create API of your type hands back an existing resource of the same name, the provider needs two more methods. See A create that adopts an existing name.

4. update

update receives the new properties and the previous ones, and changes the existing resource in place.

async update(
  logicalId: string,
  physicalId: string,
  resourceType: string,
  properties: Record<string, unknown>,
  previousProperties: Record<string, unknown>,
  context?: UpdateContext
): Promise<ResourceUpdateResult> {
  assertRegionMatch(
    context?.expectedRegion ? await this.getClient().config.region() : undefined,
    context?.expectedRegion,
    resourceType,
    logicalId,
    physicalId,
    'pre-update'
  );
  refuseMalformedDesiredTags(
    properties['Tags'],
    resourceType,
    logicalId,
    physicalId
  );

  try {
    const subnetIds = properties['SubnetIds'] as string[] | undefined;
    const sendSubnetIds = subnetIds !== undefined && subnetIds.length > 0;
    await this.getClient().send(
      new ModifyDBSubnetGroupCommand({
        DBSubnetGroupName: physicalId,
        DBSubnetGroupDescription: clearOnUpdateRemoval(
          properties['DBSubnetGroupDescription'] as string | undefined,
          previousProperties['DBSubnetGroupDescription'] as string | undefined,
          `Subnet group for ${logicalId}`
        ),
        ...(sendSubnetIds && { SubnetIds: subnetIds }),
      })
    );

    // ... apply the tag diff between previousProperties and properties ...

    return {
      physicalId,
      wasReplaced: false,
      attributes: { DBSubnetGroupName: physicalId },
    };
  } catch (error) {
    // ... rethrow as ProvisioningError, as in create ...
  }
}

assertRegionMatch runs before any AWS call. context.expectedRegion is the region cdkd recorded in state, and a client in another region would send the update to a resource of the same name that cdkd does not manage. Declare the context parameter even when your update sends nothing: scripts/check-provider-update-context.ts fails on a provider that leaves it out.

The call to clearOnUpdateRemoval is the part that is easy to miss. Suppose a user sets DBSubnetGroupDescription, deploys, then deletes the property from the template and deploys again. CloudFormation would reset the description to its default. Most AWS update APIs treat a missing field as "leave it alone", so a provider that passes properties straight through keeps the old description in AWS while state says it is gone.

clearOnUpdateRemoval(newValue, previousValue, clearValue) handles the three cases:

The property It returns
Has a new value The new value
Was set before and is now absent The clear value
Was never set undefined

Check every optional property your update sends. The per-field checklist is in Update removal semantics.

update handles in-place changes only. When a create-only property changes, the diff classifies the change as a replacement. The engine then calls your create for the new resource and your delete for the old one.

5. delete

delete removes the resource. Deleting a resource that is already gone succeeds.

async delete(
  logicalId: string,
  physicalId: string,
  resourceType: string,
  _properties?: Record<string, unknown>,
  context?: DeleteContext
): Promise<void> {
  try {
    await this.getClient().send(
      new DeleteDBSubnetGroupCommand({ DBSubnetGroupName: physicalId })
    );
  } catch (error) {
    if (isDocDBNotFoundError(error, 'DBSubnetGroupNotFoundFault')) {
      const clientRegion = await this.getClient().config.region();
      assertRegionMatch(
        clientRegion,
        context?.expectedRegion,
        resourceType,
        logicalId,
        physicalId
      );
      return;
    }
    // ... rethrow as ProvisioningError ...
  }
}

Before you treat "not found" as success, call assertRegionMatch (src/provisioning/region-check.ts), as the example does.

Warning

A destroy that runs against the wrong region gets "not found" for every resource. Without the region check, cdkd would remove them all from state and leave the real resources running in the other region.

Returning normally from delete tells the engine that the resource is gone. If your delete decides to make no AWS call, return { outcome: 'skipped', reason } instead. cdkd destroy then keeps the state record and reports the skip. See Reporting a skipped delete.

6. Register the provider

Registration takes one line in each of two files. First re-export the class from src/provisioning/provider-classes.ts:

export {
  DocDBSubnetGroupProvider,
} from './providers/docdb-subnet-group-provider.js';

Then open registerAllProviders() in src/provisioning/register-providers.ts. Add the class to the const { ... } = classes destructure and register the type:

registry.register('AWS::DocDB::DBSubnetGroup', new DocDBSubnetGroupProvider());

Do not import the provider module anywhere else. A provider module loads its @aws-sdk/client-* package, so a static import would put that client on the startup path of every command. provider-classes.ts is loaded on demand, and a unit test fails on a static import.

A provider that serves several types registers one instance for all of them:

const docdbProvider = new DocDBProvider();
registry.register('AWS::DocDB::DBCluster', docdbProvider);
registry.register('AWS::DocDB::DBInstance', docdbProvider);

7. Add import and drift support (optional)

The provider can now deploy and destroy the type. Two optional methods connect it to two more commands:

  • import lets cdkd import adopt a resource that already exists in AWS.
  • readCurrentState lets cdkd drift compare the resource in AWS with state.

Import and drift support for a provider shows both methods for this provider.

8. Test it and finish the pull request

The provider now works, but a pull request needs four more things. Each one is a section of Testing and shipping a provider:

  1. Fetch the type's CloudFormation schema, and account for every property in it. The property-coverage unit test fails until you do.
  2. Write unit tests for each method, with the SDK client mocked.
  3. Add an integration fixture that deploys and destroys the type in real AWS.
  4. List the type in the supported-resources tables, regenerate the coverage matrix, and add a changelog entry.

Then run the checks, and go through the rules every provider obeys:

vp run check && vp test run && vp run build

If the provider does not behave as you expect, see When something does not work.

Last updated: