From 30680f546e71d3db14c991780dd05b25c796d98a Mon Sep 17 00:00:00 2001 From: Alexandre Quercia Date: Mon, 17 Feb 2025 22:01:43 +0100 Subject: [PATCH] feat(job): schedule job at specific timezone --- internal/pkg/cli/validate.go | 4 +- internal/pkg/cli/validate_test.go | 4 + .../cloudformation/stack/scheduled_job.go | 6 + .../stack/scheduled_job_test.go | 8 ++ .../testdata/workloads/job-test.params.json | 1 + .../testdata/workloads/job-test.stack.yml | 26 ++-- internal/pkg/initialize/workload.go | 2 + internal/pkg/initialize/workload_test.go | 122 ++++++++++-------- internal/pkg/manifest/errors.go | 9 ++ internal/pkg/manifest/job.go | 8 ++ .../marshal_manifest_integration_test.go | 1 + ...cheduled-job-fully-specified-placement.yml | 9 +- .../testdata/scheduled-job-no-retries.yml | 9 +- .../scheduled-job-no-timeout-or-retries.yml | 9 +- .../testdata/scheduled-job-no-timeout.yml | 9 +- internal/pkg/manifest/validate.go | 11 ++ internal/pkg/manifest/validate_test.go | 66 +++++++++- .../workloads/jobs/scheduled-job/cf.yml | 4 +- .../workloads/jobs/scheduled-job/manifest.yml | 9 +- .../cf/{eventrule.yml => schedule.yml} | 28 ++-- internal/pkg/template/workload.go | 3 +- internal/pkg/template/workload_test.go | 4 +- .../content/docs/manifest/scheduled-job.en.md | 7 +- 23 files changed, 266 insertions(+), 93 deletions(-) rename internal/pkg/template/templates/workloads/partials/cf/{eventrule.yml => schedule.yml} (60%) diff --git a/internal/pkg/cli/validate.go b/internal/pkg/cli/validate.go index cad9fa02c25..5a36d7fc51a 100644 --- a/internal/pkg/cli/validate.go +++ b/internal/pkg/cli/validate.go @@ -125,7 +125,7 @@ var ( domainNameRegexp = regexp.MustCompile(`\.`) // Check for at least one dot in domain name. - awsScheduleRegexp = regexp.MustCompile(`(?:rate|cron)\(.*\)`) // Check for strings of the form rate(*) or cron(*). + awsScheduleRegexp = regexp.MustCompile(`(?:rate|cron|at)\(.*\)`) // Check for strings of the form rate(*) or cron(*). ) // RDS Aurora Serverless validation expressions. @@ -493,7 +493,7 @@ func basicNameValidation(val interface{}) error { } func validateCron(sched string) error { - // If the schedule is wrapped in aws terms `rate()` or `cron()`, don't validate it-- + // If the schedule is wrapped in aws terms `rate()` or `cron()` or `at`, don't validate it-- // instead, pass it in as-is for serverside validation. AWS cron is weird (year field, nonstandard wildcards) // so for edge cases we need to support it awsSchedMatch := awsScheduleRegexp.FindStringSubmatch(sched) diff --git a/internal/pkg/cli/validate_test.go b/internal/pkg/cli/validate_test.go index 22ae6b44e4a..622b6723f62 100644 --- a/internal/pkg/cli/validate_test.go +++ b/internal/pkg/cli/validate_test.go @@ -701,6 +701,10 @@ func TestValidateCron(t *testing.T) { input: "cron(0 9 3W * ? *)", shouldPass: true, }, + "bypass with at()": { + input: "at(2022-11-20T13:00:00)", + shouldPass: true, + }, } for name, tc := range testCases { t.Run(name, func(t *testing.T) { diff --git a/internal/pkg/deploy/cloudformation/stack/scheduled_job.go b/internal/pkg/deploy/cloudformation/stack/scheduled_job.go index f68ac97b1ef..30a2c0ab332 100644 --- a/internal/pkg/deploy/cloudformation/stack/scheduled_job.go +++ b/internal/pkg/deploy/cloudformation/stack/scheduled_job.go @@ -25,6 +25,7 @@ import ( // Parameter logical IDs for a scheduled job const ( ScheduledJobScheduleParamKey = "Schedule" + ScheduledJobScheduleTimezoneParamKey = "ScheduleTimezone" ) // ScheduledJob represents the configuration needed to create a Cloudformation stack from a @@ -184,6 +185,7 @@ func (j *ScheduledJob) Template() (string, error) { AddonsExtraParams: addonsParams, Sidecars: sidecars, ScheduleExpression: schedule, + ScheduleTimezone: aws.StringValue(j.manifest.On.Timezone), StateMachine: stateMachine, HealthCheck: convertContainerHealthCheck(j.manifest.ImageConfig.HealthCheck), LogConfig: convertLogging(j.manifest.Logging), @@ -228,6 +230,10 @@ func (j *ScheduledJob) Parameters() ([]*cloudformation.Parameter, error) { ParameterKey: aws.String(ScheduledJobScheduleParamKey), ParameterValue: aws.String(schedule), }, + { + ParameterKey: aws.String(ScheduledJobScheduleTimezoneParamKey), + ParameterValue: j.manifest.On.Timezone, + }, }...), nil } diff --git a/internal/pkg/deploy/cloudformation/stack/scheduled_job_test.go b/internal/pkg/deploy/cloudformation/stack/scheduled_job_test.go index fc0bae78ffd..e7464a0dd97 100644 --- a/internal/pkg/deploy/cloudformation/stack/scheduled_job_test.go +++ b/internal/pkg/deploy/cloudformation/stack/scheduled_job_test.go @@ -66,6 +66,7 @@ func TestScheduledJob_Template(t *testing.T) { require.Equal(t, template.WorkloadOpts{ WorkloadType: manifestinfo.ScheduledJobType, ScheduleExpression: "cron(0 0 * * ? *)", + ScheduleTimezone: "UTC", StateMachine: &template.StateMachineOpts{ Timeout: aws.Int(5400), Retries: aws.Int(3), @@ -102,6 +103,7 @@ func TestScheduledJob_Template(t *testing.T) { AddonsExtraParams: `ServiceName: !GetAtt Service.Name DiscoveryServiceArn: !GetAtt DiscoveryService.Arn`, ScheduleExpression: "cron(0 0 * * ? *)", + ScheduleTimezone: "UTC", StateMachine: &template.StateMachineOpts{ Timeout: aws.Int(5400), Retries: aws.Int(3), @@ -450,6 +452,7 @@ func TestScheduledJob_Parameters(t *testing.T) { Dockerfile: "frontend/Dockerfile", }, Schedule: "@daily", + Timezone: "GMT", } testScheduledJobManifest := manifest.NewScheduledJob(baseProps) testScheduledJobManifest.Count = manifest.Count{ @@ -504,6 +507,10 @@ func TestScheduledJob_Parameters(t *testing.T) { ParameterKey: aws.String(ScheduledJobScheduleParamKey), ParameterValue: aws.String("cron(0 0 * * ? *)"), }, + { + ParameterKey: aws.String(ScheduledJobScheduleTimezoneParamKey), + ParameterValue: aws.String("GMT"), + }, } testCases := map[string]struct { httpsEnabled bool @@ -600,6 +607,7 @@ func TestScheduledJob_SerializedParameters(t *testing.T) { "EnvName": "test", "LogRetention": "30", "Schedule": "cron(0 0 * * ? *)", + "ScheduleTimezone": "UTC", "TaskCPU": "256", "TaskCount": "1", "TaskMemory": "512", diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/job-test.params.json b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/job-test.params.json index bfa8723f9c3..40581048c1d 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/job-test.params.json +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/job-test.params.json @@ -9,6 +9,7 @@ "EnvName": "test", "LogRetention": "30", "Schedule": "cron(0 12 ? * MON *)", + "ScheduleTimezone": "UTC", "TaskCPU": "256", "TaskCount": "1", "TaskMemory": "512", diff --git a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/job-test.stack.yml b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/job-test.stack.yml index 9f250c61896..51c6fa4de80 100644 --- a/internal/pkg/deploy/cloudformation/stack/testdata/workloads/job-test.stack.yml +++ b/internal/pkg/deploy/cloudformation/stack/testdata/workloads/job-test.stack.yml @@ -13,6 +13,8 @@ Parameters: Type: String Schedule: Type: String + ScheduleTimezone: + Type: String ContainerImage: Type: String TaskCPU: @@ -392,18 +394,20 @@ Resources: Resource: - !Ref mytopicfifoSNSTopic - Rule: + Schedule: Metadata: - 'aws:copilot:description': "A CloudWatch event rule to trigger the job's state machine" - Type: AWS::Events::Rule + 'aws:copilot:description': "An EventBridge Schedule to trigger the job's state machine" + Type: AWS::Scheduler::Schedule Properties: ScheduleExpression: !Ref Schedule State: ENABLED - Targets: - - Arn: !Ref StateMachine - Id: statemachine - RoleArn: !GetAtt RuleRole.Arn - RuleRole: + FlexibleTimeWindow: + Mode: "OFF" + ScheduleExpressionTimezone: !Ref ScheduleTimezone + Target: + Arn: !Ref StateMachine + RoleArn: !GetAtt ScheduleRole.Arn + ScheduleRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: @@ -411,10 +415,10 @@ Resources: Statement: - Effect: Allow Principal: - Service: events.amazonaws.com + Service: scheduler.amazonaws.com Action: sts:AssumeRole Policies: - - PolicyName: EventRulePolicy + - PolicyName: SchedulePolicy PolicyDocument: Version: '2012-10-17' Statement: @@ -617,4 +621,4 @@ Resources: Resource: !Ref mytopicfifoSNSTopic Condition: StringEquals: - "sns:Protocol": "sqs" \ No newline at end of file + "sns:Protocol": "sqs" diff --git a/internal/pkg/initialize/workload.go b/internal/pkg/initialize/workload.go index 9f07ce05730..a6eeaee7272 100644 --- a/internal/pkg/initialize/workload.go +++ b/internal/pkg/initialize/workload.go @@ -72,6 +72,7 @@ type WorkloadProps struct { type JobProps struct { WorkloadProps Schedule string + Timezone string HealthCheck manifest.ContainerHealthCheck Timeout string Retries int @@ -299,6 +300,7 @@ func newJobManifest(i *JobProps) (encoding.BinaryMarshaler, error) { HealthCheck: i.HealthCheck, Platform: i.Platform, Schedule: i.Schedule, + Timezone: i.Timezone, Timeout: i.Timeout, Retries: i.Retries, }), nil diff --git a/internal/pkg/initialize/workload_test.go b/internal/pkg/initialize/workload_test.go index e693b7ebe48..b5135a6a72b 100644 --- a/internal/pkg/initialize/workload_test.go +++ b/internal/pkg/initialize/workload_test.go @@ -17,6 +17,31 @@ import ( "github.com/stretchr/testify/require" ) +const ACCOUNT_ID = "1234" + +func givenStoreWithAppAndWithoutJob(t *testing.T, m *mocks.MockStore, appName string, jobName string) { + m.EXPECT().CreateJob(gomock.Any()). + Do(func(app *config.Workload) { + require.Equal(t, &config.Workload{ + Name: jobName, + App: appName, + Type: manifestinfo.ScheduledJobType, + }, app) + }). + Return(nil) + m.EXPECT().GetApplication(appName).Return(&config.Application{ + Name: appName, + AccountID: ACCOUNT_ID, + }, nil) +} + +func willAddJobToApp(m *mocks.MockWorkloadAdder, jobName string, appName string) { + m.EXPECT().AddJobToApp(&config.Application{ + Name: appName, + AccountID: ACCOUNT_ID, + }, jobName) +} + func TestWorkloadInitializer_Job(t *testing.T) { testCases := map[string]struct { inJobType string @@ -27,6 +52,7 @@ func TestWorkloadInitializer_Job(t *testing.T) { inPlatform manifest.PlatformArgsOrString inSchedule string + inTimezone string inRetries int inTimeout string @@ -52,25 +78,10 @@ func TestWorkloadInitializer_Job(t *testing.T) { m.EXPECT().WriteJobManifest(gomock.Any(), "resizer").Return("/resizer/copilot/manifest.yml", nil) }, mockstore: func(m *mocks.MockStore) { - m.EXPECT().CreateJob(gomock.Any()). - Do(func(app *config.Workload) { - require.Equal(t, &config.Workload{ - Name: "resizer", - App: "app", - Type: manifestinfo.ScheduledJobType, - }, app) - }). - Return(nil) - m.EXPECT().GetApplication("app").Return(&config.Application{ - Name: "app", - AccountID: "1234", - }, nil) + givenStoreWithAppAndWithoutJob(t, m, "app", "resizer") }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { - m.EXPECT().AddJobToApp(&config.Application{ - Name: "app", - AccountID: "1234", - }, "resizer") + willAddJobToApp(m, "resizer", "app") }, }, "using existing image": { @@ -89,25 +100,33 @@ func TestWorkloadInitializer_Job(t *testing.T) { }).Return("/resizer/manifest.yml", nil) }, mockstore: func(m *mocks.MockStore) { - m.EXPECT().CreateJob(gomock.Any()). - Do(func(app *config.Workload) { - require.Equal(t, &config.Workload{ - Name: "resizer", - App: "app", - Type: manifestinfo.ScheduledJobType, - }, app) - }). - Return(nil) - m.EXPECT().GetApplication("app").Return(&config.Application{ - Name: "app", - AccountID: "1234", - }, nil) + givenStoreWithAppAndWithoutJob(t, m, "app", "resizer") }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { - m.EXPECT().AddJobToApp(&config.Application{ - Name: "app", - AccountID: "1234", - }, "resizer") + willAddJobToApp(m, "resizer", "app") + }, + }, + "configure schedule": { + inJobType: manifestinfo.ScheduledJobType, + inAppName: "app", + inJobName: "resizer", + inImage: "mockImage", + + inSchedule: "@daily", + inTimezone: "GMT", + + mockWriter: func(m *mocks.MockWorkspace) { + m.EXPECT().Rel("/resizer/manifest.yml").Return("manifest.yml", nil) + m.EXPECT().WriteJobManifest(gomock.Any(), "resizer").Do(func(m *manifest.ScheduledJob, _ string) { + require.Equal(t, *m.On.Schedule, "@daily") + require.Equal(t, *m.On.Timezone, "GMT") + }).Return("/resizer/manifest.yml", nil) + }, + mockstore: func(m *mocks.MockStore) { + givenStoreWithAppAndWithoutJob(t, m, "app", "resizer") + }, + mockappDeployer: func(m *mocks.MockWorkloadAdder) { + willAddJobToApp(m, "resizer", "app") }, }, "write manifest error": { @@ -157,7 +176,7 @@ func TestWorkloadInitializer_Job(t *testing.T) { mockstore: func(m *mocks.MockStore) { m.EXPECT().GetApplication(gomock.Any()).Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { @@ -223,6 +242,7 @@ func TestWorkloadInitializer_Job(t *testing.T) { Platform: tc.inPlatform, }, Schedule: tc.inSchedule, + Timezone: tc.inTimezone, Retries: tc.inRetries, Timeout: tc.inTimeout, } @@ -481,13 +501,13 @@ func TestWorkloadInitializer_Service(t *testing.T) { Return(nil) m.EXPECT().GetApplication("app").Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { m.EXPECT().AddServiceToApp(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, "frontend") }, }, @@ -514,13 +534,13 @@ func TestWorkloadInitializer_Service(t *testing.T) { Return(nil) m.EXPECT().GetApplication("app").Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { m.EXPECT().AddServiceToApp(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, "static", gomock.Any()) }, }, @@ -556,7 +576,7 @@ func TestWorkloadInitializer_Service(t *testing.T) { m.EXPECT().ListServices("app") m.EXPECT().GetApplication("app").Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, wantedErr: errors.New("write service manifest: some error"), @@ -577,7 +597,7 @@ func TestWorkloadInitializer_Service(t *testing.T) { m.EXPECT().ListServices("app").Return([]*config.Workload{}, nil) m.EXPECT().GetApplication(gomock.Any()).Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { @@ -637,13 +657,13 @@ func TestWorkloadInitializer_Service(t *testing.T) { m.EXPECT().GetApplication("app").Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { m.EXPECT().AddServiceToApp(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, "backend") }, }, @@ -678,13 +698,13 @@ func TestWorkloadInitializer_Service(t *testing.T) { m.EXPECT().GetApplication("app").Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { m.EXPECT().AddServiceToApp(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, "backend") }, }, @@ -730,13 +750,13 @@ func TestWorkloadInitializer_Service(t *testing.T) { Return(nil) m.EXPECT().GetApplication("app").Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { m.EXPECT().AddServiceToApp(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, "backend") }, }, @@ -777,13 +797,13 @@ func TestWorkloadInitializer_Service(t *testing.T) { m.EXPECT().GetApplication("app").Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { m.EXPECT().AddServiceToApp(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, "worker") }, }, @@ -825,13 +845,13 @@ func TestWorkloadInitializer_Service(t *testing.T) { m.EXPECT().GetApplication("app").Return(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, nil) }, mockappDeployer: func(m *mocks.MockWorkloadAdder) { m.EXPECT().AddServiceToApp(&config.Application{ Name: "app", - AccountID: "1234", + AccountID: ACCOUNT_ID, }, "worker") }, }, diff --git a/internal/pkg/manifest/errors.go b/internal/pkg/manifest/errors.go index f67657a872a..9d28d488bd9 100644 --- a/internal/pkg/manifest/errors.go +++ b/internal/pkg/manifest/errors.go @@ -259,3 +259,12 @@ func (e *errHealthCheckPortExposedWithInvalidProtocol) Error() string { e.container, e.healthCheckPort, e.protocol, english.PluralWord(len(validHealthCheckProtocols), "is", "are"), english.WordSeries(quoteStringSlice(validHealthCheckProtocols), "or")) } + +type ErrInvalidTimezone struct { + Timezone string + Field string +} + +func (e *ErrInvalidTimezone) Error() string { + return fmt.Sprintf(`invalid %q: only support IANA database timezones, like "UTC" or "America/New_York". given %q`, e.Field, e.Timezone) +} diff --git a/internal/pkg/manifest/job.go b/internal/pkg/manifest/job.go index dae34719822..f6379a23c9a 100644 --- a/internal/pkg/manifest/job.go +++ b/internal/pkg/manifest/job.go @@ -46,6 +46,7 @@ type ScheduledJobConfig struct { // JobTriggerConfig represents the configuration for the event that triggers the job. type JobTriggerConfig struct { Schedule *string `yaml:"schedule"` + Timezone *string `yaml:"timezone"` } // JobFailureHandlerConfig represents the error handling configuration for the job. @@ -58,6 +59,7 @@ type JobFailureHandlerConfig struct { type ScheduledJobProps struct { *WorkloadProps Schedule string + Timezone string Timeout string HealthCheck ContainerHealthCheck // Optional healthcheck configuration. Platform PlatformArgsOrString // Optional platform configuration. @@ -78,6 +80,9 @@ func NewScheduledJob(props *ScheduledJobProps) *ScheduledJob { job.TaskConfig.Memory = aws.Int(MinWindowsTaskMemory) } job.On.Schedule = stringP(props.Schedule) + if props.Timezone != "" { + job.On.Timezone = stringP(props.Timezone) + } if props.Retries != 0 { job.Retries = aws.Int(props.Retries) } @@ -178,6 +183,9 @@ func newDefaultScheduledJob() *ScheduledJob { }, ScheduledJobConfig: ScheduledJobConfig{ ImageConfig: ImageWithHealthcheck{}, + On: JobTriggerConfig{ + Timezone: aws.String("UTC"), + }, TaskConfig: TaskConfig{ CPU: aws.Int(256), Memory: aws.Int(512), diff --git a/internal/pkg/manifest/marshal_manifest_integration_test.go b/internal/pkg/manifest/marshal_manifest_integration_test.go index 5202fe9248d..c90d20833b2 100644 --- a/internal/pkg/manifest/marshal_manifest_integration_test.go +++ b/internal/pkg/manifest/marshal_manifest_integration_test.go @@ -246,6 +246,7 @@ func TestScheduledJob_InitialManifestIntegration(t *testing.T) { PlatformArgs: PlatformArgs{}, }, Schedule: "@weekly", + Timezone: "CET", }, wantedTestData: "scheduled-job-no-timeout-or-retries.yml", }, diff --git a/internal/pkg/manifest/testdata/scheduled-job-fully-specified-placement.yml b/internal/pkg/manifest/testdata/scheduled-job-fully-specified-placement.yml index a5340b596a7..b933ff6ad83 100644 --- a/internal/pkg/manifest/testdata/scheduled-job-fully-specified-placement.yml +++ b/internal/pkg/manifest/testdata/scheduled-job-fully-specified-placement.yml @@ -9,8 +9,13 @@ type: Scheduled Job # Trigger for your task. on: # The scheduled trigger for your job. You can specify a Unix cron schedule or keyword (@weekly) or a rate (@every 1h30m) - # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html + # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html schedule: "0 */2 * * *" + + # The timezone in which the scheduling expression is evaluated. + # You can specify a time location like (America/New_York) or like (CET) + # More details: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#time-zones + timezone: "UTC" retries: 3 # Optional. The number of times to retry the job before failing. timeout: 1h30m # Optional. The timeout after which to stop the job if it's still running. You can use the units (h, m, s). @@ -37,4 +42,4 @@ environments: vpc: placement: 'private' # The tasks will be placed on private subnets for the "phonetool" environment. # prod: -# cpu: 2048 # Larger CPU value for prod environment. \ No newline at end of file +# cpu: 2048 # Larger CPU value for prod environment. diff --git a/internal/pkg/manifest/testdata/scheduled-job-no-retries.yml b/internal/pkg/manifest/testdata/scheduled-job-no-retries.yml index 76324e7bb78..2bec4b2ab8f 100644 --- a/internal/pkg/manifest/testdata/scheduled-job-no-retries.yml +++ b/internal/pkg/manifest/testdata/scheduled-job-no-retries.yml @@ -9,8 +9,13 @@ type: Scheduled Job # Trigger for your task. on: # The scheduled trigger for your job. You can specify a Unix cron schedule or keyword (@weekly) or a rate (@every 1h30m) - # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html + # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html schedule: "@every 5h" + + # The timezone in which the scheduling expression is evaluated. + # You can specify a time location like (America/New_York) or like (CET) + # More details: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#time-zones + timezone: "UTC" #retries: 3 # Optional. The number of times to retry the job before failing. timeout: 3h # Optional. The timeout after which to stop the job if it's still running. You can use the units (h, m, s). @@ -33,4 +38,4 @@ memory: 512 # Amount of memory in MiB used by the task. # You can override any of the values defined above by environment. #environments: # prod: -# cpu: 2048 # Larger CPU value for prod environment. \ No newline at end of file +# cpu: 2048 # Larger CPU value for prod environment. diff --git a/internal/pkg/manifest/testdata/scheduled-job-no-timeout-or-retries.yml b/internal/pkg/manifest/testdata/scheduled-job-no-timeout-or-retries.yml index bcfa4aabf26..da5c524bbb7 100644 --- a/internal/pkg/manifest/testdata/scheduled-job-no-timeout-or-retries.yml +++ b/internal/pkg/manifest/testdata/scheduled-job-no-timeout-or-retries.yml @@ -9,8 +9,13 @@ type: Scheduled Job # Trigger for your task. on: # The scheduled trigger for your job. You can specify a Unix cron schedule or keyword (@weekly) or a rate (@every 1h30m) - # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html + # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html schedule: "@weekly" + + # The timezone in which the scheduling expression is evaluated. + # You can specify a time location like (America/New_York) or like (CET) + # More details: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#time-zones + timezone: "CET" #retries: 3 # Optional. The number of times to retry the job before failing. #timeout: 1h30m # Optional. The timeout after which to stop the job if it's still running. You can use the units (h, m, s). @@ -32,4 +37,4 @@ memory: 512 # Amount of memory in MiB used by the task. # You can override any of the values defined above by environment. #environments: # prod: -# cpu: 2048 # Larger CPU value for prod environment. \ No newline at end of file +# cpu: 2048 # Larger CPU value for prod environment. diff --git a/internal/pkg/manifest/testdata/scheduled-job-no-timeout.yml b/internal/pkg/manifest/testdata/scheduled-job-no-timeout.yml index ed3abb69766..8cca4c75653 100644 --- a/internal/pkg/manifest/testdata/scheduled-job-no-timeout.yml +++ b/internal/pkg/manifest/testdata/scheduled-job-no-timeout.yml @@ -9,8 +9,13 @@ type: Scheduled Job # Trigger for your task. on: # The scheduled trigger for your job. You can specify a Unix cron schedule or keyword (@weekly) or a rate (@every 1h30m) - # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html + # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html schedule: "@every 5h" + + # The timezone in which the scheduling expression is evaluated. + # You can specify a time location like (America/New_York) or like (CET) + # More details: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#time-zones + timezone: "UTC" retries: 5 # Optional. The number of times to retry the job before failing. #timeout: 1h30m # Optional. The timeout after which to stop the job if it's still running. You can use the units (h, m, s). @@ -33,4 +38,4 @@ memory: 512 # Amount of memory in MiB used by the task. # You can override any of the values defined above by environment. #environments: # prod: -# cpu: 2048 # Larger CPU value for prod environment. \ No newline at end of file +# cpu: 2048 # Larger CPU value for prod environment. diff --git a/internal/pkg/manifest/validate.go b/internal/pkg/manifest/validate.go index 911e7f84213..a8613e3aa08 100644 --- a/internal/pkg/manifest/validate.go +++ b/internal/pkg/manifest/validate.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/arn" @@ -1756,6 +1757,16 @@ func (c JobTriggerConfig) validate() error { missingField: "schedule", } } + if c.Timezone != nil { + _, err := time.LoadLocation(aws.StringValue(c.Timezone)) + + if err != nil { + return &ErrInvalidTimezone{ + Timezone: aws.StringValue(c.Timezone), + Field: "timezone", + } + } + } return nil } diff --git a/internal/pkg/manifest/validate_test.go b/internal/pkg/manifest/validate_test.go index 9983dbd893d..26c3b4ac141 100644 --- a/internal/pkg/manifest/validate_test.go +++ b/internal/pkg/manifest/validate_test.go @@ -1257,7 +1257,71 @@ func TestScheduledJob_validate(t *testing.T) { On: JobTriggerConfig{}, }, }, - wantedErrorMsgPrefix: `validate "on": `, + wantedErrorMsgPrefix: `validate "on": "schedule" must be specified`, + }, + "Only support IANA database timezones": { + config: ScheduledJob{ + ScheduledJobConfig: ScheduledJobConfig{ + ImageConfig: testImageConfig, + On: JobTriggerConfig{ + Schedule: aws.String("mockSchedule"), + Timezone: aws.String("some invalid timezone"), + }, + }, + }, + wantedErrorMsgPrefix: `validate "on": invalid "timezone": only support IANA database timezones, like "UTC" or "America/New_York". given "some invalid timezone"`, + }, + "UTC timezone is valid": { + config: ScheduledJob{ + Workload: Workload{Name: aws.String("some workload name")}, + ScheduledJobConfig: ScheduledJobConfig{ + ImageConfig: testImageConfig, + On: JobTriggerConfig{ + Schedule: aws.String("mockSchedule"), + Timezone: aws.String("UTC"), + }, + }, + }, + wantedErrorMsgPrefix: "", + }, + "America/New_York timezone is valid": { + config: ScheduledJob{ + Workload: Workload{Name: aws.String("some workload name")}, + ScheduledJobConfig: ScheduledJobConfig{ + ImageConfig: testImageConfig, + On: JobTriggerConfig{ + Schedule: aws.String("mockSchedule"), + Timezone: aws.String("America/New_York"), + }, + }, + }, + wantedErrorMsgPrefix: "", + }, + "CET timezone is valid": { + config: ScheduledJob{ + Workload: Workload{Name: aws.String("some workload name")}, + ScheduledJobConfig: ScheduledJobConfig{ + ImageConfig: testImageConfig, + On: JobTriggerConfig{ + Schedule: aws.String("mockSchedule"), + Timezone: aws.String("CET"), + }, + }, + }, + wantedErrorMsgPrefix: "", + }, + "Etc/GMT+1 timezone is valid": { + config: ScheduledJob{ + Workload: Workload{Name: aws.String("some workload name")}, + ScheduledJobConfig: ScheduledJobConfig{ + ImageConfig: testImageConfig, + On: JobTriggerConfig{ + Schedule: aws.String("mockSchedule"), + Timezone: aws.String("Etc/GMT+1"), + }, + }, + }, + wantedErrorMsgPrefix: "", }, "error if fail to validate publish config": { config: ScheduledJob{ diff --git a/internal/pkg/template/templates/workloads/jobs/scheduled-job/cf.yml b/internal/pkg/template/templates/workloads/jobs/scheduled-job/cf.yml index d5626edb72a..b1f09cb68d8 100644 --- a/internal/pkg/template/templates/workloads/jobs/scheduled-job/cf.yml +++ b/internal/pkg/template/templates/workloads/jobs/scheduled-job/cf.yml @@ -17,6 +17,8 @@ Parameters: Type: String Schedule: Type: String + ScheduleTimezone: + Type: String ContainerImage: Type: String TaskCPU: @@ -85,7 +87,7 @@ Resources: {{include "taskrole" . | indent 2}} -{{include "eventrule" . | indent 2}} +{{include "schedule" . | indent 2}} {{include "state-machine" . | indent 2}} diff --git a/internal/pkg/template/templates/workloads/jobs/scheduled-job/manifest.yml b/internal/pkg/template/templates/workloads/jobs/scheduled-job/manifest.yml index 5df13fa2b72..0ac3742f29f 100644 --- a/internal/pkg/template/templates/workloads/jobs/scheduled-job/manifest.yml +++ b/internal/pkg/template/templates/workloads/jobs/scheduled-job/manifest.yml @@ -9,8 +9,13 @@ type: {{.Type}} # Trigger for your task. on: # The scheduled trigger for your job. You can specify a Unix cron schedule or keyword (@weekly) or a rate (@every 1h30m) - # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html + # AWS Schedule Expressions are also accepted: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html schedule: "{{.On.Schedule}}" + + # The timezone in which the scheduling expression is evaluated. + # You can specify a time location like (America/New_York) or like (CET) + # More details: https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#time-zones + timezone: "{{.On.Timezone}}" {{- if .Retries}} retries: {{.Retries}} # Optional. The number of times to retry the job before failing. {{- else}} @@ -65,4 +70,4 @@ environments: {{ range $key, $value := .Environments}} {{- end}} # prod: # cpu: 2048 # Larger CPU value for prod environment. -{{- end}} \ No newline at end of file +{{- end}} diff --git a/internal/pkg/template/templates/workloads/partials/cf/eventrule.yml b/internal/pkg/template/templates/workloads/partials/cf/schedule.yml similarity index 60% rename from internal/pkg/template/templates/workloads/partials/cf/eventrule.yml rename to internal/pkg/template/templates/workloads/partials/cf/schedule.yml index 1056a0ef5cc..046134a0106 100644 --- a/internal/pkg/template/templates/workloads/partials/cf/eventrule.yml +++ b/internal/pkg/template/templates/workloads/partials/cf/schedule.yml @@ -1,20 +1,22 @@ -Rule: +Schedule: Metadata: - 'aws:copilot:description': "A CloudWatch event rule to trigger the job's state machine" - Type: AWS::Events::Rule + 'aws:copilot:description': "An EventBridge Schedule to trigger the job's state machine" + Type: AWS::Scheduler::Schedule Properties: + ScheduleExpressionTimezone: !Ref ScheduleTimezone {{- if eq .ScheduleExpression "none"}} ScheduleExpression: "rate(5 minutes)" - State: DISABLED + State: DISABLED {{- else }} ScheduleExpression: !Ref Schedule - State: ENABLED + State: ENABLED {{- end }} - Targets: - - Arn: !Ref StateMachine - Id: statemachine - RoleArn: !GetAtt RuleRole.Arn -RuleRole: + FlexibleTimeWindow: + Mode: "OFF" + Target: + Arn: !Ref StateMachine + RoleArn: !GetAtt ScheduleRole.Arn +ScheduleRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: @@ -22,16 +24,16 @@ RuleRole: Statement: - Effect: Allow Principal: - Service: events.amazonaws.com + Service: scheduler.amazonaws.com Action: sts:AssumeRole {{- if .PermissionsBoundary}} PermissionsBoundary: !Sub 'arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/{{.PermissionsBoundary}}' {{- end}} Policies: - - PolicyName: EventRulePolicy + - PolicyName: SchedulePolicy PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: states:StartExecution - Resource: !Ref StateMachine \ No newline at end of file + Resource: !Ref StateMachine diff --git a/internal/pkg/template/workload.go b/internal/pkg/template/workload.go index 0b5a2f88f57..ea2f26ed0df 100644 --- a/internal/pkg/template/workload.go +++ b/internal/pkg/template/workload.go @@ -92,7 +92,7 @@ var ( "sidecars", "logconfig", "autoscaling", - "eventrule", + "schedule", "state-machine", "state-machine-definition.json", "efs-access-point", @@ -835,6 +835,7 @@ type WorkloadOpts struct { // Additional options for job templates. ScheduleExpression string + ScheduleTimezone string StateMachine *StateMachineOpts // Additional options for request driven web service templates. diff --git a/internal/pkg/template/workload_test.go b/internal/pkg/template/workload_test.go index 3ce75194316..28b6b04b37f 100644 --- a/internal/pkg/template/workload_test.go +++ b/internal/pkg/template/workload_test.go @@ -48,7 +48,7 @@ func TestTemplate_ParseSvc(t *testing.T) { _ = afero.WriteFile(fs, "templates/workloads/partials/cf/logconfig.yml", []byte("logconfig"), 0644) _ = afero.WriteFile(fs, "templates/workloads/partials/cf/autoscaling.yml", []byte("autoscaling"), 0644) _ = afero.WriteFile(fs, "templates/workloads/partials/cf/state-machine-definition.json.yml", []byte("state-machine-definition"), 0644) - _ = afero.WriteFile(fs, "templates/workloads/partials/cf/eventrule.yml", []byte("eventrule"), 0644) + _ = afero.WriteFile(fs, "templates/workloads/partials/cf/schedule.yml", []byte("schedule"), 0644) _ = afero.WriteFile(fs, "templates/workloads/partials/cf/state-machine.yml", []byte("state-machine"), 0644) _ = afero.WriteFile(fs, "templates/workloads/partials/cf/efs-access-point.yml", []byte("efs-access-point"), 0644) _ = afero.WriteFile(fs, "templates/workloads/partials/cf/https-listener.yml", []byte("https-listener"), 0644) @@ -84,7 +84,7 @@ func TestTemplate_ParseSvc(t *testing.T) { sidecars logconfig autoscaling - eventrule + schedule state-machine state-machine-definition efs-access-point diff --git a/site/content/docs/manifest/scheduled-job.en.md b/site/content/docs/manifest/scheduled-job.en.md index 34591048c92..fd8500591a9 100644 --- a/site/content/docs/manifest/scheduled-job.en.md +++ b/site/content/docs/manifest/scheduled-job.en.md @@ -55,7 +55,7 @@ You can specify a rate to periodically trigger your job. Supported rates: | `"@hourly"` | `"cron(0 * * * ? *)"` | at minute 0 | * `"@every {duration}"` (For example, "1m", "5m") -* `"rate({duration})"` based on CloudWatch's [rate expressions](https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html#RateExpressions) +* `"rate({duration})"` based on EventBridge Scheduler's [rate expressions](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#rate-based) Alternatively, you can specify a cron schedule if you'd like to trigger the job at a specific time: @@ -68,6 +68,11 @@ on: schedule: "none" ``` +on.`timezone` String +You can specify a time zone to trigger your job like (America/New_York) or like (CET). +More details on the [time zone format](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#time-zones) + +
{% include 'image.md' %}