Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"author": "Brian LeRoux <b@brian.io>",
"license": "Apache-2.0",
"dependencies": {
"@architect/inventory": "~6.0.0",
"@architect/inventory": "~6.1.0",
"@architect/utils": "~6.0.0"
},
"devDependencies": {
Expand Down
64 changes: 44 additions & 20 deletions src/visitors/scheduled/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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
}
})

Expand Down
187 changes: 187 additions & 0 deletions test/unit/visitors/scheduled/index-test.js
Original file line number Diff line number Diff line change
@@ -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')
})