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
- Import and drift support for a provider
shows the two optional methods,
importandreadCurrentState. - Testing and shipping a provider covers the schema check, the unit tests, the integration fixture and the documentation rows a new provider needs.
- Provider reference lists every member
of the
ResourceProviderinterface and the rules every provider obeys, and has the fixes for the common problems. - Provider implementation rules is the complete rulebook, with the reasoning and the code for each rule.
- Provider Development internals covers the less common return fields and methods.
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.
Two consequences follow for a provider author:
- Your provider only sees a resource whose top-level properties are all in the
provider's
handledPropertiesset. 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:
- Does a provider already exist? Check
src/provisioning/register-providers.ts, because a type is registered once. - Which SDK client do you need, and which calls create, update, delete and describe the resource? Those calls are the body of the provider.
- What does CloudFormation use as the physical id, the value
Refreturns? Yourcreatemust return the same thing. - Which attributes does
Fn::GetAttexpose? Yourcreateandupdatereturn them. - 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()fromsrc/utils/aws-clients.tshas shared clients for the most used services (.s3,.iam,.lambdaand 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 inFALLBACK_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 thegenerated-name-route-parityandgenerated-name-limitsunit tests list it.generateResourceNameunderneath 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'swithStackScope: 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 carrynameGenerations(built from the state record bynameGenerationsOfinsrc/state/name-generation.ts), or the name throws. A provider never picks a generation itself: the deploy binds a replacement's next generation withwithNameGenerationaround the create. A unit test that derives a generated name openswithStackScope({ stackName, stackInstanceId: undefined, nameGenerations: undefined }, ...)for a legacy name, or passes a map such asnew Map()beside an id.The physical id. Return what CloudFormation's
Refreturns 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 asProvisioningErrorwith 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 indeployments/{runId}.jsonl, and AWS's text for an access denial names the caller's role, so the message must not quote AWS. DefinewrapCaughtErroras a private method of the provider:gen-update-wrap-coveragerecognises athis.<method>(...)call that returns aProvisioningErroras a wrap. For an AWS-authored failure it puts the error's class and a--verbosepointer in the message, and sends AWS's text, masked, to the debug log. The body is a few lines; herewrapDocDBFailureindocdb-shared.tsholds it, andwrapCaughtErrorinrds-provider.tsis 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.
safeMsgkeeps 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:
importletscdkd importadopt a resource that already exists in AWS.readCurrentStateletscdkd driftcompare 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:
- Fetch the type's CloudFormation schema, and account for every property in
it. The
property-coverageunit test fails until you do. - Write unit tests for each method, with the SDK client mocked.
- Add an integration fixture that deploys and destroys the type in real AWS.
- 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.
Related
- Import and drift support for a provider
- Testing and shipping a provider
- Provider reference: the interface members and the rules checklist
- Provider implementation rules: the complete rulebook
- Provider Development internals: the less common return fields and methods
- Integration fixture conventions: the rules the fixture for your provider follows
- Supported Resources: the types that have a provider today
- CloudFormation resource reference
- AWS SDK for JavaScript v3