Skip to content

Latest commit

 

History

History
443 lines (330 loc) · 15.9 KB

aws-guidelines.md

File metadata and controls

443 lines (330 loc) · 15.9 KB

AWS Construct Library Design Guidelines

The AWS Construct Library is a rich class library of CDK constructs which represent all resources offered by the AWS Cloud.

The purpose of this document is to describe common guidelines for designing the APIs in the AWS Construct Library in order to ensure a consistent and integrated experience across the entire AWS surface area.

As much as possible, the guidelines in this document are enforced using the awslint tool which reflects on the APIs and verifies that the APIs adhere to the guidelines.

When a guideline is backed by a linter rule, the rule name will be referenced like this: awslint|resource-class-is-construct and anchored with the rule name.

For the purpose of this document we will use "Foo" to denote the official name of the resource as defined in the AWS CloudFormation resource specification (i.e. "Bucket", "Queue", "Topic", etc). This notation allows deriving names from the official name. For example, FooProps would be BucketProps, TopicProps, etc, IFoo would be IBucket, ITopic and so forth.

The guidelines in this document use TypeScript (and npm package names) since this is the source programming language used to author the library, which is later packaged and published to all programming languages through jsii.

Modules

awslint: module-name

AWS resources are organized into modules based on their AWS service. For example, the "Bucket" resource, which is offered by the Amazon S3 service will be available under the @aws-cdk/aws-s3 module.

Constructs

Constructs are the basic building block of CDK applications. They represent abstract cloud resources of any complexity.

awslint: construct-ctor

Construct initializer (constructor) signature should always be:

constructor(scope: cdk.Construct, id: string, props: FooProps)

TODO: awslint: construct-ctor-optional-props

If all initialization properties are optional, the props argument must also be optional.

NOTE: This rule breaks down for the case where there are two (or more) potential arguments, and exactly one (or at least one) of them is required. Then the props will be marked as optional, but the whole object is not optional since there is no valid use of not specifying anything.

Ideally we rather not design APIs such as this, but there might be cases where this is the best approach. In those cases, it is okay to "exclude" this role:scope in package.json.

constructor(scope: cdk.Construct, id: string, props: FooProps = { })

Each module in the AWS Construct Library includes generated constructs which represent the "raw" CloudFormation resources.

  1. These classes are named CfnFoo
  2. They extend cdk.Resource (which extends cdk.Construct)
  3. Their constructor accepts a props parameter of type CfnFooProps
  4. CfnFooProps represents the exact set of properties as defined in the AWS CloudFormation resource specification.
  5. They have readonly properties which represent all the runtime attributes of the resource.

NOTE: there are no linting rules against this section because this layer is entirely generated by the cfn2ts tool according to these guidelines so there is no need to lint against it.

Resource Interface

awslint: resource-interface

Every AWS resource should have a resource interface IFoo.

This interface represents both resources defined within the same stack (aka "internal" or "owned") or resources that are defined in different stack/app (aka "imported", "existing", "external" or "unowned"). Throughout this document we shall refer to these two types of resources as "internal" and "external".

awslint: resource-interface-extends-construct

Resource interfaces should extend cdk.IConstruct in order to allow consumers to take advantage of construct capabilities such as unique IDs, paths, scopes, etc.

TODO: awslint: resource-ref-interface

When resources are referenced anywhere in the API (e.g. in properties or methods of other resources or higher level constructs), the resource interface (IFoo) should be preferred over the concrete resource class (Foo). This will allow users to supply either internal or external resources.

Resource Attributes

Every AWS resource has a set of "physical" runtime attributes such as ARN, physical names, URLs, etc. These attributes are commonly late-bound, which means they can only be resolved when AWS CloudFormation actually provisions the resource.

Resource attributes almost always represent string values (URL, ARN, name). Sometimes they might also represent a list of strings.

awslint: resource-attribute
awslint: resource-attribute-immutable

All resource attributes must be represented as readonly properties of the resource interface. The names of the attributes must correspond to the CloudFormation resource attribute name.

TODO: awslint: resource-attribute-type

Since attribute values can either be late-bound ("a promise to a string") or concrete ("a string"), the AWS CDK has a mechanism called "tokens" which allows codifying late-bound values into strings or string arrays. This approach was chosen in order to dramatically simplify the type-system and ergonomics of CDK code. As long as users treat these attributes as opaque values (e.g. not try to parse them or manipulate them), they can be used interchangeably.

