UNPKG

@aws-cdk/aws-dynamodb

Version:

The CDK Construct Library for AWS::DynamoDB

1,099 lines (1,098 loc) 181 kB
"use strict"; var _a; Object.defineProperty(exports, "__esModule", { value: true }); exports.TableClass = exports.StreamViewType = exports.ProjectionType = exports.BillingMode = exports.AttributeType = exports.Table = exports.TableEncryption = exports.Operation = void 0; const jsiiDeprecationWarnings = require("../.warnings.jsii.js"); const JSII_RTTI_SYMBOL_1 = Symbol.for("jsii.rtti"); const appscaling = require("@aws-cdk/aws-applicationautoscaling"); const cloudwatch = require("@aws-cdk/aws-cloudwatch"); const iam = require("@aws-cdk/aws-iam"); const kms = require("@aws-cdk/aws-kms"); const core_1 = require("@aws-cdk/core"); const dynamodb_canned_metrics_generated_1 = require("./dynamodb-canned-metrics.generated"); const dynamodb_generated_1 = require("./dynamodb.generated"); const perms = require("./perms"); const replica_provider_1 = require("./replica-provider"); const scalable_table_attribute_1 = require("./scalable-table-attribute"); // keep this import separate from other imports to reduce chance for merge conflicts with v2-main // eslint-disable-next-line no-duplicate-imports, import/order const core_2 = require("@aws-cdk/core"); const HASH_KEY_TYPE = 'HASH'; const RANGE_KEY_TYPE = 'RANGE'; // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-secondary-indexes const MAX_LOCAL_SECONDARY_INDEX_COUNT = 5; /** * Supported DynamoDB table operations. */ var Operation; (function (Operation) { /** GetItem */ Operation["GET_ITEM"] = "GetItem"; /** BatchGetItem */ Operation["BATCH_GET_ITEM"] = "BatchGetItem"; /** Scan */ Operation["SCAN"] = "Scan"; /** Query */ Operation["QUERY"] = "Query"; /** GetRecords */ Operation["GET_RECORDS"] = "GetRecords"; /** PutItem */ Operation["PUT_ITEM"] = "PutItem"; /** DeleteItem */ Operation["DELETE_ITEM"] = "DeleteItem"; /** UpdateItem */ Operation["UPDATE_ITEM"] = "UpdateItem"; /** BatchWriteItem */ Operation["BATCH_WRITE_ITEM"] = "BatchWriteItem"; /** TransactWriteItems */ Operation["TRANSACT_WRITE_ITEMS"] = "TransactWriteItems"; /** TransactGetItems */ Operation["TRANSACT_GET_ITEMS"] = "TransactGetItems"; /** ExecuteTransaction */ Operation["EXECUTE_TRANSACTION"] = "ExecuteTransaction"; /** BatchExecuteStatement */ Operation["BATCH_EXECUTE_STATEMENT"] = "BatchExecuteStatement"; /** ExecuteStatement */ Operation["EXECUTE_STATEMENT"] = "ExecuteStatement"; })(Operation = exports.Operation || (exports.Operation = {})); /** * What kind of server-side encryption to apply to this table. */ var TableEncryption; (function (TableEncryption) { /** * Server-side KMS encryption with a master key owned by AWS. */ TableEncryption["DEFAULT"] = "AWS_OWNED"; /** * Server-side KMS encryption with a customer master key managed by customer. * If `encryptionKey` is specified, this key will be used, otherwise, one will be defined. * * > **NOTE**: if `encryptionKey` is not specified and the `Table` construct creates * > a KMS key for you, the key will be created with default permissions. If you are using * > CDKv2, these permissions will be sufficient to enable the key for use with DynamoDB tables. * > If you are using CDKv1, make sure the feature flag `@aws-cdk/aws-kms:defaultKeyPolicies` * > is set to `true` in your `cdk.json`. */ TableEncryption["CUSTOMER_MANAGED"] = "CUSTOMER_MANAGED"; /** * Server-side KMS encryption with a master key managed by AWS. */ TableEncryption["AWS_MANAGED"] = "AWS_MANAGED"; })(TableEncryption = exports.TableEncryption || (exports.TableEncryption = {})); class TableBase extends core_1.Resource { constructor() { super(...arguments); this.regionalArns = new Array(); } /** * Adds an IAM policy statement associated with this table to an IAM * principal's policy. * * If `encryptionKey` is present, appropriate grants to the key needs to be added * separately using the `table.encryptionKey.grant*` methods. * * @param grantee The principal (no-op if undefined) * @param actions The set of actions to allow (i.e. "dynamodb:PutItem", "dynamodb:GetItem", ...) */ grant(grantee, ...actions) { return iam.Grant.addToPrincipal({ grantee, actions, resourceArns: [ this.tableArn, core_1.Lazy.string({ produce: () => this.hasIndex ? `${this.tableArn}/index/*` : core_1.Aws.NO_VALUE }), ...this.regionalArns, ...this.regionalArns.map(arn => core_1.Lazy.string({ produce: () => this.hasIndex ? `${arn}/index/*` : core_1.Aws.NO_VALUE, })), ], scope: this, }); } /** * Adds an IAM policy statement associated with this table's stream to an * IAM principal's policy. * * If `encryptionKey` is present, appropriate grants to the key needs to be added * separately using the `table.encryptionKey.grant*` methods. * * @param grantee The principal (no-op if undefined) * @param actions The set of actions to allow (i.e. "dynamodb:DescribeStream", "dynamodb:GetRecords", ...) */ grantStream(grantee, ...actions) { if (!this.tableStreamArn) { throw new Error(`DynamoDB Streams must be enabled on the table ${this.node.path}`); } return iam.Grant.addToPrincipal({ grantee, actions, resourceArns: [this.tableStreamArn], scope: this, }); } /** * Permits an IAM principal all data read operations from this table: * BatchGetItem, GetRecords, GetShardIterator, Query, GetItem, Scan, DescribeTable. * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ grantReadData(grantee) { const tableActions = perms.READ_DATA_ACTIONS.concat(perms.DESCRIBE_TABLE); return this.combinedGrant(grantee, { keyActions: perms.KEY_READ_ACTIONS, tableActions }); } /** * Permits an IAM Principal to list streams attached to current dynamodb table. * * @param grantee The principal (no-op if undefined) */ grantTableListStreams(grantee) { if (!this.tableStreamArn) { throw new Error(`DynamoDB Streams must be enabled on the table ${this.node.path}`); } return iam.Grant.addToPrincipal({ grantee, actions: ['dynamodb:ListStreams'], resourceArns: ['*'], }); } /** * Permits an IAM principal all stream data read operations for this * table's stream: * DescribeStream, GetRecords, GetShardIterator, ListStreams. * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ grantStreamRead(grantee) { this.grantTableListStreams(grantee); return this.combinedGrant(grantee, { keyActions: perms.KEY_READ_ACTIONS, streamActions: perms.READ_STREAM_DATA_ACTIONS }); } /** * Permits an IAM principal all data write operations to this table: * BatchWriteItem, PutItem, UpdateItem, DeleteItem, DescribeTable. * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ grantWriteData(grantee) { const tableActions = perms.WRITE_DATA_ACTIONS.concat(perms.DESCRIBE_TABLE); const keyActions = perms.KEY_READ_ACTIONS.concat(perms.KEY_WRITE_ACTIONS); return this.combinedGrant(grantee, { keyActions, tableActions }); } /** * Permits an IAM principal to all data read/write operations to this table. * BatchGetItem, GetRecords, GetShardIterator, Query, GetItem, Scan, * BatchWriteItem, PutItem, UpdateItem, DeleteItem, DescribeTable * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ grantReadWriteData(grantee) { const tableActions = perms.READ_DATA_ACTIONS.concat(perms.WRITE_DATA_ACTIONS).concat(perms.DESCRIBE_TABLE); const keyActions = perms.KEY_READ_ACTIONS.concat(perms.KEY_WRITE_ACTIONS); return this.combinedGrant(grantee, { keyActions, tableActions }); } /** * Permits all DynamoDB operations ("dynamodb:*") to an IAM principal. * * Appropriate grants will also be added to the customer-managed KMS key * if one was configured. * * @param grantee The principal to grant access to */ grantFullAccess(grantee) { const keyActions = perms.KEY_READ_ACTIONS.concat(perms.KEY_WRITE_ACTIONS); return this.combinedGrant(grantee, { keyActions, tableActions: ['dynamodb:*'] }); } /** * Return the given named metric for this Table * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ metric(metricName, props) { return new cloudwatch.Metric({ namespace: 'AWS/DynamoDB', metricName, dimensionsMap: { TableName: this.tableName, }, ...props, }).attachTo(this); } /** * Metric for the consumed read capacity units this table * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ metricConsumedReadCapacityUnits(props) { return this.cannedMetric(dynamodb_canned_metrics_generated_1.DynamoDBMetrics.consumedReadCapacityUnitsSum, props); } /** * Metric for the consumed write capacity units this table * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ metricConsumedWriteCapacityUnits(props) { return this.cannedMetric(dynamodb_canned_metrics_generated_1.DynamoDBMetrics.consumedWriteCapacityUnitsSum, props); } /** * Metric for the system errors this table * * @deprecated use `metricSystemErrorsForOperations`. */ metricSystemErrors(props) { if (!props?.dimensions?.Operation && !props?.dimensionsMap?.Operation) { // 'Operation' must be passed because its an operational metric. throw new Error("'Operation' dimension must be passed for the 'SystemErrors' metric."); } const dimensionsMap = { TableName: this.tableName, ...props?.dimensions ?? {}, ...props?.dimensionsMap ?? {}, }; return this.metric('SystemErrors', { statistic: 'sum', ...props, dimensionsMap }); } /** * Metric for the user errors. Note that this metric reports user errors across all * the tables in the account and region the table resides in. * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ metricUserErrors(props) { if (props?.dimensions) { throw new Error("'dimensions' is not supported for the 'UserErrors' metric"); } // overriding 'dimensions' here because this metric is an account metric. // see 'UserErrors' in https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/metrics-dimensions.html return this.metric('UserErrors', { statistic: 'sum', ...props, dimensionsMap: {} }); } /** * Metric for the conditional check failed requests this table * * By default, the metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ metricConditionalCheckFailedRequests(props) { return this.metric('ConditionalCheckFailedRequests', { statistic: 'sum', ...props }); } /** * How many requests are throttled on this table * * Default: sum over 5 minutes * * @deprecated Do not use this function. It returns an invalid metric. Use `metricThrottledRequestsForOperation` instead. */ metricThrottledRequests(props) { return this.metric('ThrottledRequests', { statistic: 'sum', ...props }); } /** * How many requests are throttled on this table, for the given operation * * Default: sum over 5 minutes */ metricThrottledRequestsForOperation(operation, props) { return new cloudwatch.Metric({ ...dynamodb_canned_metrics_generated_1.DynamoDBMetrics.throttledRequestsSum({ Operation: operation, TableName: this.tableName }), ...props, }).attachTo(this); } /** * Metric for the successful request latency this table. * * By default, the metric will be calculated as an average over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ metricSuccessfulRequestLatency(props) { if (!props?.dimensions?.Operation && !props?.dimensionsMap?.Operation) { throw new Error("'Operation' dimension must be passed for the 'SuccessfulRequestLatency' metric."); } const dimensionsMap = { TableName: this.tableName, Operation: props.dimensionsMap?.Operation ?? props.dimensions?.Operation, }; return new cloudwatch.Metric({ ...dynamodb_canned_metrics_generated_1.DynamoDBMetrics.successfulRequestLatencyAverage(dimensionsMap), ...props, dimensionsMap, }).attachTo(this); } /** * Metric for the system errors this table. * * This will sum errors across all possible operations. * Note that by default, each individual metric will be calculated as a sum over a period of 5 minutes. * You can customize this by using the `statistic` and `period` properties. */ metricSystemErrorsForOperations(props) { if (props?.dimensions?.Operation) { throw new Error("The Operation dimension is not supported. Use the 'operations' property."); } const operations = props?.operations ?? Object.values(Operation); const values = this.createMetricsForOperations('SystemErrors', operations, { statistic: 'sum', ...props }); const sum = new cloudwatch.MathExpression({ expression: `${Object.keys(values).join(' + ')}`, usingMetrics: { ...values }, color: props?.color, label: 'Sum of errors across all operations', period: props?.period, }); return sum; } /** * Create a map of metrics that can be used in a math expression. * * Using the return value of this function as the `usingMetrics` property in `cloudwatch.MathExpression` allows you to * use the keys of this map as metric names inside you expression. * * @param metricName The metric name. * @param operations The list of operations to create metrics for. * @param props Properties for the individual metrics. * @param metricNameMapper Mapper function to allow controlling the individual metric name per operation. */ createMetricsForOperations(metricName, operations, props, metricNameMapper) { const metrics = {}; const mapper = metricNameMapper ?? (op => op.toLowerCase()); if (props?.dimensions?.Operation) { throw new Error('Invalid properties. Operation dimension is not supported when calculating operational metrics'); } for (const operation of operations) { const metric = this.metric(metricName, { ...props, dimensionsMap: { TableName: this.tableName, Operation: operation, ...props?.dimensions, }, }); const operationMetricName = mapper(operation); const firstChar = operationMetricName.charAt(0); if (firstChar === firstChar.toUpperCase()) { // https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/using-metric-math.html#metric-math-syntax throw new Error(`Mapper generated an illegal operation metric name: ${operationMetricName}. Must start with a lowercase letter`); } metrics[operationMetricName] = metric; } return metrics; } /** * Adds an IAM policy statement associated with this table to an IAM * principal's policy. * @param grantee The principal (no-op if undefined) * @param opts Options for keyActions, tableActions and streamActions */ combinedGrant(grantee, opts) { if (opts.tableActions) { const resources = [this.tableArn, core_1.Lazy.string({ produce: () => this.hasIndex ? `${this.tableArn}/index/*` : core_1.Aws.NO_VALUE }), ...this.regionalArns, ...this.regionalArns.map(arn => core_1.Lazy.string({ produce: () => this.hasIndex ? `${arn}/index/*` : core_1.Aws.NO_VALUE, }))]; const ret = iam.Grant.addToPrincipal({ grantee, actions: opts.tableActions, resourceArns: resources, scope: this, }); if (this.encryptionKey && opts.keyActions) { this.encryptionKey.grant(grantee, ...opts.keyActions); } return ret; } if (opts.streamActions) { if (!this.tableStreamArn) { throw new Error(`DynamoDB Streams must be enabled on the table ${this.node.path}`); } const resources = [this.tableStreamArn]; const ret = iam.Grant.addToPrincipal({ grantee, actions: opts.streamActions, resourceArns: resources, scope: this, }); return ret; } throw new Error(`Unexpected 'action', ${opts.tableActions || opts.streamActions}`); } cannedMetric(fn, props) { return new cloudwatch.Metric({ ...fn({ TableName: this.tableName }), ...props, }).attachTo(this); } } /** * Provides a DynamoDB table. */ class Table extends TableBase { constructor(scope, id, props) { super(scope, id, { physicalName: props.tableName, }); this.keySchema = new Array(); this.attributeDefinitions = new Array(); this.globalSecondaryIndexes = new Array(); this.localSecondaryIndexes = new Array(); this.secondaryIndexSchemas = new Map(); this.nonKeyAttributes = new Set(); this.tableScaling = {}; this.indexScaling = new Map(); this.globalReplicaCustomResources = new Array(); try { jsiiDeprecationWarnings._aws_cdk_aws_dynamodb_TableProps(props); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, Table); } throw error; } const { sseSpecification, encryptionKey } = this.parseEncryption(props); let streamSpecification; if (props.replicationRegions) { if (props.stream && props.stream !== StreamViewType.NEW_AND_OLD_IMAGES) { throw new Error('`stream` must be set to `NEW_AND_OLD_IMAGES` when specifying `replicationRegions`'); } streamSpecification = { streamViewType: StreamViewType.NEW_AND_OLD_IMAGES }; this.billingMode = props.billingMode ?? BillingMode.PAY_PER_REQUEST; } else { this.billingMode = props.billingMode ?? BillingMode.PROVISIONED; if (props.stream) { streamSpecification = { streamViewType: props.stream }; } } this.validateProvisioning(props); this.table = new dynamodb_generated_1.CfnTable(this, 'Resource', { tableName: this.physicalName, keySchema: this.keySchema, attributeDefinitions: this.attributeDefinitions, globalSecondaryIndexes: core_1.Lazy.any({ produce: () => this.globalSecondaryIndexes }, { omitEmptyArray: true }), localSecondaryIndexes: core_1.Lazy.any({ produce: () => this.localSecondaryIndexes }, { omitEmptyArray: true }), pointInTimeRecoverySpecification: props.pointInTimeRecovery != null ? { pointInTimeRecoveryEnabled: props.pointInTimeRecovery } : undefined, billingMode: this.billingMode === BillingMode.PAY_PER_REQUEST ? this.billingMode : undefined, provisionedThroughput: this.billingMode === BillingMode.PAY_PER_REQUEST ? undefined : { readCapacityUnits: props.readCapacity || 5, writeCapacityUnits: props.writeCapacity || 5, }, sseSpecification, streamSpecification, tableClass: props.tableClass, timeToLiveSpecification: props.timeToLiveAttribute ? { attributeName: props.timeToLiveAttribute, enabled: true } : undefined, contributorInsightsSpecification: props.contributorInsightsEnabled !== undefined ? { enabled: props.contributorInsightsEnabled } : undefined, kinesisStreamSpecification: props.kinesisStream ? { streamArn: props.kinesisStream.streamArn } : undefined, }); this.table.applyRemovalPolicy(props.removalPolicy); this.encryptionKey = encryptionKey; this.tableArn = this.getResourceArnAttribute(this.table.attrArn, { service: 'dynamodb', resource: 'table', resourceName: this.physicalName, }); this.tableName = this.getResourceNameAttribute(this.table.ref); if (props.tableName) { this.node.addMetadata('aws:cdk:hasPhysicalName', this.tableName); } this.tableStreamArn = streamSpecification ? this.table.attrStreamArn : undefined; this.scalingRole = this.makeScalingRole(); this.addKey(props.partitionKey, HASH_KEY_TYPE); this.tablePartitionKey = props.partitionKey; if (props.sortKey) { this.addKey(props.sortKey, RANGE_KEY_TYPE); this.tableSortKey = props.sortKey; } if (props.replicationRegions && props.replicationRegions.length > 0) { this.createReplicaTables(props.replicationRegions, props.replicationTimeout, props.waitForReplicationToFinish); } } /** * Permits an IAM Principal to list all DynamoDB Streams. * @deprecated Use {@link #grantTableListStreams} for more granular permission * @param grantee The principal (no-op if undefined) */ static grantListStreams(grantee) { try { jsiiDeprecationWarnings.print("@aws-cdk/aws-dynamodb.Table#grantListStreams", "Use {@link #grantTableListStreams} for more granular permission"); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, this.grantListStreams); } throw error; } return iam.Grant.addToPrincipal({ grantee, actions: ['dynamodb:ListStreams'], resourceArns: ['*'], }); } /** * Creates a Table construct that represents an external table via table name. * * @param scope The parent creating construct (usually `this`). * @param id The construct's name. * @param tableName The table's name. */ static fromTableName(scope, id, tableName) { return Table.fromTableAttributes(scope, id, { tableName }); } /** * Creates a Table construct that represents an external table via table arn. * * @param scope The parent creating construct (usually `this`). * @param id The construct's name. * @param tableArn The table's ARN. */ static fromTableArn(scope, id, tableArn) { return Table.fromTableAttributes(scope, id, { tableArn }); } /** * Creates a Table construct that represents an external table. * * @param scope The parent creating construct (usually `this`). * @param id The construct's name. * @param attrs A `TableAttributes` object. */ static fromTableAttributes(scope, id, attrs) { try { jsiiDeprecationWarnings._aws_cdk_aws_dynamodb_TableAttributes(attrs); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, this.fromTableAttributes); } throw error; } class Import extends TableBase { constructor(_tableArn, tableName, tableStreamArn) { super(scope, id); this.hasIndex = (attrs.globalIndexes ?? []).length > 0 || (attrs.localIndexes ?? []).length > 0; this.tableArn = _tableArn; this.tableName = tableName; this.tableStreamArn = tableStreamArn; this.encryptionKey = attrs.encryptionKey; } } let name; let arn; const stack = core_1.Stack.of(scope); if (!attrs.tableName) { if (!attrs.tableArn) { throw new Error('One of tableName or tableArn is required!'); } arn = attrs.tableArn; const maybeTableName = stack.splitArn(attrs.tableArn, core_1.ArnFormat.SLASH_RESOURCE_NAME).resourceName; if (!maybeTableName) { throw new Error('ARN for DynamoDB table must be in the form: ...'); } name = maybeTableName; } else { if (attrs.tableArn) { throw new Error('Only one of tableArn or tableName can be provided'); } name = attrs.tableName; arn = stack.formatArn({ service: 'dynamodb', resource: 'table', resourceName: attrs.tableName, }); } return new Import(arn, name, attrs.tableStreamArn); } /** * Add a global secondary index of table. * * @param props the property of global secondary index */ addGlobalSecondaryIndex(props) { try { jsiiDeprecationWarnings._aws_cdk_aws_dynamodb_GlobalSecondaryIndexProps(props); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, this.addGlobalSecondaryIndex); } throw error; } this.validateProvisioning(props); this.validateIndexName(props.indexName); // build key schema and projection for index const gsiKeySchema = this.buildIndexKeySchema(props.partitionKey, props.sortKey); const gsiProjection = this.buildIndexProjection(props); this.globalSecondaryIndexes.push({ indexName: props.indexName, keySchema: gsiKeySchema, projection: gsiProjection, provisionedThroughput: this.billingMode === BillingMode.PAY_PER_REQUEST ? undefined : { readCapacityUnits: props.readCapacity || 5, writeCapacityUnits: props.writeCapacity || 5, }, }); this.secondaryIndexSchemas.set(props.indexName, { partitionKey: props.partitionKey, sortKey: props.sortKey, }); this.indexScaling.set(props.indexName, {}); } /** * Add a local secondary index of table. * * @param props the property of local secondary index */ addLocalSecondaryIndex(props) { try { jsiiDeprecationWarnings._aws_cdk_aws_dynamodb_LocalSecondaryIndexProps(props); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, this.addLocalSecondaryIndex); } throw error; } // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-secondary-indexes if (this.localSecondaryIndexes.length >= MAX_LOCAL_SECONDARY_INDEX_COUNT) { throw new RangeError(`a maximum number of local secondary index per table is ${MAX_LOCAL_SECONDARY_INDEX_COUNT}`); } this.validateIndexName(props.indexName); // build key schema and projection for index const lsiKeySchema = this.buildIndexKeySchema(this.tablePartitionKey, props.sortKey); const lsiProjection = this.buildIndexProjection(props); this.localSecondaryIndexes.push({ indexName: props.indexName, keySchema: lsiKeySchema, projection: lsiProjection, }); this.secondaryIndexSchemas.set(props.indexName, { partitionKey: this.tablePartitionKey, sortKey: props.sortKey, }); } /** * Enable read capacity scaling for this table * * @returns An object to configure additional AutoScaling settings */ autoScaleReadCapacity(props) { try { jsiiDeprecationWarnings._aws_cdk_aws_dynamodb_EnableScalingProps(props); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, this.autoScaleReadCapacity); } throw error; } if (this.tableScaling.scalableReadAttribute) { throw new Error('Read AutoScaling already enabled for this table'); } if (this.billingMode === BillingMode.PAY_PER_REQUEST) { throw new Error('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode'); } return this.tableScaling.scalableReadAttribute = new scalable_table_attribute_1.ScalableTableAttribute(this, 'ReadScaling', { serviceNamespace: appscaling.ServiceNamespace.DYNAMODB, resourceId: `table/${this.tableName}`, dimension: 'dynamodb:table:ReadCapacityUnits', role: this.scalingRole, ...props, }); } /** * Enable write capacity scaling for this table * * @returns An object to configure additional AutoScaling settings for this attribute */ autoScaleWriteCapacity(props) { try { jsiiDeprecationWarnings._aws_cdk_aws_dynamodb_EnableScalingProps(props); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, this.autoScaleWriteCapacity); } throw error; } if (this.tableScaling.scalableWriteAttribute) { throw new Error('Write AutoScaling already enabled for this table'); } if (this.billingMode === BillingMode.PAY_PER_REQUEST) { throw new Error('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode'); } this.tableScaling.scalableWriteAttribute = new scalable_table_attribute_1.ScalableTableAttribute(this, 'WriteScaling', { serviceNamespace: appscaling.ServiceNamespace.DYNAMODB, resourceId: `table/${this.tableName}`, dimension: 'dynamodb:table:WriteCapacityUnits', role: this.scalingRole, ...props, }); for (const globalReplicaCustomResource of this.globalReplicaCustomResources) { globalReplicaCustomResource.node.addDependency(this.tableScaling.scalableWriteAttribute); } return this.tableScaling.scalableWriteAttribute; } /** * Enable read capacity scaling for the given GSI * * @returns An object to configure additional AutoScaling settings for this attribute */ autoScaleGlobalSecondaryIndexReadCapacity(indexName, props) { try { jsiiDeprecationWarnings._aws_cdk_aws_dynamodb_EnableScalingProps(props); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, this.autoScaleGlobalSecondaryIndexReadCapacity); } throw error; } if (this.billingMode === BillingMode.PAY_PER_REQUEST) { throw new Error('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode'); } const attributePair = this.indexScaling.get(indexName); if (!attributePair) { throw new Error(`No global secondary index with name ${indexName}`); } if (attributePair.scalableReadAttribute) { throw new Error('Read AutoScaling already enabled for this index'); } return attributePair.scalableReadAttribute = new scalable_table_attribute_1.ScalableTableAttribute(this, `${indexName}ReadScaling`, { serviceNamespace: appscaling.ServiceNamespace.DYNAMODB, resourceId: `table/${this.tableName}/index/${indexName}`, dimension: 'dynamodb:index:ReadCapacityUnits', role: this.scalingRole, ...props, }); } /** * Enable write capacity scaling for the given GSI * * @returns An object to configure additional AutoScaling settings for this attribute */ autoScaleGlobalSecondaryIndexWriteCapacity(indexName, props) { try { jsiiDeprecationWarnings._aws_cdk_aws_dynamodb_EnableScalingProps(props); } catch (error) { if (process.env.JSII_DEBUG !== "1" && error.name === "DeprecationError") { Error.captureStackTrace(error, this.autoScaleGlobalSecondaryIndexWriteCapacity); } throw error; } if (this.billingMode === BillingMode.PAY_PER_REQUEST) { throw new Error('AutoScaling is not available for tables with PAY_PER_REQUEST billing mode'); } const attributePair = this.indexScaling.get(indexName); if (!attributePair) { throw new Error(`No global secondary index with name ${indexName}`); } if (attributePair.scalableWriteAttribute) { throw new Error('Write AutoScaling already enabled for this index'); } return attributePair.scalableWriteAttribute = new scalable_table_attribute_1.ScalableTableAttribute(this, `${indexName}WriteScaling`, { serviceNamespace: appscaling.ServiceNamespace.DYNAMODB, resourceId: `table/${this.tableName}/index/${indexName}`, dimension: 'dynamodb:index:WriteCapacityUnits', role: this.scalingRole, ...props, }); } /** * Get schema attributes of table or index. * * @returns Schema of table or index. */ schema(indexName) { if (!indexName) { return { partitionKey: this.tablePartitionKey, sortKey: this.tableSortKey, }; } let schema = this.secondaryIndexSchemas.get(indexName); if (!schema) { throw new Error(`Cannot find schema for index: ${indexName}. Use 'addGlobalSecondaryIndex' or 'addLocalSecondaryIndex' to add index`); } return schema; } /** * Validate the table construct. * * @returns an array of validation error message */ validate() { const errors = new Array(); if (!this.tablePartitionKey) { errors.push('a partition key must be specified'); } if (this.localSecondaryIndexes.length > 0 && !this.tableSortKey) { errors.push('a sort key of the table must be specified to add local secondary indexes'); } if (this.globalReplicaCustomResources.length > 0 && this.billingMode === BillingMode.PROVISIONED) { const writeAutoScaleAttribute = this.tableScaling.scalableWriteAttribute; if (!writeAutoScaleAttribute) { errors.push('A global Table that uses PROVISIONED as the billing mode needs auto-scaled write capacity. ' + 'Use the autoScaleWriteCapacity() method to enable it.'); } else if (!writeAutoScaleAttribute._scalingPolicyCreated) { errors.push('A global Table that uses PROVISIONED as the billing mode needs auto-scaled write capacity with a policy. ' + 'Call one of the scaleOn*() methods of the object returned from autoScaleWriteCapacity()'); } } return errors; } /** * Validate read and write capacity are not specified for on-demand tables (billing mode PAY_PER_REQUEST). * * @param props read and write capacity properties */ validateProvisioning(props) { if (this.billingMode === BillingMode.PAY_PER_REQUEST) { if (props.readCapacity !== undefined || props.writeCapacity !== undefined) { throw new Error('you cannot provision read and write capacity for a table with PAY_PER_REQUEST billing mode'); } } } /** * Validate index name to check if a duplicate name already exists. * * @param indexName a name of global or local secondary index */ validateIndexName(indexName) { if (this.secondaryIndexSchemas.has(indexName)) { // a duplicate index name causes validation exception, status code 400, while trying to create CFN stack throw new Error(`a duplicate index name, ${indexName}, is not allowed`); } } /** * Validate non-key attributes by checking limits within secondary index, which may vary in future. * * @param nonKeyAttributes a list of non-key attribute names */ validateNonKeyAttributes(nonKeyAttributes) { if (this.nonKeyAttributes.size + nonKeyAttributes.length > 100) { // https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Limits.html#limits-secondary-indexes throw new RangeError('a maximum number of nonKeyAttributes across all of secondary indexes is 100'); } // store all non-key attributes nonKeyAttributes.forEach(att => this.nonKeyAttributes.add(att)); } buildIndexKeySchema(partitionKey, sortKey) { this.registerAttribute(partitionKey); const indexKeySchema = [ { attributeName: partitionKey.name, keyType: HASH_KEY_TYPE }, ]; if (sortKey) { this.registerAttribute(sortKey); indexKeySchema.push({ attributeName: sortKey.name, keyType: RANGE_KEY_TYPE }); } return indexKeySchema; } buildIndexProjection(props) { if (props.projectionType === ProjectionType.INCLUDE && !props.nonKeyAttributes) { // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-dynamodb-projectionobject.html throw new Error(`non-key attributes should be specified when using ${ProjectionType.INCLUDE} projection type`); } if (props.projectionType !== ProjectionType.INCLUDE && props.nonKeyAttributes) { // this combination causes validation exception, status code 400, while trying to create CFN stack throw new Error(`non-key attributes should not be specified when not using ${ProjectionType.INCLUDE} projection type`); } if (props.nonKeyAttributes) { this.validateNonKeyAttributes(props.nonKeyAttributes); } return { projectionType: props.projectionType ?? ProjectionType.ALL, nonKeyAttributes: props.nonKeyAttributes ?? undefined, }; } findKey(keyType) { return this.keySchema.find(prop => prop.keyType === keyType); } addKey(attribute, keyType) { const existingProp = this.findKey(keyType); if (existingProp) { throw new Error(`Unable to set ${attribute.name} as a ${keyType} key, because ${existingProp.attributeName} is a ${keyType} key`); } this.registerAttribute(attribute); this.keySchema.push({ attributeName: attribute.name, keyType, }); return this; } /** * Register the key attribute of table or secondary index to assemble attribute definitions of TableResourceProps. * * @param attribute the key attribute of table or secondary index */ registerAttribute(attribute) { const { name, type } = attribute; const existingDef = this.attributeDefinitions.find(def => def.attributeName === name); if (existingDef && existingDef.attributeType !== type) { throw new Error(`Unable to specify ${name} as ${type} because it was already defined as ${existingDef.attributeType}`); } if (!existingDef) { this.attributeDefinitions.push({ attributeName: name, attributeType: type, }); } } /** * Return the role that will be used for AutoScaling */ makeScalingRole() { // Use a Service Linked Role. // https://docs.aws.amazon.com/autoscaling/application/userguide/application-auto-scaling-service-linked-roles.html return iam.Role.fromRoleArn(this, 'ScalingRole', core_1.Stack.of(this).formatArn({ service: 'iam', region: '', resource: 'role/aws-service-role/dynamodb.application-autoscaling.amazonaws.com', resourceName: 'AWSServiceRoleForApplicationAutoScaling_DynamoDBTable', })); } /** * Creates replica tables * * @param regions regions where to create tables */ createReplicaTables(regions, timeout, waitForReplicationToFinish) { const stack = core_1.Stack.of(this); if (!core_1.Token.isUnresolved(stack.region) && regions.includes(stack.region)) { throw new Error('`replicationRegions` cannot include the region where this stack is deployed.'); } const provider = replica_provider_1.ReplicaProvider.getOrCreate(this, { timeout }); // Documentation at https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/V2gt_IAM.html // is currently incorrect. AWS Support recommends `dynamodb:*` in both source and destination regions const onEventHandlerPolicy = new SourceTableAttachedPolicy(this, provider.onEventHandler.role); const isCompleteHandlerPolicy = new SourceTableAttachedPolicy(this, provider.isCompleteHandler.role); // Permissions in the source region this.grant(onEventHandlerPolicy, 'dynamodb:*'); this.grant(isCompleteHandlerPolicy, 'dynamodb:DescribeTable'); let previousRegion; let previousRegionCondition; for (const region of new Set(regions)) { // Remove duplicates // Use multiple custom resources because multiple create/delete // updates cannot be combined in a single API call. const currentRegion = new core_1.CustomResource(this, `Replica${region}`, { serviceToken: provider.provider.serviceToken, resourceType: 'Custom::DynamoDBReplica', properties: { TableName: this.tableName, Region: region, SkipReplicationCompletedWait: waitForReplicationToFinish == null ? undefined // CFN changes Custom Resource properties to strings anyways, // so let's do that ourselves to make it clear in the handler this is a string, not a boolean : (!waitForReplicationToFinish).toString(), }, }); currentRegion.node.addDependency(onEventHandlerPolicy.policy, isCompleteHandlerPolicy.policy); this.globalReplicaCustomResources.push(currentRegion); // Deploy time check to prevent from creating a replica in the region // where this stack is deployed. Only needed for environment agnostic // stacks. let createReplica; if (core_1.Token.isUnresolved(stack.region)) { createReplica = new core_1.CfnCondition(this, `StackRegionNotEquals${region}`, { expression: core_1.Fn.conditionNot(core_1.Fn.conditionEquals(region, core_1.Aws.REGION)), }); const cfnCustomResource = currentRegion.node.defaultChild; cfnCustomResource.cfnOptions.condition = createReplica; } // Save regional arns for grantXxx() methods this.regionalArns.push(stack.formatArn({ region, service: 'dynamodb', resource: 'table', resourceName: this.tableName, })); // We need to create/delete regions sequentially because we cannot // have multiple table updates at the same time. The `isCompleteHandler` // of the provider waits until the replica is in an ACTIVE state. if (previousRegion) { if (previousRegionCondition) { // we can't simply use a Dependency, // because the previousRegion is protected by the "different region" Condition, // and you can't have Fn::If in DependsOn. // Instead, rely on Ref adding a dependency implicitly! const previousRegionCfnResource = previousRegion.node.defaultChild; const currentRegionCfnResource = currentRegion.node.defaultChild; currentRegionCfnResource.addMetadata('DynamoDbReplicationDependency', core_1.Fn.conditionIf(previousRegionCondition.logicalId, previousRegionCfnResource.ref, core_1.Aws.NO_VALUE)); } else { currentRegion.node.addDependency(previousRegion); } } previousRegion = currentRegion; previousRegionCondition = createReplica; } // Permissions in the destination regions (outside of the loop to // minimize statements in the policy) onEventHandlerPolicy.grantPrincipal.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['dynamodb:*'], resources: this.regionalArns, })); } /** * Whether this table has indexes */ get hasIndex() { return this.globalSecondaryIndexes.length + this.localSecondaryIndexes.length > 0; } /** * Set up key properties and return the Table encryption property from the * user's configuration. */ parseEncryption(props) { let encryptionType = props.encryption; if (encryptionType != null && props.serverSideEncryption != null) { throw new Error('Only one of encryption and serverSideEncryption can be specified, but both were provided'); } if (props.serverSideEncryption && props.encryptionKey) { throw new Error('encryptionKey cannot be specified when serverSideEncryption is specified. Use encryption instead'); } if (encryptionType === undefined) { encryptionType = props.encryptionKey != null // If there is a configured encryptionKey, the encryption is implicitly CUSTOMER_MANAGED ? TableEncryption.CUSTOMER_MANAGED // Otherwise, if severSideEncryption is enabled, it's AWS_MANAGED; else undefined (do not set anything) : props.serverSideEncryption ? TableEncryption.AWS_MANAGED : undefined; } if (encryptionType !== TableEncryption.CUSTOMER_MANAGED && props.encryptionKey) { throw new Error('`encryptionKey cannot be specified unless encryption is set to TableEncryption.CUSTOMER_MANAGED (it was set to ${encryptionType})`'); } if (encryptionType === TableEncryption.CUSTOMER_MANAGED && props.replicationRegions) { throw new Error('TableEncryption.CUSTOMER_MANAGED is not supported by DynamoDB Global Tables (where replicationRegions was set)'); } switch (encryptionType) { case TableEncryption.CUSTOMER_MANAGED: const encryptionKey = props.encryptionKey ?? new kms.Key(this, 'Key', { description: `Customer-managed key auto-created for encrypting DynamoDB table at ${this.node.path}`, enableKeyRotation: true, }); return { sseSpecification: { sseEnabled: true, kmsMasterKeyId: encryptionKey.keyArn, sseType: 'KMS' }, encryptionKey, }; case TableEncryption.AWS_MANAGED: // Not specifying "sseType: 'KMS'" here because it would cause phony changes to existing stacks. return { sseSpecification: { sseEnabled: true } }; case TableEncryption.DEFAULT: return { sseSpecification: { sseEnabled: false } }; case undefined: // Not specifying "sseEnabled: false" here because it would cause phony changes to existing stacks.