diff --git a/changelog.md b/changelog.md index ec964bd..107f3d5 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,12 @@ --- +## [11.0.1 - 11.1.0] 2026-01-07 + +- Add support for `timezone` property for scheduled functions + +--- + ## [11.0.0 - 11.0.1] 2025-11-28 - updated to >= node22 diff --git a/package.json b/package.json index 1c2a808..1da553c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "author": "Brian LeRoux ", "license": "Apache-2.0", "dependencies": { - "@architect/inventory": "~6.0.0", + "@architect/inventory": "~6.1.0", "@architect/utils": "~6.0.0" }, "devDependencies": { diff --git a/src/visitors/scheduled/index.js b/src/visitors/scheduled/index.js index 4385912..ed1ca44 100644 --- a/src/visitors/scheduled/index.js +++ b/src/visitors/scheduled/index.js @@ -8,16 +8,32 @@ module.exports = function visitScheduled (inventory, template) { let { inv } = inventory if (!inv.scheduled) return template - // we leave the bucket name generation up to cloudfront + // Create a single shared IAM role for EventBridge Scheduler to invoke all scheduled Lambdas + template.Resources.SchedulerRole = { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ { + Effect: 'Allow', + Principal: { + Service: 'scheduler.amazonaws.com', + }, + Action: 'sts:AssumeRole', + } ], + }, + Policies: [], + }, + } + inv.scheduled.forEach(schedule => { - let { rate, cron } = schedule + let { rate, cron, timezone } = schedule let rule = rate || cron rule = `${rate ? 'rate' : 'cron'}(${rule.expression})` let name = toLogicalID(schedule.name) let scheduleLambda = `${name}ScheduledLambda` let scheduleEvent = `${name}ScheduledEvent` - let schedulePermission = `${name}ScheduledPermission` // Create the Lambda template.Resources[scheduleLambda] = createLambda({ @@ -26,29 +42,37 @@ module.exports = function visitScheduled (inventory, template) { template, }) - // Create the scheduled event rule + // Add policy to the shared scheduler role to invoke this Lambda + template.Resources.SchedulerRole.Properties.Policies.push({ + PolicyName: `${name}InvokeLambdaPolicy`, + PolicyDocument: { + Version: '2012-10-17', + Statement: [ { + Effect: 'Allow', + Action: 'lambda:InvokeFunction', + Resource: { 'Fn::GetAtt': [ scheduleLambda, 'Arn' ] }, + } ], + }, + }) + + // Create the schedule using AWS::Scheduler::Schedule template.Resources[scheduleEvent] = { - Type: 'AWS::Events::Rule', + Type: 'AWS::Scheduler::Schedule', Properties: { ScheduleExpression: rule, - Targets: [ - { - Arn: { 'Fn::GetAtt': [ scheduleLambda, 'Arn' ] }, - Id: scheduleLambda, - }, - ], + FlexibleTimeWindow: { + Mode: 'OFF', + }, + Target: { + Arn: { 'Fn::GetAtt': [ scheduleLambda, 'Arn' ] }, + RoleArn: { 'Fn::GetAtt': [ 'SchedulerRole', 'Arn' ] }, + }, }, } - // Wire the permission - template.Resources[schedulePermission] = { - Type: 'AWS::Lambda::Permission', - Properties: { - Action: 'lambda:InvokeFunction', - FunctionName: { Ref: scheduleLambda }, - Principal: 'events.amazonaws.com', - SourceArn: { 'Fn::GetAtt': [ scheduleEvent, 'Arn' ] }, - }, + // Add timezone if specified + if (timezone) { + template.Resources[scheduleEvent].Properties.ScheduleExpressionTimezone = timezone } }) diff --git a/test/unit/visitors/scheduled/index-test.js b/test/unit/visitors/scheduled/index-test.js new file mode 100644 index 0000000..b995978 --- /dev/null +++ b/test/unit/visitors/scheduled/index-test.js @@ -0,0 +1,187 @@ +const { test } = require('node:test') +const assert = require('node:assert') +let inventory = require('@architect/inventory') +let pkg = require('../../../../') + +let base = `@app +myapp +` +let deployStage = 'staging' +let arc = config => `${base}\n${config ? config : ''}` + +test('Scheduled visitor should return untouched template if inventory contains no scheduled functions', async () => { + let rawArc = arc() + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + let scheduledResources = Object.keys(cfn.Resources).filter(k => k.includes('Scheduled')) + assert.strictEqual(scheduledResources.length, 0, 'no scheduled resources created without @scheduled pragma') +}) + +test('Scheduled visitor creates AWS::Scheduler::Schedule resource for rate expression', async () => { + let rawArc = arc(`@scheduled +daily-task rate(1 day) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + // Check the schedule resource exists and has correct type + assert.ok(cfn.Resources.DailyTaskScheduledEvent, 'schedule event resource exists') + assert.strictEqual(cfn.Resources.DailyTaskScheduledEvent.Type, 'AWS::Scheduler::Schedule', 'uses AWS::Scheduler::Schedule type') +}) + +test('Scheduled visitor creates AWS::Scheduler::Schedule resource for cron expression', async () => { + let rawArc = arc(`@scheduled +hourly-job cron(0 * * * ? *) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + assert.ok(cfn.Resources.HourlyJobScheduledEvent, 'schedule event resource exists') + assert.strictEqual(cfn.Resources.HourlyJobScheduledEvent.Type, 'AWS::Scheduler::Schedule', 'uses AWS::Scheduler::Schedule type') + assert.ok(cfn.Resources.HourlyJobScheduledEvent.Properties.ScheduleExpression.startsWith('cron('), 'schedule expression is a cron expression') +}) + +test('Scheduled visitor creates Lambda function resource', async () => { + let rawArc = arc(`@scheduled +my-task rate(5 minutes) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + assert.ok(cfn.Resources.MyTaskScheduledLambda, 'Lambda resource exists') + assert.strictEqual(cfn.Resources.MyTaskScheduledLambda.Type, 'AWS::Serverless::Function', 'Lambda has correct type') +}) + +test('Scheduled visitor creates shared IAM role for EventBridge Scheduler', async () => { + let rawArc = arc(`@scheduled +my-task rate(1 hour) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + // Check the shared scheduler role exists + assert.ok(cfn.Resources.SchedulerRole, 'shared scheduler role resource exists') + assert.strictEqual(cfn.Resources.SchedulerRole.Type, 'AWS::IAM::Role', 'scheduler role has correct type') + + // Check the assume role policy allows scheduler.amazonaws.com + let assumeRolePolicy = cfn.Resources.SchedulerRole.Properties.AssumeRolePolicyDocument + assert.ok(assumeRolePolicy, 'assume role policy exists') + let statement = assumeRolePolicy.Statement[0] + assert.strictEqual(statement.Principal.Service, 'scheduler.amazonaws.com', 'scheduler service is allowed to assume role') + + // Check the policy allows lambda:InvokeFunction + let policies = cfn.Resources.SchedulerRole.Properties.Policies + assert.ok(policies.length > 0, 'policies exist') + let invokeStatement = policies[0].PolicyDocument.Statement[0] + assert.strictEqual(invokeStatement.Action, 'lambda:InvokeFunction', 'policy allows lambda:InvokeFunction') +}) + +test('Scheduled visitor sets FlexibleTimeWindow to OFF', async () => { + let rawArc = arc(`@scheduled +my-task rate(1 hour) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + let flexWindow = cfn.Resources.MyTaskScheduledEvent.Properties.FlexibleTimeWindow + assert.ok(flexWindow, 'FlexibleTimeWindow exists') + assert.strictEqual(flexWindow.Mode, 'OFF', 'FlexibleTimeWindow mode is OFF') +}) + +test('Scheduled visitor configures Target with Lambda Arn and RoleArn', async () => { + let rawArc = arc(`@scheduled +my-task rate(1 hour) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + let target = cfn.Resources.MyTaskScheduledEvent.Properties.Target + assert.ok(target, 'Target exists') + assert.ok(target.Arn, 'Target has Arn') + assert.ok(target.RoleArn, 'Target has RoleArn') + + // Verify the Arn references the Lambda + assert.deepStrictEqual(target.Arn, { 'Fn::GetAtt': [ 'MyTaskScheduledLambda', 'Arn' ] }, 'Target Arn references Lambda') + + // Verify the RoleArn references the shared scheduler role + assert.deepStrictEqual(target.RoleArn, { 'Fn::GetAtt': [ 'SchedulerRole', 'Arn' ] }, 'Target RoleArn references shared scheduler role') +}) + +test('Scheduled visitor adds timezone when specified', async () => { + let rawArc = arc(`@scheduled +my-task + rate 1 day + timezone America/New_York +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + assert.ok(cfn.Resources.MyTaskScheduledEvent.Properties.ScheduleExpressionTimezone, 'timezone property exists') + assert.strictEqual(cfn.Resources.MyTaskScheduledEvent.Properties.ScheduleExpressionTimezone, 'America/New_York', 'timezone is set correctly') +}) + +test('Scheduled visitor does not add timezone when not specified', async () => { + let rawArc = arc(`@scheduled +my-task rate(1 hour) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + assert.strictEqual(cfn.Resources.MyTaskScheduledEvent.Properties.ScheduleExpressionTimezone, undefined, 'timezone property is not set') +}) + +test('Scheduled visitor creates resources for multiple scheduled functions with shared role', async () => { + let rawArc = arc(`@scheduled +task-one rate(1 hour) +task-two rate(1 day) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + // Check both Lambdas and schedule events exist + assert.ok(cfn.Resources.TaskOneScheduledLambda, 'first Lambda exists') + assert.ok(cfn.Resources.TaskOneScheduledEvent, 'first schedule event exists') + + assert.ok(cfn.Resources.TaskTwoScheduledLambda, 'second Lambda exists') + assert.ok(cfn.Resources.TaskTwoScheduledEvent, 'second schedule event exists') + + // Check there is only one shared scheduler role + assert.ok(cfn.Resources.SchedulerRole, 'shared scheduler role exists') + let roleCount = Object.keys(cfn.Resources).filter(k => k.includes('SchedulerRole') || k.includes('ScheduledRole')).length + assert.strictEqual(roleCount, 1, 'only one scheduler role is created') + + // Check the shared role has policies for both Lambdas + let policies = cfn.Resources.SchedulerRole.Properties.Policies + assert.strictEqual(policies.length, 2, 'shared role has policies for both Lambdas') + assert.ok(policies.find(p => p.PolicyName === 'TaskOneInvokeLambdaPolicy'), 'policy for first Lambda exists') + assert.ok(policies.find(p => p.PolicyName === 'TaskTwoInvokeLambdaPolicy'), 'policy for second Lambda exists') +}) + +test('Scheduled visitor does not create AWS::Lambda::Permission (uses IAM role instead)', async () => { + let rawArc = arc(`@scheduled +my-task rate(1 hour) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + // Ensure no Lambda permission resource is created (scheduler uses IAM role) + let permissionResources = Object.keys(cfn.Resources).filter(k => k.includes('ScheduledPermission')) + assert.strictEqual(permissionResources.length, 0, 'no Lambda permission resources created') +}) + +test('Scheduled visitor handles hyphenated names correctly', async () => { + let rawArc = arc(`@scheduled +my-hyphenated-task rate(1 hour) +`) + let inv = await inventory({ rawArc, deployStage }) + let cfn = pkg(inv) + + assert.ok(cfn.Resources.MyHyphenatedTaskScheduledLambda, 'Lambda with hyphenated name exists') + assert.ok(cfn.Resources.MyHyphenatedTaskScheduledEvent, 'schedule event with hyphenated name exists') + assert.ok(cfn.Resources.SchedulerRole, 'shared scheduler role exists') + + // Check the policy name uses the logical ID + let policies = cfn.Resources.SchedulerRole.Properties.Policies + assert.ok(policies.find(p => p.PolicyName === 'MyHyphenatedTaskInvokeLambdaPolicy'), 'policy uses logical ID from hyphenated name') +}) +