From 79e54afd58f962f3ceac8e00f69c796886852784 Mon Sep 17 00:00:00 2001 From: Simon MacDonald Date: Wed, 7 Jan 2026 16:33:58 -0500 Subject: [PATCH 1/5] feat: allow scheduled function to specify timezone Signed-off-by: Simon MacDonald --- src/visitors/scheduled/index.js | 60 ++++--- test/unit/visitors/scheduled/index-test.js | 174 +++++++++++++++++++++ 2 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 test/unit/visitors/scheduled/index-test.js diff --git a/src/visitors/scheduled/index.js b/src/visitors/scheduled/index.js index 4385912..963a904 100644 --- a/src/visitors/scheduled/index.js +++ b/src/visitors/scheduled/index.js @@ -8,16 +8,15 @@ module.exports = function visitScheduled (inventory, template) { let { inv } = inventory if (!inv.scheduled) return template - // we leave the bucket name generation up to cloudfront 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` + let scheduleRole = `${name}ScheduledRole` // Create the Lambda template.Resources[scheduleLambda] = createLambda({ @@ -26,30 +25,53 @@ module.exports = function visitScheduled (inventory, template) { template, }) - // Create the scheduled event rule - template.Resources[scheduleEvent] = { - Type: 'AWS::Events::Rule', + // Create IAM role for EventBridge Scheduler to invoke the Lambda + template.Resources[scheduleRole] = { + Type: 'AWS::IAM::Role', Properties: { - ScheduleExpression: rule, - Targets: [ - { - Arn: { 'Fn::GetAtt': [ scheduleLambda, 'Arn' ] }, - Id: scheduleLambda, + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ { + Effect: 'Allow', + Principal: { + Service: 'scheduler.amazonaws.com', + }, + Action: 'sts:AssumeRole', + } ], + }, + Policies: [ { + PolicyName: 'InvokeLambdaPolicy', + PolicyDocument: { + Version: '2012-10-17', + Statement: [ { + Effect: 'Allow', + Action: 'lambda:InvokeFunction', + Resource: { 'Fn::GetAtt': [ scheduleLambda, 'Arn' ] }, + } ], }, - ], + } ], }, } - // Wire the permission - template.Resources[schedulePermission] = { - Type: 'AWS::Lambda::Permission', + // Create the schedule using AWS::Scheduler::Schedule + template.Resources[scheduleEvent] = { + Type: 'AWS::Scheduler::Schedule', Properties: { - Action: 'lambda:InvokeFunction', - FunctionName: { Ref: scheduleLambda }, - Principal: 'events.amazonaws.com', - SourceArn: { 'Fn::GetAtt': [ scheduleEvent, 'Arn' ] }, + ScheduleExpression: rule, + FlexibleTimeWindow: { + Mode: 'OFF', + }, + Target: { + Arn: { 'Fn::GetAtt': [ scheduleLambda, 'Arn' ] }, + RoleArn: { 'Fn::GetAtt': [ scheduleRole, 'Arn' ] }, + }, }, } + + // Add timezone if specified + if (timezone) { + template.Resources[scheduleEvent].Properties.ScheduleExpressionTimezone = timezone + } }) return template diff --git a/test/unit/visitors/scheduled/index-test.js b/test/unit/visitors/scheduled/index-test.js new file mode 100644 index 0000000..58a8615 --- /dev/null +++ b/test/unit/visitors/scheduled/index-test.js @@ -0,0 +1,174 @@ +const { test } = require('node:test') +const assert = require('node:assert') +let inventory = require('../../../../../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 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 scheduler role exists + assert.ok(cfn.Resources.MyTaskScheduledRole, 'scheduler role resource exists') + assert.strictEqual(cfn.Resources.MyTaskScheduledRole.Type, 'AWS::IAM::Role', 'scheduler role has correct type') + + // Check the assume role policy allows scheduler.amazonaws.com + let assumeRolePolicy = cfn.Resources.MyTaskScheduledRole.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.MyTaskScheduledRole.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 scheduler role + assert.deepStrictEqual(target.RoleArn, { 'Fn::GetAtt': [ 'MyTaskScheduledRole', 'Arn' ] }, 'Target RoleArn references 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', 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 sets of resources exist + assert.ok(cfn.Resources.TaskOneScheduledLambda, 'first Lambda exists') + assert.ok(cfn.Resources.TaskOneScheduledEvent, 'first schedule event exists') + assert.ok(cfn.Resources.TaskOneScheduledRole, 'first scheduler role exists') + + assert.ok(cfn.Resources.TaskTwoScheduledLambda, 'second Lambda exists') + assert.ok(cfn.Resources.TaskTwoScheduledEvent, 'second schedule event exists') + assert.ok(cfn.Resources.TaskTwoScheduledRole, 'second scheduler role 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.MyHyphenatedTaskScheduledRole, 'scheduler role with hyphenated name exists') +}) + From 532f91f99ab939f6d68b2a0de72147d6032451c5 Mon Sep 17 00:00:00 2001 From: Simon MacDonald Date: Wed, 7 Jan 2026 16:36:21 -0500 Subject: [PATCH 2/5] doc: update changelog.md Signed-off-by: Simon MacDonald --- changelog.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 433f4a3162f1617465c8d3c2504a4b5508749674 Mon Sep 17 00:00:00 2001 From: Simon MacDonald Date: Wed, 7 Jan 2026 18:29:42 -0500 Subject: [PATCH 3/5] fix: reduce number of roles Signed-off-by: Simon MacDonald --- src/visitors/scheduled/index.js | 56 +++++++++++----------- test/unit/visitors/scheduled/index-test.js | 39 ++++++++++----- 2 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/visitors/scheduled/index.js b/src/visitors/scheduled/index.js index 963a904..ed1ca44 100644 --- a/src/visitors/scheduled/index.js +++ b/src/visitors/scheduled/index.js @@ -8,6 +8,24 @@ module.exports = function visitScheduled (inventory, template) { let { inv } = inventory if (!inv.scheduled) return template + // 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, timezone } = schedule let rule = rate || cron @@ -16,7 +34,6 @@ module.exports = function visitScheduled (inventory, template) { let name = toLogicalID(schedule.name) let scheduleLambda = `${name}ScheduledLambda` let scheduleEvent = `${name}ScheduledEvent` - let scheduleRole = `${name}ScheduledRole` // Create the Lambda template.Resources[scheduleLambda] = createLambda({ @@ -25,33 +42,18 @@ module.exports = function visitScheduled (inventory, template) { template, }) - // Create IAM role for EventBridge Scheduler to invoke the Lambda - template.Resources[scheduleRole] = { - Type: 'AWS::IAM::Role', - Properties: { - AssumeRolePolicyDocument: { - Version: '2012-10-17', - Statement: [ { - Effect: 'Allow', - Principal: { - Service: 'scheduler.amazonaws.com', - }, - Action: 'sts:AssumeRole', - } ], - }, - Policies: [ { - PolicyName: 'InvokeLambdaPolicy', - PolicyDocument: { - Version: '2012-10-17', - Statement: [ { - Effect: 'Allow', - Action: 'lambda:InvokeFunction', - Resource: { 'Fn::GetAtt': [ scheduleLambda, 'Arn' ] }, - } ], - }, + // 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] = { @@ -63,7 +65,7 @@ module.exports = function visitScheduled (inventory, template) { }, Target: { Arn: { 'Fn::GetAtt': [ scheduleLambda, 'Arn' ] }, - RoleArn: { 'Fn::GetAtt': [ scheduleRole, 'Arn' ] }, + RoleArn: { 'Fn::GetAtt': [ 'SchedulerRole', 'Arn' ] }, }, }, } diff --git a/test/unit/visitors/scheduled/index-test.js b/test/unit/visitors/scheduled/index-test.js index 58a8615..ad7a199 100644 --- a/test/unit/visitors/scheduled/index-test.js +++ b/test/unit/visitors/scheduled/index-test.js @@ -52,25 +52,25 @@ my-task rate(5 minutes) assert.strictEqual(cfn.Resources.MyTaskScheduledLambda.Type, 'AWS::Serverless::Function', 'Lambda has correct type') }) -test('Scheduled visitor creates IAM role for EventBridge Scheduler', async () => { +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 scheduler role exists - assert.ok(cfn.Resources.MyTaskScheduledRole, 'scheduler role resource exists') - assert.strictEqual(cfn.Resources.MyTaskScheduledRole.Type, 'AWS::IAM::Role', 'scheduler role has correct type') + // 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.MyTaskScheduledRole.Properties.AssumeRolePolicyDocument + 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.MyTaskScheduledRole.Properties.Policies + 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') @@ -103,8 +103,8 @@ my-task rate(1 hour) // Verify the Arn references the Lambda assert.deepStrictEqual(target.Arn, { 'Fn::GetAtt': [ 'MyTaskScheduledLambda', 'Arn' ] }, 'Target Arn references Lambda') - // Verify the RoleArn references the scheduler role - assert.deepStrictEqual(target.RoleArn, { 'Fn::GetAtt': [ 'MyTaskScheduledRole', 'Arn' ] }, 'Target RoleArn references scheduler role') + // 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 () => { @@ -130,7 +130,7 @@ my-task rate(1 hour) assert.strictEqual(cfn.Resources.MyTaskScheduledEvent.Properties.ScheduleExpressionTimezone, undefined, 'timezone property is not set') }) -test('Scheduled visitor creates resources for multiple scheduled functions', async () => { +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) @@ -138,14 +138,23 @@ task-two rate(1 day) let inv = await inventory({ rawArc, deployStage }) let cfn = pkg(inv) - // Check both sets of resources exist + // 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.TaskOneScheduledRole, 'first scheduler role exists') assert.ok(cfn.Resources.TaskTwoScheduledLambda, 'second Lambda exists') assert.ok(cfn.Resources.TaskTwoScheduledEvent, 'second schedule event exists') - assert.ok(cfn.Resources.TaskTwoScheduledRole, 'second scheduler role 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 () => { @@ -169,6 +178,10 @@ my-hyphenated-task rate(1 hour) 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.MyHyphenatedTaskScheduledRole, 'scheduler role 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') }) From 680da5e22ee29ef333041110701b76db61506e1b Mon Sep 17 00:00:00 2001 From: Simon MacDonald Date: Thu, 15 Jan 2026 14:29:54 -0500 Subject: [PATCH 4/5] chore: update inventory version Signed-off-by: Simon MacDonald --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From a2efd1345942cadcb804b3641d46d146226d10fa Mon Sep 17 00:00:00 2001 From: Simon MacDonald Date: Thu, 15 Jan 2026 14:33:31 -0500 Subject: [PATCH 5/5] test: fix require path in test Signed-off-by: Simon MacDonald --- test/unit/visitors/scheduled/index-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/visitors/scheduled/index-test.js b/test/unit/visitors/scheduled/index-test.js index ad7a199..b995978 100644 --- a/test/unit/visitors/scheduled/index-test.js +++ b/test/unit/visitors/scheduled/index-test.js @@ -1,6 +1,6 @@ const { test } = require('node:test') const assert = require('node:assert') -let inventory = require('../../../../../inventory') +let inventory = require('@architect/inventory') let pkg = require('../../../../') let base = `@app