From 0f554bb5852d33c9c77a39539bac9f918003b6ac Mon Sep 17 00:00:00 2001 From: Brent Champion Date: Mon, 2 Mar 2026 20:09:28 -0500 Subject: [PATCH] feat: add support for durable functions --- README.md | 1 + __tests__/validations.test.js | 184 ++++++++++++++++++++++++++++++++++ action.yml | 3 + dist/index.js | 51 ++++++++-- index.js | 26 +++-- validations.js | 25 ++++- 6 files changed, 268 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index cbe7d50..6ee0ff0 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ Below are two commonly used Build examples for Node.js and Python: | `logging-config` | CloudWatch Logs configuration | No | | | `code-signing-config-arn` | ARN of code-signing configuration | No | | | `tags` | Tags to apply to the function as JSON string | No | | +| `durable-config` | Configuration settings for durable functions as JSON string | No | | ## Outputs diff --git a/__tests__/validations.test.js b/__tests__/validations.test.js index 877985e..cd14771 100644 --- a/__tests__/validations.test.js +++ b/__tests__/validations.test.js @@ -1204,6 +1204,190 @@ describe('Validations Tests', () => { expect(core.setFailed).not.toHaveBeenCalled(); }); }); + + describe('durable-config validation', () => { + test('should accept valid durable-config with ExecutionTimeout', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{"ExecutionTimeout":3600}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(true); + expect(result.parsedDurableConfig).toEqual({ ExecutionTimeout: 3600 }); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + test('should accept valid durable-config with RetentionPeriodInDays', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{"RetentionPeriodInDays":7}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(true); + expect(result.parsedDurableConfig).toEqual({ RetentionPeriodInDays: 7 }); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + test('should accept valid durable-config with both properties', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{"ExecutionTimeout":3600,"RetentionPeriodInDays":7}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(true); + expect(result.parsedDurableConfig).toEqual({ + ExecutionTimeout: 3600, + RetentionPeriodInDays: 7 + }); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + test('should reject ExecutionTimeout below minimum', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{"ExecutionTimeout":0}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(false); + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('ExecutionTimeout must be between 1 and 31622400') + ); + }); + + test('should reject ExecutionTimeout above maximum', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{"ExecutionTimeout":31622401}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(false); + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('ExecutionTimeout must be between 1 and 31622400') + ); + }); + + test('should reject RetentionPeriodInDays below minimum', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{"RetentionPeriodInDays":0}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(false); + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('RetentionPeriodInDays must be between 1 and 90') + ); + }); + + test('should reject RetentionPeriodInDays above maximum', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{"RetentionPeriodInDays":91}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(false); + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('RetentionPeriodInDays must be between 1 and 90') + ); + }); + + test('should accept maximum valid values', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{"ExecutionTimeout":31622400,"RetentionPeriodInDays":90}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(true); + expect(result.parsedDurableConfig).toEqual({ + ExecutionTimeout: 31622400, + RetentionPeriodInDays: 90 + }); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + + test('should reject invalid JSON in durable-config', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'durable-config') { + return '{invalid json}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(false); + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('Invalid JSON in durable-config') + ); + }); + }); }); describe('getAdditionalInputs function', () => { test('should handle invalid publish input and default to false', () => { diff --git a/action.yml b/action.yml index 5963185..aa0f7e3 100644 --- a/action.yml +++ b/action.yml @@ -95,6 +95,9 @@ inputs: tags: description: 'Tags to apply to the function as a JSON string (e.g. {"Environment":"Production","Team":"DevOps"})' required: false + durable-config: + description: 'Configuration settings for durable functions as a JSON string (e.g. {"ExecutionTimeout":3600,"RetentionPeriodInDays":7})' + required: false outputs: function-arn: description: 'The ARN of the updated Lambda function.' diff --git a/dist/index.js b/dist/index.js index b92333c..3490111 100644 --- a/dist/index.js +++ b/dist/index.js @@ -28,10 +28,11 @@ async function run() { role, codeSigningConfigArn, kmsKeyArn, sourceKmsKeyArn, vpcConfig, deadLetterConfig, tracingConfig, layers, fileSystemConfigs, imageConfig, snapStart, - loggingConfig, tags, + loggingConfig, tags, durableConfig, parsedEnvironment, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, parsedSnapStart, parsedLoggingConfig, parsedTags, + parsedDurableConfig, functionDescription, dryRun, publish, revisionId, runtime, handler, architectures } = inputs; @@ -81,9 +82,10 @@ async function run() { revisionId, vpcConfig, parsedEnvironment, deadLetterConfig, tracingConfig, layers, fileSystemConfigs, imageConfig, snapStart, loggingConfig, tags, kmsKeyArn, codeSigningConfigArn, + durableConfig, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, - parsedSnapStart, parsedLoggingConfig, parsedTags + parsedSnapStart, parsedLoggingConfig, parsedTags, parsedDurableConfig }, functionExists); // Update function configuration @@ -108,7 +110,8 @@ async function run() { ...(fileSystemConfigs && { FileSystemConfigs: parsedFileSystemConfigs }), ...(imageConfig && { ImageConfig: parsedImageConfig }), ...(snapStart && { SnapStart: parsedSnapStart }), - ...(loggingConfig && { LoggingConfig: parsedLoggingConfig }) + ...(loggingConfig && { LoggingConfig: parsedLoggingConfig }), + ...(durableConfig && { DurableConfig: parsedDurableConfig }) }); if (configChanged) { @@ -136,6 +139,7 @@ async function run() { imageConfig, snapStart, loggingConfig, + durableConfig, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, @@ -143,7 +147,8 @@ async function run() { parsedFileSystemConfigs, parsedImageConfig, parsedSnapStart, - parsedLoggingConfig + parsedLoggingConfig, + parsedDurableConfig }); } else { core.info('No configuration changes detected'); @@ -302,9 +307,10 @@ async function createFunction(client, inputs, functionExists) { timeout, publish, architectures, ephemeralStorage, revisionId, vpcConfig, parsedEnvironment, deadLetterConfig, tracingConfig, layers, fileSystemConfigs, imageConfig, snapStart, loggingConfig, tags, - kmsKeyArn, codeSigningConfigArn, parsedVpcConfig, parsedDeadLetterConfig, + kmsKeyArn, codeSigningConfigArn, durableConfig, + parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, - parsedSnapStart, parsedLoggingConfig, parsedTags + parsedSnapStart, parsedLoggingConfig, parsedTags, parsedDurableConfig } = inputs; if (!functionExists) { @@ -390,6 +396,7 @@ async function createFunction(client, inputs, functionExists) { ...(tags && { Tags: parsedTags }), ...(kmsKeyArn && { KMSKeyArn: kmsKeyArn }), ...(codeSigningConfigArn && { CodeSigningConfigArn: codeSigningConfigArn }), + ...(durableConfig && { DurableConfig: parsedDurableConfig }) }; core.info(`Creating new Lambda function: ${functionName}`); @@ -480,10 +487,10 @@ async function updateFunctionConfiguration(client, params) { functionName, role, handler, functionDescription, parsedMemorySize, timeout, runtime, kmsKeyArn, ephemeralStorage, vpcConfig, parsedEnvironment, deadLetterConfig, tracingConfig, layers, - fileSystemConfigs, imageConfig, snapStart, loggingConfig, + fileSystemConfigs, imageConfig, snapStart, loggingConfig, durableConfig, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, - parsedSnapStart, parsedLoggingConfig + parsedSnapStart, parsedLoggingConfig, parsedDurableConfig } = params; try { @@ -505,7 +512,8 @@ async function updateFunctionConfiguration(client, params) { ...(fileSystemConfigs && { FileSystemConfigs: parsedFileSystemConfigs }), ...(imageConfig && { ImageConfig: parsedImageConfig }), ...(snapStart && { SnapStart: parsedSnapStart }), - ...(loggingConfig && { LoggingConfig: parsedLoggingConfig }) + ...(loggingConfig && { LoggingConfig: parsedLoggingConfig }), + ...(durableConfig && { DurableConfig: parsedDurableConfig }) }; core.info(`Updating function configuration for ${functionName}`); @@ -98020,10 +98028,11 @@ function validateJsonInputs() { const snapStart = core.getInput('snap-start', { required: false }); const loggingConfig = core.getInput('logging-config', { required: false }); const tags = core.getInput('tags', { required: false }); + const durableConfig = core.getInput('durable-config', { required: false }); let parsedEnvironment, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, parsedSnapStart, - parsedLoggingConfig, parsedTags; + parsedLoggingConfig, parsedTags, parsedDurableConfig; try { if (environment) { @@ -98094,6 +98103,24 @@ function validateJsonInputs() { throw new Error('tags must be an object of key-value pairs'); } } + + if (durableConfig) { + parsedDurableConfig = parseJsonInput(durableConfig, 'durable-config'); + if (parsedDurableConfig.ExecutionTimeout !== undefined) { + if (typeof parsedDurableConfig.ExecutionTimeout !== 'number' || + parsedDurableConfig.ExecutionTimeout < 1 || + parsedDurableConfig.ExecutionTimeout > 31622400) { + throw new Error('durable-config ExecutionTimeout must be between 1 and 31622400 seconds'); + } + } + if (parsedDurableConfig.RetentionPeriodInDays !== undefined) { + if (typeof parsedDurableConfig.RetentionPeriodInDays !== 'number' || + parsedDurableConfig.RetentionPeriodInDays < 1 || + parsedDurableConfig.RetentionPeriodInDays > 90) { + throw new Error('durable-config RetentionPeriodInDays must be between 1 and 90 days'); + } + } + } } catch (error) { core.setFailed(`Input validation error: ${error.message}`); return { valid: false }; @@ -98111,6 +98138,7 @@ function validateJsonInputs() { snapStart, loggingConfig, tags, + durableConfig, parsedEnvironment, parsedVpcConfig, parsedDeadLetterConfig, @@ -98120,7 +98148,8 @@ function validateJsonInputs() { parsedImageConfig, parsedSnapStart, parsedLoggingConfig, - parsedTags + parsedTags, + parsedDurableConfig }; } diff --git a/index.js b/index.js index 086da08..4b662a1 100644 --- a/index.js +++ b/index.js @@ -22,10 +22,11 @@ async function run() { role, codeSigningConfigArn, kmsKeyArn, sourceKmsKeyArn, vpcConfig, deadLetterConfig, tracingConfig, layers, fileSystemConfigs, imageConfig, snapStart, - loggingConfig, tags, + loggingConfig, tags, durableConfig, parsedEnvironment, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, parsedSnapStart, parsedLoggingConfig, parsedTags, + parsedDurableConfig, functionDescription, dryRun, publish, revisionId, runtime, handler, architectures } = inputs; @@ -75,9 +76,10 @@ async function run() { revisionId, vpcConfig, parsedEnvironment, deadLetterConfig, tracingConfig, layers, fileSystemConfigs, imageConfig, snapStart, loggingConfig, tags, kmsKeyArn, codeSigningConfigArn, + durableConfig, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, - parsedSnapStart, parsedLoggingConfig, parsedTags + parsedSnapStart, parsedLoggingConfig, parsedTags, parsedDurableConfig }, functionExists); // Update function configuration @@ -102,7 +104,8 @@ async function run() { ...(fileSystemConfigs && { FileSystemConfigs: parsedFileSystemConfigs }), ...(imageConfig && { ImageConfig: parsedImageConfig }), ...(snapStart && { SnapStart: parsedSnapStart }), - ...(loggingConfig && { LoggingConfig: parsedLoggingConfig }) + ...(loggingConfig && { LoggingConfig: parsedLoggingConfig }), + ...(durableConfig && { DurableConfig: parsedDurableConfig }) }); if (configChanged) { @@ -130,6 +133,7 @@ async function run() { imageConfig, snapStart, loggingConfig, + durableConfig, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, @@ -137,7 +141,8 @@ async function run() { parsedFileSystemConfigs, parsedImageConfig, parsedSnapStart, - parsedLoggingConfig + parsedLoggingConfig, + parsedDurableConfig }); } else { core.info('No configuration changes detected'); @@ -296,9 +301,10 @@ async function createFunction(client, inputs, functionExists) { timeout, publish, architectures, ephemeralStorage, revisionId, vpcConfig, parsedEnvironment, deadLetterConfig, tracingConfig, layers, fileSystemConfigs, imageConfig, snapStart, loggingConfig, tags, - kmsKeyArn, codeSigningConfigArn, parsedVpcConfig, parsedDeadLetterConfig, + kmsKeyArn, codeSigningConfigArn, durableConfig, + parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, - parsedSnapStart, parsedLoggingConfig, parsedTags + parsedSnapStart, parsedLoggingConfig, parsedTags, parsedDurableConfig } = inputs; if (!functionExists) { @@ -384,6 +390,7 @@ async function createFunction(client, inputs, functionExists) { ...(tags && { Tags: parsedTags }), ...(kmsKeyArn && { KMSKeyArn: kmsKeyArn }), ...(codeSigningConfigArn && { CodeSigningConfigArn: codeSigningConfigArn }), + ...(durableConfig && { DurableConfig: parsedDurableConfig }) }; core.info(`Creating new Lambda function: ${functionName}`); @@ -474,10 +481,10 @@ async function updateFunctionConfiguration(client, params) { functionName, role, handler, functionDescription, parsedMemorySize, timeout, runtime, kmsKeyArn, ephemeralStorage, vpcConfig, parsedEnvironment, deadLetterConfig, tracingConfig, layers, - fileSystemConfigs, imageConfig, snapStart, loggingConfig, + fileSystemConfigs, imageConfig, snapStart, loggingConfig, durableConfig, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, - parsedSnapStart, parsedLoggingConfig + parsedSnapStart, parsedLoggingConfig, parsedDurableConfig } = params; try { @@ -499,7 +506,8 @@ async function updateFunctionConfiguration(client, params) { ...(fileSystemConfigs && { FileSystemConfigs: parsedFileSystemConfigs }), ...(imageConfig && { ImageConfig: parsedImageConfig }), ...(snapStart && { SnapStart: parsedSnapStart }), - ...(loggingConfig && { LoggingConfig: parsedLoggingConfig }) + ...(loggingConfig && { LoggingConfig: parsedLoggingConfig }), + ...(durableConfig && { DurableConfig: parsedDurableConfig }) }; core.info(`Updating function configuration for ${functionName}`); diff --git a/validations.js b/validations.js index 4f8998c..ee40e42 100644 --- a/validations.js +++ b/validations.js @@ -141,10 +141,11 @@ function validateJsonInputs() { const snapStart = core.getInput('snap-start', { required: false }); const loggingConfig = core.getInput('logging-config', { required: false }); const tags = core.getInput('tags', { required: false }); + const durableConfig = core.getInput('durable-config', { required: false }); let parsedEnvironment, parsedVpcConfig, parsedDeadLetterConfig, parsedTracingConfig, parsedLayers, parsedFileSystemConfigs, parsedImageConfig, parsedSnapStart, - parsedLoggingConfig, parsedTags; + parsedLoggingConfig, parsedTags, parsedDurableConfig; try { if (environment) { @@ -215,6 +216,24 @@ function validateJsonInputs() { throw new Error('tags must be an object of key-value pairs'); } } + + if (durableConfig) { + parsedDurableConfig = parseJsonInput(durableConfig, 'durable-config'); + if (parsedDurableConfig.ExecutionTimeout !== undefined) { + if (typeof parsedDurableConfig.ExecutionTimeout !== 'number' || + parsedDurableConfig.ExecutionTimeout < 1 || + parsedDurableConfig.ExecutionTimeout > 31622400) { + throw new Error('durable-config ExecutionTimeout must be between 1 and 31622400 seconds'); + } + } + if (parsedDurableConfig.RetentionPeriodInDays !== undefined) { + if (typeof parsedDurableConfig.RetentionPeriodInDays !== 'number' || + parsedDurableConfig.RetentionPeriodInDays < 1 || + parsedDurableConfig.RetentionPeriodInDays > 90) { + throw new Error('durable-config RetentionPeriodInDays must be between 1 and 90 days'); + } + } + } } catch (error) { core.setFailed(`Input validation error: ${error.message}`); return { valid: false }; @@ -232,6 +251,7 @@ function validateJsonInputs() { snapStart, loggingConfig, tags, + durableConfig, parsedEnvironment, parsedVpcConfig, parsedDeadLetterConfig, @@ -241,7 +261,8 @@ function validateJsonInputs() { parsedImageConfig, parsedSnapStart, parsedLoggingConfig, - parsedTags + parsedTags, + parsedDurableConfig }; }