diff --git a/libs/wingsdk/src/target-awscdk/app.ts b/libs/wingsdk/src/target-awscdk/app.ts index acc9b9b9310..2a32af14055 100644 --- a/libs/wingsdk/src/target-awscdk/app.ts +++ b/libs/wingsdk/src/target-awscdk/app.ts @@ -8,6 +8,7 @@ import { Bucket } from "./bucket"; import { Counter } from "./counter"; import { Function } from "./function"; import { Queue } from "./queue"; +import { Schedule } from "./schedule"; import { Secret } from "./secret"; import { TestRunner } from "./test-runner"; import { CdkTokens } from "./tokens"; @@ -20,6 +21,7 @@ import { QUEUE_FQN, SECRET_FQN, TOPIC_FQN, + SCHEDULE_FQN, } from "../cloud"; import { App as CoreApp, AppProps, preSynthesizeAllConstructs } from "../core"; import { PluginManager } from "../core/plugin-manager"; @@ -145,6 +147,9 @@ export class App extends CoreApp { case COUNTER_FQN: return new Counter(scope, id, args[0]); + case SCHEDULE_FQN: + return new Schedule(scope, id, args[0]); + case QUEUE_FQN: return new Queue(scope, id, args[0]); diff --git a/libs/wingsdk/src/target-awscdk/index.ts b/libs/wingsdk/src/target-awscdk/index.ts index 9f2df5c0f29..0f82863c52e 100644 --- a/libs/wingsdk/src/target-awscdk/index.ts +++ b/libs/wingsdk/src/target-awscdk/index.ts @@ -3,4 +3,5 @@ export * from "./bucket"; export * from "./counter"; export * from "./function"; export * from "./queue"; +export * from "./schedule"; export * from "./secret"; diff --git a/libs/wingsdk/src/target-awscdk/schedule.ts b/libs/wingsdk/src/target-awscdk/schedule.ts new file mode 100644 index 00000000000..55d4bc7f700 --- /dev/null +++ b/libs/wingsdk/src/target-awscdk/schedule.ts @@ -0,0 +1,119 @@ +import { join } from "path"; +import { Duration } from "aws-cdk-lib"; +import { Rule, Schedule as EventSchedule } from "aws-cdk-lib/aws-events"; +import { + LambdaFunction, + addLambdaPermission, +} from "aws-cdk-lib/aws-events-targets"; +import { Construct } from "constructs"; +import { Function } from "./function"; +import * as cloud from "../cloud"; +import * as core from "../core"; +import { convertBetweenHandlers } from "../shared/convert"; +import { Resource } from "../std"; + +/** + * AWS implementation of `cloud.Schedule`. + * + * @inflight `@winglang/sdk.cloud.IScheduleClient` + */ +export class Schedule extends cloud.Schedule { + private readonly scheduleExpression: EventSchedule; + private readonly rule: Rule; + + constructor(scope: Construct, id: string, props: cloud.ScheduleProps = {}) { + super(scope, id, props); + + const { rate, cron } = props; + + /* + * The schedule cron string is Unix cron format: [minute] [hour] [day of month] [month] [day of week] + * AWS EventBridge Schedule uses a 6 field format which includes year: [minute] [hour] [day of month] [month] [day of week] [year] + * https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#cron-based + * + * We append * to the cron string for year field. + */ + if (cron) { + const cronArr = cron.split(" "); + let cronOpt: { [k: string]: string } = { + minute: cronArr[0], + hour: cronArr[1], + month: cronArr[3], + year: "*", + }; + if (cronArr[2] !== "?") { + cronOpt.day = cronArr[2]; + } + if (cronArr[4] !== "?") { + cronOpt.weekDay = cronArr[4]; + } + + this.scheduleExpression = EventSchedule.cron(cronOpt); + } else { + this.scheduleExpression = EventSchedule.rate( + Duration.minutes(rate!.minutes) + ); + } + + this.rule = new Rule(this, "Schedule", { + enabled: true, + schedule: this.scheduleExpression, + }); + } + + public onTick( + inflight: cloud.IScheduleOnTickHandler, + props?: cloud.ScheduleOnTickProps | undefined + ): cloud.Function { + const hash = inflight.node.addr.slice(-8); + const functionHandler = convertBetweenHandlers( + this.node.scope!, // ok since we're not a tree root + `${this.node.id}-OnTickHandler-${hash}`, + inflight, + join( + __dirname.replace("target-awscdk", "shared-aws"), + "schedule.ontick.inflight.js" + ), + "ScheduleOnTickHandlerClient" + ); + + const fn = Function._newFunction( + this.node.scope!, // ok since we're not a tree root + `${this.node.id}-SetConsumer-${hash}`, + functionHandler, + props + ); + + // TODO: remove this constraint by adding generic permission APIs to cloud.Function + if (!(fn instanceof Function)) { + throw new Error( + "Schedule only supports creating awscdk.Function right now" + ); + } + + this.rule.addTarget(new LambdaFunction(fn._function)); + addLambdaPermission(this.rule, fn._function); + + Resource.addConnection({ + from: this, + to: fn, + relationship: "on_tick", + }); + + return fn; + } + + /** @internal */ + public _toInflight(): core.Code { + return core.InflightClient.for( + __dirname.replace("target-awscdk", "shared-aws"), + __filename, + "ScheduleClient", + [`process.env["${this.envName()}"]`] + ); + } + + private envName(): string { + return `SCHEDULE_EVENT_${this.node.addr.slice(-8)}`; + } +} diff --git a/libs/wingsdk/test/target-awscdk/__snapshots__/schedule.test.ts.snap b/libs/wingsdk/test/target-awscdk/__snapshots__/schedule.test.ts.snap new file mode 100644 index 00000000000..401eb906ee7 --- /dev/null +++ b/libs/wingsdk/test/target-awscdk/__snapshots__/schedule.test.ts.snap @@ -0,0 +1,494 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`schedule behavior with cron 1`] = ` +{ + "Outputs": { + "WingTestRunnerFunctionArns": { + "Value": "[]", + }, + }, + "Parameters": { + "BootstrapVersion": { + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "Schedule251B1F83": { + "Properties": { + "ScheduleExpression": "cron(0/1 * ? * * *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e309AC31A4", + "Arn", + ], + }, + "Id": "Target0", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "ScheduleAllowEventRulemyprojectScheduleSetConsumerc185c7e38BBD7CF4F7734C87": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e309AC31A4", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "Schedule251B1F83", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ScheduleSetConsumerc185c7e309AC31A4": { + "DependsOn": [ + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847", + ], + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "99cc55258c346f62db746a74a3b335f909d0326a46c142758fdf8f8d189b2678.zip", + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847", + "Arn", + ], + }, + "Runtime": "nodejs18.x", + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5", + ], + { + "Ref": "BootstrapVersion", + }, + ], + }, + ], + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", + }, + ], + }, + }, +} +`; + +exports[`schedule behavior with rate 1`] = ` +{ + "Outputs": { + "WingTestRunnerFunctionArns": { + "Value": "[]", + }, + }, + "Parameters": { + "BootstrapVersion": { + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "Schedule251B1F83": { + "Properties": { + "ScheduleExpression": "rate(2 minutes)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e309AC31A4", + "Arn", + ], + }, + "Id": "Target0", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "ScheduleAllowEventRulemyprojectScheduleSetConsumerc185c7e38BBD7CF4F7734C87": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e309AC31A4", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "Schedule251B1F83", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ScheduleSetConsumerc185c7e309AC31A4": { + "DependsOn": [ + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847", + ], + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "99cc55258c346f62db746a74a3b335f909d0326a46c142758fdf8f8d189b2678.zip", + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847", + "Arn", + ], + }, + "Runtime": "nodejs18.x", + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5", + ], + { + "Ref": "BootstrapVersion", + }, + ], + }, + ], + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", + }, + ], + }, + }, +} +`; + +exports[`schedule with two functions 1`] = ` +{ + "Outputs": { + "WingTestRunnerFunctionArns": { + "Value": "[]", + }, + }, + "Parameters": { + "BootstrapVersion": { + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "Schedule251B1F83": { + "Properties": { + "ScheduleExpression": "cron(0/1 * ? * * *)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e309AC31A4", + "Arn", + ], + }, + "Id": "Target0", + }, + { + "Arn": { + "Fn::GetAtt": [ + "ScheduleSetConsumer6e1b4252729BE1C0", + "Arn", + ], + }, + "Id": "Target1", + }, + ], + }, + "Type": "AWS::Events::Rule", + }, + "ScheduleAllowEventRulemyprojectScheduleSetConsumer6e1b425227258B03DBB57E99": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ScheduleSetConsumer6e1b4252729BE1C0", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "Schedule251B1F83", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ScheduleAllowEventRulemyprojectScheduleSetConsumerc185c7e38BBD7CF4F7734C87": { + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e309AC31A4", + "Arn", + ], + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "Schedule251B1F83", + "Arn", + ], + }, + }, + "Type": "AWS::Lambda::Permission", + }, + "ScheduleSetConsumer6e1b4252729BE1C0": { + "DependsOn": [ + "ScheduleSetConsumer6e1b4252ServiceRole88F787AF", + ], + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "99cc55258c346f62db746a74a3b335f909d0326a46c142758fdf8f8d189b2678.zip", + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "ScheduleSetConsumer6e1b4252ServiceRole88F787AF", + "Arn", + ], + }, + "Runtime": "nodejs18.x", + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "ScheduleSetConsumer6e1b4252ServiceRole88F787AF": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "ScheduleSetConsumerc185c7e309AC31A4": { + "DependsOn": [ + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847", + ], + "Properties": { + "Code": { + "S3Bucket": { + "Fn::Sub": "cdk-hnb659fds-assets-\${AWS::AccountId}-\${AWS::Region}", + }, + "S3Key": "99cc55258c346f62db746a74a3b335f909d0326a46c142758fdf8f8d189b2678.zip", + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847", + "Arn", + ], + }, + "Runtime": "nodejs18.x", + "Timeout": 30, + }, + "Type": "AWS::Lambda::Function", + }, + "ScheduleSetConsumerc185c7e3ServiceRoleAA894847": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5", + ], + { + "Ref": "BootstrapVersion", + }, + ], + }, + ], + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI.", + }, + ], + }, + }, +} +`; diff --git a/libs/wingsdk/test/target-awscdk/queue.test.ts b/libs/wingsdk/test/target-awscdk/queue.test.ts index 650e6de3732..97d47955388 100644 --- a/libs/wingsdk/test/target-awscdk/queue.test.ts +++ b/libs/wingsdk/test/target-awscdk/queue.test.ts @@ -1,4 +1,4 @@ -import { Match, Template } from "aws-cdk-lib/assertions"; +import { Template } from "aws-cdk-lib/assertions"; import { test, expect } from "vitest"; import { Queue } from "../../src/cloud"; import * as std from "../../src/std"; diff --git a/libs/wingsdk/test/target-awscdk/schedule.test.ts b/libs/wingsdk/test/target-awscdk/schedule.test.ts new file mode 100644 index 00000000000..52138097614 --- /dev/null +++ b/libs/wingsdk/test/target-awscdk/schedule.test.ts @@ -0,0 +1,155 @@ +import { Match, Template, MatchResult } from "aws-cdk-lib/assertions"; +import { test, expect } from "vitest"; +import { Schedule } from "../../src/cloud"; +import * as std from "../../src/std"; +import * as awscdk from "../../src/target-awscdk"; +import { Testing } from "../../src/testing"; +import { mkdtemp } from "../util"; + +const CDK_APP_OPTS = { + stackName: "my-project", +}; + +test("schedule behavior with rate", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + const fn = Testing.makeHandler( + app, + "Handler", + `async handle(event) { console.log("Received: ", event); }` + ); + const schedule = Schedule._newSchedule(app, "Schedule", { + rate: std.Duration.fromMinutes(2), + }); + schedule.onTick(fn); + const output = app.synth(); + + // THEN + const template = Template.fromJSON(JSON.parse(output)); + template.resourceCountIs("AWS::Events::Rule", 1); + template.hasResourceProperties("AWS::Events::Rule", { + ScheduleExpression: "rate(2 minutes)", + }); + expect(template.toJSON()).toMatchSnapshot(); +}); + +test("schedule behavior with cron", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + const fn = Testing.makeHandler( + app, + "Handler", + `async handle(event) { console.log("Received: ", event); }` + ); + const schedule = Schedule._newSchedule(app, "Schedule", { + cron: "0/1 * ? * *", + }); + schedule.onTick(fn); + const output = app.synth(); + + // THEN + const template = Template.fromJSON(JSON.parse(output)); + template.resourceCountIs("AWS::Events::Rule", 1); + template.hasResourceProperties("AWS::Events::Rule", { + ScheduleExpression: "cron(0/1 * ? * * *)", + }); + expect(template.toJSON()).toMatchSnapshot(); +}); + +test("schedule with two functions", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + const fn = Testing.makeHandler( + app, + "Handler", + `async handle(event) { console.log("Received: ", event); }` + ); + const fn2 = Testing.makeHandler( + app, + "Handler2", + `async handle(event) { console.log("Received: ", event); }` + ); + const schedule = Schedule._newSchedule(app, "Schedule", { + cron: "0/1 * ? * *", + }); + schedule.onTick(fn); + schedule.onTick(fn2); + const output = app.synth(); + + // THEN + const template = Template.fromJSON(JSON.parse(output)); + template.hasResourceProperties("AWS::Events::Rule", { + Targets: Match.arrayWith([ + Match.objectLike({ + Id: "Target0", + }), + Match.objectLike({ + Id: "Target1", + }), + ]), + }); + expect(template.toJSON()).toMatchSnapshot(); +}); + +test("schedule with rate and cron simultaneously", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + + // THEN + expect(() => + Schedule._newSchedule(app, "Schedule", { + rate: std.Duration.fromSeconds(30), + cron: "0/1 * ? * *", + }) + ).toThrow("rate and cron cannot be configured simultaneously."); +}); + +test("cron with more than five values", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + + // THEN + expect(() => + Schedule._newSchedule(app, "Schedule", { + cron: "0/1 * ? * * *", + }) + ).toThrow( + "cron string must be UNIX cron format [minute] [hour] [day of month] [month] [day of week]" + ); +}); + +test("schedule without rate or cron", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + + // THEN + expect(() => Schedule._newSchedule(app, "Schedule")).toThrow( + "rate or cron need to be filled." + ); +}); + +test("schedule with rate less than 1 minute", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + + // THEN + expect(() => + Schedule._newSchedule(app, "Schedule", { + rate: std.Duration.fromSeconds(30), + }) + ).toThrow("rate can not be set to less than 1 minute."); +}); + +test("cron with Day-of-month and Day-of-week setting with *", () => { + // GIVEN + const app = new awscdk.App({ outdir: mkdtemp(), ...CDK_APP_OPTS }); + + // THEN + expect(() => + Schedule._newSchedule(app, "Schedule", { + cron: "0/1 * * * *", + }) + ).toThrow( + "cannot use * in both the Day-of-month and Day-of-week fields. If you use it in one, you must use ? in the other" + ); +});