As long as attribute values are not manipulated, they can still be concatenated idiomatically. For example:

`This is my bucket name: ${bucket.bucketName} and bucket ARN: ${bucket.bucketArn}`

Even though bucketName and bucketArn will only be resolved during deployment, the CDK will identify those as tokens and will convert this string into an { "Fn::Join" } expression which includes the relevant intrinsic functions.

If needed, you can query whether an object includes unresolved tokens by using the cdk.isToken(x) function.

Resource attributes should use a type that corresponds to the resolved AWS CloudFormation type (e.g. string, string[]).

At the moment, attributes that represent strings, are represented as string in the CfnFoo resource. However, other types of tokens (string arrays, numbers) are still represented as Token. You can use token.toList() to represent a token as a string array, and soon we will also have toNumber().

Resource Class

awslint: resource-class

Each CfnFoo resource must have a corresponding Foo high-level (L2) class.

awslint: resource-class-is-construct

Classes which represent AWS resources are constructs (they must extend the cdk.Construct class directly or indirectly).

Resource Props

awslint: resource-props

Resource constructs are initialized with a set of properties defined in an interface FooProps.

Initialization properties should enable developers to define the resource in their application. Generally, they should expose most of the surface area of the resource.

Initialization properties should be required only if there is no sane default that can be provided or calculated. By providing sensible and safe defaults (or "smart defaults"), developers can get started quickly.

Imports

In order to allow users to work with resources that are either internal or external to their stack, AWS resources should provide an "import/export" mechanism as described in this section.

awslint: import

Every AWS resource class must include a static method called import with the following signature:

static import(scope: cdk.Construct, id: string, props: XxxImportProps): IXxx

This method returns an object that implements the resource interface (IXxx) and represents an "imported resource".

awslint: import-props-interface

The "props" argument is XxxImportProps, which is an interface that declares properties that allow the user to specify an external resource identity, usually by providing one or more resource attributes such as ARN, physical name, etc.

The import interface should have the minimum required properties, that is: if it is possible to parse the resource name from the ARN (using cdk.Stack.parseArn), then only the ARN should be required. In cases where it is not possible to parse the ARN (e.g. if it is a token and the resource name might have use "/" characters), both the ARN and the name should be optional and runtime-checks should be performed to require that at least one will be defined. See ecr.RepositoryAttributes for an example.

The recommended way to implement the import method is as follows:

  1. A public abstract base class called XxxBase which implements IXxx and extends cdk.Construct.
  2. The base class should provide as much of the implementation of IXxx as possible given the context it has. In most cases, grant methods, metric methods, etc. can be implemented at at that level.
  3. A private class called ImportedXxx which extends XxxBase and implements any remaining abstract members.
  4. The import static method should be have the following implementation:
public static import(scope: cdk.Construct, id: string, props: XxxImportProps): IXxx {
  return new ImportedXxx(scope, id, props);
}

Exports

awslint: export

All resource interfaces (IXxx) must declare a method called export with the following signature:

export(): XxxImportProps

This method can be used to export this resource from the current stack and use it in another stack through an Xxx.import call.

The implementation of export is different between internal resources (Xxx) and external imported resource (ImportedXxx as recommended above):

For internal resources, the export method should produce a CloudFormation Output for each resource attribute, and return a set of { "Fn::ImportValue" } tokens so they can be imported to another stack.

class Xxx extends XxxBase {
  public export(): XxxImportProps {
    return {
      attr1: new cdk.CfnOutput(this, 'Attr1', { value: this.attr1 }).makeImportValue().toString(),
      attr2: new cdk.CfnOutput(this, 'Attr2', { value: this.attr2 }).makeImportValue().toString(),
    }
  }
}

For external resources, we know the actual values, so basically you would want to reflect your props as is:

class ImportedXxx extends XxxBase {
  constructor(scope: cdk.Construct, id: string, private readonly props: XxxImportProps) {
    // ...
  }

  public export() {
    return this.props;
  }
}

The reason we are defining export on the resource interface and not on the resource class is in order to allow "composite export" scenarios, where a higher-level construct wants to implement export by composing the exports of multiple resources:

interface MyCompositeProps {
  bucket: s3.IBucket;
  topic: sns.ITopic;
}

class MyComposite extends cdk.Construct {
  public export(): MyCompositeImportProps {
    return {
      bucket: this.bucket.export(),
      topic: this.topic.export()
    }
  }
}

