diff --git a/packages/@aws-cdk/aws-synthetics/README.md b/packages/@aws-cdk/aws-synthetics/README.md index c69849f13882f..6b62b87db495e 100644 --- a/packages/@aws-cdk/aws-synthetics/README.md +++ b/packages/@aws-cdk/aws-synthetics/README.md @@ -1,4 +1,5 @@ -## AWS::Synthetics Construct Library +## Amazon CloudWatch Synthetics Construct Library + --- @@ -6,11 +7,142 @@ > All classes with the `Cfn` prefix in this module ([CFN Resources](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib)) are always stable and safe to use. +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. They are subject to non-backward compatible changes or removal in any future version. These are not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be announced in the release notes. This means that while you may use them, you may need to update your source code when upgrading to a newer version of this package. + --- -This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. +Amazon CloudWatch Synthetics allow you to monitor your application by generating **synthetic** traffic. The traffic is produced by a **canary**: a configurable script that runs on a schedule. You configure the canary script to follow the same routes and perform the same actions as a user, which allows you to continually verify your user experience even when you don't have any traffic on your applications. + +## Canary + +To illustrate how to use a canary, assume your application defines the following endpoint: + +```bash +% curl "https://api.example.com/user/books/topbook/" +The Hitchhikers Guide to the Galaxy + +``` + +The below code defines a canary that will hit the `books/topbook` endpoint every 5 minutes: ```ts import * as synthetics from '@aws-cdk/aws-synthetics'; + +const canary = new synthetics.Canary(this, 'MyCanary', { + schedule: synthetics.Schedule.rate(Duration.minutes(5)), + test: Test.custom({ + code: Code.fromAsset(path.join(__dirname, 'canary'))), + handler: 'index.handler', + }), +}); +``` + +The following is an example of an `index.js` file which exports the `handler` function: + +```js +var synthetics = require('Synthetics'); +const log = require('SyntheticsLogger'); + +const pageLoadBlueprint = async function () { + + // INSERT URL here + const URL = "https://api.example.com/user/books/topbook/"; + + let page = await synthetics.getPage(); + const response = await page.goto(URL, {waitUntil: 'domcontentloaded', timeout: 30000}); + //Wait for page to render. + //Increase or decrease wait time based on endpoint being monitored. + await page.waitFor(15000); + // This will take a screenshot that will be included in test output artifacts + await synthetics.takeScreenshot('loaded', 'loaded'); + let pageTitle = await page.title(); + log.info('Page title: ' + pageTitle); + if (response.status() !== 200) { + throw "Failed to load page!"; + } +}; + +exports.handler = async () => { + return await pageLoadBlueprint(); +}; +``` + +> **Note:** The function **must** be called `handler`. + +The canary will automatically produce a CloudWatch Dashboard: + +![UI Screenshot](images/ui-screenshot.png) + +The Canary code will be executed in a lambda function created by Synthetics on your behalf. The Lambda function includes a custom [runtime](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_Library.html) provided by Synthetics. The provided runtime includes a variety of handy tools such as [Puppeteer](https://www.npmjs.com/package/puppeteer-core) and Chromium. To learn more about Synthetics capabilities, check out the [docs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries.html). + +### Configuring the Canary Script + +To configure the script the canary executes, use the `test` property. The `test` property accepts a `Test` instance that can be initialized by the `Test` class static methods. Currently, the only implemented method is `Test.custom()`, which allows you to bring your own code. In the future, other methods will be added. `Test.custom()` accepts `code` and `handler` properties -- both are required by Synthetics to create a lambda function on your behalf. + +The `synthetics.Code` class exposes static methods to bundle your code artifacts: + + - `code.fromInline(code)` - specify an inline script. + - `code.fromAsset(path)` - specify a .zip file or a directory in the local filesystem which will be zipped and uploaded to S3 on deployment. See the above Note for directory structure. + - `code.fromBucket(bucket, key[, objectVersion])` - specify an S3 object that contains the .zip file of your runtime code. See the above Note for directory structure. + +Using the `Code` class static initializers: + +```ts +// To supply the code inline: +const canary = new Canary(this, 'MyCanary', { + test: Test.custom({ + code: Code.fromInline('/* Synthetics handler code */'), + handler: 'index.handler', // must be 'index.handler' + }), +}); + +// To supply the code from your local filesystem: +const canary = new Canary(this, 'MyCanary', { + test: Test.custom({ + code: Code.fromAsset(path.join(__dirname, 'canary')), + handler: 'index.handler', // must end with '.handler' + }), +}); + +// To supply the code from a S3 bucket: +const canary = new Canary(this, 'MyCanary', { + test: Test.custom({ + code: Code.fromBucket(bucket, 'canary.zip'), + handler: 'index.handler', // must end with '.handler' + }), +}); +``` + +> **Note:** For `code.fromAsset()` and `code.fromBucket()`, the canary resource requires the following folder structure: +>``` +>canary/ +>├── nodejs/ +> ├── node_modules/ +> ├── .js +>``` +> See Synthetics [docs](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html). + +### Alarms + +You can configure a CloudWatch Alarm on a canary metric. Metrics are emitted by CloudWatch automatically and can be accessed by the following APIs: +- `canary.metricSuccessPercent()` - percentage of successful canary runs over a given time +- `canary.metricDuration()` - how much time each canary run takes, in seconds. +- `canary.metricFailed()` - number of failed canary runs over a given time + +Create an alarm that tracks the canary metric: + +```ts +new cloudwatch.Alarm(this, 'CanaryAlarm', { + metric: canary.metricSuccessPercent(), + evaluationPeriods: 2, + threshold: 90, + comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD, +}); ``` + +### Future Work + +- Add blueprints to the Test class [#9613](https://github.com/aws/aws-cdk/issues/9613#issue-677134857). diff --git a/packages/@aws-cdk/aws-synthetics/images/endpoint-example.png b/packages/@aws-cdk/aws-synthetics/images/endpoint-example.png new file mode 100644 index 0000000000000..31ee9fcdbb2e4 Binary files /dev/null and b/packages/@aws-cdk/aws-synthetics/images/endpoint-example.png differ diff --git a/packages/@aws-cdk/aws-synthetics/images/ui-screenshot.png b/packages/@aws-cdk/aws-synthetics/images/ui-screenshot.png new file mode 100644 index 0000000000000..239825fa9bb14 Binary files /dev/null and b/packages/@aws-cdk/aws-synthetics/images/ui-screenshot.png differ diff --git a/packages/@aws-cdk/aws-synthetics/lib/canary.ts b/packages/@aws-cdk/aws-synthetics/lib/canary.ts new file mode 100644 index 0000000000000..e819ff40c56c5 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/lib/canary.ts @@ -0,0 +1,411 @@ +import * as crypto from 'crypto'; +import { Metric, MetricOptions } from '@aws-cdk/aws-cloudwatch'; +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; +import { Code } from './code'; +import { Schedule } from './schedule'; +import { CfnCanary } from './synthetics.generated'; + +/** + * Specify a test that the canary should run + */ +export class Test { + /** + * Specify a custom test with your own code + * + * @returns `Test` associated with the specified Code object + * @param options The configuration options + */ + public static custom(options: CustomTestOptions): Test { + Test.validateHandler(options.handler); + return new Test(options.code, options.handler); + } + + /** + * Verifies that the given handler ends in '.handler'. Returns the handler if successful and + * throws an error if not. + * + * @param handler - the handler given by the user + */ + private static validateHandler(handler: string) { + if (!handler.endsWith('.handler')) { + throw new Error(`Canary Handler must end in '.handler' (${handler})`); + } + if (handler.length > 21) { + throw new Error(`Canary Handler must be less than 21 characters (${handler})`); + } + } + + /** + * Construct a Test property + * + * @param code The code that the canary should run + * @param handler The handler of the canary + */ + private constructor(public readonly code: Code, public readonly handler: string){ + } +} + +/** + * Properties for specifying a test + */ +export interface CustomTestOptions { + /** + * The code of the canary script + */ + readonly code: Code, + + /** + * The handler for the code. Must end with `.handler`. + */ + readonly handler: string, +} + +/** + * Runtime options for a canary + */ +export class Runtime { + /** + * `syn-1.0` includes the following: + * + * - Synthetics library 1.0 + * - Synthetics handler code 1.0 + * - Lambda runtime Node.js 10.x + * - Puppeteer-core version 1.14.0 + * - The Chromium version that matches Puppeteer-core 1.14.0 + */ + public static readonly SYNTHETICS_1_0 = new Runtime('syn-1.0'); + + /** + * @param name The name of the runtime version + */ + public constructor(public readonly name: string){ + } +} + +/** + * Options for specifying the s3 location that stores the data of each canary run. The artifacts bucket location **cannot** + * be updated once the canary is created. + */ +export interface ArtifactsBucketLocation { + /** + * The s3 location that stores the data of each run. + */ + readonly bucket: s3.IBucket; + + /** + * The S3 bucket prefix. Specify this if you want a more specific path within the artifacts bucket. + * + * @default - no prefix + */ + readonly prefix?: string; +} + +/** + * Properties for a canary + */ +export interface CanaryProps { + /** + * The s3 location that stores the data of the canary runs. + * + * @default - A new s3 bucket will be created without a prefix. + */ + readonly artifactsBucketLocation?: ArtifactsBucketLocation; + + /** + * Canary execution role. + * + * This is the role that will be assumed by the canary upon execution. + * It controls the permissions that the canary will have. The role must + * be assumable by the AWS Lambda service principal. + * + * If not supplied, a role will be created with all the required permissions. + * If you provide a Role, you must add the required permissions. + * + * @see required permissions: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-synthetics-canary.html#cfn-synthetics-canary-executionrolearn + * + * @default - A unique role will be generated for this canary. + * You can add permissions to roles by calling 'addToRolePolicy'. + */ + readonly role?: iam.IRole; + + /** + * How long the canary will be in a 'RUNNING' state. For example, if you set `timeToLive` to be 1 hour and `schedule` to be `rate(10 minutes)`, + * your canary will run at 10 minute intervals for an hour, for a total of 6 times. + * + * @default - no limit + */ + readonly timeToLive?: cdk.Duration; + + /** + * Specify the schedule for how often the canary runs. For example, if you set `schedule` to `rate(10 minutes)`, then the canary will run every 10 minutes. + * You can set the schedule with `Schedule.rate(Duration)` (recommended) or you can specify an expression using `Schedule.expression()`. + * @default 'rate(5 minutes)' + */ + readonly schedule?: Schedule; + + /** + * Whether or not the canary should start after creation. + * + * @default true + */ + readonly startAfterCreation?: boolean; + + /** + * How many days should successful runs be retained. + * + * @default Duration.days(31) + */ + readonly successRetentionPeriod?: cdk.Duration; + + /** + * How many days should failed runs be retained. + * + * @default Duration.days(31) + */ + readonly failureRetentionPeriod?: cdk.Duration; + + /** + * The name of the canary. Be sure to give it a descriptive name that distinguishes it from + * other canaries in your account. + * + * Do not include secrets or proprietary information in your canary name. The canary name + * makes up part of the canary ARN, which is included in outbound calls over the internet. + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/servicelens_canaries_security.html + * + * @default - A unique name will be generated from the construct ID + */ + readonly canaryName?: string; + + /** + * Specify the runtime version to use for the canary. Currently, the only valid value is `Runtime.SYNTHETICS_1.0`. + * + * @default Runtime.SYNTHETICS_1_0 + */ + readonly runtime?: Runtime; + + /** + * The type of test that you want your canary to run. Use `Test.custom()` to specify the test to run. + */ + readonly test: Test; + +} + +/** + * Define a new Canary + */ +export class Canary extends cdk.Resource { + /** + * Execution role associated with this Canary. + */ + public readonly role: iam.IRole; + + /** + * The canary ID + * @attribute + */ + public readonly canaryId: string; + + /** + * The state of the canary. For example, 'RUNNING', 'STOPPED', 'NOT STARTED', or 'ERROR'. + * @attribute + */ + public readonly canaryState: string; + + /** + * The canary Name + * @attribute + */ + public readonly canaryName: string; + + /** + * Bucket where data from each canary run is stored. + */ + public readonly artifactsBucket: s3.IBucket; + + public constructor(scope: cdk.Construct, id: string, props: CanaryProps) { + if (props.canaryName && !cdk.Token.isUnresolved(props.canaryName)) { + validateName(props.canaryName); + } + + super(scope, id, { + physicalName: props.canaryName || cdk.Lazy.stringValue({ + produce: () => this.generateUniqueName(), + }), + }); + + this.artifactsBucket = props.artifactsBucketLocation?.bucket ?? new s3.Bucket(this, 'ArtifactsBucket', { + encryption: s3.BucketEncryption.KMS_MANAGED, + }); + + this.role = props.role ?? this.createDefaultRole(props.artifactsBucketLocation?.prefix); + + const resource: CfnCanary = new CfnCanary(this, 'Resource', { + artifactS3Location: this.artifactsBucket.s3UrlForObject(props.artifactsBucketLocation?.prefix), + executionRoleArn: this.role.roleArn, + startCanaryAfterCreation: props.startAfterCreation ?? true, + runtimeVersion: props.runtime?.name ?? Runtime.SYNTHETICS_1_0.name, + name: this.physicalName, + schedule: this.createSchedule(props), + failureRetentionPeriod: props.failureRetentionPeriod?.toDays(), + successRetentionPeriod: props.successRetentionPeriod?.toDays(), + code: this.createCode(props), + }); + + this.canaryId = resource.attrId; + this.canaryState = resource.attrState; + this.canaryName = this.getResourceNameAttribute(resource.ref); + } + + /** + * Measure the Duration of a single canary run, in seconds. + * + * @param options - configuration options for the metric + * + * @default avg over 5 minutes + */ + public metricDuration(options?: MetricOptions): Metric { + return this.metric('Duration', options); + } + + /** + * Measure the percentage of successful canary runs. + * + * @param options - configuration options for the metric + * + * @default avg over 5 minutes + */ + public metricSuccessPercent(options?: MetricOptions): Metric { + return this.metric('SuccessPercent', options); + } + + /** + * Measure the number of failed canary runs over a given time period. + * + * @param options - configuration options for the metric + * + * @default avg over 5 minutes + */ + public metricFailed(options?: MetricOptions): Metric { + return this.metric('Failed', options); + } + + /** + * @param metricName - the name of the metric + * @param options - configuration options for the metric + * + * @returns a CloudWatch metric associated with the canary. + * @default avg over 5 minutes + */ + private metric(metricName: string, options?: MetricOptions): Metric { + return new Metric({ + metricName, + namespace: 'CloudWatchSynthetics', + dimensions: { CanaryName: this.canaryName }, + statistic: 'avg', + ...options, + }).attachTo(this); + } + + /** + * Returns a default role for the canary + */ + private createDefaultRole(prefix?: string): iam.IRole { + // Created role will need these policies to run the Canary. + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-synthetics-canary.html#cfn-synthetics-canary-executionrolearn + const policy = new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + resources: ['*'], + actions: ['s3:ListAllMyBuckets'], + }), + new iam.PolicyStatement({ + resources: [this.artifactsBucket.arnForObjects(`${prefix ? prefix+'/*' : '*'}`)], + actions: ['s3:PutObject', 's3:GetBucketLocation'], + }), + new iam.PolicyStatement({ + resources: ['*'], + actions: ['cloudwatch:PutMetricData'], + conditions: {StringEquals: {'cloudwatch:namespace': 'CloudWatchSynthetics'}}, + }), + new iam.PolicyStatement({ + resources: ['arn:aws:logs:::*'], + actions: ['logs:CreateLogStream', 'logs:CreateLogGroup', 'logs:PutLogEvents'], + }), + ], + }); + + return new iam.Role(this, 'ServiceRole', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + inlinePolicies: { + canaryPolicy: policy, + }, + }); + } + + /** + * Returns the code object taken in by the canary resource. + */ + private createCode(props: CanaryProps): CfnCanary.CodeProperty { + const codeConfig = { + handler: props.test.handler, + ...props.test.code.bind(this, props.test.handler), + }; + return { + handler: codeConfig.handler, + script: codeConfig.inlineCode, + s3Bucket: codeConfig.s3Location?.bucketName, + s3Key: codeConfig.s3Location?.objectKey, + s3ObjectVersion: codeConfig.s3Location?.objectVersion, + }; + } + + /** + * Returns a canary schedule object + */ + private createSchedule(props: CanaryProps): CfnCanary.ScheduleProperty { + return { + durationInSeconds: String(`${props.timeToLive?.toSeconds() ?? 0}`), + expression: props.schedule?.expressionString ?? 'rate(5 minutes)', + }; + } + + /** + * Creates a unique name for the canary. The generated name is the physical ID of the canary. + */ + private generateUniqueName(): string { + const name = this.node.uniqueId.toLowerCase().replace(' ', '-'); + if (name.length <= 21){ + return name; + } else { + return name.substring(0,15) + nameHash(name); + } + } +} + +/** + * Take a hash of the given name. + * + * @param name the name to be hashed + */ +function nameHash(name: string): string { + const md5 = crypto.createHash('sha256').update(name).digest('hex'); + return md5.slice(0,6); +} + +const nameRegex: RegExp = /^[0-9a-z_\-]+$/; + +/** + * Verifies that the name fits the regex expression: ^[0-9a-z_\-]+$. + * + * @param name - the given name of the canary + */ +function validateName(name: string) { + if (name.length > 21) { + throw new Error(`Canary name is too large, must be between 1 and 21 characters, but is ${name.length} (got "${name}")`); + } + if (!nameRegex.test(name)) { + throw new Error(`Canary name must be lowercase, numbers, hyphens, or underscores (got "${name}")`); + } +} diff --git a/packages/@aws-cdk/aws-synthetics/lib/code.ts b/packages/@aws-cdk/aws-synthetics/lib/code.ts new file mode 100644 index 0000000000000..b5dd4e87aac23 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/lib/code.ts @@ -0,0 +1,183 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as s3_assets from '@aws-cdk/aws-s3-assets'; +import { Construct } from '@aws-cdk/core'; + +/** + * The code the canary should execute + */ +export abstract class Code { + + /** + * Specify code inline. + * + * @param code The actual handler code (limited to 4KiB) + * + * @returns `InlineCode` with inline code. + */ + public static fromInline(code: string): InlineCode { + return new InlineCode(code); + } + + /** + * Specify code from a local path. Path must include the folder structure `nodejs/node_modules/myCanaryFilename.js`. + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html#CloudWatch_Synthetics_Canaries_write_from_scratch + * + * @param assetPath Either a directory or a .zip file + * + * @returns `AssetCode` associated with the specified path. + */ + public static fromAsset(assetPath: string, options?: s3_assets.AssetOptions): AssetCode { + return new AssetCode(assetPath, options); + } + + /** + * Specify code from an s3 bucket. The object in the s3 bucket must be a .zip file that contains + * the structure `nodejs/node_modules/myCanaryFilename.js`. + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html#CloudWatch_Synthetics_Canaries_write_from_scratch + * + * @param bucket The S3 bucket + * @param key The object key + * @param objectVersion Optional S3 object version + * + * @returns `S3Code` associated with the specified S3 object. + */ + public static fromBucket(bucket: s3.IBucket, key: string, objectVersion?: string): S3Code { + return new S3Code(bucket, key, objectVersion); + } + + /** + * Called when the canary is initialized to allow this object to bind + * to the stack, add resources and have fun. + * + * @param scope The binding scope. Don't be smart about trying to down-cast or + * assume it's initialized. You may just use it as a construct scope. + * + * @returns a bound `CodeConfig`. + */ + public abstract bind(scope: Construct, handler: string): CodeConfig; +} + +/** + * Configuration of the code class + */ +export interface CodeConfig { + /** + * The location of the code in S3 (mutually exclusive with `inlineCode`). + * + * @default - none + */ + readonly s3Location?: s3.Location; + + /** + * Inline code (mutually exclusive with `s3Location`). + * + * @default - none + */ + readonly inlineCode?: string; +} + +/** + * Canary code from an Asset + */ +export class AssetCode extends Code { + private asset?: s3_assets.Asset; + + /** + * @param assetPath The path to the asset file or directory. + */ + public constructor(private assetPath: string, private options?: s3_assets.AssetOptions) { + super(); + + if (!fs.existsSync(this.assetPath)) { + throw new Error(`${this.assetPath} is not a valid path`); + } + } + + public bind(scope: Construct, handler: string): CodeConfig { + this.validateCanaryAsset(handler); + + // If the same AssetCode is used multiple times, retain only the first instantiation. + if (!this.asset){ + this.asset = new s3_assets.Asset(scope, 'Code', { + path: this.assetPath, + ...this.options, + }); + } + + return { + s3Location: { + bucketName: this.asset.s3BucketName, + objectKey: this.asset.s3ObjectKey, + }, + }; + } + + /** + * Validates requirements specified by the canary resource. For example, the canary code with handler `index.handler` + * must be found in the file structure `nodejs/node_modules/index.js`. + * + * Requires path to be either zip file or directory. + * Requires asset directory to have the structure 'nodejs/node_modules'. + * Requires canary file to be directly inside node_modules folder. + * Requires canary file name matches the handler name. + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html + * + * @param handler the canary handler + */ + private validateCanaryAsset(handler: string) { + if (path.extname(this.assetPath) !== '.zip') { + if (!fs.lstatSync(this.assetPath).isDirectory()) { + throw new Error(`Asset must be a .zip file or a directory (${this.assetPath})`); + } + const filename = `${handler.split('.')[0]}.js`; + if (!fs.existsSync(path.join(this.assetPath,'nodejs', 'node_modules', filename))) { + throw new Error(`The canary resource requires that the handler is present at "nodejs/node_modules/${filename}" but not found at ${this.assetPath} (https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html#CloudWatch_Synthetics_Canaries_write_from_scratch)`); + } + } + } +} + +/** + * Canary code from an inline string. + */ +export class InlineCode extends Code { + public constructor(private code: string) { + super(); + + if (code.length === 0) { + throw new Error('Canary inline code cannot be empty'); + } + } + + public bind(_scope: Construct, handler: string): CodeConfig { + + if (handler !== 'index.handler') { + throw new Error(`The handler for inline code must be "index.handler" (got "${handler}")`); + } + + return { + inlineCode: this.code, + }; + } +} + +/** + * S3 bucket path to the code zip file + */ +export class S3Code extends Code { + public constructor(private bucket: s3.IBucket, private key: string, private objectVersion?: string) { + super(); + } + + public bind(_scope: Construct, _handler: string): CodeConfig { + return { + s3Location: { + bucketName: this.bucket.bucketName, + objectKey: this.key, + objectVersion: this.objectVersion, + }, + }; + } +} diff --git a/packages/@aws-cdk/aws-synthetics/lib/index.ts b/packages/@aws-cdk/aws-synthetics/lib/index.ts index 4e9fab4ff01dd..f769a0309352e 100644 --- a/packages/@aws-cdk/aws-synthetics/lib/index.ts +++ b/packages/@aws-cdk/aws-synthetics/lib/index.ts @@ -1,2 +1,6 @@ +export * from './canary'; +export * from './code'; +export * from './schedule'; + // AWS::Synthetics CloudFormation Resources: export * from './synthetics.generated'; diff --git a/packages/@aws-cdk/aws-synthetics/lib/schedule.ts b/packages/@aws-cdk/aws-synthetics/lib/schedule.ts new file mode 100644 index 0000000000000..1102ba603367e --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/lib/schedule.ts @@ -0,0 +1,50 @@ +import { Duration } from '@aws-cdk/core'; + +/** + * Schedule for canary runs + */ +export class Schedule { + + /** + * The canary will be executed once. + */ + public static once(): Schedule { + return new Schedule('rate(0 minutes)'); + } + + /** + * Construct a schedule from a literal schedule expression. The expression must be in a `rate(number units)` format. + * For example, `Schedule.expression('rate(10 minutes)')` + * + * @param expression The expression to use. + */ + public static expression(expression: string): Schedule { + return new Schedule(expression); + } + + /** + * Construct a schedule from an interval. Allowed values: 0 (for a single run) or between 1 and 60 minutes. + * To specify a single run, you can use `Schedule.once()`. + * + * @param interval The interval at which to run the canary + */ + public static rate(interval: Duration): Schedule { + const minutes = interval.toMinutes(); + if (minutes > 60) { + throw new Error('Schedule duration must be between 1 and 60 minutes'); + } + if (minutes === 0) { + return Schedule.once(); + } + if (minutes === 1) { + return new Schedule('rate(1 minute)'); + } + return new Schedule(`rate(${minutes} minutes)`); + } + + private constructor( + /** + * The Schedule expression + */ + public readonly expressionString: string){} +} diff --git a/packages/@aws-cdk/aws-synthetics/package.json b/packages/@aws-cdk/aws-synthetics/package.json index b553ad1e05325..020a3038d3f81 100644 --- a/packages/@aws-cdk/aws-synthetics/package.json +++ b/packages/@aws-cdk/aws-synthetics/package.json @@ -67,20 +67,31 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "cdk-build-tools": "0.0.0", + "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", "pkglint": "0.0.0" }, "dependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", + "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-cloudwatch": "0.0.0", + "constructs": "^3.0.2" }, "peerDependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/core": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/aws-s3-assets": "0.0.0", + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-cloudwatch": "0.0.0", + "constructs": "^3.0.2" }, "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "experimental", - "maturity": "cfn-only", + "maturity": "experimental", "awscdkio": { "announce": false } diff --git a/packages/@aws-cdk/aws-synthetics/test/canaries/nodejs/node_modules/canary.js b/packages/@aws-cdk/aws-synthetics/test/canaries/nodejs/node_modules/canary.js new file mode 100644 index 0000000000000..0fa437f6288a2 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/test/canaries/nodejs/node_modules/canary.js @@ -0,0 +1,53 @@ +var synthetics = require('Synthetics'); +const log = require('SyntheticsLogger'); +const https = require('https'); +const http = require('http'); + +const apiCanaryBlueprint = async function () { + const postData = ""; + + const verifyRequest = async function (requestOption) { + return new Promise((resolve, reject) => { + log.info("Making request with options: " + JSON.stringify(requestOption)); + let req + if (requestOption.port === 443) { + req = https.request(requestOption); + } else { + req = http.request(requestOption); + } + req.on('response', (res) => { + log.info(`Status Code: ${res.statusCode}`) + log.info(`Response Headers: ${JSON.stringify(res.headers)}`) + if (res.statusCode !== 200) { + reject("Failed: " + requestOption.path); + } + res.on('data', (d) => { + log.info("Response: " + d); + }); + res.on('end', () => { + resolve(); + }) + }); + + req.on('error', (error) => { + reject(error); + }); + + if (postData) { + req.write(postData); + } + req.end(); + }); + } + + const headers = {} + headers['User-Agent'] = [synthetics.getCanaryUserAgentString(), headers['User-Agent']].join(' '); + const requestOptions = {"hostname":"ajt66lp5wj.execute-api.us-east-1.amazonaws.com","method":"GET","path":"/prod/","port":443} + requestOptions['headers'] = headers; + await verifyRequest(requestOptions); +}; + + +exports.handler = async () => { + return await apiCanaryBlueprint(); +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-synthetics/test/canary.test.ts b/packages/@aws-cdk/aws-synthetics/test/canary.test.ts new file mode 100644 index 0000000000000..ecd12308271c5 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/test/canary.test.ts @@ -0,0 +1,343 @@ +import '@aws-cdk/assert/jest'; +import { objectLike } from '@aws-cdk/assert'; +import * as iam from '@aws-cdk/aws-iam'; +import * as s3 from '@aws-cdk/aws-s3'; +import { App, Duration, Lazy, Stack } from '@aws-cdk/core'; +import * as synthetics from '../lib'; + +test('Basic canary properties work', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + canaryName: 'mycanary', + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + successRetentionPeriod: Duration.days(10), + failureRetentionPeriod: Duration.days(10), + startAfterCreation: false, + timeToLive: Duration.minutes(30), + runtime: synthetics.Runtime.SYNTHETICS_1_0, + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + Name: 'mycanary', + SuccessRetentionPeriod: 10, + FailureRetentionPeriod: 10, + StartCanaryAfterCreation: false, + Schedule: objectLike({ DurationInSeconds: '1800'}), + RuntimeVersion: 'syn-1.0', + }); +}); + +test('Canary can have generated name', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + Name: 'canariescanary8dfb794', + }); +}); + +test('Name validation does not fail when using Tokens', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + canaryName: Lazy.stringValue({ produce: () => 'My Canary' }), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN: no exception + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary'); +}); + +test('Throws when name is specified incorrectly', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // THEN + expect(() => new synthetics.Canary(stack, 'Canary', { + canaryName: 'My Canary', + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + })) + .toThrowError('Canary name must be lowercase, numbers, hyphens, or underscores (got "My Canary")'); +}); + +test('Throws when name has more than 21 characters', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // THEN + expect(() => new synthetics.Canary(stack, 'Canary', { + canaryName: 'a'.repeat(22), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + })) + .toThrowError(`Canary name is too large, must be between 1 and 21 characters, but is 22 (got "${'a'.repeat(22)}")`); +}); + +test('An existing role can be specified instead of auto-created', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + const role = new iam.Role(stack, 'role', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + }); + + // role.addToPolicy(/* required permissions per the documentation */); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + role, + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + ExecutionRoleArn: stack.resolve(role.roleArn), + }); +}); + +test('An existing bucket and prefix can be specified instead of auto-created', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + const bucket = new s3.Bucket(stack, 'mytestbucket'); + const prefix = 'canary'; + + // WHEN + new synthetics.Canary(stack, 'Canary', { + artifactsBucketLocation: { bucket, prefix }, + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + ArtifactS3Location: stack.resolve(bucket.s3UrlForObject(prefix)), + }); +}); + +test('Runtime can be specified', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + runtime: synthetics.Runtime.SYNTHETICS_1_0, + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + RuntimeVersion: 'syn-1.0', + }); +}); + +test('Runtime can be customized', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + runtime: new synthetics.Runtime('fancy-future-runtime-1337.42'), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + RuntimeVersion: 'fancy-future-runtime-1337.42', + }); +}); + +test('Schedule can be set with Rate', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + schedule: synthetics.Schedule.rate(Duration.minutes(3)), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + Schedule: objectLike({ Expression: 'rate(3 minutes)'}), + }); +}); + +test('Schedule can be set to 1 minute', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + schedule: synthetics.Schedule.rate(Duration.minutes(1)), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + Schedule: objectLike({ Expression: 'rate(1 minute)'}), + }); +}); + +test('Schedule can be set with Expression', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + schedule: synthetics.Schedule.expression('rate(1 hour)'), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + Schedule: objectLike({ Expression: 'rate(1 hour)'}), + }); +}); + +test('Schedule can be set to run once', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + schedule: synthetics.Schedule.once(), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + Schedule: objectLike({ Expression: 'rate(0 minutes)'}), + }); +}); + +test('Throws when rate above 60 minutes', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // THEN + expect(() => new synthetics.Canary(stack, 'Canary', { + schedule: synthetics.Schedule.rate(Duration.minutes(61)), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + })) + .toThrowError('Schedule duration must be between 1 and 60 minutes'); +}); + +test('Throws when rate above is not a whole number of minutes', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // THEN + expect(() => new synthetics.Canary(stack, 'Canary', { + schedule: synthetics.Schedule.rate(Duration.seconds(59)), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + })) + .toThrowError('\'59 seconds\' cannot be converted into a whole number of minutes.'); +}); + +test('Can share artifacts bucket between canaries', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + const canary1 = new synthetics.Canary(stack, 'Canary1', { + schedule: synthetics.Schedule.once(), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + }); + + const canary2 = new synthetics.Canary(stack, 'Canary2', { + schedule: synthetics.Schedule.once(), + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('/* Synthetics handler code */'), + }), + artifactsBucketLocation: { bucket: canary1.artifactsBucket }, + }); + + // THEN + expect(canary1.artifactsBucket).toEqual(canary2.artifactsBucket); +}); + +test('can specify custom test', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + new synthetics.Canary(stack, 'Canary', { + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline(` + exports.handler = async () => { + console.log(\'hello world\'); + };`), + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + Code: { + Handler: 'index.handler', + Script: ` + exports.handler = async () => { + console.log(\'hello world\'); + };`, + }, + }); +}); diff --git a/packages/@aws-cdk/aws-synthetics/test/canary.zip b/packages/@aws-cdk/aws-synthetics/test/canary.zip new file mode 100644 index 0000000000000..b8d48b451cf33 Binary files /dev/null and b/packages/@aws-cdk/aws-synthetics/test/canary.zip differ diff --git a/packages/@aws-cdk/aws-synthetics/test/code.test.ts b/packages/@aws-cdk/aws-synthetics/test/code.test.ts new file mode 100644 index 0000000000000..73abc34c95e66 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/test/code.test.ts @@ -0,0 +1,143 @@ +import '@aws-cdk/assert/jest'; +import * as path from 'path'; +import * as s3 from '@aws-cdk/aws-s3'; +import { App, Stack } from '@aws-cdk/core'; +import * as synthetics from '../lib'; + +describe(synthetics.Code.fromInline, () => { + test('fromInline works', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + const inline = synthetics.Code.fromInline(` + exports.handler = async () => { + console.log(\'hello world\'); + };`); + + // THEN + expect(inline.bind(stack, 'index.handler').inlineCode).toEqual(` + exports.handler = async () => { + console.log(\'hello world\'); + };`); + }); + + test('fails if empty', () => { + expect(() => synthetics.Code.fromInline('')) + .toThrowError('Canary inline code cannot be empty'); + }); + + test('fails if handler is not "index.handler"', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // THEN + expect(() => synthetics.Code.fromInline('code').bind(stack, 'canary.handler')) + .toThrowError('The handler for inline code must be "index.handler" (got "canary.handler")'); + }); +}); + +describe(synthetics.Code.fromAsset, () => { + test('fromAsset works', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // WHEN + const directoryAsset = synthetics.Code.fromAsset(path.join(__dirname, 'canaries')); + new synthetics.Canary(stack, 'Canary', { + test: synthetics.Test.custom({ + handler: 'canary.handler', + code: directoryAsset, + }), + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::Synthetics::Canary', { + Code: { + Handler: 'canary.handler', + S3Bucket: stack.resolve(directoryAsset.bind(stack, 'canary.handler').s3Location?.bucketName), + S3Key: stack.resolve(directoryAsset.bind(stack, 'canary.handler').s3Location?.objectKey), + }, + }); + }); + + test('only one Asset object gets created even if multiple canaries use the same AssetCode', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'canaries'); + + // WHEN + const directoryAsset = synthetics.Code.fromAsset(path.join(__dirname, 'canaries')); + new synthetics.Canary(stack, 'Canary1', { + test: synthetics.Test.custom({ + handler: 'canary.handler', + code: directoryAsset, + }), + }); + new synthetics.Canary(stack, 'Canary2', { + test: synthetics.Test.custom({ + handler: 'canary.handler', + code: directoryAsset, + }), + }); + + // THEN + const assembly = app.synth(); + const synthesized = assembly.stacks[0]; + + expect(synthesized.assets.length).toEqual(1); + }); + + test('fails if path does not exist', () => { + const assetPath = path.join(__dirname, 'does-not-exist'); + expect(() => synthetics.Code.fromAsset(assetPath)) + .toThrowError(`${assetPath} is not a valid path`); + }); + + test('fails if non-zip asset is used', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // THEN + const assetPath = path.join(__dirname, 'canaries', 'nodejs', 'node_modules', 'canary.js'); + expect(() => synthetics.Code.fromAsset(assetPath).bind(stack, 'canary.handler')) + .toThrowError(`Asset must be a .zip file or a directory (${assetPath})`); + }); + + test('fails if "nodejs/node_modules" folder structure not used', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // THEN + const assetPath = path.join(__dirname, 'canaries', 'nodejs', 'node_modules'); + expect(() => synthetics.Code.fromAsset(assetPath).bind(stack, 'canary.handler')) + .toThrowError(`The canary resource requires that the handler is present at "nodejs/node_modules/canary.js" but not found at ${assetPath} (https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html#CloudWatch_Synthetics_Canaries_write_from_scratch)`); + }); + + test('fails if handler is specified incorrectly', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + // THEN + const assetPath = path.join(__dirname, 'canaries', 'nodejs', 'node_modules'); + expect(() => synthetics.Code.fromAsset(assetPath).bind(stack, 'incorrect.handler')) + .toThrowError(`The canary resource requires that the handler is present at "nodejs/node_modules/incorrect.js" but not found at ${assetPath} (https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Synthetics_Canaries_WritingCanary.html#CloudWatch_Synthetics_Canaries_write_from_scratch)`); + }); +}); + + +describe(synthetics.Code.fromBucket, () => { + test('fromBucket works', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + const bucket = new s3.Bucket(stack, 'CodeBucket'); + + // WHEN + const code = synthetics.Code.fromBucket(bucket, 'code.js'); + const codeConfig = code.bind(stack, 'code.handler'); + + // THEN + expect(codeConfig.s3Location?.bucketName).toEqual(bucket.bucketName); + expect(codeConfig.s3Location?.objectKey).toEqual('code.js'); + }); +}); diff --git a/packages/@aws-cdk/aws-synthetics/test/integ.asset.expected.json b/packages/@aws-cdk/aws-synthetics/test/integ.asset.expected.json new file mode 100644 index 0000000000000..62cee1d8660c7 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/test/integ.asset.expected.json @@ -0,0 +1,342 @@ +{ + "Resources": { + "MyCanaryArtifactsBucket89975E6D": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms" + } + } + ] + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "MyCanaryServiceRole593F9DD9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:ListAllMyBuckets", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "s3:PutObject", + "s3:GetBucketLocation" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "MyCanaryArtifactsBucket89975E6D", + "Arn" + ] + }, + "/*" + ] + ] + } + }, + { + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "CloudWatchSynthetics" + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:::*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "canaryPolicy" + } + ] + } + }, + "MyCanary1A94CAFA": { + "Type": "AWS::Synthetics::Canary", + "Properties": { + "ArtifactS3Location": { + "Fn::Join": [ + "", + [ + "s3://", + { + "Ref": "MyCanaryArtifactsBucket89975E6D" + } + ] + ] + }, + "Code": { + "Handler": "canary.handler", + "S3Bucket": { + "Ref": "AssetParameters5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529bS3Bucket58589EB6" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529bS3VersionKey8FF13E90" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529bS3VersionKey8FF13E90" + } + ] + } + ] + } + ] + ] + } + }, + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyCanaryServiceRole593F9DD9", + "Arn" + ] + }, + "Name": "assetcanary-one", + "RuntimeVersion": "syn-1.0", + "Schedule": { + "DurationInSeconds": "0", + "Expression": "rate(5 minutes)" + }, + "StartCanaryAfterCreation": true + } + }, + "MyCanaryTwoArtifactsBucket79B179B6": { + "Type": "AWS::S3::Bucket", + "Properties": { + "BucketEncryption": { + "ServerSideEncryptionConfiguration": [ + { + "ServerSideEncryptionByDefault": { + "SSEAlgorithm": "aws:kms" + } + } + ] + } + }, + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "MyCanaryTwoServiceRole041E85D4": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:ListAllMyBuckets", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "s3:PutObject", + "s3:GetBucketLocation" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "MyCanaryTwoArtifactsBucket79B179B6", + "Arn" + ] + }, + "/*" + ] + ] + } + }, + { + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "CloudWatchSynthetics" + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:::*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "canaryPolicy" + } + ] + } + }, + "MyCanaryTwo6501D55F": { + "Type": "AWS::Synthetics::Canary", + "Properties": { + "ArtifactS3Location": { + "Fn::Join": [ + "", + [ + "s3://", + { + "Ref": "MyCanaryTwoArtifactsBucket79B179B6" + } + ] + ] + }, + "Code": { + "Handler": "canary.handler", + "S3Bucket": { + "Ref": "AssetParametersb1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820S3Bucket705C3761" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersb1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820S3VersionKeyE546342B" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersb1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820S3VersionKeyE546342B" + } + ] + } + ] + } + ] + ] + } + }, + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyCanaryTwoServiceRole041E85D4", + "Arn" + ] + }, + "Name": "assetcanary-two", + "RuntimeVersion": "syn-1.0", + "Schedule": { + "DurationInSeconds": "0", + "Expression": "rate(5 minutes)" + }, + "StartCanaryAfterCreation": true + } + } + }, + "Parameters": { + "AssetParameters5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529bS3Bucket58589EB6": { + "Type": "String", + "Description": "S3 bucket for asset \"5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529b\"" + }, + "AssetParameters5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529bS3VersionKey8FF13E90": { + "Type": "String", + "Description": "S3 key for asset version \"5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529b\"" + }, + "AssetParameters5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529bArtifactHash74DCED3D": { + "Type": "String", + "Description": "Artifact hash for asset \"5bf46c83158ab3b336aba1449c21b02cbac2ccea621f17d842593bb39e3e529b\"" + }, + "AssetParametersb1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820S3Bucket705C3761": { + "Type": "String", + "Description": "S3 bucket for asset \"b1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820\"" + }, + "AssetParametersb1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820S3VersionKeyE546342B": { + "Type": "String", + "Description": "S3 key for asset version \"b1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820\"" + }, + "AssetParametersb1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820ArtifactHash536FDCC3": { + "Type": "String", + "Description": "Artifact hash for asset \"b1b777dcb79a2fa2790059927207d10bf5f4747d6dd1516e2780726d9d6fa820\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-synthetics/test/integ.asset.ts b/packages/@aws-cdk/aws-synthetics/test/integ.asset.ts new file mode 100644 index 0000000000000..49595e69b4286 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/test/integ.asset.ts @@ -0,0 +1,32 @@ +/// !cdk-integ canary-asset + +import * as path from 'path'; +import * as cdk from '@aws-cdk/core'; +import * as synthetics from '../lib'; + +/* + * Stack verification steps: + * + * -- aws synthetics get-canary --name assetcanary-one has a state of 'RUNNING' + * -- aws synthetics get-canary --name assetcanary-two has a state of 'RUNNING' + */ +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'canary-asset'); + +new synthetics.Canary(stack, 'MyCanary', { + canaryName: 'assetcanary-one', + test: synthetics.Test.custom({ + handler: 'canary.handler', + code: synthetics.Code.fromAsset(path.join(__dirname, 'canaries')), + }), +}); + +new synthetics.Canary(stack, 'MyCanaryTwo', { + canaryName: 'assetcanary-two', + test: synthetics.Test.custom({ + handler: 'canary.handler', + code: synthetics.Code.fromAsset(path.join(__dirname, 'canary.zip')), + }), +}); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-synthetics/test/integ.canary.expected.json b/packages/@aws-cdk/aws-synthetics/test/integ.canary.expected.json new file mode 100644 index 0000000000000..fb21d4808e26e --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/test/integ.canary.expected.json @@ -0,0 +1,115 @@ +{ + "Resources": { + "mytestbucket8DC16178": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "MyCanaryServiceRole593F9DD9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Policies": [ + { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:ListAllMyBuckets", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "s3:PutObject", + "s3:GetBucketLocation" + ], + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "mytestbucket8DC16178", + "Arn" + ] + }, + "/integ/*" + ] + ] + } + }, + { + "Action": "cloudwatch:PutMetricData", + "Condition": { + "StringEquals": { + "cloudwatch:namespace": "CloudWatchSynthetics" + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "logs:CreateLogStream", + "logs:CreateLogGroup", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "arn:aws:logs:::*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "canaryPolicy" + } + ] + } + }, + "MyCanary1A94CAFA": { + "Type": "AWS::Synthetics::Canary", + "Properties": { + "ArtifactS3Location": { + "Fn::Join": [ + "", + [ + "s3://", + { + "Ref": "mytestbucket8DC16178" + }, + "/integ" + ] + ] + }, + "Code": { + "Handler": "index.handler", + "Script": "\n exports.handler = async () => {\n console.log('hello world');\n };" + }, + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "MyCanaryServiceRole593F9DD9", + "Arn" + ] + }, + "Name": "canary-integ", + "RuntimeVersion": "syn-1.0", + "Schedule": { + "DurationInSeconds": "0", + "Expression": "rate(1 minute)" + }, + "StartCanaryAfterCreation": true + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-synthetics/test/integ.canary.ts b/packages/@aws-cdk/aws-synthetics/test/integ.canary.ts new file mode 100644 index 0000000000000..846361d82d7d7 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/test/integ.canary.ts @@ -0,0 +1,32 @@ +/// !cdk-integ canary-one + +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; +import * as synthetics from '../lib'; + +/* + * Stack verification steps: + * + * -- aws synthetics get-canary --name canary-one has a state of 'RUNNING' + */ +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'canary-one'); + +const bucket = new s3.Bucket(stack, 'mytestbucket'); +const prefix = 'integ'; + +new synthetics.Canary(stack, 'MyCanary', { + canaryName: 'canary-integ', + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline(` + exports.handler = async () => { + console.log(\'hello world\'); + };`), + }), + runtime: synthetics.Runtime.SYNTHETICS_1_0, + schedule: synthetics.Schedule.rate(cdk.Duration.minutes(1)), + artifactsBucketLocation: { bucket, prefix }, +}); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-synthetics/test/metric.test.ts b/packages/@aws-cdk/aws-synthetics/test/metric.test.ts new file mode 100644 index 0000000000000..5a968b8922d84 --- /dev/null +++ b/packages/@aws-cdk/aws-synthetics/test/metric.test.ts @@ -0,0 +1,69 @@ +import '@aws-cdk/assert/jest'; +import { App, Stack } from '@aws-cdk/core'; +import * as synthetics from '../lib'; + +test('.metricXxx() methods can be used to obtain Metrics for the canary', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + const canary = new synthetics.Canary(stack, 'mycanary', { + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('foo'), + }), + }); + + // WHEN + const metricSuccess = canary.metricSuccessPercent(); + const metricFailed = canary.metricFailed(); + const metricDuration = canary.metricDuration(); + + // THEN + expect(metricSuccess).toEqual({ + period: { amount: 5, unit: { inMillis: 60000, label: 'minutes' } }, + dimensions: { CanaryName: canary.canaryName }, + namespace: 'CloudWatchSynthetics', + metricName: 'SuccessPercent', + statistic: 'Average', + }); + + expect(metricFailed).toEqual({ + period: { amount: 5, unit: { inMillis: 60000, label: 'minutes' } }, + dimensions: { CanaryName: canary.canaryName }, + namespace: 'CloudWatchSynthetics', + metricName: 'Failed', + statistic: 'Average', + }); + + expect(metricDuration).toEqual({ + period: { amount: 5, unit: { inMillis: 60000, label: 'minutes' } }, + dimensions: { CanaryName: canary.canaryName }, + namespace: 'CloudWatchSynthetics', + metricName: 'Duration', + statistic: 'Average', + }); +}); + +test('Metric can specify statistic', () => { + // GIVEN + const stack = new Stack(new App(), 'canaries'); + + const canary = new synthetics.Canary(stack, 'mycanary', { + test: synthetics.Test.custom({ + handler: 'index.handler', + code: synthetics.Code.fromInline('foo'), + }), + }); + + // WHEN + const metric = canary.metricFailed({statistic: 'Sum'}); + + // THEN + expect(metric).toEqual({ + period: { amount: 5, unit: { inMillis: 60000, label: 'minutes' } }, + dimensions: { CanaryName: canary.canaryName }, + namespace: 'CloudWatchSynthetics', + metricName: 'Failed', + statistic: 'Sum', + }); +}); diff --git a/packages/@aws-cdk/aws-synthetics/test/synthetics.test.ts b/packages/@aws-cdk/aws-synthetics/test/synthetics.test.ts deleted file mode 100644 index e394ef336bfb4..0000000000000 --- a/packages/@aws-cdk/aws-synthetics/test/synthetics.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assert/jest'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -});