From 7861c6fe27274a4e311d222f9d6f9cc48f19a96d Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 25 Oct 2018 14:11:26 +0200 Subject: [PATCH] feat: add construct library for Application AutoScaling (#933) Adds a construct library for Application AutoScaling. The DynamoDB construct library has been updated to use the new AutoScaling mechanism, which allows more configuration and uses a Service Linked Role instead of a role per table. BREAKING CHANGE: instead of `addReadAutoScaling()`, call `autoScaleReadCapacity()`, and similar for write scaling. Fixes #856, #861, #640, #644. --- .../aws-applicationautoscaling/README.md | 151 ++- .../lib/base-scalable-attribute.ts | 99 ++ .../aws-applicationautoscaling/lib/cron.ts | 19 + .../aws-applicationautoscaling/lib/index.ts | 7 + .../lib/interval-utils.ts | 227 ++++ .../lib/scalable-target.ts | 256 +++++ .../lib/step-scaling-action.ts | 196 ++++ .../lib/step-scaling-policy.ts | 163 +++ .../lib/target-tracking-scaling-policy.ts | 168 +++ .../package-lock.json | 38 +- .../aws-applicationautoscaling/package.json | 3 + .../test/test.applicationautoscaling.ts | 8 - .../test/test.cron.ts | 14 + .../test/test.intervals.ts | 115 ++ .../test/test.scalable-target.ts | 61 ++ .../test/test.step-scaling-policy.ts | 228 ++++ .../test/test.target-tracking.ts | 61 ++ .../aws-applicationautoscaling/test/util.ts | 125 +++ .../@aws-cdk/aws-cloudwatch/lib/metric.ts | 10 +- .../aws-cloudwatch/lib/util.statistic.ts | 11 + .../aws-codebuild/test/test.codebuild.ts | 2 +- packages/@aws-cdk/aws-dynamodb/README.md | 77 +- packages/@aws-cdk/aws-dynamodb/lib/index.ts | 1 + .../lib/scalable-attribute-api.ts | 41 + .../lib/scalable-table-attribute.ts | 51 + packages/@aws-cdk/aws-dynamodb/lib/table.ts | 262 ++--- .../test/integ.autoscaling.lit.expected.json | 97 ++ .../test/integ.autoscaling.lit.ts | 29 + .../aws-dynamodb/test/test.dynamodb.ts | 982 ++---------------- packages/@aws-cdk/aws-iam/lib/group.ts | 4 +- packages/@aws-cdk/aws-iam/lib/index.ts | 1 + packages/@aws-cdk/aws-iam/lib/lazy-role.ts | 93 ++ packages/@aws-cdk/aws-iam/lib/policy.ts | 2 +- packages/@aws-cdk/aws-iam/lib/role.ts | 58 +- packages/@aws-cdk/aws-iam/lib/user.ts | 4 +- packages/@aws-cdk/aws-kinesis/lib/stream.ts | 8 +- packages/@aws-cdk/aws-sns/lib/topic-ref.ts | 2 +- .../aws-stepfunctions/test/test.activity.ts | 4 +- .../test/test.state-machine-resources.ts | 4 +- packages/@aws-cdk/runtime-values/lib/rtv.ts | 2 +- .../app/dotnet/src/HelloCdk/HelloConstruct.cs | 4 +- 41 files changed, 2541 insertions(+), 1147 deletions(-) create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/lib/base-scalable-attribute.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/lib/cron.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/lib/interval-utils.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/lib/scalable-target.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/lib/step-scaling-action.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/lib/step-scaling-policy.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/lib/target-tracking-scaling-policy.ts delete mode 100644 packages/@aws-cdk/aws-applicationautoscaling/test/test.applicationautoscaling.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/test/test.cron.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/test/test.intervals.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/test/test.scalable-target.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/test/test.step-scaling-policy.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/test/test.target-tracking.ts create mode 100644 packages/@aws-cdk/aws-applicationautoscaling/test/util.ts create mode 100644 packages/@aws-cdk/aws-dynamodb/lib/scalable-attribute-api.ts create mode 100644 packages/@aws-cdk/aws-dynamodb/lib/scalable-table-attribute.ts create mode 100644 packages/@aws-cdk/aws-dynamodb/test/integ.autoscaling.lit.expected.json create mode 100644 packages/@aws-cdk/aws-dynamodb/test/integ.autoscaling.lit.ts create mode 100644 packages/@aws-cdk/aws-iam/lib/lazy-role.ts diff --git a/packages/@aws-cdk/aws-applicationautoscaling/README.md b/packages/@aws-cdk/aws-applicationautoscaling/README.md index be8773df23e0f..d37687823c63f 100644 --- a/packages/@aws-cdk/aws-applicationautoscaling/README.md +++ b/packages/@aws-cdk/aws-applicationautoscaling/README.md @@ -1,2 +1,149 @@ -## The CDK Construct Library for AWS Application Auto-Scaling -This module is part of the [AWS Cloud Development Kit](https://github.com/awslabs/aws-cdk) project. +## AWS Application AutoScaling Construct Library + +**Application AutoScaling** is used to configure autoscaling for all +services other than scaling EC2 instances. For example, you will use this to +scale ECS tasks, DynamoDB capacity, Spot Fleet sizes and more. + +As a CDK user, you will probably not have to interact with this library +directly; instead, it will be used by other construct libraries to +offer AutoScaling features for their own constructs. + +This document will describe the general autoscaling features and concepts; +your particular service may offer only a subset of these. + +### AutoScaling basics + +Resources can offer one or more **attributes** to autoscale, typically +representing some capacity dimension of the underlying service. For example, +a DynamoDB Table offers autoscaling of the read and write capacity of the +table proper and its Global Secondary Indexes, an ECS Service offers +autoscaling of its task count, an RDS Aurora cluster offers scaling of its +replica count, and so on. + +When you enable autoscaling for an attribute, you specify a minimum and a +maximum value for the capacity. AutoScaling policies that respond to metrics +will never go higher or lower than the indicated capacity (but scheduled +scaling actions might, see below). + +There are three ways to scale your capacity: + +* **In response to a metric**; for example, you might want to scale out + if the CPU usage across your cluster starts to rise, and scale in + when it drops again. +* **By trying to keep a certain metric around a given value**; you might + want to automatically scale out an in to keep your CPU usage around 50%. +* **On a schedule**; you might want to organize your scaling around traffic + flows you expect, by scaling out in the morning and scaling in in the + evening. + +The general pattern of autoscaling will look like this: + +```ts +const capacity = resource.autoScaleCapacity({ + minCapacity: 5, + maxCapacity: 100 +}); + +// Enable a type of metric scaling and/or schedule scaling +capacity.scaleOnMetric(...); +capacity.scaleToTrackMetric(...); +capacity.scaleOnSchedule(...); +``` + +### AutoScaling in response to a metric + +This type of scaling scales in and out in deterministics steps that you +configure, in response to metric values. For example, your scaling strategy +to scale in response to CPU usage might look like this: + +``` + Scaling -1 (no change) +1 +3 + │ │ │ │ │ + ├────────┼───────────────────────┼────────┼────────┤ + │ │ │ │ │ +CPU usage 0% 10% 50% 70% 100% +``` + +(Note that this is not necessarily a recommended scaling strategy, but it's +a possible one. You will have to determine what thresholds are right for you). + +You would configure it like this: + +```ts +capacity.scaleOnMetric('ScaleToCPU', { + metric: service.metricCpuUtilization(), + scalingSteps: [ + { upper: 10, change: -1 }, + { lower: 50, change: +1 }, + { lower: 70, change: +3 }, + ], + + // Change this to AdjustmentType.PercentChangeInCapacity to interpret the + // 'change' numbers before as percentages instead of capacity counts. + adjustmentType: autoscaling.AdjustmentType.ChangeInCapacity, +}); +``` + +The AutoScaling construct library will create the required CloudWatch alarms and +AutoScaling policies for you. + +### AutoScaling by tracking a metric value + +This type of scaling scales in and out in order to keep a metric (typically +representing utilization) around a value you prefer. This type of scaling is +typically heavily service-dependent in what metric you can use, and so +different services will have different methods here to set up target tracking +scaling. + +The following example configures the read capacity of a DynamoDB table +to be around 60% utilization: + +```ts +const readCapacity = table.autosScaleReadCapacity({ + minCapacity: 10, + maxCapacity: 1000 +}); +readCapacity.scaleOnUtilization({ + targetUtilizationPercent: 60 +}); +``` + +### AutoScaling on a schedule + +This type of scaling is used to change capacities based on time. It works +by changing the `minCapacity` and `maxCapacity` of the attribute, and so +can be used for two purposes: + +* Scale in and out on a schedule by setting the `minCapacity` high or + the `maxCapacity` low. +* Still allow the regular scaling actions to do their job, but restrict + the range they can scale over (by setting both `minCapacity` and + `maxCapacity` but changing their range over time). + +The following schedule expressions can be used: + +* `at(yyyy-mm-ddThh:mm:ss)` -- scale at a particular moment in time +* `rate(value unit)` -- scale every minute/hour/day +* `cron(mm hh dd mm dow)` -- scale on arbitrary schedules + +Of these, the cron expression is the most useful but also the most +complicated. There is a `Cron` helper class to help build cron expressions. + +The following example scales the fleet out in the morning, and lets natural +scaling take over at night: + +```ts +const capacity = resource.autoScaleCapacity({ + minCapacity: 1, + maxCapacity: 50, +}); + +capacity.scaleOnSchedule('PrescaleInTheMorning', { + schedule: autoscaling.Cron.dailyUtc(8), + minCapacity: 20, +}); + +capacity.scaleOnSchedule('AllowDownscalingAtNight', { + schedule: autoscaling.Cron.dailyUtc(20), + minCapacity: 1 +}); diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/base-scalable-attribute.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/base-scalable-attribute.ts new file mode 100644 index 0000000000000..4f7ebc66e55b2 --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/base-scalable-attribute.ts @@ -0,0 +1,99 @@ +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import { ScalableTarget, ScalingSchedule, ServiceNamespace } from './scalable-target'; +import { BasicStepScalingPolicyProps } from './step-scaling-policy'; +import { BasicTargetTrackingScalingPolicyProps } from './target-tracking-scaling-policy'; + +/** + * Properties for a ScalableTableAttribute + */ +export interface BaseScalableAttributeProps extends EnableScalingProps { + /** + * Service namespace of the scalable attribute + */ + serviceNamespace: ServiceNamespace; + + /** + * Resource ID of the attribute + */ + resourceId: string; + + /** + * Scalable dimension of the attribute + */ + dimension: string; + + /** + * Role to use for scaling + */ + role: iam.IRole; +} + +/** + * Represent an attribute for which autoscaling can be configured + * + * This class is basically a light wrapper around ScalableTarget, but with + * all methods protected instead of public so they can be selectively + * exposed and/or more specific versions of them can be exposed by derived + * classes for individual services support autoscaling. + * + * Typical use cases: + * + * - Hide away the PredefinedMetric enum for target tracking policies. + * - Don't expose all scaling methods (for example Dynamo tables don't support + * Step Scaling, so the Dynamo subclass won't expose this method). + */ +export abstract class BaseScalableAttribute extends cdk.Construct { + private target: ScalableTarget; + + public constructor(parent: cdk.Construct, id: string, protected readonly props: BaseScalableAttributeProps) { + super(parent, id); + + this.target = new ScalableTarget(this, 'Target', { + serviceNamespace: this.props.serviceNamespace, + scalableDimension: this.props.dimension, + resourceId: this.props.resourceId, + role: this.props.role, + minCapacity: props.minCapacity !== undefined ? props.minCapacity : 1, + maxCapacity: props.maxCapacity + }); + } + + /** + * Scale out or in based on time + */ + protected scaleOnSchedule(id: string, props: ScalingSchedule) { + this.target.scaleOnSchedule(id, props); + } + + /** + * Scale out or in based on a metric value + */ + protected scaleOnMetric(id: string, props: BasicStepScalingPolicyProps) { + this.target.scaleOnMetric(id, props); + } + + /** + * Scale out or in in order to keep a metric around a target value + */ + protected scaleToTrackMetric(id: string, props: BasicTargetTrackingScalingPolicyProps) { + this.target.scaleToTrackMetric(id, props); + } +} + +/** + * Properties for enabling DynamoDB capacity scaling + */ +export interface EnableScalingProps { + /** + * Minimum capacity to scale to + * + * @default 1 + */ + minCapacity?: number; + + /** + * Maximum capacity to scale to + */ + maxCapacity: number; +} diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/cron.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/cron.ts new file mode 100644 index 0000000000000..9aa4369930a82 --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/cron.ts @@ -0,0 +1,19 @@ +/** + * Helper class to generate Cron expressions + */ +export class Cron { + + /** + * Return a cron expression to run every day at a particular time + * + * The time is specified in UTC. + * + * @param hour The hour in UTC to schedule this action + * @param minute The minute in the our to schedule this action (defaults to 0) + */ + public static dailyUtc(hour: number, minute?: number) { + minute = minute || 0; + // 3rd and 5th expression are mutually exclusive, one of them should be ? + return `cron(${minute} ${hour} * * ?)`; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/index.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/index.ts index 5f831ad820f97..843ea1a4a1d53 100644 --- a/packages/@aws-cdk/aws-applicationautoscaling/lib/index.ts +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/index.ts @@ -1,2 +1,9 @@ // AWS::ApplicationAutoScaling CloudFormation Resources: export * from './applicationautoscaling.generated'; + +export * from './base-scalable-attribute'; +export * from './cron'; +export * from './scalable-target'; +export * from './step-scaling-policy'; +export * from './step-scaling-action'; +export * from './target-tracking-scaling-policy'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/interval-utils.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/interval-utils.ts new file mode 100644 index 0000000000000..7403499c50929 --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/interval-utils.ts @@ -0,0 +1,227 @@ +import { ScalingInterval } from "./step-scaling-policy"; + +export interface CompleteScalingInterval { + lower: number; + upper: number; + change?: number; +} + +/** + * Normalize the given interval set to cover the complete number line and make sure it has at most one gap + */ +export function normalizeIntervals(intervals: ScalingInterval[], changesAreAbsolute: boolean): CompleteScalingInterval[] { + // Make intervals a complete numberline + const full = orderAndCompleteIntervals(intervals); + // Add 'undefined's in uncovered areas of the number line + makeGapsUndefined(full); + + // In case of relative changes, treat 0-change also as 'undefined' (= no change action) + if (!changesAreAbsolute) { makeZerosUndefined(full); } + + // Combine adjacent undefines and make sure there's at most one of them + combineUndefineds(full); + validateAtMostOneUndefined(full); + + return full; +} + +/** + * Completely order scaling intervals, making their lower and upper bounds concrete. + */ +function orderAndCompleteIntervals(intervals: ScalingInterval[]): CompleteScalingInterval[] { + if (intervals.length < 2) { + throw new Error('Require at least 2 intervals'); + } + + for (const interval of intervals) { + if (interval.lower === undefined && interval.upper === undefined) { + throw new Error(`Must supply at least one of 'upper' or 'lower', got: ${JSON.stringify(interval)}`); + } + } + + // Make a copy + intervals = intervals.map(x => ({...x})); + + // Sort by whatever number we have for each interval + intervals.sort(comparatorFromKey((x: ScalingInterval) => x.lower !== undefined ? x.lower : x.upper)); + + // Propagate boundaries until no more change + while (propagateBounds(intervals)) { /* Repeat */ } + + // Validate that no intervals have undefined bounds now, which must mean they're complete. + if (intervals[0].lower === undefined) { intervals[0].lower = 0; } + if (last(intervals).upper === undefined) { last(intervals).upper = Infinity; } + for (const interval of intervals) { + if (interval.lower === undefined || interval.upper === undefined) { + throw new Error(`Could not determine the lower and upper bounds for ${JSON.stringify(interval)}`); + } + } + + const completeIntervals = intervals as CompleteScalingInterval[]; + + // Validate that we have nonoverlapping intervals now. + for (let i = 0; i < completeIntervals.length - 1; i++) { + if (overlap(completeIntervals[i], completeIntervals[i + 1])) { + throw new Error(`Two intervals overlap: ${JSON.stringify(completeIntervals[i])} and ${JSON.stringify(completeIntervals[i + 1])}`); + } + } + + // Fill up the gaps + + return completeIntervals; +} + +/** + * Make the intervals cover the complete number line + * + * This entails adding intervals with an 'undefined' change to fill up the gaps. + * + * Since metrics have a halfopen interval, the first one will get a lower bound + * of 0, the last one will get an upper bound of +Infinity. + * + * In case of absolute adjustments, the lower number of the adjacent bound will + * be used, which means conservative change. In case of relative adjustments, + * we'll use relative adjusment 0 (which means no change). + */ +function makeGapsUndefined(intervals: CompleteScalingInterval[]) { + // Add edge intervals if necessary, but only for relative adjustments. Since we're + // going to make scaling intervals extend all the way out to infinity on either side, + // the result is the same for absolute adjustments anyway. + if (intervals[0].lower !== 0) { + intervals.splice(0, 1, { + lower: 0, + upper: intervals[0].lower, + change: undefined, + }); + } + if (last(intervals).upper !== Infinity) { + intervals.push({ + lower: last(intervals).upper, + upper: Infinity, + change: undefined + }); + } + + let i = 1; + while (i < intervals.length) { + if (intervals[i - 1].upper < intervals[i].lower) { + intervals.splice(i, 0, { + lower: intervals[i - 1].upper, + upper: intervals[i].lower, + change: undefined + }); + } else { + i++; + } + } +} + +/** + * Turn zero changes into undefined, in-place + */ +function makeZerosUndefined(intervals: CompleteScalingInterval[]) { + for (const interval of intervals) { + if (interval.change === 0) { + interval.change = undefined; + } + } +} + +/** + * If there are adjacent "undefined" intervals, combine them + */ +function combineUndefineds(intervals: CompleteScalingInterval[]) { + let i = 0; + while (i < intervals.length - 1) { + if (intervals[i].change === undefined && intervals[i + 1].change === undefined) { + intervals[i].upper = intervals[i + 1].upper; + intervals.splice(i + 1, 1); + } else { + i++; + } + } +} + +function validateAtMostOneUndefined(intervals: CompleteScalingInterval[]) { + const undef = intervals.filter(x => x.change === undefined); + if (undef.length > 1) { + throw new Error(`Can have at most one no-change interval, got ${JSON.stringify(undef)}`); + } +} + +function comparatorFromKey(keyFn: (x: T) => U) { + return (a: T, b: T) => { + const keyA = keyFn(a); + const keyB = keyFn(b); + + if (keyA < keyB) { return -1; } + if (keyA === keyB) { return 0; } + return 1; + }; +} + +function propagateBounds(intervals: ScalingInterval[]) { + let ret = false; + + // Propagate upper bounds upwards + for (let i = 0; i < intervals.length - 1; i++) { + if (intervals[i].upper !== undefined && intervals[i + 1].lower === undefined) { + intervals[i + 1].lower = intervals[i].upper; + ret = true; + } + } + + // Propagate lower bounds downwards + for (let i = intervals.length - 1; i >= 1; i--) { + if (intervals[i].lower !== undefined && intervals[i - 1].upper === undefined) { + intervals[i - 1].upper = intervals[i].lower; + ret = true; + } + } + + return ret; +} + +/** + * Whether two intervals overlap + */ +function overlap(a: CompleteScalingInterval, b: CompleteScalingInterval) { + return a.lower < b.upper && a.upper > b.lower; +} + +function last(xs: T[]) { + return xs[xs.length - 1]; +} + +export interface Alarms { + lowerAlarmIntervalIndex?: number; + upperAlarmIntervalIndex?: number; +} + +/** + * Locate the intervals that should have the alarm thresholds, by index. + * + * Pick the intervals on either side of the singleton "undefined" interval, or + * pick the middle interval if there's no such interval. + */ +export function findAlarmThresholds(intervals: CompleteScalingInterval[]): Alarms { + const gapIndex = intervals.findIndex(x => x.change === undefined); + + if (gapIndex !== -1) { + return { + lowerAlarmIntervalIndex: gapIndex > 0 ? gapIndex - 1 : undefined, + upperAlarmIntervalIndex: gapIndex < intervals.length - 1 ? gapIndex + 1 : undefined, + }; + } + + if (intervals.length === 1) { + return { upperAlarmIntervalIndex: 0 }; + } + + const middleIndex = Math.floor(intervals.length / 2); + + return { + lowerAlarmIntervalIndex: middleIndex - 1, + upperAlarmIntervalIndex: middleIndex + }; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/scalable-target.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/scalable-target.ts new file mode 100644 index 0000000000000..3f089474a12a0 --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/scalable-target.ts @@ -0,0 +1,256 @@ +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import { cloudformation } from './applicationautoscaling.generated'; +import { BasicStepScalingPolicyProps, StepScalingPolicy } from './step-scaling-policy'; +import { BasicTargetTrackingScalingPolicyProps, TargetTrackingScalingPolicy } from './target-tracking-scaling-policy'; + +/** + * Properties for a scalable target + */ +export interface ScalableTargetProps { + /** + * The minimum value that Application Auto Scaling can use to scale a target during a scaling activity. + */ + minCapacity: number; + + /** + * The maximum value that Application Auto Scaling can use to scale a target during a scaling activity. + */ + maxCapacity: number; + + /** + * Role that allows Application Auto Scaling to modify your scalable target. + * + * @default A role is automatically created + */ + role?: iam.IRole; + + /** + * The resource identifier to associate with this scalable target. + * + * This string consists of the resource type and unique identifier. + * + * @example service/ecsStack-MyECSCluster-AB12CDE3F4GH/ecsStack-MyECSService-AB12CDE3F4GH + * @see https://docs.aws.amazon.com/autoscaling/application/APIReference/API_RegisterScalableTarget.html + */ + resourceId: string; + + /** + * The scalable dimension that's associated with the scalable target. + * + * Specify the service namespace, resource type, and scaling property. + * + * @example ecs:service:DesiredCount + * @see https://docs.aws.amazon.com/autoscaling/application/APIReference/API_ScalingPolicy.html + */ + scalableDimension: string; + + /** + * The namespace of the AWS service that provides the resource or + * custom-resource for a resource provided by your own application or + * service. + * + * For valid AWS service namespace values, see the RegisterScalableTarget + * action in the Application Auto Scaling API Reference. + * + * @see https://docs.aws.amazon.com/autoscaling/application/APIReference/API_RegisterScalableTarget.html + */ + serviceNamespace: ServiceNamespace; +} + +/** + * Define a scalable target + */ +export class ScalableTarget extends cdk.Construct { + /** + * ID of the Scalable Target + * + * @example service/ecsStack-MyECSCluster-AB12CDE3F4GH/ecsStack-MyECSService-AB12CDE3F4GH|ecs:service:DesiredCount|ecs + */ + public readonly scalableTargetId: string; + + /** + * The role used to give AutoScaling permissions to your resource + */ + public readonly role: iam.IRole; + + private readonly actions = new Array(); + + constructor(parent: cdk.Construct, id: string, props: ScalableTargetProps) { + super(parent, id); + + if (props.maxCapacity < 0) { + throw new RangeError(`maxCapacity cannot be negative, got: ${props.maxCapacity}`); + } + if (props.minCapacity < 0) { + throw new RangeError(`minCapacity cannot be negative, got: ${props.minCapacity}`); + } + if (props.maxCapacity < props.minCapacity) { + throw new RangeError(`minCapacity (${props.minCapacity}) should be lower than maxCapacity (${props.maxCapacity})`); + } + + this.role = props.role || new iam.Role(this, 'Role', { + assumedBy: new iam.ServicePrincipal('application-autoscaling.amazonaws.com') + }); + + const resource = new cloudformation.ScalableTargetResource(this, 'Resource', { + maxCapacity: props.maxCapacity, + minCapacity: props.minCapacity, + resourceId: props.resourceId, + roleArn: this.role.roleArn, + scalableDimension: props.scalableDimension, + scheduledActions: this.actions, + serviceNamespace: props.serviceNamespace + }); + + this.scalableTargetId = resource.scalableTargetId; + } + + /** + * Add a policy statement to the role's policy + */ + public addToRolePolicy(statement: iam.PolicyStatement) { + this.role.addToPolicy(statement); + } + + /** + * Scale out or in based on time + */ + public scaleOnSchedule(id: string, action: ScalingSchedule) { + if (action.minCapacity === undefined && action.maxCapacity === undefined) { + throw new Error(`You must supply at least one of minCapacity or maxCapacity, got ${JSON.stringify(action)}`); + } + this.actions.push({ + scheduledActionName: id, + schedule: action.schedule, + startTime: action.startTime, + endTime: action.endTime, + scalableTargetAction: { + maxCapacity: action.maxCapacity, + minCapacity: action.minCapacity + }, + }); + } + + /** + * Scale out or in, in response to a metric + */ + public scaleOnMetric(id: string, props: BasicStepScalingPolicyProps) { + return new StepScalingPolicy(this, id, { ...props, scalingTarget: this }); + } + + /** + * Scale out or in in order to keep a metric around a target value + */ + public scaleToTrackMetric(id: string, props: BasicTargetTrackingScalingPolicyProps) { + return new TargetTrackingScalingPolicy(this, id, { ...props, scalingTarget: this }); + } +} + +/** + * A scheduled scaling action + */ +export interface ScalingSchedule { + /** + * When to perform this action. + * + * Support formats: + * - at(yyyy-mm-ddThh:mm:ss) + * - rate(value unit) + * - cron(fields) + * + * "At" expressions are useful for one-time schedules. Specify the time in + * UTC. + * + * For "rate" expressions, value is a positive integer, and unit is minute, + * minutes, hour, hours, day, or days. + * + * For more information about cron expressions, see https://en.wikipedia.org/wiki/Cron. + * + * @example rate(12 hours) + */ + schedule: string; + + /** + * When this scheduled action becomes active. + * + * @default The rule is activate immediately + */ + startTime?: Date + + /** + * When this scheduled action expires. + * + * @default The rule never expires. + */ + endTime?: Date; + + /** + * The new minimum capacity. + * + * During the scheduled time, if the current capacity is below the minimum + * capacity, Application Auto Scaling scales out to the minimum capacity. + * + * At least one of maxCapacity and minCapacity must be supplied. + * + * @default No new minimum capacity + */ + minCapacity?: number; + + /** + * The new maximum capacity. + * + * During the scheduled time, the current capacity is above the maximum + * capacity, Application Auto Scaling scales in to the maximum capacity. + * + * At least one of maxCapacity and minCapacity must be supplied. + * + * @default No new maximum capacity + */ + maxCapacity?: number; +} + +/** + * The service that supports Application AutoScaling + */ +export enum ServiceNamespace { + /** + * Elastic Container Service + */ + Ecs = 'ecs', + + /** + * Elastic Map Reduce + */ + ElasticMapReduce = 'elasticmapreduce', + + /** + * Elastic Compute Cloud + */ + Ec2 = 'ec2', + + /** + * App Stream + */ + AppStream = 'appstream', + + /** + * Dynamo DB + */ + DynamoDb = 'dynamodb', + + /** + * Relational Database Service + */ + Rds = 'rds', + + /** + * SageMaker + */ + SageMaker = 'sagemaker', + + /** + * Custom Resource + */ + CustomResource = 'custom-resource', +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/step-scaling-action.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/step-scaling-action.ts new file mode 100644 index 0000000000000..75ec22c9e6a0b --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/step-scaling-action.ts @@ -0,0 +1,196 @@ +import cloudwatch = require('@aws-cdk/aws-cloudwatch'); +import cdk = require('@aws-cdk/cdk'); +import { cloudformation } from './applicationautoscaling.generated'; +import { ScalableTarget } from './scalable-target'; + +/** + * Properties for a scaling policy + */ +export interface StepScalingActionProps { + /** + * The scalable target + */ + scalingTarget: ScalableTarget; + + /** + * A name for the scaling policy + * + * @default Automatically generated name + */ + policyName?: string; + + /** + * How the adjustment numbers are interpreted + * + * @default ChangeInCapacity + */ + adjustmentType?: AdjustmentType; + + /** + * Grace period after scaling activity. + * + * For scale out policies, multiple scale outs during the cooldown period are + * squashed so that only the biggest scale out happens. + * + * For scale in policies, subsequent scale ins during the cooldown period are + * ignored. + * + * @see https://docs.aws.amazon.com/autoscaling/application/APIReference/API_StepScalingPolicyConfiguration.html + * @default No cooldown period + */ + cooldownSec?: number; + + /** + * Minimum absolute number to adjust capacity with as result of percentage scaling. + * + * Only when using AdjustmentType = PercentChangeInCapacity, this number controls + * the minimum absolute effect size. + * + * @default No minimum scaling effect + */ + minAdjustmentMagnitude?: number; + + /** + * The aggregation type for the CloudWatch metrics. + * + * @default Average + */ + metricAggregationType?: MetricAggregationType; +} + +/** + * Define a step scaling action + * + * This kind of scaling policy adjusts the target capacity in configurable + * steps. The size of the step is configurable based on the metric's distance + * to its alarm threshold. + * + * This Action must be used as the target of a CloudWatch alarm to take effect. + */ +export class StepScalingAction extends cdk.Construct implements cloudwatch.IAlarmAction { + /** + * ARN of the scaling policy + */ + public readonly scalingPolicyArn: string; + + /** + * ARN when this scaling policy is used as an Alarm action + */ + public readonly alarmActionArn: string; + + private readonly adjustments = new Array(); + + constructor(parent: cdk.Construct, id: string, props: StepScalingActionProps) { + super(parent, id); + + const resource = new cloudformation.ScalingPolicyResource(this, 'Resource', { + policyName: props.policyName || this.uniqueId, + policyType: 'StepScaling', + stepScalingPolicyConfiguration: { + adjustmentType: props.adjustmentType, + cooldown: props.cooldownSec, + minAdjustmentMagnitude: props.minAdjustmentMagnitude, + metricAggregationType: props.metricAggregationType, + stepAdjustments: new cdk.Token(() => this.adjustments), + } as cloudformation.ScalingPolicyResource.StepScalingPolicyConfigurationProperty + }); + + this.scalingPolicyArn = resource.scalingPolicyArn; + this.alarmActionArn = this.scalingPolicyArn; + } + + /** + * Add an adjusment interval to the ScalingAction + */ + public addAdjustment(adjustment: AdjustmentTier) { + if (adjustment.lowerBound === undefined && adjustment.upperBound === undefined) { + throw new Error('At least one of lowerBound or upperBound is required'); + } + this.adjustments.push({ + metricIntervalLowerBound: adjustment.lowerBound, + metricIntervalUpperBound: adjustment.upperBound, + scalingAdjustment: adjustment.adjustment, + }); + } +} + +/** + * How adjustment numbers are interpreted + */ +export enum AdjustmentType { + /** + * Add the adjustment number to the current capacity. + * + * A positive number increases capacity, a negative number decreases capacity. + */ + ChangeInCapacity = 'ChangeInCapacity', + + /** + * Add this percentage of the current capacity to itself. + * + * The number must be between -100 and 100; a positive number increases + * capacity and a negative number decreases it. + */ + PercentChangeInCapacity = 'PercentChangeInCapacity', + + /** + * Make the capacity equal to the exact number given. + */ + ExactCapacity = 'ExactCapacity', +} + +/** + * How the scaling metric is going to be aggregated + */ +export enum MetricAggregationType { + /** + * Average + */ + Average = 'Average', + + /** + * Minimum + */ + Minimum = 'Minimum', + + /** + * Maximum + */ + Maximum = 'Maximum' +} + +/** + * An adjustment + */ +export interface AdjustmentTier { + /** + * What number to adjust the capacity with + * + * The number is interpeted as an added capacity, a new fixed capacity or an + * added percentage depending on the AdjustmentType value of the + * StepScalingPolicy. + * + * Can be positive or negative. + */ + adjustment: number; + + /** + * Lower bound where this scaling tier applies. + * + * The scaling tier applies if the difference between the metric + * value and its alarm threshold is higher than this value. + * + * @default -Infinity if this is the first tier, otherwise the upperBound of the previous tier + */ + lowerBound?: number; + + /** + * Upper bound where this scaling tier applies + * + * The scaling tier applies if the difference between the metric + * value and its alarm threshold is lower than this value. + * + * @default +Infinity + */ + upperBound?: number; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/step-scaling-policy.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/step-scaling-policy.ts new file mode 100644 index 0000000000000..41b8538e19f3d --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/step-scaling-policy.ts @@ -0,0 +1,163 @@ +import cloudwatch = require('@aws-cdk/aws-cloudwatch'); +import cdk = require('@aws-cdk/cdk'); +import { findAlarmThresholds, normalizeIntervals } from './interval-utils'; +import { ScalableTarget } from './scalable-target'; +import { AdjustmentType, MetricAggregationType, StepScalingAction } from './step-scaling-action'; + +export interface BasicStepScalingPolicyProps { + /** + * Metric to scale on. + */ + metric: cloudwatch.Metric; + + /** + * The intervals for scaling. + * + * Maps a range of metric values to a particular scaling behavior. + */ + scalingSteps: ScalingInterval[]; + + /** + * How the adjustment numbers inside 'intervals' are interpreted. + * + * @default ChangeInCapacity + */ + adjustmentType?: AdjustmentType; + + /** + * Grace period after scaling activity. + * + * Subsequent scale outs during the cooldown period are squashed so that only + * the biggest scale out happens. + * + * Subsequent scale ins during the cooldown period are ignored. + * + * @see https://docs.aws.amazon.com/autoscaling/application/APIReference/API_StepScalingPolicyConfiguration.html + * @default No cooldown period + */ + cooldownSec?: number; + + /** + * Minimum absolute number to adjust capacity with as result of percentage scaling. + * + * Only when using AdjustmentType = PercentChangeInCapacity, this number controls + * the minimum absolute effect size. + * + * @default No minimum scaling effect + */ + minAdjustmentMagnitude?: number; +} + +export interface StepScalingPolicyProps extends BasicStepScalingPolicyProps { + /** + * The scaling target + */ + scalingTarget: ScalableTarget; +} + +/** + * Define a acaling strategy which scales depending on absolute values of some metric. + * + * You can specify the scaling behavior for various values of the metric. + * + * Implemented using one or more CloudWatch alarms and Step Scaling Policies. + */ +export class StepScalingPolicy extends cdk.Construct { + public readonly lowerAlarm?: cloudwatch.Alarm; + public readonly lowerAction?: StepScalingAction; + public readonly upperAlarm?: cloudwatch.Alarm; + public readonly upperAction?: StepScalingAction; + + constructor(parent: cdk.Construct, id: string, props: StepScalingPolicyProps) { + super(parent, id); + + if (props.scalingSteps.length < 2) { + throw new Error('You must supply at least 2 intervals for autoscaling'); + } + + const adjustmentType = props.adjustmentType || AdjustmentType.ChangeInCapacity; + const changesAreAbsolute = adjustmentType === AdjustmentType.ExactCapacity; + + const intervals = normalizeIntervals(props.scalingSteps, changesAreAbsolute); + const alarms = findAlarmThresholds(intervals); + + if (alarms.lowerAlarmIntervalIndex) { + const threshold = intervals[alarms.lowerAlarmIntervalIndex].upper; + + this.lowerAction = new StepScalingAction(this, 'LowerPolicy', { + adjustmentType: props.adjustmentType, + cooldownSec: props.cooldownSec, + metricAggregationType: aggregationTypeFromMetric(props.metric), + minAdjustmentMagnitude: props.minAdjustmentMagnitude, + scalingTarget: props.scalingTarget, + }); + + for (let i = alarms.lowerAlarmIntervalIndex; i >= 0; i--) { + this.lowerAction.addAdjustment({ + adjustment: intervals[i].change!, + lowerBound: i !== 0 ? intervals[i].lower - threshold : undefined, // Extend last interval to -infinity + upperBound: intervals[i].upper - threshold, + }); + } + + this.lowerAlarm = new cloudwatch.Alarm(this, 'LowerAlarm', { + // Recommended by AutoScaling + metric: props.metric.with({ periodSec: 60 }), + alarmDescription: 'Lower threshold scaling alarm', + comparisonOperator: cloudwatch.ComparisonOperator.LessThanThreshold, + evaluationPeriods: 1, + threshold, + }); + this.lowerAlarm.onAlarm(this.lowerAction); + } + + if (alarms.upperAlarmIntervalIndex) { + const threshold = intervals[alarms.upperAlarmIntervalIndex].lower; + + this.upperAction = new StepScalingAction(this, 'UpperPolicy', { + adjustmentType: props.adjustmentType, + cooldownSec: props.cooldownSec, + metricAggregationType: aggregationTypeFromMetric(props.metric), + minAdjustmentMagnitude: props.minAdjustmentMagnitude, + scalingTarget: props.scalingTarget, + }); + + for (let i = alarms.upperAlarmIntervalIndex; i < intervals.length; i++) { + this.upperAction.addAdjustment({ + adjustment: intervals[i].change!, + lowerBound: intervals[i].lower - threshold, + upperBound: i !== intervals.length - 1 ? intervals[i].upper - threshold : undefined, // Extend last interval to +infinity + }); + } + + this.upperAlarm = new cloudwatch.Alarm(this, 'UpperAlarm', { + // Recommended by AutoScaling + metric: props.metric.with({ periodSec: 60 }), + alarmDescription: 'Upper threshold scaling alarm', + comparisonOperator: cloudwatch.ComparisonOperator.GreaterThanThreshold, + evaluationPeriods: 1, + threshold, + }); + this.upperAlarm.onAlarm(this.upperAction); + } + } +} + +export interface ScalingInterval { + lower?: number; + upper?: number; + change: number; +} + +function aggregationTypeFromMetric(metric: cloudwatch.Metric): MetricAggregationType { + switch (metric.statistic) { + case 'Average': + return MetricAggregationType.Average; + case 'Minimum': + return MetricAggregationType.Minimum; + case 'Maximum': + return MetricAggregationType.Maximum; + default: + throw new Error(`Cannot only scale on 'Minimum', 'Maximum', 'Average' metrics, got ${metric.statistic}`); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/target-tracking-scaling-policy.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/target-tracking-scaling-policy.ts new file mode 100644 index 0000000000000..655ddf09d065b --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/target-tracking-scaling-policy.ts @@ -0,0 +1,168 @@ +import cloudwatch = require('@aws-cdk/aws-cloudwatch'); +import cdk = require('@aws-cdk/cdk'); +import { cloudformation } from './applicationautoscaling.generated'; +import { ScalableTarget } from './scalable-target'; + +/** + * Base interface for target tracking props + * + * Contains the attributes that are common to target tracking policies, + * except the ones relating to the metric and to the scalable target. + * + * This interface is reused by more specific target tracking props objects + * in other services. + */ +export interface BaseTargetTrackingProps { + /** + * A name for the scaling policy + * + * @default Automatically generated name + */ + policyName?: string; + + /** + * Indicates whether scale in by the target tracking policy is disabled. + * + * If the value is true, scale in is disabled and the target tracking policy + * won't remove capacity from the scalable resource. Otherwise, scale in is + * enabled and the target tracking policy can remove capacity from the + * scalable resource. + * + * @default false + */ + disableScaleIn?: boolean; + + /** + * Period after a scale in activity completes before another scale in activity can start. + * + * @default No scale in cooldown + */ + scaleInCooldownSec?: number; + + /** + * Period after a scale out activity completes before another scale out activity can start. + * + * @default No scale out cooldown + */ + scaleOutCooldownSec?: number; +} + +/** + * Properties for a Target Tracking policy that include the metric but exclude the target + */ +export interface BasicTargetTrackingScalingPolicyProps extends BaseTargetTrackingProps { + /** + * The target value for the metric. + */ + targetValue: number; + + /** + * A predefined metric for application autoscaling + * + * The metric must track utilization. Scaling out will happen if the metric is higher than + * the target value, scaling in will happen in the metric is lower than the target value. + * + * Exactly one of customMetric or predefinedMetric must be specified. + */ + predefinedMetric?: PredefinedMetric; + + /** + * Identify the resource associated with the metric type. + * + * Only used for predefined metric ALBRequestCountPerTarget. + * + * @example app///targetgroup// + */ + resourceLabel?: string; + + /** + * A custom metric for application autoscaling + * + * The metric must track utilization. Scaling out will happen if the metric is higher than + * the target value, scaling in will happen in the metric is lower than the target value. + * + * Exactly one of customMetric or predefinedMetric must be specified. + */ + customMetric?: cloudwatch.Metric; +} + +/** + * Properties for a concrete TargetTrackingPolicy + * + * Adds the scalingTarget. + */ +export interface TargetTrackingScalingPolicyProps extends BasicTargetTrackingScalingPolicyProps { + /* + * The scalable target + */ + scalingTarget: ScalableTarget; +} + +export class TargetTrackingScalingPolicy extends cdk.Construct { + /** + * ARN of the scaling policy + */ + public readonly scalingPolicyArn: string; + + constructor(parent: cdk.Construct, id: string, props: TargetTrackingScalingPolicyProps) { + if ((props.customMetric === undefined) === (props.predefinedMetric === undefined)) { + throw new Error(`Exactly one of 'customMetric' or 'predefinedMetric' must be specified.`); + } + + if (props.scaleInCooldownSec !== undefined && props.scaleInCooldownSec < 0) { + throw new RangeError(`scaleInCooldown cannot be negative, got: ${props.scaleInCooldownSec}`); + } + if (props.scaleOutCooldownSec !== undefined && props.scaleOutCooldownSec < 0) { + throw new RangeError(`scaleOutCooldown cannot be negative, got: ${props.scaleOutCooldownSec}`); + } + + super(parent, id); + + const resource = new cloudformation.ScalingPolicyResource(this, 'Resource', { + policyName: props.policyName || this.uniqueId, + policyType: 'TargetTrackingScaling', + scalingTargetId: props.scalingTarget.scalableTargetId, + targetTrackingScalingPolicyConfiguration: { + customizedMetricSpecification: renderCustomMetric(props.customMetric), + disableScaleIn: props.disableScaleIn, + predefinedMetricSpecification: props.predefinedMetric !== undefined ? { + predefinedMetricType: props.predefinedMetric, + resourceLabel: props.resourceLabel, + } : undefined, + scaleInCooldown: props.scaleInCooldownSec, + scaleOutCooldown: props.scaleOutCooldownSec, + targetValue: props.targetValue + } + }); + + this.scalingPolicyArn = resource.scalingPolicyArn; + } +} + +function renderCustomMetric(metric?: cloudwatch.Metric): cloudformation.ScalingPolicyResource.CustomizedMetricSpecificationProperty | undefined { + if (!metric) { return undefined; } + return { + dimensions: metric.dimensionsAsList(), + metricName: metric.metricName, + namespace: metric.namespace, + statistic: metric.statistic, + unit: metric.unit + }; +} + +/** + * One of the predefined autoscaling metrics + */ +export enum PredefinedMetric { + DynamoDBReadCapacityUtilization = 'DynamoDBReadCapacityUtilization', + DynamoDBWriteCapacityUtilization = 'DynamoDBWriteCapacityUtilization', + ALBRequestCountPerTarget = 'ALBRequestCountPerTarget', + RDSReaderAverageCPUUtilization = 'RDSReaderAverageCPUUtilization', + RDSReaderAverageDatabaseConnections = 'RDSReaderAverageDatabaseConnections', + EC2SpotFleetRequestAverageCPUUtilization = 'EC2SpotFleetRequestAverageCPUUtilization', + EC2SpotFleetRequestAverageNetworkIn = 'EC2SpotFleetRequestAverageNetworkIn', + EC2SpotFleetRequestAverageNetworkOut = 'EC2SpotFleetRequestAverageNetworkOut', + SageMakerVariantInvocationsPerInstance = 'SageMakerVariantInvocationsPerInstance', + ECSServiceAverageCPUUtilization = 'ECSServiceAverageCPUUtilization', + ECSServiceAverageMemoryUtilization = 'ECSServiceAverageMemoryUtilization', +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/package-lock.json b/packages/@aws-cdk/aws-applicationautoscaling/package-lock.json index d022abf251f62..d3487567d0caa 100644 --- a/packages/@aws-cdk/aws-applicationautoscaling/package-lock.json +++ b/packages/@aws-cdk/aws-applicationautoscaling/package-lock.json @@ -1,5 +1,39 @@ { "name": "@aws-cdk/aws-applicationautoscaling", - "version": "0.9.0", - "lockfileVersion": 1 + "version": "0.10.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "fast-check": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-1.6.1.tgz", + "integrity": "sha512-Y2Ew0tt5KCRHVRYLxl0Ar4RQ36cS4SAn6GvGYBl2jkFcFZI++drjxRU8GsLbuhzF1FuuoHfLnJamBTH+aXbPxQ==", + "dev": true, + "requires": { + "lorem-ipsum": "~1.0.6", + "pure-rand": "^1.4.0" + } + }, + "lorem-ipsum": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/lorem-ipsum/-/lorem-ipsum-1.0.6.tgz", + "integrity": "sha512-Rx4XH8X4KSDCKAVvWGYlhAfNqdUP5ZdT4rRyf0jjrvWgtViZimDIlopWNfn/y3lGM5K4uuiAoY28TaD+7YKFrQ==", + "dev": true, + "requires": { + "minimist": "~1.2.0" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "pure-rand": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-1.4.2.tgz", + "integrity": "sha512-5WrOH3ZPZgwW5CRyeNxmZ8BcQnL6s0YWGOZL6SROLfhIw9Uc1SseEyeNw9q5tc3Y5E783yzvNlsE9KJY8IuxcA==", + "dev": true + } + } } diff --git a/packages/@aws-cdk/aws-applicationautoscaling/package.json b/packages/@aws-cdk/aws-applicationautoscaling/package.json index 0b1bfd68ef240..cf08d9223293d 100644 --- a/packages/@aws-cdk/aws-applicationautoscaling/package.json +++ b/packages/@aws-cdk/aws-applicationautoscaling/package.json @@ -55,9 +55,12 @@ "@aws-cdk/assert": "^0.13.0", "cdk-build-tools": "^0.13.0", "cfn2ts": "^0.13.0", + "fast-check": "^1.6.1", "pkglint": "^0.13.0" }, "dependencies": { + "@aws-cdk/aws-cloudwatch": "^0.13.0", + "@aws-cdk/aws-iam": "^0.13.0", "@aws-cdk/cdk": "^0.13.0" }, "homepage": "https://github.com/awslabs/aws-cdk" diff --git a/packages/@aws-cdk/aws-applicationautoscaling/test/test.applicationautoscaling.ts b/packages/@aws-cdk/aws-applicationautoscaling/test/test.applicationautoscaling.ts deleted file mode 100644 index 820f6b467f38f..0000000000000 --- a/packages/@aws-cdk/aws-applicationautoscaling/test/test.applicationautoscaling.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Test, testCase } from 'nodeunit'; - -export = testCase({ - notTested(test: Test) { - test.ok(true, 'No tests are specified for this package.'); - test.done(); - } -}); diff --git a/packages/@aws-cdk/aws-applicationautoscaling/test/test.cron.ts b/packages/@aws-cdk/aws-applicationautoscaling/test/test.cron.ts new file mode 100644 index 0000000000000..3697c9affe4f8 --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/test/test.cron.ts @@ -0,0 +1,14 @@ +import { Test } from 'nodeunit'; +import appscaling = require('../lib'); + +export = { + 'test utc cron, hour only'(test: Test) { + test.equals(appscaling.Cron.dailyUtc(18), 'cron(0 18 * * ?)'); + test.done(); + }, + + 'test utc cron, hour and minute'(test: Test) { + test.equals(appscaling.Cron.dailyUtc(18, 24), 'cron(24 18 * * ?)'); + test.done(); + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/test/test.intervals.ts b/packages/@aws-cdk/aws-applicationautoscaling/test/test.intervals.ts new file mode 100644 index 0000000000000..1141316ff760b --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/test/test.intervals.ts @@ -0,0 +1,115 @@ +import fc = require('fast-check'); +import { Test } from 'nodeunit'; +import appscaling = require('../lib'); +import { findAlarmThresholds, normalizeIntervals } from '../lib/interval-utils'; +import { arbitrary_complete_intervals } from './util'; + +export = { + 'test bounds propagation'(test: Test) { + const intervals = normalizeIntervals(realisticRelativeIntervals(), false); + + test.deepEqual(intervals, [ + { lower: 0, upper: 10, change: -2 }, + { lower: 10, upper: 20, change: -1 }, + { lower: 20, upper: 80, change: undefined }, + { lower: 80, upper: 90, change: +1 }, + { lower: 90, upper: Infinity, change: +2 }, + ]); + + test.done(); + }, + + 'bounds propagation fails if middle boundary missing'(test: Test) { + test.throws(() => { + normalizeIntervals([ + { lower: 0, change: -2 }, + { upper: 20, change: -1 }, + ], false); + }); + + test.done(); + }, + + 'lower alarm index is lower than higher alarm index'(test: Test) { + fc.assert(fc.property( + arbitrary_complete_intervals(), + (intervals) => { + const alarms = findAlarmThresholds(intervals); + + return (alarms.lowerAlarmIntervalIndex === undefined + || alarms.upperAlarmIntervalIndex === undefined + || alarms.lowerAlarmIntervalIndex < alarms.upperAlarmIntervalIndex); + } + )); + + test.done(); + }, + + 'never pick undefined intervals for relative alarms'(test: Test) { + fc.assert(fc.property( + arbitrary_complete_intervals(), + (intervals) => { + const alarms = findAlarmThresholds(intervals); + + return (alarms.lowerAlarmIntervalIndex === undefined || intervals[alarms.lowerAlarmIntervalIndex].change !== undefined) + && (alarms.upperAlarmIntervalIndex === undefined || intervals[alarms.upperAlarmIntervalIndex].change !== undefined); + } + )); + + test.done(); + }, + + 'pick intervals on either side of the undefined interval, if present'(test: Test) { + fc.assert(fc.property( + arbitrary_complete_intervals(), + (intervals) => { + // There must be an undefined interval and it must not be at the edges + const i = intervals.findIndex(x => x.change === undefined); + fc.pre(i > 0 && i < intervals.length - 1); + + const alarms = findAlarmThresholds(intervals); + return (alarms.lowerAlarmIntervalIndex === i - 1 && alarms.upperAlarmIntervalIndex === i + 1); + } + )); + + test.done(); + }, + + 'no picking upper bound infinity for lower alarm'(test: Test) { + fc.assert(fc.property( + arbitrary_complete_intervals(), + (intervals) => { + const alarms = findAlarmThresholds(intervals); + fc.pre(alarms.lowerAlarmIntervalIndex !== undefined); + + return intervals[alarms.lowerAlarmIntervalIndex!].upper !== Infinity; + } + )); + + test.done(); + }, + + 'no picking lower bound 0 for upper alarm'(test: Test) { + fc.assert(fc.property( + arbitrary_complete_intervals(), + (intervals) => { + const alarms = findAlarmThresholds(intervals); + fc.pre(alarms.upperAlarmIntervalIndex !== undefined); + + return intervals[alarms.upperAlarmIntervalIndex!].lower !== 0; + } + )); + + test.done(); + }, +}; + +function realisticRelativeIntervals(): appscaling.ScalingInterval[] { + // Function so we don't have to worry about cloning + return [ + { upper: 10, change: -2 }, + { upper: 20, change: -1 }, + { lower: 80, change: +1 }, + { lower: 90, change: +2 }, + ]; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/test/test.scalable-target.ts b/packages/@aws-cdk/aws-applicationautoscaling/test/test.scalable-target.ts new file mode 100644 index 0000000000000..1afe47ee0031f --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/test/test.scalable-target.ts @@ -0,0 +1,61 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import appscaling = require('../lib'); +import { createScalableTarget } from './util'; + +export = { + 'test scalable target creation'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new appscaling.ScalableTarget(stack, 'Target', { + serviceNamespace: appscaling.ServiceNamespace.DynamoDb, + scalableDimension: 'test:TestCount', + resourceId: 'test:this/test', + minCapacity: 1, + maxCapacity: 20, + }); + + // THEN + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + ServiceNamespace: 'dynamodb', + ScalableDimension: 'test:TestCount', + ResourceId: 'test:this/test', + MinCapacity: 1, + MaxCapacity: 20, + })); + + test.done(); + }, + + 'add scheduled scaling'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const target = createScalableTarget(stack); + + // WHEN + target.scaleOnSchedule('ScaleUp', { + schedule: 'rate(1 second)', + maxCapacity: 50, + minCapacity: 1, + }); + + // THEN + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + ScheduledActions: [ + { + ScalableTargetAction: { + MaxCapacity: 50, + MinCapacity: 1 + }, + Schedule: "rate(1 second)", + ScheduledActionName: "ScaleUp" + } + ] + })); + + test.done(); + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/test/test.step-scaling-policy.ts b/packages/@aws-cdk/aws-applicationautoscaling/test/test.step-scaling-policy.ts new file mode 100644 index 0000000000000..375bce24ec59b --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/test/test.step-scaling-policy.ts @@ -0,0 +1,228 @@ +import cloudwatch = require('@aws-cdk/aws-cloudwatch'); +import cdk = require('@aws-cdk/cdk'); +import fc = require('fast-check'); +import { Test } from 'nodeunit'; +import appscaling = require('../lib'); +import { arbitrary_input_intervals, createScalableTarget } from './util'; + +export = { + 'alarm thresholds are valid numbers'(test: Test) { + fc.assert(fc.property( + arbitrary_input_intervals(), + (intervals) => { + const template = setupStepScaling(intervals); + + const lowerThreshold = template.lowerThreshold; + const upperThreshold = template.upperThreshold; + + return reportFalse( + (lowerThreshold === undefined || (lowerThreshold > 0 && lowerThreshold !== Infinity)) + && (upperThreshold === undefined || (upperThreshold > 0 && upperThreshold !== Infinity)), + lowerThreshold, + upperThreshold); + } + )); + + test.done(); + }, + + 'generated step intervals are valid intervals'(test: Test) { + fc.assert(fc.property( + arbitrary_input_intervals(), + (intervals) => { + const template = setupStepScaling(intervals); + const steps = template.allStepsAbsolute(); + + return reportFalse(steps.every(step => { + return step.MetricIntervalLowerBound! < step.MetricIntervalUpperBound!; + }), steps, 'template', JSON.stringify(template, undefined, 2)); + } + )); + + test.done(); + }, + + 'generated step intervals are nonoverlapping'(test: Test) { + fc.assert(fc.property( + arbitrary_input_intervals(), + (intervals) => { + const template = setupStepScaling(intervals); + const steps = template.allStepsAbsolute(); + + for (let i = 0; i < steps.length; i++) { + const compareTo = steps.slice(i + 1); + if (compareTo.some(x => overlaps(steps[i], x))) { + return reportFalse(false, steps); + } + } + + return true; + } + ), { verbose: true }); + + test.done(); + }, + + 'all template intervals occur in input array'(test: Test) { + fc.assert(fc.property( + arbitrary_input_intervals(), + (intervals) => { + const template = setupStepScaling(intervals); + const steps = template.allStepsAbsolute(); + + return steps.every(step => { + return reportFalse(intervals.find(interval => { + const acceptableLowerBounds = step.MetricIntervalLowerBound === -Infinity ? [undefined, 0] : [undefined, step.MetricIntervalLowerBound]; + // tslint:disable-next-line:max-line-length + const acceptableUpperBounds = step.MetricIntervalUpperBound === Infinity ? [undefined, Infinity] : [undefined, step.MetricIntervalUpperBound]; + + return (acceptableLowerBounds.includes(interval.lower) && acceptableUpperBounds.includes(interval.upper)); + }) !== undefined, step, intervals); + }); + } + )); + + test.done(); + }, + + 'lower alarm uses lower policy'(test: Test) { + fc.assert(fc.property( + arbitrary_input_intervals(), + (intervals) => { + const template = setupStepScaling(intervals); + const alarm = template.resource(template.lowerAlarm); + fc.pre(alarm !== undefined); + + return reportFalse(alarm.Properties.AlarmActions[0].Ref === template.lowerPolicy, alarm); + } + )); + + test.done(); + }, + + 'upper alarm uses upper policy'(test: Test) { + fc.assert(fc.property( + arbitrary_input_intervals(), + (intervals) => { + const template = setupStepScaling(intervals); + const alarm = template.resource(template.upperAlarm); + fc.pre(alarm !== undefined); + + return reportFalse(alarm.Properties.AlarmActions[0].Ref === template.upperPolicy, alarm); + } + )); + + test.done(); + }, +}; + +/** + * Synthesize the given step scaling setup to a template + */ +function setupStepScaling(intervals: appscaling.ScalingInterval[]) { + const stack = new cdk.Stack(); + const target = createScalableTarget(stack); + + target.scaleOnMetric('ScaleInterval', { + metric: new cloudwatch.Metric({ namespace: 'Test', metricName: 'Success' }), + scalingSteps: intervals + }); + + return new ScalingStackTemplate(stack.toCloudFormation()); +} + +class ScalingStackTemplate { + public readonly lowerPolicy = 'TargetScaleIntervalLowerPolicy6F26D597'; + public readonly lowerAlarm = 'TargetScaleIntervalLowerAlarm4B5CE869'; + public readonly upperPolicy = 'TargetScaleIntervalUpperPolicy7C751132'; + public readonly upperAlarm = 'TargetScaleIntervalUpperAlarm69FD1BBB'; + + constructor(private readonly template: any) { + } + + public get lowerThreshold() { + return this.threshold(this.lowerAlarm); + } + + public get upperThreshold() { + return this.threshold(this.upperAlarm); + } + + public get lowerSteps() { + return this.steps(this.lowerPolicy); + } + + public get upperSteps() { + return this.steps(this.upperPolicy); + } + + public allStepsAbsolute() { + const ret = new Array(); + const lowerThreshold = this.lowerThreshold; + if (lowerThreshold !== undefined) { ret.push(...this.lowerSteps!.map(x => makeAbsolute(lowerThreshold, x))); } + + const upperThreshold = this.upperThreshold; + if (upperThreshold !== undefined) { ret.push(...this.upperSteps!.map(x => makeAbsolute(upperThreshold, x))); } + + return ret; + } + + public resource(id: string): object | any { + return this.template.Resources[id]; + } + + private threshold(id: string): number | undefined { + return apply(this.resource(id), x => x.Properties.Threshold); + } + + private steps(id: string): TemplateStep[] | undefined { + return apply(this.resource(id), x => x.Properties.StepScalingPolicyConfiguration.StepAdjustments); + } +} + +interface TemplateStep { + MetricIntervalLowerBound?: number; + MetricIntervalUpperBound?: number; + ScalingAdjustment: number; +} + +function makeAbsolute(threshold: number, step: TemplateStep) { + return concrete({ + MetricIntervalLowerBound: apply(step.MetricIntervalLowerBound, x => x + threshold), + MetricIntervalUpperBound: apply(step.MetricIntervalUpperBound, x => x + threshold), + ScalingAdjustment: step.ScalingAdjustment + }); +} + +function overlaps(a: TemplateStep, b: TemplateStep) { + return (a.MetricIntervalLowerBound! < b.MetricIntervalUpperBound! + && a.MetricIntervalUpperBound! > b.MetricIntervalLowerBound!); +} + +function concrete(step: TemplateStep) { + return { + MetricIntervalLowerBound: ifUndefined(step.MetricIntervalLowerBound, -Infinity), + MetricIntervalUpperBound: ifUndefined(step.MetricIntervalUpperBound, Infinity), + ScalingAdjustment: step.ScalingAdjustment + }; +} + +function ifUndefined(x: T | undefined, def: T): T { + return x !== undefined ? x : def; +} + +function apply(x: T | undefined, f: (x: T) => U | undefined): U | undefined { + if (x === undefined) { return undefined; } + return f(x); +} + +/** + * Helper function to print variables in case of a failing property check + */ +function reportFalse(cond: boolean, ...repr: any[]) { + if (!cond) { + // tslint:disable-next-line:no-console + console.error('PROPERTY FAILS ON:', ...repr); + } + return cond; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-applicationautoscaling/test/test.target-tracking.ts b/packages/@aws-cdk/aws-applicationautoscaling/test/test.target-tracking.ts new file mode 100644 index 0000000000000..213736e95fb11 --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/test/test.target-tracking.ts @@ -0,0 +1,61 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cloudwatch = require('@aws-cdk/aws-cloudwatch'); +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import appscaling = require('../lib'); +import { createScalableTarget } from './util'; + +export = { + 'test setup target tracking on predefined metric'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const target = createScalableTarget(stack); + + // WHEN + target.scaleToTrackMetric('Tracking', { + predefinedMetric: appscaling.PredefinedMetric.EC2SpotFleetRequestAverageCPUUtilization, + targetValue: 30, + }); + + // THEN + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + PolicyType: "TargetTrackingScaling", + TargetTrackingScalingPolicyConfiguration: { + PredefinedMetricSpecification: { PredefinedMetricType: "EC2SpotFleetRequestAverageCPUUtilization" }, + TargetValue: 30 + } + + })); + + test.done(); + }, + + 'test setup target tracking on custom metric'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const target = createScalableTarget(stack); + + // WHEN + target.scaleToTrackMetric('Tracking', { + customMetric: new cloudwatch.Metric({ namespace: 'Test', metricName: 'Metric' }), + targetValue: 30, + }); + + // THEN + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + PolicyType: "TargetTrackingScaling", + TargetTrackingScalingPolicyConfiguration: { + CustomizedMetricSpecification: { + Dimensions: [], + MetricName: "Metric", + Namespace: "Test", + Statistic: "Average" + }, + TargetValue: 30 + } + + })); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-applicationautoscaling/test/util.ts b/packages/@aws-cdk/aws-applicationautoscaling/test/util.ts new file mode 100644 index 0000000000000..459203351fdc1 --- /dev/null +++ b/packages/@aws-cdk/aws-applicationautoscaling/test/util.ts @@ -0,0 +1,125 @@ +import cdk = require('@aws-cdk/cdk'); +import fc = require('fast-check'); +import appscaling = require('../lib'); +import { ServiceNamespace } from '../lib'; +import { normalizeIntervals } from '../lib/interval-utils'; + +/** + * Arbitrary (valid) array of intervals + * + * There are many invalid combinations of interval arrays, so we have + * to be very specific about generating arrays that are valid. We do this + * by taking a full, valid interval schedule and progressively stripping parts + * away from it. + * + * Some of the changes may change its meaning, but we take care to never leave + * a schedule with insufficient information so that the parser will error out. + */ +export class ArbitraryIntervals extends fc.Arbitrary { + public generate(mrng: fc.Random): fc.Shrinkable { + const ret = new Array(); + + const absolute = mrng.nextBoolean(); + + // Ascending or descending scaling + const factor = (mrng.nextBoolean() ? 1 : -1) * (absolute ? 10 : 1); + const bias = absolute ? 50 : 0; + + // Begin with a full schedule + ret.push({ lower: 0, upper: 10, change: -2 * factor + bias }); + ret.push({ lower: 10, upper: 20, change: -1 * factor + bias }); + ret.push({ lower: 20, upper: 60, change: 0 + bias }); + ret.push({ lower: 60, upper: 80, change: 0 + bias }); + ret.push({ lower: 80, upper: 90, change: 1 * factor + bias }); + ret.push({ lower: 90, upper: Infinity, change: 2 * factor + bias}); + + // Take away parts from this. First we see if we do something to the 0-change alarms. + // The actions can be: remove it OR turn it into a regular change value. + const noChanges = ret.filter(x => x.change === bias); + + if (!absolute) { + if (mrng.nextBoolean()) { + if (mrng.nextBoolean()) { + ret.splice(ret.indexOf(noChanges[0]), 1); + } else { + noChanges[0].change = -1 * factor + bias; + } + } + if (mrng.nextBoolean()) { + if (mrng.nextBoolean()) { + ret.splice(ret.indexOf(noChanges[1]), 1); + } else { + noChanges[1].change = 1 * factor + bias; + } + } + } else { + // In absolute mode both have to get the same treatment at the same time + // otherwise we'll end up with a timeline with two gaps + if (mrng.nextBoolean()) { + ret.splice(ret.indexOf(noChanges[0]), 1); + ret.splice(ret.indexOf(noChanges[1]), 1); + } else { + noChanges[0].change = -1 * factor + bias; + noChanges[1].change = 1 * factor + bias; + } + } + + // We might also take away either the bottom or the upper half + if (mrng.nextInt(0, 2) === 0) { + const signToStrip = mrng.nextBoolean() ? -1 : 1; + let ix = ret.findIndex(x => Math.sign(x.change - bias) === signToStrip); + while (ix >= 0) { + ret.splice(ix, 1); + ix = ret.findIndex(x => Math.sign(x.change - bias) === signToStrip); + } + } + + // Then we're going to arbitrarily get rid of bounds in the most naive way possible + const iterations = mrng.nextInt(0, 10); + for (let iter = 0; iter < iterations; iter++) { + const i = mrng.nextInt(0, ret.length - 1); + if (mrng.nextBoolean()) { + // scrap lower bound + // okay if current interval has an upper bound AND the preceding interval has an upper bound + if (ret[i].upper !== undefined && (i === 0 || ret[i - 1].upper !== undefined)) { + ret[i].lower = undefined; + } + } else { + // scrap upper bound + // okay if current interval has a lower bound AND the succeeding interval has a lower bound + if (ret[i].lower !== undefined && (i === ret.length - 1 || ret[i + 1].lower !== undefined)) { + ret[i].upper = undefined; + } + } + } + + // Hide a property on the array + (ret as any).absolute = absolute; + + // Shrinkable that doesn't actually shrink + return new fc.Shrinkable(ret); + } +} + +export function arbitrary_input_intervals() { + return new ArbitraryIntervals(); +} + +/** + * Normalized interval array + */ +export function arbitrary_complete_intervals() { + return new ArbitraryIntervals().map(x => { + return normalizeIntervals(x, (x as any).absolute); + }); +} + +export function createScalableTarget(parent: cdk.Construct) { + return new appscaling.ScalableTarget(parent, 'Target', { + serviceNamespace: ServiceNamespace.DynamoDb, + scalableDimension: 'test:TestCount', + resourceId: 'test:this/test', + minCapacity: 1, + maxCapacity: 20, + }); +} diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts b/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts index f938fa85d7b73..6d09191c0a854 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/metric.ts @@ -1,7 +1,7 @@ import iam = require('@aws-cdk/aws-iam'); import cdk = require('@aws-cdk/cdk'); import { Alarm, ComparisonOperator, TreatMissingData } from './alarm'; -import { parseStatistic } from './util.statistic'; +import { normalizeStatistic } from './util.statistic'; export type DimensionHash = {[dim: string]: any}; @@ -87,7 +87,7 @@ export class Metric { * * @param identity The IAM identity to give permissions to. */ - public static grantPutMetricData(identity?: iam.IIdentityResource) { + public static grantPutMetricData(identity?: iam.IPrincipal) { if (!identity) { return; } identity.addToPolicy(new iam.PolicyStatement() @@ -115,13 +115,11 @@ export class Metric { this.namespace = props.namespace; this.metricName = props.metricName; this.periodSec = props.periodSec !== undefined ? props.periodSec : 300; - this.statistic = props.statistic || "Average"; + // Try parsing, this will throw if it's not a valid stat + this.statistic = normalizeStatistic(props.statistic || "Average"); this.label = props.label; this.color = props.color; this.unit = props.unit; - - // Try parsing, this will throw if it's not a valid stat - parseStatistic(this.statistic); } /** diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/util.statistic.ts b/packages/@aws-cdk/aws-cloudwatch/lib/util.statistic.ts index 2ba4c604a5fea..d21910c5ea9bd 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/util.statistic.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/util.statistic.ts @@ -47,3 +47,14 @@ export function parseStatistic(stat: string): SimpleStatistic | PercentileStatis throw new Error(`Not a valid statistic: '${stat}', must be one of Average | Minimum | Maximum | SampleCount | Sum | pNN.NN`); } + +export function normalizeStatistic(stat: string): string { + const parsed = parseStatistic(stat); + if (parsed.type === 'simple') { + return parsed.statistic; + } else { + // Already percentile. Avoid parsing because we might get into + // floating point rounding issues, return as-is but lowercase the p. + return stat.toLowerCase(); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts b/packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts index 1cc894514ca04..1ed0689e5e484 100644 --- a/packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts +++ b/packages/@aws-cdk/aws-codebuild/test/test.codebuild.ts @@ -742,7 +742,7 @@ export = { const metricBuilds = project.metricBuilds(); test.same(metricBuilds.dimensions!.ProjectName, project.projectName); test.deepEqual(metricBuilds.namespace, 'AWS/CodeBuild'); - test.deepEqual(metricBuilds.statistic, 'sum', 'default stat is SUM'); + test.deepEqual(metricBuilds.statistic, 'Sum', 'default stat is SUM'); test.deepEqual(metricBuilds.metricName, 'Builds'); const metricDuration = project.metricDuration({ label: 'hello' }); diff --git a/packages/@aws-cdk/aws-dynamodb/README.md b/packages/@aws-cdk/aws-dynamodb/README.md index 0e4bd0a382b92..73b2b2dc91e0b 100644 --- a/packages/@aws-cdk/aws-dynamodb/README.md +++ b/packages/@aws-cdk/aws-dynamodb/README.md @@ -1,72 +1,29 @@ ## AWS DynamoDB Construct Library + Add a DynamoDB table to your stack like so: + ```ts import dynamodb = require('@aws-cdk/aws-dynamodb'); -const defaultTable = new dynamodb.Table(stack, 'TableName'); +const table = new dynamodb.Table(stack, 'Table', { + // You can leave this out to automatically generate a name. + tableName: 'MyTableName', -const customTable = new dynamodb.Table(stack, 'CustomTable', { - readCapacity: readUnits, // Default is 5 - writeCapacity: writeUnits, // Default is 5 - tableName: 'MyTableName' // Default is CloudFormation-generated, which is the preferred approach + // If you leave these out they default to 5 + readCapacity: 100, + writeCapacity: 10, }) ``` -### Setup Auto Scaling for DynamoDB Table -further reading: -https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/AutoScaling.html -https://aws.amazon.com/blogs/database/how-to-use-aws-cloudformation-to-configure-auto-scaling-for-amazon-dynamodb-tables-and-indexes/ - -#### Setup via Constructor -```ts -import dynamodb = require('@aws-cdk/aws-dynamodb'); +### Configure AutoScaling for your table -const customTable = new dynamodb.Table(stack, 'CustomTable', { - readCapacity: readUnits, // Default is 5 - writeCapacity: writeUnits, // Default is 5 - tableName: 'MyTableName', // Default is CloudFormation-generated, which is the preferred approach - readAutoScaling: { - minCapacity: 500, - maxCapacity: 5000, - targetValue: 75.0, - scaleInCooldown: 30, - scaleOutCooldown: 30, - scalingPolicyName: 'MyAwesomeReadPolicyName' - }, - writeAutoScaling: { - minCapacity: 50, - maxCapacity: 500, - targetValue: 50.0, - scaleInCooldown: 10, - scaleOutCooldown: 10, - scalingPolicyName: 'MyAwesomeWritePolicyName' - }, -}); -``` +You can have DynamoDB automatically raise and lower the read and write capacities +of your table by setting up autoscaling. You can use this to either keep your +tables at a desired utilization level, or by scaling up and down at preconfigured +times of the day: -#### Setup via addAutoScaling -```ts -import dynamodb = require('@aws-cdk/aws-dynamodb'); +[Example of configuring autoscaling](test/integ.autoscaling.lit.ts) -const customTable = new dynamodb.Table(stack, 'CustomTable', { - readCapacity: readUnits, // Default is 5 - writeCapacity: writeUnits, // Default is 5 - tableName: 'MyTableName' // Default is CloudFormation-generated, which is the preferred approach -}); -table.addReadAutoScaling({ - minCapacity: 500, - maxCapacity: 5000, - targetValue: 75.0, - scaleInCooldown: 30, - scaleOutCooldown: 30, - scalingPolicyName: 'MyAwesomeReadPolicyName' -}); -table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 50.0, - scaleInCooldown: 10, - scaleOutCooldown: 10, - scalingPolicyName: 'MyAwesomeWritePolicyName' -}); -``` \ No newline at end of file +Further reading: +https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/AutoScaling.html +https://aws.amazon.com/blogs/database/how-to-use-aws-cloudformation-to-configure-auto-scaling-for-amazon-dynamodb-tables-and-indexes/ \ No newline at end of file diff --git a/packages/@aws-cdk/aws-dynamodb/lib/index.ts b/packages/@aws-cdk/aws-dynamodb/lib/index.ts index 45e70d3bb0f0a..87b5d2ad67aa4 100644 --- a/packages/@aws-cdk/aws-dynamodb/lib/index.ts +++ b/packages/@aws-cdk/aws-dynamodb/lib/index.ts @@ -1,2 +1,3 @@ export * from './dynamodb.generated'; export * from './table'; +export * from './scalable-attribute-api'; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-dynamodb/lib/scalable-attribute-api.ts b/packages/@aws-cdk/aws-dynamodb/lib/scalable-attribute-api.ts new file mode 100644 index 0000000000000..ef96b3c417580 --- /dev/null +++ b/packages/@aws-cdk/aws-dynamodb/lib/scalable-attribute-api.ts @@ -0,0 +1,41 @@ +import appscaling = require('@aws-cdk/aws-applicationautoscaling'); + +/** + * Interface for scalable attributes + */ +export interface IScalableTableAttribute { + /** + * Add scheduled scaling for this scaling attribute + */ + scaleOnSchedule(id: string, actions: appscaling.ScalingSchedule): void; + + /** + * Scale out or in to keep utilization at a given level + */ + scaleOnUtilization(props: UtilizationScalingProps): void; +} + +/** + * Properties for enabling DynamoDB capacity scaling + */ +export interface EnableScalingProps { + /** + * Minimum capacity to scale to + */ + minCapacity: number; + + /** + * Maximum capacity to scale to + */ + maxCapacity: number; +} + +/** + * Properties for enabling DynamoDB utilization tracking + */ +export interface UtilizationScalingProps extends appscaling.BaseTargetTrackingProps { + /** + * Target utilization percentage for the attribute + */ + targetUtilizationPercent: number; +} diff --git a/packages/@aws-cdk/aws-dynamodb/lib/scalable-table-attribute.ts b/packages/@aws-cdk/aws-dynamodb/lib/scalable-table-attribute.ts new file mode 100644 index 0000000000000..7a7c1f4dcbbcd --- /dev/null +++ b/packages/@aws-cdk/aws-dynamodb/lib/scalable-table-attribute.ts @@ -0,0 +1,51 @@ +import appscaling = require('@aws-cdk/aws-applicationautoscaling'); +import { UtilizationScalingProps } from './scalable-attribute-api'; + +/** + * A scalable table attribute + */ +export class ScalableTableAttribute extends appscaling.BaseScalableAttribute { + /** + * Scale out or in based on time + */ + public scaleOnSchedule(id: string, action: appscaling.ScalingSchedule) { + super.scaleOnSchedule(id, action); + } + + /** + * Scale out or in to keep utilization at a given level + */ + public scaleOnUtilization(props: UtilizationScalingProps) { + if (props.targetUtilizationPercent < 10 || props.targetUtilizationPercent > 90) { + // tslint:disable-next-line:max-line-length + throw new RangeError(`targetUtilizationPercent for DynamoDB scaling must be between 10 and 90 percent, got: ${props.targetUtilizationPercent}`); + } + const predefinedMetric = this.props.dimension.indexOf('ReadCapacity') === -1 + ? appscaling.PredefinedMetric.DynamoDBWriteCapacityUtilization + : appscaling.PredefinedMetric.DynamoDBReadCapacityUtilization; + + super.scaleToTrackMetric('Tracking', { + policyName: props.policyName, + disableScaleIn: props.disableScaleIn, + scaleInCooldownSec: props.scaleInCooldownSec, + scaleOutCooldownSec: props.scaleOutCooldownSec, + targetValue: props.targetUtilizationPercent, + predefinedMetric, + }); + } +} + +/** + * Properties for enabling DynamoDB capacity scaling + */ +export interface EnableScalingProps { + /** + * Minimum capacity to scale to + */ + minCapacity: number; + + /** + * Maximum capacity to scale to + */ + maxCapacity: number; +} diff --git a/packages/@aws-cdk/aws-dynamodb/lib/table.ts b/packages/@aws-cdk/aws-dynamodb/lib/table.ts index eabbfac2d6726..948e078fdbf5f 100644 --- a/packages/@aws-cdk/aws-dynamodb/lib/table.ts +++ b/packages/@aws-cdk/aws-dynamodb/lib/table.ts @@ -1,7 +1,10 @@ -import { cloudformation as applicationautoscaling } from '@aws-cdk/aws-applicationautoscaling'; +import appscaling = require('@aws-cdk/aws-applicationautoscaling'); import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); import { Construct, TagManager, Tags, Token } from '@aws-cdk/cdk'; import { cloudformation as dynamodb } from './dynamodb.generated'; +import { EnableScalingProps, IScalableTableAttribute } from './scalable-attribute-api'; +import { ScalableTableAttribute } from './scalable-table-attribute'; const HASH_KEY_TYPE = 'HASH'; const RANGE_KEY_TYPE = 'RANGE'; @@ -84,20 +87,6 @@ export interface TableProps { * @default undefined, TTL is disabled */ ttlAttributeName?: string; - - /** - * AutoScalingProps configuration to configure Read AutoScaling for the DynamoDB table. - * This field is optional and this can be achieved via addReadAutoScaling. - * @default undefined, read auto scaling is disabled - */ - readAutoScaling?: AutoScalingProps; - - /** - * AutoScalingProps configuration to configure Write AutoScaling for the DynamoDB table. - * This field is optional and this can be achieved via addWriteAutoScaling. - * @default undefined, write auto scaling is disabled - */ - writeAutoScaling?: AutoScalingProps; } export interface SecondaryIndexProps { @@ -151,42 +140,6 @@ export interface LocalSecondaryIndexProps extends SecondaryIndexProps { sortKey: Attribute; } -/* tslint:disable:max-line-length */ -export interface AutoScalingProps { - /** - * The minimum value that Application Auto Scaling can use to scale a target during a scaling activity. - * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-applicationautoscaling-scalabletarget.html#cfn-applicationautoscaling-scalabletarget-mincapacity - */ - minCapacity: number; - /** - * The maximum value that Application Auto Scaling can use to scale a target during a scaling activity. - * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-applicationautoscaling-scalabletarget.html#cfn-applicationautoscaling-scalabletarget-maxcapacity - */ - maxCapacity: number; - /** - * Application Auto Scaling ensures that the ratio of consumed capacity to provisioned capacity stays at or near this value. You define TargetValue as a percentage. - * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-applicationautoscaling-scalingpolicy-targettrackingscalingpolicyconfiguration.html#cfn-applicationautoscaling-scalingpolicy-targettrackingscalingpolicyconfiguration-targetvalue - */ - targetValue: number; - /** - * The amount of time, in seconds, after a scale in activity completes before another scale in activity can start. - * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-applicationautoscaling-scalingpolicy-targettrackingscalingpolicyconfiguration.html#cfn-applicationautoscaling-scalingpolicy-targettrackingscalingpolicyconfiguration-scaleincooldown - */ - scaleInCooldown: number; - /** - * The amount of time, in seconds, after a scale out activity completes before another scale out activity can start. - * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-applicationautoscaling-scalingpolicy-targettrackingscalingpolicyconfiguration.html#cfn-applicationautoscaling-scalingpolicy-targettrackingscalingpolicyconfiguration-scaleoutcooldown - */ - scaleOutCooldown: number; - /** - * A name for the scaling policy. - * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-applicationautoscaling-scalingpolicy.html#cfn-applicationautoscaling-scalingpolicy-policyname - * @default {TableName}[ReadCapacity|WriteCapacity]ScalingPolicy - */ - scalingPolicyName?: string; -} -/* tslint:enable:max-line-length */ - /** * Provides a DynamoDB table. */ @@ -208,8 +161,9 @@ export class Table extends Construct { private tablePartitionKey: Attribute | undefined = undefined; private tableSortKey: Attribute | undefined = undefined; - private readScalingPolicyResource?: applicationautoscaling.ScalingPolicyResource; - private writeScalingPolicyResource?: applicationautoscaling.ScalingPolicyResource; + private readonly tableScaling: ScalableAttributePair = {}; + private readonly indexScaling = new Map(); + private readonly scalingRole: iam.IRole; constructor(parent: Construct, name: string, props: TableProps = {}) { super(parent, name); @@ -234,13 +188,8 @@ export class Table extends Construct { this.tableName = this.table.tableName; this.tableStreamArn = this.table.tableStreamArn; - if (props.readAutoScaling) { - this.addReadAutoScaling(props.readAutoScaling); - } + this.scalingRole = this.makeScalingRole(); - if (props.writeAutoScaling) { - this.addWriteAutoScaling(props.writeAutoScaling); - } } /** @@ -291,6 +240,8 @@ export class Table extends Construct { projection: gsiProjection, provisionedThroughput: { readCapacityUnits: props.readCapacity || 5, writeCapacityUnits: props.writeCapacity || 5 } }); + + this.indexScaling.set(props.indexName, {}); } /** @@ -322,12 +273,88 @@ export class Table extends Construct { }); } - public addReadAutoScaling(props: AutoScalingProps) { - this.readScalingPolicyResource = this.buildAutoScaling(this.readScalingPolicyResource, 'Read', props); + /** + * Enable read capacity scaling for this table + * + * @returns An object to configure additional AutoScaling settings + */ + public autoScaleReadCapacity(props: EnableScalingProps): IScalableTableAttribute { + if (this.tableScaling.scalableReadAttribute) { + throw new Error('Read AutoScaling already enabled for this table'); + } + + return this.tableScaling.scalableReadAttribute = new 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 + */ + public autoScaleWriteCapacity(props: EnableScalingProps): IScalableTableAttribute { + if (this.tableScaling.scalableWriteAttribute) { + throw new Error('Write AutoScaling already enabled for this table'); + } + + return this.tableScaling.scalableWriteAttribute = new ScalableTableAttribute(this, 'WriteScaling', { + serviceNamespace: appscaling.ServiceNamespace.DynamoDb, + resourceId: `table/${this.tableName}`, + dimension: 'dynamodb:table:WriteCapacityUnits', + role: this.scalingRole, + ...props, + }); + } + + /** + * Enable read capacity scaling for the given GSI + * + * @returns An object to configure additional AutoScaling settings for this attribute + */ + public autoScaleGlobalSecondaryIndexReadCapacity(indexName: string, props: EnableScalingProps): IScalableTableAttribute { + 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 ScalableTableAttribute(this, `${indexName}ReadScaling`, { + serviceNamespace: appscaling.ServiceNamespace.DynamoDb, + resourceId: `table/${this.tableName}/index/${indexName}`, + dimension: 'dynamodb:index:ReadCapacityUnits', + role: this.scalingRole, + ...props + }); } - public addWriteAutoScaling(props: AutoScalingProps) { - this.writeScalingPolicyResource = this.buildAutoScaling(this.writeScalingPolicyResource, 'Write', props); + /** + * Enable write capacity scaling for the given GSI + * + * @returns An object to configure additional AutoScaling settings for this attribute + */ + public autoScaleGlobalSecondaryIndexWriteCapacity(indexName: string, props: EnableScalingProps): IScalableTableAttribute { + 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 ScalableTableAttribute(this, `${indexName}WriteScaling`, { + serviceNamespace: appscaling.ServiceNamespace.DynamoDb, + resourceId: `table/${this.tableName}/index/${indexName}`, + dimension: 'dynamodb:index:WriteCapacityUnits', + role: this.scalingRole, + ...props + }); } /** @@ -435,25 +462,6 @@ export class Table extends Construct { }); } - private validateAutoScalingProps(props: AutoScalingProps) { - if (props.targetValue < 10 || props.targetValue > 90) { - throw new RangeError("scalingTargetValue for predefined metric type DynamoDBReadCapacityUtilization/" - + "DynamoDBWriteCapacityUtilization must be between 10 and 90; Provided value is: " + props.targetValue); - } - if (props.scaleInCooldown < 0) { - throw new RangeError("scaleInCooldown must be greater than or equal to 0; Provided value is: " + props.scaleInCooldown); - } - if (props.scaleOutCooldown < 0) { - throw new RangeError("scaleOutCooldown must be greater than or equal to 0; Provided value is: " + props.scaleOutCooldown); - } - if (props.maxCapacity < 0) { - throw new RangeError("maximumCapacity must be greater than or equal to 0; Provided value is: " + props.maxCapacity); - } - if (props.minCapacity < 0) { - throw new RangeError("minimumCapacity must be greater than or equal to 0; Provided value is: " + props.minCapacity); - } - } - private buildIndexKeySchema(partitionKey: Attribute, sortKey?: Attribute): dynamodb.TableResource.KeySchemaProperty[] { this.registerAttribute(partitionKey); const indexKeySchema: dynamodb.TableResource.KeySchemaProperty[] = [ @@ -489,73 +497,6 @@ export class Table extends Construct { }; } - private buildAutoScaling(scalingPolicyResource: applicationautoscaling.ScalingPolicyResource | undefined, - scalingType: string, - props: AutoScalingProps) { - if (scalingPolicyResource) { - throw new Error(`${scalingType} Auto Scaling already defined for Table`); - } - - this.validateAutoScalingProps(props); - const autoScalingRole = this.buildAutoScalingRole(`${scalingType}AutoScalingRole`); - - const scalableTargetResource = new applicationautoscaling.ScalableTargetResource( - this, `${scalingType}CapacityScalableTarget`, this.buildScalableTargetResourceProps( - `dynamodb:table:${scalingType}CapacityUnits`, autoScalingRole, props)); - - return new applicationautoscaling.ScalingPolicyResource( - this, `${scalingType}CapacityScalingPolicy`, - this.buildScalingPolicyResourceProps(`DynamoDB${scalingType}CapacityUtilization`, `${scalingType}Capacity`, - scalableTargetResource, props)); - } - - private buildAutoScalingRole(roleResourceName: string) { - const autoScalingRole = new iam.Role(this, roleResourceName, { - assumedBy: new iam.ServicePrincipal('application-autoscaling.amazonaws.com') - }); - autoScalingRole.addToPolicy(new iam.PolicyStatement(iam.PolicyStatementEffect.Allow) - .addActions("dynamodb:DescribeTable", "dynamodb:UpdateTable") - .addResource(this.tableArn)); - autoScalingRole.addToPolicy(new iam.PolicyStatement(iam.PolicyStatementEffect.Allow) - .addActions("cloudwatch:PutMetricAlarm", "cloudwatch:DescribeAlarms", "cloudwatch:GetMetricStatistics", - "cloudwatch:SetAlarmState", "cloudwatch:DeleteAlarms") - .addAllResources()); - return autoScalingRole; - } - - private buildScalableTargetResourceProps(scalableDimension: string, - scalingRole: iam.Role, - props: AutoScalingProps) { - return { - maxCapacity: props.maxCapacity, - minCapacity: props.minCapacity, - resourceId: `table/${this.tableName}`, - roleArn: scalingRole.roleArn, - scalableDimension, - serviceNamespace: 'dynamodb' - }; - } - - private buildScalingPolicyResourceProps(predefinedMetricType: string, - scalingParameter: string, - scalableTargetResource: applicationautoscaling.ScalableTargetResource, - props: AutoScalingProps) { - const scalingPolicyName = props.scalingPolicyName || `${this.tableName}${scalingParameter}ScalingPolicy`; - return { - policyName: scalingPolicyName, - policyType: 'TargetTrackingScaling', - scalingTargetId: scalableTargetResource.ref, - targetTrackingScalingPolicyConfiguration: { - predefinedMetricSpecification: { - predefinedMetricType - }, - scaleInCooldown: props.scaleInCooldown, - scaleOutCooldown: props.scaleOutCooldown, - targetValue: props.targetValue - } - }; - } - private findKey(keyType: string) { return this.keySchema.find(prop => prop.keyType === keyType); } @@ -592,6 +533,21 @@ export class Table extends Construct { }); } } + + /** + * Return the role that will be used for AutoScaling + */ + private makeScalingRole(): iam.IRole { + // Use a Service Linked Role. + return iam.Role.import(this, 'ScalingRole', { + roleArn: cdk.ArnUtils.fromComponents({ + // https://docs.aws.amazon.com/autoscaling/application/userguide/application-auto-scaling-service-linked-roles.html + service: 'iam', + resource: 'role/aws-service-role/dynamodb.application-autoscaling.amazonaws.com', + resourceName: 'AWSServiceRoleForApplicationAutoScaling_DynamoDBTable' + }) + }); + } } export enum AttributeType { @@ -622,3 +578,11 @@ export enum StreamViewType { /** Only the key attributes of the modified item are written to the stream. */ KeysOnly = 'KEYS_ONLY' } + +/** + * Just a convenient way to keep track of both attributes + */ +interface ScalableAttributePair { + scalableReadAttribute?: ScalableTableAttribute; + scalableWriteAttribute?: ScalableTableAttribute; +} diff --git a/packages/@aws-cdk/aws-dynamodb/test/integ.autoscaling.lit.expected.json b/packages/@aws-cdk/aws-dynamodb/test/integ.autoscaling.lit.expected.json new file mode 100644 index 0000000000000..9590004579403 --- /dev/null +++ b/packages/@aws-cdk/aws-dynamodb/test/integ.autoscaling.lit.expected.json @@ -0,0 +1,97 @@ +{ + "Resources": { + "TableCD117FA1": { + "Type": "AWS::DynamoDB::Table", + "Properties": { + "KeySchema": [ + { + "AttributeName": "hashKey", + "KeyType": "HASH" + } + ], + "ProvisionedThroughput": { + "ReadCapacityUnits": 5, + "WriteCapacityUnits": 5 + }, + "AttributeDefinitions": [ + { + "AttributeName": "hashKey", + "AttributeType": "S" + } + ] + } + }, + "TableReadScalingTargetF96E9F76": { + "Type": "AWS::ApplicationAutoScaling::ScalableTarget", + "Properties": { + "MaxCapacity": 50, + "MinCapacity": 1, + "ResourceId": { + "Fn::Join": [ + "", + [ + "table/", + { + "Ref": "TableCD117FA1" + } + ] + ] + }, + "RoleARN": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":role/aws-service-role/dynamodb.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_DynamoDBTable" + ] + ] + }, + "ScalableDimension": "dynamodb:table:ReadCapacityUnits", + "ServiceNamespace": "dynamodb", + "ScheduledActions": [ + { + "ScalableTargetAction": { + "MinCapacity": 20 + }, + "Schedule": "cron(0 8 * * ?)", + "ScheduledActionName": "ScaleUpInTheMorning" + }, + { + "ScalableTargetAction": { + "MaxCapacity": 20 + }, + "Schedule": "cron(0 20 * * ?)", + "ScheduledActionName": "ScaleDownAtNight" + } + ] + } + }, + "TableReadScalingTargetTracking67DF0596": { + "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", + "Properties": { + "PolicyName": "awscdkdynamodbTableReadScalingTargetTrackingC9729D9C", + "PolicyType": "TargetTrackingScaling", + "ScalingTargetId": { + "Ref": "TableReadScalingTargetF96E9F76" + }, + "TargetTrackingScalingPolicyConfiguration": { + "PredefinedMetricSpecification": { + "PredefinedMetricType": "DynamoDBReadCapacityUtilization" + }, + "TargetValue": 50 + } + } + } + } +} diff --git a/packages/@aws-cdk/aws-dynamodb/test/integ.autoscaling.lit.ts b/packages/@aws-cdk/aws-dynamodb/test/integ.autoscaling.lit.ts new file mode 100644 index 0000000000000..9dc09740cff10 --- /dev/null +++ b/packages/@aws-cdk/aws-dynamodb/test/integ.autoscaling.lit.ts @@ -0,0 +1,29 @@ +import appscaling = require('@aws-cdk/aws-applicationautoscaling'); +import cdk = require('@aws-cdk/cdk'); +import dynamodb = require('../lib'); + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-cdk-dynamodb'); + +const table = new dynamodb.Table(stack, 'Table', {}); +table.addPartitionKey({ name: 'hashKey', type: dynamodb.AttributeType.String }); + +/// !show +const readScaling = table.autoScaleReadCapacity({ minCapacity: 1, maxCapacity: 50 }); + +readScaling.scaleOnUtilization({ + targetUtilizationPercent: 50 +}); + +readScaling.scaleOnSchedule('ScaleUpInTheMorning', { + schedule: appscaling.Cron.dailyUtc(8), + minCapacity: 20, +}); + +readScaling.scaleOnSchedule('ScaleDownAtNight', { + schedule: appscaling.Cron.dailyUtc(20), + maxCapacity: 20 +}); +/// !hide + +app.run(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts b/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts index 20cb222e33032..0f3b945c53389 100644 --- a/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts +++ b/packages/@aws-cdk/aws-dynamodb/test/test.dynamodb.ts @@ -114,7 +114,7 @@ export = { { AttributeName: 'hashKey', KeyType: 'HASH' }, { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 } + ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 }, } } } @@ -302,7 +302,7 @@ export = { { AttributeName: 'sortKey', AttributeType: 'N' } ], StreamSpecification: { StreamViewType: 'NEW_IMAGE' }, - TableName: 'MyTable' + TableName: 'MyTable', } } } @@ -338,7 +338,7 @@ export = { { AttributeName: 'sortKey', AttributeType: 'N' } ], StreamSpecification: { StreamViewType: 'OLD_IMAGE' }, - TableName: 'MyTable' + TableName: 'MyTable', } } } @@ -855,7 +855,7 @@ export = { ], Projection: { ProjectionType: 'ALL' }, } - ] + ], } } } @@ -900,7 +900,7 @@ export = { ], Projection: { ProjectionType: 'KEYS_ONLY' }, } - ] + ], } } } @@ -948,7 +948,7 @@ export = { ], Projection: { NonKeyAttributes: ['lsiNonKey0', 'lsiNonKey1'], ProjectionType: 'INCLUDE' }, } - ] + ], } } } @@ -1021,940 +1021,122 @@ export = { test.done(); }, - 'when specifying Read Auto Scaling'(test: Test) { + 'can enable Read AutoScaling'(test: Test) { + // GIVEN const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); + const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - table.addReadAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60, - scalingPolicyName: 'MyAwesomePolicyName' - }); - const template = app.synthesizeTemplate(); - test.deepEqual(template, { Resources: - { MyTable794EDED1: - { Type: 'AWS::DynamoDB::Table', - Properties: - { KeySchema: - [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: - [ { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } ], - TableName: 'MyTable' } }, - MyTableReadAutoScalingRoleFEE68E49: - { Type: 'AWS::IAM::Role', - Properties: - { AssumeRolePolicyDocument: - { Statement: - [ { Action: 'sts:AssumeRole', - Effect: 'Allow', - Principal: { Service: 'application-autoscaling.amazonaws.com' } } ], - Version: '2012-10-17' } } }, - MyTableReadAutoScalingRoleDefaultPolicyF6A1975F: - { Type: 'AWS::IAM::Policy', - Properties: - { PolicyDocument: - { Statement: - [ { Action: [ 'dynamodb:DescribeTable', 'dynamodb:UpdateTable' ], - Effect: 'Allow', - Resource: { 'Fn::GetAtt': [ 'MyTable794EDED1', 'Arn' ] } }, - { Action: [ 'cloudwatch:PutMetricAlarm', 'cloudwatch:DescribeAlarms', 'cloudwatch:GetMetricStatistics', - 'cloudwatch:SetAlarmState', 'cloudwatch:DeleteAlarms' ], - Effect: 'Allow', Resource: '*' } ], - Version: '2012-10-17' }, - PolicyName: 'MyTableReadAutoScalingRoleDefaultPolicyF6A1975F', - Roles: [ { Ref: 'MyTableReadAutoScalingRoleFEE68E49' } ] } }, - MyTableReadCapacityScalableTarget72B0B3BF: - { Type: 'AWS::ApplicationAutoScaling::ScalableTarget', - Properties: - { MaxCapacity: 500, - MinCapacity: 50, - ResourceId: - { 'Fn::Join': [ '', [ 'table/', { Ref: 'MyTable794EDED1' } ] ] }, - RoleARN: - { 'Fn::GetAtt': [ 'MyTableReadAutoScalingRoleFEE68E49', 'Arn' ] }, - ScalableDimension: 'dynamodb:table:ReadCapacityUnits', - ServiceNamespace: 'dynamodb' } }, - MyTableReadCapacityScalingPolicyCC18E396: - { Type: 'AWS::ApplicationAutoScaling::ScalingPolicy', - Properties: - { PolicyName: 'MyAwesomePolicyName', - PolicyType: 'TargetTrackingScaling', - ScalingTargetId: { Ref: 'MyTableReadCapacityScalableTarget72B0B3BF' }, - TargetTrackingScalingPolicyConfiguration: - { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBReadCapacityUtilization' }, - ScaleInCooldown: 80, - ScaleOutCooldown: 60, - TargetValue: 75 } } } } }); + // WHEN + table.autoScaleReadCapacity({ minCapacity: 50, maxCapacity: 500 }).scaleOnUtilization({ targetUtilizationPercent: 75 }); + + // THEN + expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + MaxCapacity: 500, + MinCapacity: 50, + ScalableDimension: 'dynamodb:table:ReadCapacityUnits', + ServiceNamespace: 'dynamodb' + })); + expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + PolicyType: 'TargetTrackingScaling', + TargetTrackingScalingPolicyConfiguration: { + PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBReadCapacityUtilization' }, + TargetValue: 75 + } + })); test.done(); }, - 'when specifying Read Auto Scaling via constructor'(test: Test) { + 'can enable Write AutoScaling'(test: Test) { + // GIVEN const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337, - readAutoScaling: { - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60, - scalingPolicyName: 'MyAwesomePolicyName' - } - }); + const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { Resources: - { MyTable794EDED1: - { Type: 'AWS::DynamoDB::Table', - Properties: - { KeySchema: - [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: - [ { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } ], - TableName: 'MyTable' } }, - MyTableReadAutoScalingRoleFEE68E49: - { Type: 'AWS::IAM::Role', - Properties: - { AssumeRolePolicyDocument: - { Statement: - [ { Action: 'sts:AssumeRole', - Effect: 'Allow', - Principal: { Service: 'application-autoscaling.amazonaws.com' } } ], - Version: '2012-10-17' } } }, - MyTableReadAutoScalingRoleDefaultPolicyF6A1975F: - { Type: 'AWS::IAM::Policy', - Properties: - { PolicyDocument: - { Statement: - [ { Action: [ 'dynamodb:DescribeTable', 'dynamodb:UpdateTable' ], - Effect: 'Allow', - Resource: { 'Fn::GetAtt': [ 'MyTable794EDED1', 'Arn' ] } }, - { Action: [ 'cloudwatch:PutMetricAlarm', 'cloudwatch:DescribeAlarms', 'cloudwatch:GetMetricStatistics', - 'cloudwatch:SetAlarmState', 'cloudwatch:DeleteAlarms' ], - Effect: 'Allow', Resource: '*' } ], - Version: '2012-10-17' }, - PolicyName: 'MyTableReadAutoScalingRoleDefaultPolicyF6A1975F', - Roles: [ { Ref: 'MyTableReadAutoScalingRoleFEE68E49' } ] } }, - MyTableReadCapacityScalableTarget72B0B3BF: - { Type: 'AWS::ApplicationAutoScaling::ScalableTarget', - Properties: - { MaxCapacity: 500, - MinCapacity: 50, - ResourceId: - { 'Fn::Join': [ '', [ 'table/', { Ref: 'MyTable794EDED1' } ] ] }, - RoleARN: - { 'Fn::GetAtt': [ 'MyTableReadAutoScalingRoleFEE68E49', 'Arn' ] }, - ScalableDimension: 'dynamodb:table:ReadCapacityUnits', - ServiceNamespace: 'dynamodb' } }, - MyTableReadCapacityScalingPolicyCC18E396: - { Type: 'AWS::ApplicationAutoScaling::ScalingPolicy', - Properties: - { PolicyName: 'MyAwesomePolicyName', - PolicyType: 'TargetTrackingScaling', - ScalingTargetId: { Ref: 'MyTableReadCapacityScalableTarget72B0B3BF' }, - TargetTrackingScalingPolicyConfiguration: - { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBReadCapacityUtilization' }, - ScaleInCooldown: 80, - ScaleOutCooldown: 60, - TargetValue: 75 } } } } }); - - test.done(); - }, - 'error when specifying Read Auto Scaling via constructor and attempting to addReadAutoScaling'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337, - readAutoScaling: { - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60, - scalingPolicyName: 'MyAwesomePolicyName' + // WHEN + table.autoScaleWriteCapacity({ minCapacity: 50, maxCapacity: 500 }).scaleOnUtilization({ targetUtilizationPercent: 75 }); + + // THEN + expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + MaxCapacity: 500, + MinCapacity: 50, + ScalableDimension: 'dynamodb:table:WriteCapacityUnits', + ServiceNamespace: 'dynamodb' + })); + expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalingPolicy', { + PolicyType: 'TargetTrackingScaling', + TargetTrackingScalingPolicyConfiguration: { + PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBWriteCapacityUtilization' }, + TargetValue: 75 } - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addReadAutoScaling({ - minCapacity: 500, - maxCapacity: 5000, - targetValue: 25.0, - scaleInCooldown: 40, - scaleOutCooldown: 20, - scalingPolicyName: 'MySecondAwesomePolicyName' - }), /Read Auto Scaling already defined for Table/); + })); test.done(); }, - 'when specifying Read Auto Scaling without scalingPolicyName'(test: Test) { + 'cannot enable AutoScaling twice on the same property'(test: Test) { + // GIVEN const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); + const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - table.addReadAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - }); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { Resources: - { MyTable794EDED1: - { Type: 'AWS::DynamoDB::Table', - Properties: - { KeySchema: - [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: - [ { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } ], - TableName: 'MyTable' } }, - MyTableReadAutoScalingRoleFEE68E49: - { Type: 'AWS::IAM::Role', - Properties: - { AssumeRolePolicyDocument: - { Statement: - [ { Action: 'sts:AssumeRole', - Effect: 'Allow', - Principal: { Service: 'application-autoscaling.amazonaws.com' } } ], - Version: '2012-10-17' } } }, - MyTableReadAutoScalingRoleDefaultPolicyF6A1975F: - { Type: 'AWS::IAM::Policy', - Properties: - { PolicyDocument: - { Statement: - [ { Action: [ 'dynamodb:DescribeTable', 'dynamodb:UpdateTable' ], - Effect: 'Allow', - Resource: { 'Fn::GetAtt': [ 'MyTable794EDED1', 'Arn' ] } }, - { Action: [ 'cloudwatch:PutMetricAlarm', 'cloudwatch:DescribeAlarms', 'cloudwatch:GetMetricStatistics', - 'cloudwatch:SetAlarmState', 'cloudwatch:DeleteAlarms' ], - Effect: 'Allow', Resource: '*' } ], - Version: '2012-10-17' }, - PolicyName: 'MyTableReadAutoScalingRoleDefaultPolicyF6A1975F', - Roles: [ { Ref: 'MyTableReadAutoScalingRoleFEE68E49' } ] } }, - MyTableReadCapacityScalableTarget72B0B3BF: - { Type: 'AWS::ApplicationAutoScaling::ScalableTarget', - Properties: - { MaxCapacity: 500, - MinCapacity: 50, - ResourceId: - { 'Fn::Join': [ '', [ 'table/', { Ref: 'MyTable794EDED1' } ] ] }, - RoleARN: - { 'Fn::GetAtt': [ 'MyTableReadAutoScalingRoleFEE68E49', 'Arn' ] }, - ScalableDimension: 'dynamodb:table:ReadCapacityUnits', - ServiceNamespace: 'dynamodb' } }, - MyTableReadCapacityScalingPolicyCC18E396: - { Type: 'AWS::ApplicationAutoScaling::ScalingPolicy', - Properties: - { PolicyName: - { 'Fn::Join': [ '', [ { Ref: 'MyTable794EDED1' }, 'ReadCapacityScalingPolicy' ] ] }, - PolicyType: 'TargetTrackingScaling', - ScalingTargetId: { Ref: 'MyTableReadCapacityScalableTarget72B0B3BF' }, - TargetTrackingScalingPolicyConfiguration: - { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBReadCapacityUtilization' }, - ScaleInCooldown: 80, - ScaleOutCooldown: 60, - TargetValue: 75 } } } } }); - - test.done(); - }, + table.autoScaleReadCapacity({ minCapacity: 50, maxCapacity: 500 }).scaleOnUtilization({ targetUtilizationPercent: 75 }); - 'when specifying Read Auto Scaling without scalingPolicyName without Table Name'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - table.addReadAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 + // WHEN + test.throws(() => { + table.autoScaleReadCapacity({ minCapacity: 50, maxCapacity: 500 }); }); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { Resources: - { MyTable794EDED1: - { Type: 'AWS::DynamoDB::Table', - Properties: - { KeySchema: - [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: - [ { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } ] } }, - MyTableReadAutoScalingRoleFEE68E49: - { Type: 'AWS::IAM::Role', - Properties: - { AssumeRolePolicyDocument: - { Statement: - [ { Action: 'sts:AssumeRole', - Effect: 'Allow', - Principal: { Service: 'application-autoscaling.amazonaws.com' } } ], - Version: '2012-10-17' } } }, - MyTableReadAutoScalingRoleDefaultPolicyF6A1975F: - { Type: 'AWS::IAM::Policy', - Properties: - { PolicyDocument: - { Statement: - [ { Action: [ 'dynamodb:DescribeTable', 'dynamodb:UpdateTable' ], - Effect: 'Allow', - Resource: { 'Fn::GetAtt': [ 'MyTable794EDED1', 'Arn' ] } }, - { Action: [ 'cloudwatch:PutMetricAlarm', 'cloudwatch:DescribeAlarms', 'cloudwatch:GetMetricStatistics', - 'cloudwatch:SetAlarmState', 'cloudwatch:DeleteAlarms' ], - Effect: 'Allow', Resource: '*' } ], - Version: '2012-10-17' }, - PolicyName: 'MyTableReadAutoScalingRoleDefaultPolicyF6A1975F', - Roles: [ { Ref: 'MyTableReadAutoScalingRoleFEE68E49' } ] } }, - MyTableReadCapacityScalableTarget72B0B3BF: - { Type: 'AWS::ApplicationAutoScaling::ScalableTarget', - Properties: - { MaxCapacity: 500, - MinCapacity: 50, - ResourceId: - { 'Fn::Join': [ '', [ 'table/', { Ref: 'MyTable794EDED1' } ] ] }, - RoleARN: - { 'Fn::GetAtt': [ 'MyTableReadAutoScalingRoleFEE68E49', 'Arn' ] }, - ScalableDimension: 'dynamodb:table:ReadCapacityUnits', - ServiceNamespace: 'dynamodb' } }, - MyTableReadCapacityScalingPolicyCC18E396: - { Type: 'AWS::ApplicationAutoScaling::ScalingPolicy', - Properties: - { PolicyName: - { 'Fn::Join': [ '', [ { Ref: 'MyTable794EDED1' }, 'ReadCapacityScalingPolicy' ] ] }, - PolicyType: 'TargetTrackingScaling', - ScalingTargetId: { Ref: 'MyTableReadCapacityScalableTarget72B0B3BF' }, - TargetTrackingScalingPolicyConfiguration: - { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBReadCapacityUtilization' }, - ScaleInCooldown: 80, - ScaleOutCooldown: 60, - TargetValue: 75 } } } } }); test.done(); }, 'error when specifying Read Auto Scaling with invalid scalingTargetValue < 10'(test: Test) { + // GIVEN const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addReadAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 5.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - // tslint:disable-next-line:max-line-length - }), /scalingTargetValue for predefined metric type DynamoDBReadCapacityUtilization\/DynamoDBWriteCapacityUtilization must be between 10 and 90; Provided value is: 5/); - - test.done(); - }, - - 'error when specifying Read Auto Scaling with invalid scalingTargetValue > 90'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addReadAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 95.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - // tslint:disable-next-line:max-line-length - }), /scalingTargetValue for predefined metric type DynamoDBReadCapacityUtilization\/DynamoDBWriteCapacityUtilization must be between 10 and 90; Provided value is: 95/); - - test.done(); - }, - - 'error when specifying Read Auto Scaling with invalid scaleInCooldown'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addReadAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 50.0, - scaleInCooldown: -5, - scaleOutCooldown: 60 - }), /scaleInCooldown must be greater than or equal to 0; Provided value is: -5/); - - test.done(); - }, - - 'error when specifying Read Auto Scaling with invalid scaleOutCooldown'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addReadAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 50.0, - scaleInCooldown: 80, - scaleOutCooldown: -5 - }), /scaleOutCooldown must be greater than or equal to 0; Provided value is: -5/); - - test.done(); - }, + const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); - 'error when specifying Read Auto Scaling with invalid maximumCapacity'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 + // THEN + test.throws(() => { + table.autoScaleReadCapacity({ minCapacity: 50, maxCapacity: 500 }).scaleOnUtilization({ targetUtilizationPercent: 5 }); }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addReadAutoScaling({ - minCapacity: 50, - maxCapacity: -5, - targetValue: 50.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - }), /maximumCapacity must be greater than or equal to 0; Provided value is: -5/); test.done(); }, 'error when specifying Read Auto Scaling with invalid minimumCapacity'(test: Test) { + // GIVEN const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addReadAutoScaling({ - minCapacity: -5, - maxCapacity: 500, - targetValue: 50.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - }), /minimumCapacity must be greater than or equal to 0; Provided value is: -5/); - - test.done(); - }, - - 'when specifying Write Auto Scaling'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60, - scalingPolicyName: 'MyAwesomePolicyName' - }); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { Resources: - { MyTable794EDED1: - { Type: 'AWS::DynamoDB::Table', - Properties: - { KeySchema: - [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: - [ { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } ], - TableName: 'MyTable' } }, - MyTableWriteAutoScalingRoleDF7775DE: - { Type: 'AWS::IAM::Role', - Properties: - { AssumeRolePolicyDocument: - { Statement: - [ { Action: 'sts:AssumeRole', - Effect: 'Allow', - Principal: { Service: 'application-autoscaling.amazonaws.com' } } ], - Version: '2012-10-17' } } }, - MyTableWriteAutoScalingRoleDefaultPolicyBF1A7EBB: - { Type: 'AWS::IAM::Policy', - Properties: - { PolicyDocument: - { Statement: - [ { Action: [ 'dynamodb:DescribeTable', 'dynamodb:UpdateTable' ], - Effect: 'Allow', - Resource: { 'Fn::GetAtt': [ 'MyTable794EDED1', 'Arn' ] } }, - { Action: [ 'cloudwatch:PutMetricAlarm', 'cloudwatch:DescribeAlarms', 'cloudwatch:GetMetricStatistics', - 'cloudwatch:SetAlarmState', 'cloudwatch:DeleteAlarms' ], - Effect: 'Allow', Resource: '*' } ], - Version: '2012-10-17' }, - PolicyName: 'MyTableWriteAutoScalingRoleDefaultPolicyBF1A7EBB', - Roles: [ { Ref: 'MyTableWriteAutoScalingRoleDF7775DE' } ] } }, - MyTableWriteCapacityScalableTarget56F9809A: - { Type: 'AWS::ApplicationAutoScaling::ScalableTarget', - Properties: - { MaxCapacity: 500, - MinCapacity: 50, - ResourceId: - { 'Fn::Join': [ '', [ 'table/', { Ref: 'MyTable794EDED1' } ] ] }, - RoleARN: - { 'Fn::GetAtt': [ 'MyTableWriteAutoScalingRoleDF7775DE', 'Arn' ] }, - ScalableDimension: 'dynamodb:table:WriteCapacityUnits', - ServiceNamespace: 'dynamodb' } }, - MyTableWriteCapacityScalingPolicy766EAD7A: - { Type: 'AWS::ApplicationAutoScaling::ScalingPolicy', - Properties: - { PolicyName: 'MyAwesomePolicyName', - PolicyType: 'TargetTrackingScaling', - ScalingTargetId: { Ref: 'MyTableWriteCapacityScalableTarget56F9809A' }, - TargetTrackingScalingPolicyConfiguration: - { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBWriteCapacityUtilization' }, - ScaleInCooldown: 80, - ScaleOutCooldown: 60, - TargetValue: 75 } } } } }); - - test.done(); - }, - - 'when specifying Write Auto Scaling via constructor'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337, - writeAutoScaling: { - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60, - scalingPolicyName: 'MyAwesomePolicyName' - } - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - const template = app.synthesizeTemplate(); + const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); - test.deepEqual(template, { Resources: - { MyTable794EDED1: - { Type: 'AWS::DynamoDB::Table', - Properties: - { KeySchema: - [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: - [ { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } ], - TableName: 'MyTable' } }, - MyTableWriteAutoScalingRoleDF7775DE: - { Type: 'AWS::IAM::Role', - Properties: - { AssumeRolePolicyDocument: - { Statement: - [ { Action: 'sts:AssumeRole', - Effect: 'Allow', - Principal: { Service: 'application-autoscaling.amazonaws.com' } } ], - Version: '2012-10-17' } } }, - MyTableWriteAutoScalingRoleDefaultPolicyBF1A7EBB: - { Type: 'AWS::IAM::Policy', - Properties: - { PolicyDocument: - { Statement: - [ { Action: [ 'dynamodb:DescribeTable', 'dynamodb:UpdateTable' ], - Effect: 'Allow', - Resource: { 'Fn::GetAtt': [ 'MyTable794EDED1', 'Arn' ] } }, - { Action: [ 'cloudwatch:PutMetricAlarm', 'cloudwatch:DescribeAlarms', 'cloudwatch:GetMetricStatistics', - 'cloudwatch:SetAlarmState', 'cloudwatch:DeleteAlarms' ], - Effect: 'Allow', Resource: '*' } ], - Version: '2012-10-17' }, - PolicyName: 'MyTableWriteAutoScalingRoleDefaultPolicyBF1A7EBB', - Roles: [ { Ref: 'MyTableWriteAutoScalingRoleDF7775DE' } ] } }, - MyTableWriteCapacityScalableTarget56F9809A: - { Type: 'AWS::ApplicationAutoScaling::ScalableTarget', - Properties: - { MaxCapacity: 500, - MinCapacity: 50, - ResourceId: - { 'Fn::Join': [ '', [ 'table/', { Ref: 'MyTable794EDED1' } ] ] }, - RoleARN: - { 'Fn::GetAtt': [ 'MyTableWriteAutoScalingRoleDF7775DE', 'Arn' ] }, - ScalableDimension: 'dynamodb:table:WriteCapacityUnits', - ServiceNamespace: 'dynamodb' } }, - MyTableWriteCapacityScalingPolicy766EAD7A: - { Type: 'AWS::ApplicationAutoScaling::ScalingPolicy', - Properties: - { PolicyName: 'MyAwesomePolicyName', - PolicyType: 'TargetTrackingScaling', - ScalingTargetId: { Ref: 'MyTableWriteCapacityScalableTarget56F9809A' }, - TargetTrackingScalingPolicyConfiguration: - { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBWriteCapacityUtilization' }, - ScaleInCooldown: 80, - ScaleOutCooldown: 60, - TargetValue: 75 } } } } }); + // THEN + test.throws(() => table.autoScaleReadCapacity({ minCapacity: 10, maxCapacity: 5 })); test.done(); }, - 'error when specifying Write Auto Scaling via constructor and attempting to addWriteAutoScaling'(test: Test) { + 'can autoscale on a schedule'(test: Test) { + // GIVEN const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337, - writeAutoScaling: { - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60, - scalingPolicyName: 'MyAwesomePolicyName' - } + const table = new Table(app.stack, CONSTRUCT_NAME, { readCapacity: 42, writeCapacity: 1337 }); + table.addPartitionKey({ name: 'Hash', type: AttributeType.String }); + + // WHEN + const scaling = table.autoScaleReadCapacity({ minCapacity: 1, maxCapacity: 100 }); + scaling.scaleOnSchedule('SaveMoneyByNotScalingUp', { + schedule: 'cron(* * ? * * )', + maxCapacity: 10 }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addWriteAutoScaling({ - minCapacity: 500, - maxCapacity: 5000, - targetValue: 25.0, - scaleInCooldown: 40, - scaleOutCooldown: 20, - scalingPolicyName: 'MySecondAwesomePolicyName' - }), /Write Auto Scaling already defined for Table/); - - test.done(); - }, - 'when specifying Write Auto Scaling without scalingPolicyName'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - }); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { Resources: - { MyTable794EDED1: - { Type: 'AWS::DynamoDB::Table', - Properties: - { KeySchema: - [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: - [ { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } ], - TableName: 'MyTable' } }, - MyTableWriteAutoScalingRoleDF7775DE: - { Type: 'AWS::IAM::Role', - Properties: - { AssumeRolePolicyDocument: - { Statement: - [ { Action: 'sts:AssumeRole', - Effect: 'Allow', - Principal: { Service: 'application-autoscaling.amazonaws.com' } } ], - Version: '2012-10-17' } } }, - MyTableWriteAutoScalingRoleDefaultPolicyBF1A7EBB: - { Type: 'AWS::IAM::Policy', - Properties: - { PolicyDocument: - { Statement: - [ { Action: [ 'dynamodb:DescribeTable', 'dynamodb:UpdateTable' ], - Effect: 'Allow', - Resource: { 'Fn::GetAtt': [ 'MyTable794EDED1', 'Arn' ] } }, - { Action: [ 'cloudwatch:PutMetricAlarm', 'cloudwatch:DescribeAlarms', 'cloudwatch:GetMetricStatistics', - 'cloudwatch:SetAlarmState', 'cloudwatch:DeleteAlarms' ], - Effect: 'Allow', Resource: '*' } ], - Version: '2012-10-17' }, - PolicyName: 'MyTableWriteAutoScalingRoleDefaultPolicyBF1A7EBB', - Roles: [ { Ref: 'MyTableWriteAutoScalingRoleDF7775DE' } ] } }, - MyTableWriteCapacityScalableTarget56F9809A: - { Type: 'AWS::ApplicationAutoScaling::ScalableTarget', - Properties: - { MaxCapacity: 500, - MinCapacity: 50, - ResourceId: - { 'Fn::Join': [ '', [ 'table/', { Ref: 'MyTable794EDED1' } ] ] }, - RoleARN: - { 'Fn::GetAtt': [ 'MyTableWriteAutoScalingRoleDF7775DE', 'Arn' ] }, - ScalableDimension: 'dynamodb:table:WriteCapacityUnits', - ServiceNamespace: 'dynamodb' } }, - MyTableWriteCapacityScalingPolicy766EAD7A: - { Type: 'AWS::ApplicationAutoScaling::ScalingPolicy', - Properties: - { PolicyName: - { 'Fn::Join': [ '', [ { Ref: 'MyTable794EDED1' }, 'WriteCapacityScalingPolicy' ] ] }, - PolicyType: 'TargetTrackingScaling', - ScalingTargetId: { Ref: 'MyTableWriteCapacityScalableTarget56F9809A' }, - TargetTrackingScalingPolicyConfiguration: - { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBWriteCapacityUtilization' }, - ScaleInCooldown: 80, - ScaleOutCooldown: 60, - TargetValue: 75 } } } } }); - - test.done(); - }, - - 'when specifying Write Auto Scaling without scalingPolicyName without Table Name'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 75.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - }); - const template = app.synthesizeTemplate(); - - test.deepEqual(template, { Resources: - { MyTable794EDED1: - { Type: 'AWS::DynamoDB::Table', - Properties: - { KeySchema: - [ { AttributeName: 'hashKey', KeyType: 'HASH' }, - { AttributeName: 'sortKey', KeyType: 'RANGE' } ], - ProvisionedThroughput: { ReadCapacityUnits: 42, WriteCapacityUnits: 1337 }, - AttributeDefinitions: - [ { AttributeName: 'hashKey', AttributeType: 'S' }, - { AttributeName: 'sortKey', AttributeType: 'N' } ] } }, - MyTableWriteAutoScalingRoleDF7775DE: - { Type: 'AWS::IAM::Role', - Properties: - { AssumeRolePolicyDocument: - { Statement: - [ { Action: 'sts:AssumeRole', - Effect: 'Allow', - Principal: { Service: 'application-autoscaling.amazonaws.com' } } ], - Version: '2012-10-17' } } }, - MyTableWriteAutoScalingRoleDefaultPolicyBF1A7EBB: - { Type: 'AWS::IAM::Policy', - Properties: - { PolicyDocument: - { Statement: - [ { Action: [ 'dynamodb:DescribeTable', 'dynamodb:UpdateTable' ], - Effect: 'Allow', - Resource: { 'Fn::GetAtt': [ 'MyTable794EDED1', 'Arn' ] } }, - { Action: [ 'cloudwatch:PutMetricAlarm', 'cloudwatch:DescribeAlarms', 'cloudwatch:GetMetricStatistics', - 'cloudwatch:SetAlarmState', 'cloudwatch:DeleteAlarms' ], - Effect: 'Allow', Resource: '*' } ], - Version: '2012-10-17' }, - PolicyName: 'MyTableWriteAutoScalingRoleDefaultPolicyBF1A7EBB', - Roles: [ { Ref: 'MyTableWriteAutoScalingRoleDF7775DE' } ] } }, - MyTableWriteCapacityScalableTarget56F9809A: - { Type: 'AWS::ApplicationAutoScaling::ScalableTarget', - Properties: - { MaxCapacity: 500, - MinCapacity: 50, - ResourceId: - { 'Fn::Join': [ '', [ 'table/', { Ref: 'MyTable794EDED1' } ] ] }, - RoleARN: - { 'Fn::GetAtt': [ 'MyTableWriteAutoScalingRoleDF7775DE', 'Arn' ] }, - ScalableDimension: 'dynamodb:table:WriteCapacityUnits', - ServiceNamespace: 'dynamodb' } }, - MyTableWriteCapacityScalingPolicy766EAD7A: - { Type: 'AWS::ApplicationAutoScaling::ScalingPolicy', - Properties: - { PolicyName: - { 'Fn::Join': [ '', [ { Ref: 'MyTable794EDED1' }, 'WriteCapacityScalingPolicy' ] ] }, - PolicyType: 'TargetTrackingScaling', - ScalingTargetId: { Ref: 'MyTableWriteCapacityScalableTarget56F9809A' }, - TargetTrackingScalingPolicyConfiguration: - { PredefinedMetricSpecification: { PredefinedMetricType: 'DynamoDBWriteCapacityUtilization' }, - ScaleInCooldown: 80, - ScaleOutCooldown: 60, - TargetValue: 75 } } } } }); - - test.done(); - }, - - 'error when specifying Write Auto Scaling with invalid scalingTargetValue < 10'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 5.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - // tslint:disable-next-line:max-line-length - }), /scalingTargetValue for predefined metric type DynamoDBReadCapacityUtilization\/DynamoDBWriteCapacityUtilization must be between 10 and 90; Provided value is: 5/); - - test.done(); - }, - - 'error when specifying Write Auto Scaling with invalid scalingTargetValue > 90'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 95.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - // tslint:disable-next-line:max-line-length - }), /scalingTargetValue for predefined metric type DynamoDBReadCapacityUtilization\/DynamoDBWriteCapacityUtilization must be between 10 and 90; Provided value is: 95/); - - test.done(); - }, - - 'error when specifying Write Auto Scaling with invalid scaleInCooldown'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 50.0, - scaleInCooldown: -5, - scaleOutCooldown: 60 - }), /scaleInCooldown must be greater than or equal to 0; Provided value is: -5/); - - test.done(); - }, - - 'error when specifying Write Auto Scaling with invalid scaleOutCooldown'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: 500, - targetValue: 50.0, - scaleInCooldown: 80, - scaleOutCooldown: -5 - }), /scaleOutCooldown must be greater than or equal to 0; Provided value is: -5/); - - test.done(); - }, - - 'error when specifying Write Auto Scaling with invalid maximumCapacity'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addWriteAutoScaling({ - minCapacity: 50, - maxCapacity: -5, - targetValue: 50.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - }), /maximumCapacity must be greater than or equal to 0; Provided value is: -5/); - - test.done(); - }, - - 'error when specifying Write Auto Scaling with invalid minimumCapacity'(test: Test) { - const app = new TestApp(); - const table = new Table(app.stack, CONSTRUCT_NAME, { - tableName: TABLE_NAME, - readCapacity: 42, - writeCapacity: 1337 - }); - table.addPartitionKey(TABLE_PARTITION_KEY); - table.addSortKey(TABLE_SORT_KEY); - test.throws(() => table.addWriteAutoScaling({ - minCapacity: -5, - maxCapacity: 500, - targetValue: 50.0, - scaleInCooldown: 80, - scaleOutCooldown: 60 - }), /minimumCapacity must be greater than or equal to 0; Provided value is: -5/); + // THEN + expect(app.stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + ScheduledActions: [ + { + ScalableTargetAction: { "MaxCapacity": 10 }, + Schedule: "cron(* * ? * * )", + ScheduledActionName: "SaveMoneyByNotScalingUp" + } + ] + })); test.done(); }, @@ -2031,4 +1213,4 @@ function testGrant(test: Test, expectedActions: string[], invocation: (user: iam "Users": [ { "Ref": "user2C2B57AE" } ] })); test.done(); -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-iam/lib/group.ts b/packages/@aws-cdk/aws-iam/lib/group.ts index b4350236cf394..fc1ab3dc99613 100644 --- a/packages/@aws-cdk/aws-iam/lib/group.ts +++ b/packages/@aws-cdk/aws-iam/lib/group.ts @@ -1,6 +1,6 @@ import { Construct } from '@aws-cdk/cdk'; import { cloudformation } from './iam.generated'; -import { IIdentityResource, IPrincipal, Policy } from './policy'; +import { IPrincipal, Policy } from './policy'; import { ArnPrincipal, PolicyPrincipal, PolicyStatement } from './policy-document'; import { User } from './user'; import { AttachedPolicies, undefinedIfEmpty } from './util'; @@ -34,7 +34,7 @@ export interface GroupProps { path?: string; } -export class Group extends Construct implements IIdentityResource, IPrincipal { +export class Group extends Construct implements IPrincipal { /** * The runtime name of this group. */ diff --git a/packages/@aws-cdk/aws-iam/lib/index.ts b/packages/@aws-cdk/aws-iam/lib/index.ts index b64fb2a5139f7..2301ccd5b6ae8 100644 --- a/packages/@aws-cdk/aws-iam/lib/index.ts +++ b/packages/@aws-cdk/aws-iam/lib/index.ts @@ -4,6 +4,7 @@ export * from './role'; export * from './policy'; export * from './user'; export * from './group'; +export * from './lazy-role'; // AWS::IAM CloudFormation Resources: export * from './iam.generated'; diff --git a/packages/@aws-cdk/aws-iam/lib/lazy-role.ts b/packages/@aws-cdk/aws-iam/lib/lazy-role.ts new file mode 100644 index 0000000000000..e9dcd7e160404 --- /dev/null +++ b/packages/@aws-cdk/aws-iam/lib/lazy-role.ts @@ -0,0 +1,93 @@ +import cdk = require('@aws-cdk/cdk'); +import { Policy } from './policy'; +import { PolicyPrincipal, PolicyStatement } from './policy-document'; +import { IRole, Role, RoleProps } from './role'; + +/** + * An IAM role that only gets attached to the construct tree once it gets used, not before + * + * This construct can be used to simplify logic in other constructs + * which need to create a role but only if certain configurations occur + * (such as when AutoScaling is configured). The role can be configured in one + * place, but if it never gets used it doesn't get instantiated and will + * not be synthesized or deployed. + */ +export class LazyRole extends cdk.Construct implements IRole { + private role?: Role; + private readonly statements = new Array(); + private readonly policies = new Array(); + private readonly managedPolicies = new Array(); + + constructor(parent: cdk.Construct, id: string, private readonly props: RoleProps) { + super(parent, id); + } + + /** + * Adds a permission to the role's default policy document. + * If there is no default policy attached to this role, it will be created. + * @param permission The permission statement to add to the policy document + */ + public addToPolicy(statement: PolicyStatement): void { + if (this.role) { + this.role.addToPolicy(statement); + } else { + this.statements.push(statement); + } + } + + /** + * Attaches a policy to this role. + * @param policy The policy to attach + */ + public attachInlinePolicy(policy: Policy): void { + if (this.role) { + this.role.attachInlinePolicy(policy); + } else { + this.policies.push(policy); + } + } + + /** + * Attaches a managed policy to this role. + * @param arn The ARN of the managed policy to attach. + */ + public attachManagedPolicy(arn: string): void { + if (this.role) { + this.role.attachManagedPolicy(arn); + } else { + this.managedPolicies.push(arn); + } + } + + /** + * Returns the role. + */ + public get dependencyElements(): cdk.IDependable[] { + return this.instantiate().dependencyElements; + } + + /** + * Returns the ARN of this role. + */ + public get roleArn(): string { + return this.instantiate().roleArn; + } + + /** + * Returns a Principal object representing the ARN of this role. + */ + public get principal(): PolicyPrincipal { + return this.instantiate().principal; + } + + private instantiate(): Role { + if (!this.role) { + const role = new Role(this, 'Default', this.props); + this.statements.forEach(role.addToPolicy.bind(role)); + this.policies.forEach(role.attachInlinePolicy.bind(role)); + this.managedPolicies.forEach(role.attachManagedPolicy.bind(role)); + this.role = role; + } + return this.role; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-iam/lib/policy.ts b/packages/@aws-cdk/aws-iam/lib/policy.ts index e35a415fa372b..15b017a62dd34 100644 --- a/packages/@aws-cdk/aws-iam/lib/policy.ts +++ b/packages/@aws-cdk/aws-iam/lib/policy.ts @@ -39,7 +39,7 @@ export interface IPrincipal { * @deprecated Use IPrincipal */ // tslint:disable-next-line:no-empty-interface -export interface IIdentityResource extends IPrincipal { } +export type IIdentityResource = IPrincipal; export interface PolicyProps { /** diff --git a/packages/@aws-cdk/aws-iam/lib/role.ts b/packages/@aws-cdk/aws-iam/lib/role.ts index 1853f28952f7d..22b4d14bd22b3 100644 --- a/packages/@aws-cdk/aws-iam/lib/role.ts +++ b/packages/@aws-cdk/aws-iam/lib/role.ts @@ -1,6 +1,6 @@ import { Construct, IDependable } from '@aws-cdk/cdk'; import { cloudformation } from './iam.generated'; -import { IIdentityResource, IPrincipal, Policy } from './policy'; +import { IPrincipal, Policy } from './policy'; import { ArnPrincipal, PolicyDocument, PolicyPrincipal, PolicyStatement } from './policy-document'; import { AttachedPolicies, undefinedIfEmpty } from './util'; @@ -71,7 +71,14 @@ export interface RoleProps { * Defines an IAM role. The role is created with an assume policy document associated with * the specified AWS service principal defined in `serviceAssumeRole`. */ -export class Role extends Construct implements IIdentityResource, IPrincipal, IDependable { +export class Role extends Construct implements IRole { + /** + * Import a role that already exists + */ + public static import(parent: Construct, id: string, props: ImportedRoleProps): IRole { + return new ImportedRole(parent, id, props); + } + /** * The assume role policy document associated with this role. */ @@ -155,6 +162,16 @@ export class Role extends Construct implements IIdentityResource, IPrincipal, ID } } +/** + * A Role object + */ +export interface IRole extends IPrincipal, IDependable { + /** + * Returns the ARN of this role. + */ + readonly roleArn: string; +} + function createAssumeRolePolicy(principal: PolicyPrincipal) { return new PolicyDocument() .addStatement(new PolicyStatement() @@ -171,3 +188,40 @@ function validateMaxSessionDuration(duration?: number) { throw new Error(`maxSessionDuration is set to ${duration}, but must be >= 3600sec (1hr) and <= 43200sec (12hrs)`); } } + +/** + * Properties to import a Role + */ +export interface ImportedRoleProps { + /** + * The role's ARN + */ + roleArn: string; +} + +/** + * A role that already exists + */ +class ImportedRole extends Construct implements IRole { + public readonly roleArn: string; + public readonly principal: PolicyPrincipal; + public readonly dependencyElements: IDependable[] = []; + + constructor(parent: Construct, id: string, props: ImportedRoleProps) { + super(parent, id); + this.roleArn = props.roleArn; + this.principal = new ArnPrincipal(this.roleArn); + } + + public addToPolicy(_statement: PolicyStatement): void { + // FIXME: Add warning that we're ignoring this + } + + public attachInlinePolicy(_policy: Policy): void { + // FIXME: Add warning that we're ignoring this + } + + public attachManagedPolicy(_arn: string): void { + // FIXME: Add warning that we're ignoring this + } +} diff --git a/packages/@aws-cdk/aws-iam/lib/user.ts b/packages/@aws-cdk/aws-iam/lib/user.ts index c75a4efbf88b0..10ae2fa483d98 100644 --- a/packages/@aws-cdk/aws-iam/lib/user.ts +++ b/packages/@aws-cdk/aws-iam/lib/user.ts @@ -1,7 +1,7 @@ import { Construct } from '@aws-cdk/cdk'; import { Group } from './group'; import { cloudformation } from './iam.generated'; -import { IIdentityResource, IPrincipal, Policy } from './policy'; +import { IPrincipal, Policy } from './policy'; import { ArnPrincipal, PolicyPrincipal, PolicyStatement } from './policy-document'; import { AttachedPolicies, undefinedIfEmpty } from './util'; @@ -62,7 +62,7 @@ export interface UserProps { passwordResetRequired?: boolean; } -export class User extends Construct implements IIdentityResource, IPrincipal { +export class User extends Construct implements IPrincipal { /** * An attribute that represents the user name. diff --git a/packages/@aws-cdk/aws-kinesis/lib/stream.ts b/packages/@aws-cdk/aws-kinesis/lib/stream.ts index 648b2ee498779..4a8fc4a755c8f 100644 --- a/packages/@aws-cdk/aws-kinesis/lib/stream.ts +++ b/packages/@aws-cdk/aws-kinesis/lib/stream.ts @@ -88,7 +88,7 @@ export abstract class StreamRef extends cdk.Construct implements logs.ILogSubscr * If an encryption key is used, permission to ues the key to decrypt the * contents of the stream will also be granted. */ - public grantRead(identity?: iam.IIdentityResource) { + public grantRead(identity?: iam.IPrincipal) { if (!identity) { return; } @@ -114,7 +114,7 @@ export abstract class StreamRef extends cdk.Construct implements logs.ILogSubscr * If an encryption key is used, permission to ues the key to decrypt the * contents of the stream will also be granted. */ - public grantWrite(identity?: iam.IIdentityResource) { + public grantWrite(identity?: iam.IPrincipal) { if (!identity) { return; } @@ -142,7 +142,7 @@ export abstract class StreamRef extends cdk.Construct implements logs.ILogSubscr * If an encryption key is used, permission to use the key for * encrypt/decrypt will also be granted. */ - public grantReadWrite(identity?: iam.IIdentityResource) { + public grantReadWrite(identity?: iam.IPrincipal) { if (!identity) { return; } @@ -221,7 +221,7 @@ export abstract class StreamRef extends cdk.Construct implements logs.ILogSubscr return dest.logSubscriptionDestination(sourceLogGroup); } - private grant(identity: iam.IIdentityResource, actions: { streamActions: string[], keyActions: string[] }) { + private grant(identity: iam.IPrincipal, actions: { streamActions: string[], keyActions: string[] }) { identity.addToPolicy(new iam.PolicyStatement() .addResource(this.streamArn) .addActions(...actions.streamActions)); diff --git a/packages/@aws-cdk/aws-sns/lib/topic-ref.ts b/packages/@aws-cdk/aws-sns/lib/topic-ref.ts index e5c9c202179bb..f0582aa215023 100644 --- a/packages/@aws-cdk/aws-sns/lib/topic-ref.ts +++ b/packages/@aws-cdk/aws-sns/lib/topic-ref.ts @@ -190,7 +190,7 @@ export abstract class TopicRef extends cdk.Construct implements events.IEventRul /** * Grant topic publishing permissions to the given identity */ - public grantPublish(identity?: iam.IIdentityResource) { + public grantPublish(identity?: iam.IPrincipal) { if (!identity) { return; } diff --git a/packages/@aws-cdk/aws-stepfunctions/test/test.activity.ts b/packages/@aws-cdk/aws-stepfunctions/test/test.activity.ts index aed33b0050006..96f1921d90d6c 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/test.activity.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/test.activity.ts @@ -63,13 +63,13 @@ export = { test.deepEqual(cdk.resolve(activity.metricRunTime()), { ...sharedMetric, metricName: 'ActivityRunTime', - statistic: 'avg' + statistic: 'Average' }); test.deepEqual(cdk.resolve(activity.metricFailed()), { ...sharedMetric, metricName: 'ActivitiesFailed', - statistic: 'sum' + statistic: 'Sum' }); test.done(); diff --git a/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts b/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts index d3482e31d960b..5bbbbb48ccda2 100644 --- a/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts +++ b/packages/@aws-cdk/aws-stepfunctions/test/test.state-machine-resources.ts @@ -80,13 +80,13 @@ export = { test.deepEqual(cdk.resolve(task.metricRunTime()), { ...sharedMetric, metricName: 'FakeResourceRunTime', - statistic: 'avg' + statistic: 'Average' }); test.deepEqual(cdk.resolve(task.metricFailed()), { ...sharedMetric, metricName: 'FakeResourcesFailed', - statistic: 'sum' + statistic: 'Sum' }); test.done(); diff --git a/packages/@aws-cdk/runtime-values/lib/rtv.ts b/packages/@aws-cdk/runtime-values/lib/rtv.ts index bf2fbc7bd6fc6..5e9457f079a3f 100644 --- a/packages/@aws-cdk/runtime-values/lib/rtv.ts +++ b/packages/@aws-cdk/runtime-values/lib/rtv.ts @@ -73,7 +73,7 @@ export class RuntimeValue extends cdk.Construct { * Grants a principal read permissions on this runtime value. * @param principal The principal (e.g. Role, User, Group) */ - public grantRead(principal?: iam.IIdentityResource) { + public grantRead(principal?: iam.IPrincipal) { // sometimes "role" is optional, so we want `rtv.grantRead(role)` to be a no-op if (!principal) { diff --git a/packages/aws-cdk/lib/init-templates/app/dotnet/src/HelloCdk/HelloConstruct.cs b/packages/aws-cdk/lib/init-templates/app/dotnet/src/HelloCdk/HelloConstruct.cs index 9813259ac4550..c80d39d676287 100644 --- a/packages/aws-cdk/lib/init-templates/app/dotnet/src/HelloCdk/HelloConstruct.cs +++ b/packages/aws-cdk/lib/init-templates/app/dotnet/src/HelloCdk/HelloConstruct.cs @@ -10,7 +10,7 @@ public class HelloConstructProps { public int BucketCount { get; set; } } - + public class HelloConstruct : Construct { private readonly IEnumerable _buckets; @@ -24,7 +24,7 @@ public HelloConstruct(Construct parent, string id, HelloConstructProps props) : } // Give the specified principal read access to the buckets in this construct. - public void GrantRead(IIIdentityResource principal) + public void GrantRead(IIPrincipal principal) { foreach (Bucket bucket in _buckets) {