In this scenario, you don't know if bucket or topic are internal or external resources, but you still want export to work.

General Guidelines

Defaults must be documented on optional interface properties

TODO: awslint: interface-defaults-docs

The @default documentation tag must be included on all optional properties of interfaces.

Complete Example

Here's a complete "template" for the types required when defining a resource in the AWS construct library:

// must extend cdk.IConstruct
// extends all interfaces that are applicable for both internal
// and external resources of this type
export interface IFoo extends cdk.IConstruct, ISomething {

  // attributes
  readonly fooArn: string;
  readonly fooBoo: string;

  // security group connections (if applicable)
  readonly connections: ec2.Connections;

  // permission grants (adds statements to the principal's policy)
  grant(grantee?: iam.IGrantable, ...actions: string[]): void;
  grantFoo(grantee?: iam.IGrantable): void;
  grantBar(grantee?: iam.IGrantable): void;

  // resource policy (if applicable)
  addToResourcePolicy(statement: iam.PolicyStatement): void;

  // role (if applicable)
  addToRolePolicy(statement: iam.PolicyStatement): void;

  // pipeline (if applicable)
  addToPipeline(stage: pipelineapi.IStage, name: string, props?: FooActionProps): FooAction;

  // metrics
  metric(metricName: string, props?: cloudwatch.MetricOptions): cloudwatch.Metric;
  metricFoo(props?: cloudwatch.MetricOptions): cloudwatch.Metric;
  metricBar(props?: cloudwatch.MetricOptions): cloudwatch.Metric;

  // export
  export(): FooImportProps;

  // any other methods/properties that are applicable for both internal
  // and external resources of this type.
  // ...
}

// base class to share implementation between internal/external resources
// it has to be public sadly.
export abstract class FooBase extends cdk.Construct implements IFoo {

  // attributes are usually still abstract at this level
  public abstract readonly fooArn: string;
  public abstract readonly fooBoo: string[];

  // the "export" method is also still abstract
  public abstract export(): FooAttributes;

  // grants can usually be shared
  public grantYyy(grantee?: iam.IGrantable) {
    // ...
  }

  // metrics can usually be shared
  public metricFoo(...) { ... }
}

// extends the abstract base class and implement any interfaces that are not applicable
// for imported resources. This is quite rare usually, but can happen.
export class Foo extends FooBase implements IAnotherInterface {

  // the import method is always going to look like this.
  public static import(scope: cdk.Construct, id: string, props: FooImportProps): IFoo {
    return new ImportedFoo(scope, id, props);
  }

  // implement resource attributes as readonly properties
  public readonly fooArn: string;
  public readonly fooBoo: string[];

  // ctor's 3rd argument is always FooProps. It should be optional (`= { }`) in case
  // there are no required properties.
  constructor(scope: cdk.Construct, id: string, props: FooProps) {
    super(scope, id);

    // you would usually add a `CfnFoo` resource at this point.
    const resource = new CfnFoo(this, 'Resource', {
      // ...
    });

    // proxy resource properties
    this.fooArn = resource.fooArn;
    this.fooBoo = resource.fooBoo;
  }

  // this is how export() should be implemented on internal resources
  // they would produce a stack export and return the "Fn::ImportValue" token
  // for them so they can be imported to another stack.
  public export(): FooAttributes {
    return {
      fooArn: new cdk.CfnOutput(this, 'Arn', { value: this.fooArn }).makeImportValue().toString(), // represent Fn::ImportValue as a string
      fooBoo: new cdk.CfnOutput(this, 'Boo', { value: this.fooBoo }).makeImportValue().toList() // represent as string[]
      // ...
    }
  }
}

// an internal class (don't export it) representing the external (imported) resource
class ImportedFoo extends FooBase {
  public readonly string fooArn;
  public readonly string[] fooBoo;

  constructor(scope: cdk.Construct, id: string, private readonly props: FooImportProps) {
    super(scope, id);

    this.fooArn = props.fooArn;
    this.fooBoo = props.fooBoo;
  }

  public export() {
    return this.props; // just reflect props back
  }
}

Roadmap

  • IAM (role, addToRolePolicy, addToResourcePolicy)
  • Grants (grantXxx)
  • Metrics (metricXxx)
  • Events (onXxx)
  • Security Groups (connections)
  • Pipeline Actions (addToPipline)
  • SNS Targets
  • _asFooTarget
  • TODO: other cross AWS patterns