diff --git a/README.md b/README.md index 5550aacc..4ba9bb70 100644 --- a/README.md +++ b/README.md @@ -440,10 +440,9 @@ functions: dashboard: enabled: false # No Lambda widgets will be created for this function alarms: - Lambda: - Invocations: - Threshold: 2 # The invocation threshold is specific to - # this function's expected invocation count + Invocations: + Threshold: 2 # The invocation threshold is specific to + # this function's expected invocation count ``` To disable all alarms for any given function, use: @@ -454,8 +453,7 @@ functions: handler: basic-handler.hello slicWatch: alarms: - Lambda: - enabled: false + enabled: false ``` #### SAM/CloudFormation function-level configuration @@ -468,9 +466,8 @@ Resources: Metadata: slicWatch: alarms: - Lambda: - Invocations: - Threshold: 3 + Invocations: + Threshold: 3 dashboard: enabled: true ``` @@ -486,8 +483,7 @@ Resources: Metadata: slicWatch: alarms: - Lambda: - enabled: false + enabled: false ``` #### CDK function-level configuration @@ -497,10 +493,8 @@ const cfnFuncHello = hello.node.defaultChild as CfnResource; cfnFuncHello.cfnOptions.metadata = { slicWatch: { alarms: { - Lambda: { - Invocations: { - Threshold: 2 - } + Invocations: { + Threshold: 2 } } } diff --git a/core/alarms/lambda.ts b/core/alarms/lambda.ts index c183d0e2..8311592c 100644 --- a/core/alarms/lambda.ts +++ b/core/alarms/lambda.ts @@ -18,9 +18,9 @@ export type SlicWatchLambdaAlarmsConfig = T & { const lambdaMetrics = ['Errors', 'ThrottlesPc', 'DurationPc', 'Invocations'] /** - * Add all required Lambda alarms to the provided CloudFormation templatebased on the Lambda resources found within + * Add all required Lambda alarms to the provided CloudFormation template based on the Lambda resources found within * - * @param functionAlarmPropertiess The cascaded Lambda alarm configuration with function-specific overrides by function logical ID + * @param lambdaAlarmConfig Lambda-specific alarm configuration * @param alarmActionsConfig Notification configuration for alarm status change events * @compiledTemplate CloudFormation template object * diff --git a/core/cf-template.ts b/core/cf-template.ts index fb99ef7a..98c447f1 100644 --- a/core/cf-template.ts +++ b/core/cf-template.ts @@ -9,7 +9,7 @@ import { cascade } from './inputs/cascading-config' import { type SlicWatchMergedConfig } from './alarms/alarm-types' import { type WidgetMetricProperties } from './dashboards/dashboard-types' import { defaultConfig } from './inputs/default-config' -import { type ConfigType, cfTypeByConfigType } from './inputs/config-types' +import { ConfigType, cfTypeByConfigType } from './inputs/config-types' const logger = getLogger() @@ -69,7 +69,13 @@ export function getResourceAlarmConfigurationsByType = {} const resources = getResourcesByType(cfTypeByConfigType[type], template) for (const [funcLogicalId, resource] of Object.entries(resources)) { - const resourceConfig = resource?.Metadata?.slicWatch?.alarms // Resource-specific overrides + let legacyFallbackResourceConfig + if (type === ConfigType.Lambda) { + // Older versions only allowed function resource overrides and required the `Lambda` property within the config object + // If this is still present in configuration, we take it from here + legacyFallbackResourceConfig = resource?.Metadata?.slicWatch?.alarms?.Lambda + } + const resourceConfig = legacyFallbackResourceConfig ?? resource?.Metadata?.slicWatch?.alarms // Resource-specific overrides const defaultResourceConfig = defaultConfig.alarms?.[type] // Default configuration for the type's alarms // Cascade the default resource's configuration into the resource-specific overrides const cascadedResourceConfig = resourceConfig !== undefined ? cascade(merge({}, defaultResourceConfig, resourceConfig)) : {} @@ -97,7 +103,13 @@ export function getResourceDashboardConfigurationsByType = {} const resources = getResourcesByType(cfTypeByConfigType[type], template) for (const [logicalId, resource] of Object.entries(resources)) { - const resourceConfig = resource?.Metadata?.slicWatch?.dashboard // Resource-specific overrides + let legacyFallbackResourceConfig + if (type === ConfigType.Lambda) { + // Older versions only allowed function resource overrides and required the `Lambda` property within the config object + // If this is still present in configuration, we take it from here + legacyFallbackResourceConfig = resource?.Metadata?.slicWatch?.dashboard?.Lambda + } + const resourceConfig = legacyFallbackResourceConfig ?? resource?.Metadata?.slicWatch?.dashboard // Resource-specific overrides const defaultResourceConfig = defaultConfig.dashboard?.widgets?.[type] // Default configuration for the widget // Cascade the default resource's configuration into the resource-specific overrides const cascadedResourceConfig = resourceConfig !== undefined ? cascade(merge({}, defaultResourceConfig, resourceConfig)) : {} diff --git a/serverless-plugin/serverless-plugin.ts b/serverless-plugin/serverless-plugin.ts index 9735aa2f..e415efad 100644 --- a/serverless-plugin/serverless-plugin.ts +++ b/serverless-plugin/serverless-plugin.ts @@ -42,8 +42,8 @@ class ServerlessPlugin { } /** - * Modify the CloudFormation template before the package is finalized - */ + * Modify the CloudFormation template before the package is finalized + */ createSlicWatchResources () { const slicWatchConfig: SlicWatchConfig = this.serverless.service.custom?.slicWatch ?? {} @@ -65,6 +65,7 @@ class ServerlessPlugin { // CloudFormation Metadata on the generate AWS::Lambda::Function resource const allFunctions = this.serverless.service.getAllFunctions() as string[] this.serverless.cli.log(`Setting SLIC Watch configuration for ${allFunctions}`) + for (const funcName of allFunctions) { const func = this.serverless.service.getFunction(funcName) as any const funcConfig = func.slicWatch ?? {} diff --git a/serverless-plugin/tests/index.test.ts b/serverless-plugin/tests/index.test.ts index c0de4146..73707584 100644 --- a/serverless-plugin/tests/index.test.ts +++ b/serverless-plugin/tests/index.test.ts @@ -1,10 +1,14 @@ import { test } from 'tap' import _ from 'lodash' import ServerlessError from 'serverless/lib/serverless-error' +import type Template from 'cloudform-types/types/template' import ServerlessPlugin from '../serverless-plugin' import { getLogger } from 'slic-watch-core/logging' -import { createMockServerless, dummyLogger, pluginUtils, slsYaml } from '../../test-utils/sls-test-utils' +import { type SlsYaml, createMockServerless, dummyLogger, pluginUtils, slsYaml } from '../../test-utils/sls-test-utils' +import { type ResourceType } from 'slic-watch-core' +import { getDashboardFromTemplate, getDashboardWidgetsByTitle } from 'slic-watch-core/tests/testing-utils' +import { type MetricWidgetProperties } from 'cloudwatch-dashboard-types' interface TestData { schema? @@ -48,6 +52,134 @@ test('index', t => { t.end() }) + t.test('function-level overrides in serverless `functions` block take precedence', t => { + const compiledTemplate: Template = { + Resources: { + HelloLambdaFunction: { + Type: 'AWS::Lambda::Function', + Properties: { + FunctionName: 'serverless-test-project-dev-hello' + } + } + } + } + const slsConfig: SlsYaml = { + custom: { + slicWatch: { + alarms: { + Lambda: { + Invocations: { + enabled: true, + Threshold: 10 + } + } + }, + dashboard: { + widgets: { + Lambda: { + Invocations: { + yAxis: 'left' + } + } + } + } + } + }, + functions: { + hello: { + slicWatch: { + enabled: true + } + } + } + } + + t.test('Plugin succeeds without function-level overrides', t => { + const sls = createMockServerless(compiledTemplate, slsConfig) + const plugin = new ServerlessPlugin(sls, null, pluginUtils) + plugin.createSlicWatchResources() + const invocationAlarmProperties = (compiledTemplate.Resources as ResourceType).slicWatchLambdaInvocationsAlarmHelloLambdaFunction.Properties + t.equal(invocationAlarmProperties?.Threshold, 10) + + const { dashboard } = getDashboardFromTemplate(compiledTemplate) + const widgets = getDashboardWidgetsByTitle(dashboard, /Lambda Invocations/) + t.equal(widgets.length, 1) + t.match((widgets[0].properties as MetricWidgetProperties).metrics, [ + ['AWS/Lambda', 'Invocations', 'FunctionName', '${HelloLambdaFunction}', { stat: 'Sum', yAxis: 'left' }] + ]) + + t.end() + }) + + t.test('Plugin succeeds with function-level overrides', t => { + const modifiedSlsConfig = _.cloneDeep(slsConfig) + Object.assign(modifiedSlsConfig.functions.hello.slicWatch, { + alarms: { + Invocations: { + Threshold: 3, + enabled: true + } + }, + dashboard: { + Invocations: { + yAxis: 'right' + } + } + }) + + const sls = createMockServerless(compiledTemplate, modifiedSlsConfig) + const plugin = new ServerlessPlugin(sls, null, pluginUtils) + plugin.createSlicWatchResources() + const invocationAlarmProperties = (compiledTemplate.Resources as ResourceType).slicWatchLambdaInvocationsAlarmHelloLambdaFunction.Properties + t.equal(invocationAlarmProperties?.Threshold, 3) + + const { dashboard } = getDashboardFromTemplate(compiledTemplate) + const widgets = getDashboardWidgetsByTitle(dashboard, /Lambda Invocations/) + t.equal(widgets.length, 1) + t.match((widgets[0].properties as MetricWidgetProperties).metrics, [ + ['AWS/Lambda', 'Invocations', 'FunctionName', '${HelloLambdaFunction}', { stat: 'Sum', yAxis: 'right' }] + ]) + t.end() + }) + + t.test('Plugin succeeds with legacy function-level overrides', t => { + const modifiedSlsConfig = _.cloneDeep(slsConfig) + Object.assign(modifiedSlsConfig.functions.hello.slicWatch, { + alarms: { + Lambda: { // This extra property is the 'legacy' configuration bit + Invocations: { + Threshold: 4, + enabled: true + } + } + }, + dashboard: { + Lambda: { // This extra property is the 'legacy' configuration bit + Invocations: { + yAxis: 'right' + } + } + } + }) + + const sls = createMockServerless(compiledTemplate, modifiedSlsConfig) + const plugin = new ServerlessPlugin(sls, null, pluginUtils) + plugin.createSlicWatchResources() + const invocationAlarmProperties = (compiledTemplate.Resources as ResourceType).slicWatchLambdaInvocationsAlarmHelloLambdaFunction.Properties + t.equal(invocationAlarmProperties?.Threshold, 4) + + const { dashboard } = getDashboardFromTemplate(compiledTemplate) + const widgets = getDashboardWidgetsByTitle(dashboard, /Lambda Invocations/) + t.equal(widgets.length, 1) + t.match((widgets[0].properties as MetricWidgetProperties).metrics, [ + ['AWS/Lambda', 'Invocations', 'FunctionName', '${HelloLambdaFunction}', { stat: 'Sum', yAxis: 'right' }] + ]) + t.end() + }) + + t.end() + }) + t.test('Plugin succeeds with no custom section', t => { const plugin = new ServerlessPlugin({ ...mockServerless, diff --git a/test-utils/sls-test-utils.ts b/test-utils/sls-test-utils.ts index e4368d3d..a2c510bb 100644 --- a/test-utils/sls-test-utils.ts +++ b/test-utils/sls-test-utils.ts @@ -46,12 +46,7 @@ export function createMockServerless (compiledTemplate: Template, slsConfig = sl name: 'aws', compiledCloudFormationTemplate: compiledTemplate }, - custom: { - slicWatch: { - enabled: true, - topicArn: 'test-topic' - } - }, + custom: slsConfig.custom, getAllFunctions: () => Object.keys(slsConfig.functions ?? {}), getFunction: (funcRef) => slsConfig.functions[funcRef] }