diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c750080a..23194045 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,8 +17,8 @@ jobs: node-version: [18, 20] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }}.x cache: 'npm' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eec73f97..32254522 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,9 +19,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@master + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 18.x registry-url: "https://registry.npmjs.org" diff --git a/cf-macro/index.ts b/cf-macro/index.ts index 2f1d09e8..c5f12fbd 100644 --- a/cf-macro/index.ts +++ b/cf-macro/index.ts @@ -1,10 +1,9 @@ -import _ from 'lodash' +import { type Template } from 'cloudform-types' import pino from 'pino' -import { addAlarms, addDashboard, getResourcesByType } from '../core/index' +import { addAlarms, addDashboard } from 'slic-watch-core/index' import { setLogger } from 'slic-watch-core/logging' import { type SlicWatchConfig, resolveSlicWatchConfig } from 'slic-watch-core/inputs/general-config' -import { type Template } from 'cloudform-types' const logger = pino({ name: 'macroHandler' }) setLogger(logger) @@ -37,23 +36,11 @@ export async function handler (event: Event): Promise { const config = resolveSlicWatchConfig(slicWatchConfig) - const functionAlarmConfigs = {} - const functionDashboardConfigs = {} - - const lambdaResources = getResourcesByType('AWS::Lambda::Function', transformedTemplate) - - for (const [funcResourceName, funcResource] of Object.entries(lambdaResources)) { - const funcConfig = funcResource.Metadata?.slicWatch ?? {} - functionAlarmConfigs[funcResourceName] = funcConfig.alarms ?? {} - functionDashboardConfigs[funcResourceName] = funcConfig.dashboard - } - - _.merge(transformedTemplate) - addAlarms(config.alarms, functionAlarmConfigs, config.alarmActionsConfig, transformedTemplate) - addDashboard(config.dashboard, functionDashboardConfigs, transformedTemplate) + addAlarms(config.alarms, config.alarmActionsConfig, transformedTemplate) + addDashboard(config.dashboard, transformedTemplate) outputFragment = transformedTemplate } catch (err) { - logger.error(err) + logger.error({ err }) errorMessage = (err as Error).message status = 'fail' } diff --git a/cf-macro/tests/cdk-cf.test.ts b/cf-macro/tests/cdk-cf.test.ts index fe493539..ea053b75 100644 --- a/cf-macro/tests/cdk-cf.test.ts +++ b/cf-macro/tests/cdk-cf.test.ts @@ -1,5 +1,5 @@ import { test } from 'tap' - +import type { Template } from 'cloudform-types' import { getResourcesByType } from 'slic-watch-core/cf-template' import { handler } from '../index' import cdkStack from './resources/cdk-ecs-cf.json' @@ -9,11 +9,13 @@ import cdkStack from './resources/cdk-ecs-cf.json' */ test('ECS CDK stack', async (t) => { const event = { - fragment: cdkStack + fragment: cdkStack as Template, + requestId: 'test' } + const handlerResponse = await handler(event) t.equal(handlerResponse.status, 'success') - const compiledTemplate = handlerResponse.fragment + const compiledTemplate = handlerResponse.fragment as Template test('alarms are generated', (t) => { const alarms = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) diff --git a/cf-macro/tests/index.test.ts b/cf-macro/tests/index.test.ts index 163a3c7f..1490fb5e 100644 --- a/cf-macro/tests/index.test.ts +++ b/cf-macro/tests/index.test.ts @@ -1,13 +1,14 @@ import { test } from 'tap' import _ from 'lodash' -import type Template from 'cloudform-types/types/template' +import type { Template } from 'cloudform-types' +import type Resource from 'cloudform-types/types/resource' import { handler } from '../index' import _template from './event.json' const template = _template as Template -const event = { fragment: template } +const event = { fragment: template, requestId: 'test' } test('macro returns success', async t => { const result = await handler(event) @@ -37,7 +38,7 @@ test('macro uses topicArn if specified', async t => { const result = await handler(eventWithTopic) t.equal(result.status, 'success') t.notOk(result.errorMessage) - t.same(result.fragment.Resources.slicWatchLambdaDurationAlarmHelloLambdaFunction.Properties.AlarmActions, [topicArn]) + t.same(result?.fragment?.Resources?.slicWatchLambdaDurationAlarmHelloLambdaFunction?.Properties?.AlarmActions, [topicArn]) t.end() }) @@ -51,21 +52,23 @@ test('Macro skips SLIC Watch if top-level enabled==false', async t => { }) test('Macro adds dashboard and alarms if no function configuration is provided', async t => { + const functionResource: Resource = { + ...event.fragment.Resources?.HelloLambdaFunction, + Metadata: {} + } as unknown as Resource + const testEvent = { ...event, fragment: { ...event.fragment, Resources: { ...event.fragment.Resources, - HelloLambdaFunction: { - ...event.fragment.Resources?.HelloLambdaFunction, - Metadata: {} - } + HelloLambdaFunction: functionResource } } } const compiledTemplate = (await handler(testEvent)).fragment - t.same(compiledTemplate.Resources.Properties, template.Resources?.Properties) + t.same(compiledTemplate?.Resources?.Properties, template.Resources?.Properties) t.end() }) diff --git a/core/alarms/alarm-types.ts b/core/alarms/alarm-types.ts index 2a7b4b94..af224a36 100644 --- a/core/alarms/alarm-types.ts +++ b/core/alarms/alarm-types.ts @@ -23,29 +23,31 @@ export interface AlarmTemplate { Properties: AlarmProperties } +/** + * Alarm configuration type used *before* all mandatory fields have been applied + */ export interface SlicWatchAlarmConfig extends Omit { ComparisonOperator?: string + EvaluationPeriods?: number enabled?: boolean } +/** + * Alarm configuration type used *after* all mandatory fields have been applied + */ export interface SlicWatchMergedConfig extends AlarmProperties { enabled: boolean } export type InputOutput = SlicWatchAlarmConfig | SlicWatchMergedConfig -export interface ReturnAlarm { - resourceName: string - resource: Resource -} - export interface AlarmActionsConfig { actionsEnabled?: boolean okActions?: string[] alarmActions?: string[] } -export interface SlicWatchCascadedAlarmsConfig extends AlarmProperties { +export type SlicWatchCascadedAlarmsConfig = T & { enabled: boolean Lambda: SlicWatchLambdaAlarmsConfig ApiGateway: SlicWatchApiGwAlarmsConfig diff --git a/core/alarms/alarm-utils.ts b/core/alarms/alarm-utils.ts index 44e787e4..c66c7d47 100644 --- a/core/alarms/alarm-utils.ts +++ b/core/alarms/alarm-utils.ts @@ -3,12 +3,7 @@ import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import { pascal } from 'case' import type { AlarmActionsConfig, AlarmTemplate, CloudFormationResources, OptionalAlarmProps, SlicWatchMergedConfig } from './alarm-types' -import { getResourcesByType } from '../cf-template' -import type { SlicWatchAlbAlarmsConfig } from './alb' -import type { SlicWatchDynamoDbAlarmsConfig } from './dynamodb' -import type { SlicWatchEventsAlarmsConfig } from './eventbridge' -import type { SlicWatchSnsAlarmsConfig } from './sns' -import type { SlicWatchSfAlarmsConfig } from './step-functions' +import { getResourceAlarmConfigurationsByType } from '../cf-template' /* * RegEx to filter out invalid CloudFormation Logical ID characters @@ -27,8 +22,6 @@ const LOGICAL_ID_FILTER_REGEX = /[^a-z0-9]/gi */ type SpecificAlarmPropertiesGeneratorFunction = (metric: string, resourceName: string, config: SlicWatchMergedConfig) => Omit -type CommonAlarmsConfigs = SlicWatchAlbAlarmsConfig | SlicWatchDynamoDbAlarmsConfig | SlicWatchEventsAlarmsConfig | SlicWatchSnsAlarmsConfig | SlicWatchSfAlarmsConfig - /** * Create CloudFormation 'AWS::CloudWatch::Alarm' resources based on metrics for a specfic resources type * @@ -43,17 +36,18 @@ type CommonAlarmsConfigs = SlicWatchAlbAlarmsConfig | Sli * @returns An object containing the alarm resources in CloudFormation syntax by logical ID */ export function createCfAlarms ( - type: string, service: string, metrics: string[], config: CommonAlarmsConfigs, alarmActionsConfig: AlarmActionsConfig, + type: string, service: string, metrics: string[], config: SlicWatchMergedConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template, genSpecificAlarmProps: SpecificAlarmPropertiesGeneratorFunction ): CloudFormationResources { const resources: CloudFormationResources = {} - const resourcesOfType = getResourcesByType(type, compiledTemplate) + const resourceConfigs = getResourceAlarmConfigurationsByType(type, compiledTemplate, config) - for (const resourceLogicalId of Object.keys(resourcesOfType)) { + for (const resourceLogicalId of Object.keys(resourceConfigs.resources)) { for (const metric of metrics) { - const { enabled, ...rest } = config[metric] - if (enabled !== false) { - const alarm = genSpecificAlarmProps(metric, resourceLogicalId, rest) + const mergedConfig = resourceConfigs.alarmConfigurations[resourceLogicalId][metric] as SlicWatchMergedConfig + const { enabled, ...rest } = mergedConfig + if (enabled) { + const alarm = genSpecificAlarmProps(metric, resourceLogicalId, mergedConfig) const alarmLogicalId = makeAlarmLogicalId(service, pascal(resourceLogicalId), metric) const resource = createAlarm({ MetricName: metric, @@ -66,6 +60,7 @@ export function createCfAlarms ( } return resources } + /** * Create a CloudFormation Alarm resourc * diff --git a/core/alarms/alarms.ts b/core/alarms/alarms.ts index dcfd4798..193990b2 100644 --- a/core/alarms/alarms.ts +++ b/core/alarms/alarms.ts @@ -2,14 +2,12 @@ import type Resource from 'cloudform-types/types/resource' import type Template from 'cloudform-types/types/template' import { cascade } from '../inputs/cascading-config' -import { applyAlarmConfig } from '../inputs/function-config' import type { - AlarmActionsConfig, InputOutput, + AlarmActionsConfig, SlicWatchAlarmConfig, SlicWatchCascadedAlarmsConfig, SlicWatchMergedConfig } from './alarm-types' -import type { FunctionAlarmProperties } from './lambda' import createLambdaAlarms from './lambda' import createApiGatewayAlarms from './api-gateway' import createStatesAlarms from './step-functions' @@ -34,7 +32,6 @@ import { addResource } from '../cf-template' */ export default function addAlarms ( alarmProperties: SlicWatchCascadedAlarmsConfig, - functionAlarmProperties: FunctionAlarmProperties, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template ) { @@ -50,12 +47,11 @@ export default function addAlarms ( Events: ruleConfig, ApplicationELB: albConfig, ApplicationELBTarget: albTargetConfig, - AppSync: appSyncConfig + AppSync: appSyncConfig, + enabled } = cascade(alarmProperties) as SlicWatchCascadedAlarmsConfig - const cascadedFunctionAlarmProperties = applyAlarmConfig(lambdaConfig, functionAlarmProperties) - - const funcsWithConfig: Array<{ config: SlicWatchAlarmConfig, alarmFunc: any }> = [ + const funcsWithConfig: Array<{ config: SlicWatchMergedConfig, alarmFunc: any }> = [ { config: apiGwConfig, alarmFunc: createApiGatewayAlarms }, { config: sfConfig, alarmFunc: createStatesAlarms }, { config: dynamoDbConfig, alarmFunc: createDynamoDbAlarms }, @@ -66,12 +62,12 @@ export default function addAlarms ( { config: ruleConfig, alarmFunc: createRuleAlarms }, { config: albConfig, alarmFunc: createAlbAlarms }, { config: albTargetConfig, alarmFunc: createAlbTargetAlarms }, - { config: appSyncConfig, alarmFunc: createAppSyncAlarms } + { config: appSyncConfig, alarmFunc: createAppSyncAlarms }, + { config: lambdaConfig, alarmFunc: createLambdaAlarms } ] const resources = {} - if (alarmProperties.enabled) { - Object.assign(resources, createLambdaAlarms(cascadedFunctionAlarmProperties, alarmActionsConfig, compiledTemplate)) + if (enabled) { for (const { config, alarmFunc } of funcsWithConfig) { Object.assign(resources, alarmFunc(config, alarmActionsConfig, compiledTemplate)) } diff --git a/core/alarms/alb-target-group.ts b/core/alarms/alb-target-group.ts index 1031ed28..6f25d4fc 100644 --- a/core/alarms/alb-target-group.ts +++ b/core/alarms/alb-target-group.ts @@ -2,12 +2,12 @@ import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createAlarm, getStatisticName, makeAlarmLogicalId } from './alarm-utils' import type { ResourceType } from '../cf-template' -import { getResourcesByType } from '../cf-template' +import { getResourceAlarmConfigurationsByType, getResourcesByType } from '../cf-template' -export interface SlicWatchAlbTargetAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchAlbTargetAlarmsConfig = T & { HTTPCode_Target_5XX_Count: T UnHealthyHostCount: T LambdaInternalError: T @@ -128,15 +128,16 @@ function createAlbTargetCfAlarm ( export default function createAlbTargetAlarms ( albTargetAlarmsConfig: SlicWatchAlbTargetAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template ): CloudFormationResources { - const targetGroupResources = getResourcesByType('AWS::ElasticLoadBalancingV2::TargetGroup', compiledTemplate) + const resourceConfigs = getResourceAlarmConfigurationsByType('AWS::ElasticLoadBalancingV2::TargetGroup', compiledTemplate, albTargetAlarmsConfig) const resources: CloudFormationResources = {} - for (const [targetGroupResourceName, targetGroupResource] of Object.entries(targetGroupResources)) { - const loadBalancerLogicalIds = findLoadBalancersForTargetGroup(targetGroupResourceName, compiledTemplate) - Object.assign(resources, createAlbTargetCfAlarm(targetGroupResourceName, executionMetrics, loadBalancerLogicalIds, albTargetAlarmsConfig, alarmActionsConfig)) + for (const [targetGroupLogicalId, targetGroupResource] of Object.entries(resourceConfigs.resources)) { + const mergedConfig = resourceConfigs.alarmConfigurations[targetGroupLogicalId] + const loadBalancerLogicalIds = findLoadBalancersForTargetGroup(targetGroupLogicalId, compiledTemplate) + Object.assign(resources, createAlbTargetCfAlarm(targetGroupLogicalId, executionMetrics, loadBalancerLogicalIds, mergedConfig, alarmActionsConfig)) if (targetGroupResource.Properties?.TargetType === 'lambda') { // Create additional alarms for Lambda-specific ALB metrics - Object.assign(resources, createAlbTargetCfAlarm(targetGroupResourceName, executionMetricsLambda, loadBalancerLogicalIds, albTargetAlarmsConfig, alarmActionsConfig)) + Object.assign(resources, createAlbTargetCfAlarm(targetGroupLogicalId, executionMetricsLambda, loadBalancerLogicalIds, mergedConfig, alarmActionsConfig)) } } return resources diff --git a/core/alarms/alb.ts b/core/alarms/alb.ts index a2c5be81..e5fefb47 100644 --- a/core/alarms/alb.ts +++ b/core/alarms/alb.ts @@ -2,10 +2,10 @@ import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createCfAlarms, getStatisticName } from './alarm-utils' -export interface SlicWatchAlbAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchAlbAlarmsConfig = T & { HTTPCode_ELB_5XX_Count: T RejectedConnectionCount: T } diff --git a/core/alarms/api-gateway.ts b/core/alarms/api-gateway.ts index 5d00ac19..592af135 100644 --- a/core/alarms/api-gateway.ts +++ b/core/alarms/api-gateway.ts @@ -3,11 +3,11 @@ import type Resource from 'cloudform-types/types/resource' import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createAlarm, getStatisticName, makeAlarmLogicalId } from './alarm-utils' -import { getResourcesByType } from '../cf-template' +import { getResourceAlarmConfigurationsByType } from '../cf-template' -export interface SlicWatchApiGwAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchApiGwAlarmsConfig = T & { '5XXError': T '4XXError': T Latency: T @@ -83,18 +83,18 @@ export default function createApiGatewayAlarms ( apiGwAlarmsConfig: SlicWatchApiGwAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template ): CloudFormationResources { const resources: CloudFormationResources = {} - const apiResources = getResourcesByType('AWS::ApiGateway::RestApi', compiledTemplate) + const configuredResources = getResourceAlarmConfigurationsByType('AWS::ApiGateway::RestApi', compiledTemplate, apiGwAlarmsConfig) - for (const [apiLogicalId, apiResource] of Object.entries(apiResources)) { + for (const [apiLogicalId, apiResource] of Object.entries(configuredResources.resources)) { for (const metric of executionMetrics) { - const config: SlicWatchMergedConfig = apiGwAlarmsConfig[metric] - if (config.enabled) { - const { enabled, ...rest } = config + const mergedConfig: SlicWatchMergedConfig = configuredResources.alarmConfigurations[apiLogicalId][metric] + if (mergedConfig.enabled) { + const { enabled, ...rest } = mergedConfig const apiName = resolveRestApiNameAsCfn(apiResource, apiLogicalId) const apiNameForSub = resolveRestApiNameForSub(apiResource, apiLogicalId) const apiAlarmProperties: AlarmProperties = { AlarmName: Fn.Sub(`ApiGW_${metric}_${apiNameForSub}`, {}), - AlarmDescription: Fn.Sub(`API Gateway ${metric} ${getStatisticName(config)} for ${apiNameForSub} breaches ${config.Threshold}`, {}), + AlarmDescription: Fn.Sub(`API Gateway ${metric} ${getStatisticName(mergedConfig)} for ${apiNameForSub} breaches ${mergedConfig.Threshold}`, {}), MetricName: metric, Namespace: 'AWS/ApiGateway', Dimensions: [{ Name: 'ApiName', Value: apiName }], diff --git a/core/alarms/appsync.ts b/core/alarms/appsync.ts index c8b1d79e..767141be 100644 --- a/core/alarms/appsync.ts +++ b/core/alarms/appsync.ts @@ -2,11 +2,11 @@ import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createAlarm, getStatisticName, makeAlarmLogicalId } from './alarm-utils' -import { getResourcesByType } from '../cf-template' +import { getResourceAlarmConfigurationsByType } from '../cf-template' -export interface SlicWatchAppSyncAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchAppSyncAlarmsConfig = T & { '5XXError': T Latency: T } @@ -27,13 +27,13 @@ export default function createAppSyncAlarms ( appSyncAlarmsConfig: SlicWatchAppSyncAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template ): CloudFormationResources { const resources = {} - const appSyncResources = getResourcesByType('AWS::AppSync::GraphQLApi', compiledTemplate) + const configuredResources = getResourceAlarmConfigurationsByType('AWS::AppSync::GraphQLApi', compiledTemplate, appSyncAlarmsConfig) - for (const [appSyncLogicalId, appSyncResource] of Object.entries(appSyncResources)) { + for (const [appSyncLogicalId, appSyncResource] of Object.entries(configuredResources.resources)) { for (const metric of executionMetrics) { - const config: SlicWatchMergedConfig = appSyncAlarmsConfig[metric] - if (config.enabled) { - const { enabled, ...rest } = config + const config: SlicWatchMergedConfig = configuredResources.alarmConfigurations[appSyncLogicalId][metric] + const { enabled, ...rest } = config + if (enabled) { const graphQLName: string = appSyncResource.Properties?.Name const appSyncAlarmProperties: AlarmProperties = { AlarmName: `AppSync_${metric}Alarm_${graphQLName}`, diff --git a/core/alarms/dynamodb.ts b/core/alarms/dynamodb.ts index 3a64c1f0..682295f9 100644 --- a/core/alarms/dynamodb.ts +++ b/core/alarms/dynamodb.ts @@ -2,11 +2,11 @@ import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createAlarm, makeAlarmLogicalId } from './alarm-utils' -import { getResourcesByType } from '../cf-template' +import { getResourceAlarmConfigurationsByType } from '../cf-template' -export interface SlicWatchDynamoDbAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchDynamoDbAlarmsConfig = T & { ReadThrottleEvents: T WriteThrottleEvents: T UserErrors: T @@ -27,16 +27,18 @@ const dynamoDbGsiMetrics = ['ReadThrottleEvents', 'WriteThrottleEvents'] * @returns DynamoDB-specific CloudFormation Alarm resources */ export default function createDynamoDbAlarms ( - dynamoDbAlarmsConfig: SlicWatchDynamoDbAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template + dynamoDbAlarmsConfig: SlicWatchDynamoDbAlarmsConfig, + alarmActionsConfig: AlarmActionsConfig, + compiledTemplate: Template ): CloudFormationResources { const resources: CloudFormationResources = {} - const tableResources = getResourcesByType('AWS::DynamoDB::Table', compiledTemplate) + const configuredResources = getResourceAlarmConfigurationsByType('AWS::DynamoDB::Table', compiledTemplate, dynamoDbAlarmsConfig) - for (const [tableLogicalId, tableResource] of Object.entries(tableResources)) { + for (const [tableLogicalId, tableResource] of Object.entries(configuredResources.resources)) { for (const metric of dynamoDbMetrics) { - const config: SlicWatchMergedConfig = dynamoDbAlarmsConfig[metric] - if (config.enabled) { - const { enabled, ...rest } = config + const config: SlicWatchMergedConfig = configuredResources.alarmConfigurations[tableLogicalId][metric] + const { enabled, ...rest } = config + if (enabled) { const dynamoDbAlarmProperties: AlarmProperties = { AlarmName: Fn.Sub(`DDB_${metric}_Alarm_\${${tableLogicalId}}`, {}), AlarmDescription: Fn.Sub(`DynamoDB ${config.Statistic} for \${${tableLogicalId}} breaches ${config.Threshold}`, {}), @@ -51,12 +53,12 @@ export default function createDynamoDbAlarms ( } } for (const metric of dynamoDbGsiMetrics) { - const config: SlicWatchMergedConfig = dynamoDbAlarmsConfig[metric] + const config: SlicWatchDynamoDbAlarmsConfig = configuredResources.alarmConfigurations[tableLogicalId][metric] for (const gsi of tableResource.Properties?.GlobalSecondaryIndexes ?? []) { - if (dynamoDbAlarmsConfig.ReadThrottleEvents.enabled && dynamoDbAlarmsConfig.WriteThrottleEvents.enabled) { - const { enabled, ...rest } = config - const gsiName: string = gsi.IndexName - const gsiIdentifierSub = `\${${tableLogicalId}}${gsiName}` + const gsiName: string = gsi.IndexName + const gsiIdentifierSub = `\${${tableLogicalId}}${gsiName}` + const { enabled, ...rest } = config + if (enabled) { const dynamoDbAlarmsConfig: AlarmProperties = { AlarmName: Fn.Sub(`DDB_${metric}_Alarm_${gsiIdentifierSub}`, {}), AlarmDescription: Fn.Sub(`DynamoDB ${config.Statistic} for ${gsiIdentifierSub} breaches ${config.Threshold}`, {}), diff --git a/core/alarms/ecs.ts b/core/alarms/ecs.ts index 95239fab..38bb8261 100644 --- a/core/alarms/ecs.ts +++ b/core/alarms/ecs.ts @@ -2,11 +2,11 @@ import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createAlarm } from './alarm-utils' -import { getResourcesByType } from '../cf-template' +import { getResourceAlarmConfigurationsByType } from '../cf-template' -export interface SlicWatchEcsAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchEcsAlarmsConfig = T & { MemoryUtilization: T CPUUtilization: T } @@ -49,15 +49,15 @@ export default function createECSAlarms ( ecsAlarmsConfig: SlicWatchEcsAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template ): CloudFormationResources { const resources: CloudFormationResources = {} - const serviceResources = getResourcesByType('AWS::ECS::Service', compiledTemplate) + const configuredResources = getResourceAlarmConfigurationsByType('AWS::ECS::Service', compiledTemplate, ecsAlarmsConfig) - for (const [serviceLogicalId, serviceResource] of Object.entries(serviceResources)) { + for (const [serviceLogicalId, serviceResource] of Object.entries(configuredResources.resources)) { for (const metric of executionMetrics) { const cluster = serviceResource.Properties?.Cluster const clusterName = resolveEcsClusterNameAsCfn(cluster) - const config: SlicWatchMergedConfig = ecsAlarmsConfig[metric] - if (config.enabled) { - const { enabled, ...rest } = config + const config: SlicWatchMergedConfig = configuredResources.alarmConfigurations[serviceLogicalId][metric] + const { enabled, ...rest } = config + if (enabled) { const ecsAlarmProperties: AlarmProperties = { AlarmName: Fn.Sub(`ECS_${metric.replaceAll('Utilization', 'Alarm')}_\${${serviceLogicalId}.Name}`, {}), AlarmDescription: Fn.Sub(`ECS ${metric} for \${${serviceLogicalId}.Name} breaches ${config.Threshold}`, {}), diff --git a/core/alarms/eventbridge.ts b/core/alarms/eventbridge.ts index 91aab2e5..60e73e3e 100644 --- a/core/alarms/eventbridge.ts +++ b/core/alarms/eventbridge.ts @@ -1,10 +1,10 @@ import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createCfAlarms } from './alarm-utils' -export interface SlicWatchEventsAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchEventsAlarmsConfig = T & { FailedInvocations: T ThrottledRules: T } diff --git a/core/alarms/kinesis.ts b/core/alarms/kinesis.ts index 3e36b8f5..a2a6b427 100644 --- a/core/alarms/kinesis.ts +++ b/core/alarms/kinesis.ts @@ -3,11 +3,11 @@ import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' import { pascal } from 'case' -import { getResourcesByType } from '../cf-template' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import { getResourceAlarmConfigurationsByType } from '../cf-template' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createAlarm, getStatisticName, makeAlarmLogicalId } from './alarm-utils' -export interface SlicWatchKinesisAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchKinesisAlarmsConfig = T & { 'GetRecords.IteratorAgeMilliseconds': T ReadProvisionedThroughputExceeded: T WriteProvisionedThroughputExceeded: T @@ -39,13 +39,13 @@ export default function createKinesisAlarms ( kinesisAlarmsConfig: SlicWatchKinesisAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template ): CloudFormationResources { const resources: CloudFormationResources = {} - const streamResources = getResourcesByType('AWS::Kinesis::Stream', compiledTemplate) + const configuredResources = getResourceAlarmConfigurationsByType('AWS::Kinesis::Stream', compiledTemplate, kinesisAlarmsConfig) - for (const [streamLogicalId] of Object.entries(streamResources)) { + for (const [streamLogicalId] of Object.entries(configuredResources.resources)) { for (const [type, metric] of Object.entries(kinesisAlarmTypes)) { - const config: SlicWatchMergedConfig = kinesisAlarmsConfig[metric] - if (config.enabled) { - const { enabled, ...rest } = config + const config: SlicWatchMergedConfig = configuredResources.alarmConfigurations[streamLogicalId][metric] + const { enabled, ...rest } = config + if (enabled) { const kinesisAlarmProperties: AlarmProperties = { AlarmName: Fn.Sub(`Kinesis_${type}_\${${streamLogicalId}}`, {}), AlarmDescription: Fn.Sub(`Kinesis ${getStatisticName(config)} ${metric} for \${${streamLogicalId}} breaches ${config.Threshold} milliseconds`, {}), diff --git a/core/alarms/lambda.ts b/core/alarms/lambda.ts index 14c59f15..08db913c 100644 --- a/core/alarms/lambda.ts +++ b/core/alarms/lambda.ts @@ -2,11 +2,11 @@ import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import { getEventSourceMappingFunctions, getResourcesByType } from '../cf-template' -import type { AlarmActionsConfig, InputOutput, Value, SlicWatchMergedConfig, SlicWatchAlarmConfig } from './alarm-types' +import { getEventSourceMappingFunctions, getResourceAlarmConfigurationsByType } from '../cf-template' +import type { AlarmActionsConfig, InputOutput, Value, SlicWatchMergedConfig } from './alarm-types' import { createAlarm } from './alarm-utils' -export interface SlicWatchLambdaAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchLambdaAlarmsConfig = T & { Errors: T ThrottlesPc: T DurationPc: T @@ -14,19 +14,6 @@ export interface SlicWatchLambdaAlarmsConfig extends Slic IteratorAge: T } -export interface FunctionAlarmProperties { - HelloLambdaFunction?: SlicWatchLambdaAlarmsConfig - ThrottlerLambdaFunction?: SlicWatchLambdaAlarmsConfig - DriveStreamLambdaFunction?: SlicWatchLambdaAlarmsConfig - DriveQueueLambdaFunction?: SlicWatchLambdaAlarmsConfig - DriveTableLambdaFunction?: SlicWatchLambdaAlarmsConfig - StreamProcessorLambdaFunction?: SlicWatchLambdaAlarmsConfig - HttpGetterLambdaFunction?: SlicWatchLambdaAlarmsConfig - SubscriptionHandlerLambdaFunction?: SlicWatchLambdaAlarmsConfig - EventsRuleLambdaFunction?: SlicWatchLambdaAlarmsConfig - AlbEventLambdaFunction?: SlicWatchLambdaAlarmsConfig -} - const lambdaMetrics = ['Errors', 'ThrottlesPc', 'DurationPc', 'Invocations'] /** @@ -38,93 +25,89 @@ const lambdaMetrics = ['Errors', 'ThrottlesPc', 'DurationPc', 'Invocations'] * * @returns Lambda-specific CloudFormation Alarm resources */ -export default function createLambdaAlarms (functionAlarmProperties: SlicWatchLambdaAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template) { +export default function createLambdaAlarms ( + lambdaAlarmConfig: SlicWatchLambdaAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template +) { const resources = {} - const lambdaResources = getResourcesByType('AWS::Lambda::Function', compiledTemplate) - for (const [funcLogicalId, funcResource] of Object.entries(lambdaResources)) { - const config: SlicWatchLambdaAlarmsConfig = functionAlarmProperties[funcLogicalId] + const configuredLambdaResources = getResourceAlarmConfigurationsByType('AWS::Lambda::Function', compiledTemplate, lambdaAlarmConfig) + for (const [funcLogicalId, funcResource] of Object.entries(configuredLambdaResources.resources)) { + const mergedConfig = configuredLambdaResources.alarmConfigurations[funcLogicalId] - if (config === undefined) { - console.warn(`${funcLogicalId} is not found in the template. Alarms will not be created for this function.`) - } else { - for (const metric of lambdaMetrics) { - if (config.enabled === false || config[metric].enabled === false) { - continue - } - if (metric === 'ThrottlesPc') { - const properties = config.ThrottlesPc - properties.Metrics = [ - { - Id: 'throttles_pc', - Expression: '(throttles / ( throttles + invocations )) * 100', - Label: '% Throttles', - ReturnData: true - }, - { - Id: 'throttles', - MetricStat: { - Metric: { - Namespace: 'AWS/Lambda', - MetricName: 'Throttles', - Dimensions: [{ Name: 'FunctionName', Value: Fn.Ref(funcLogicalId) }] - }, - Period: properties.Period as Value, - Stat: properties.Statistic as Value + for (const metric of lambdaMetrics) { + if (!mergedConfig.enabled || mergedConfig[metric].enabled === false) { + continue + } + if (metric === 'ThrottlesPc') { + const properties = mergedConfig.ThrottlesPc + properties.Metrics = [ + { + Id: 'throttles_pc', + Expression: '(throttles / ( throttles + invocations )) * 100', + Label: '% Throttles', + ReturnData: true + }, + { + Id: 'throttles', + MetricStat: { + Metric: { + Namespace: 'AWS/Lambda', + MetricName: 'Throttles', + Dimensions: [{ Name: 'FunctionName', Value: Fn.Ref(funcLogicalId) }] }, - ReturnData: false + Period: properties.Period as Value, + Stat: properties.Statistic as Value }, - { - Id: 'invocations', - MetricStat: { - Metric: { - Namespace: 'AWS/Lambda', - MetricName: 'Invocations', - Dimensions: [{ Name: 'FunctionName', Value: Fn.Ref(funcLogicalId) }] - }, - Period: properties.Period as Value, - Stat: properties.Statistic as Value + ReturnData: false + }, + { + Id: 'invocations', + MetricStat: { + Metric: { + Namespace: 'AWS/Lambda', + MetricName: 'Invocations', + Dimensions: [{ Name: 'FunctionName', Value: Fn.Ref(funcLogicalId) }] }, - ReturnData: false - } - ] - } - if (metric === 'DurationPc') { - const properties = config.DurationPc - const funcTimeout: number = funcResource.Properties?.Timeout ?? 3 - const threshold: Value = properties.Threshold as number - const alarmDescription = Fn.Sub(`Max duration for \${${funcLogicalId}} breaches ${properties.Threshold}% of timeout (${funcTimeout})`, {}) - properties.AlarmDescription = alarmDescription - properties.Threshold = (threshold * funcTimeout * 1000) / 100 - } - if (metric === 'Errors') { - const properties = config.Errors - const alarmDescription = Fn.Sub(`Error count for \${${funcLogicalId}} breaches ${properties.Threshold}`, {}) - properties.AlarmDescription = alarmDescription - } - - if (metric === 'ThrottlesPc') { - const properties = config.ThrottlesPc - const alarmDescription = Fn.Sub(`Throttles % for \${${funcLogicalId}} breaches ${properties.Threshold}`, {}) - properties.AlarmDescription = alarmDescription - } + Period: properties.Period as Value, + Stat: properties.Statistic as Value + }, + ReturnData: false + } + ] + } + if (metric === 'DurationPc') { + const properties = mergedConfig.DurationPc + const funcTimeout: number = funcResource.Properties?.Timeout ?? 3 + const threshold: Value = properties.Threshold as number + const alarmDescription = Fn.Sub(`Max duration for \${${funcLogicalId}} breaches ${properties.Threshold}% of timeout (${funcTimeout})`, {}) + properties.AlarmDescription = alarmDescription + properties.Threshold = (threshold * funcTimeout * 1000) / 100 + } + if (metric === 'Errors') { + const properties = mergedConfig.Errors + const alarmDescription = Fn.Sub(`Error count for \${${funcLogicalId}} breaches ${properties.Threshold}`, {}) + properties.AlarmDescription = alarmDescription + } - if (metric === 'Invocations') { - const properties = config.Invocations - const alarmDescription = Fn.Sub(`Total invocations for \${${funcLogicalId}} breaches ${properties.Threshold}`, {}) - properties.AlarmDescription = alarmDescription - } + if (metric === 'ThrottlesPc') { + const properties = mergedConfig.ThrottlesPc + const alarmDescription = Fn.Sub(`Throttles % for \${${funcLogicalId}} breaches ${properties.Threshold}`, {}) + properties.AlarmDescription = alarmDescription + } - Object.assign(resources, createLambdaCfAlarm(config[metric], metric, funcLogicalId, compiledTemplate, alarmActionsConfig)) + if (metric === 'Invocations') { + const properties = mergedConfig.Invocations + const alarmDescription = Fn.Sub(`Total invocations for \${${funcLogicalId}} breaches ${properties.Threshold}`, {}) + properties.AlarmDescription = alarmDescription } + + Object.assign(resources, createLambdaCfAlarm(mergedConfig[metric], metric, funcLogicalId, compiledTemplate, alarmActionsConfig)) } - for (const funcLogicalId of Object.keys(getEventSourceMappingFunctions(compiledTemplate))) { - const config = functionAlarmProperties[funcLogicalId] - if (config === undefined) { - console.warn(`${funcLogicalId} is not found in the template. Alarms will not be created for this function.`) - } else if (config.enabled !== false && config.IteratorAge.enabled !== false) { - Object.assign(resources, createLambdaCfAlarm(config.IteratorAge, 'IteratorAge', funcLogicalId, compiledTemplate, alarmActionsConfig)) - } + } + for (const funcLogicalId of Object.keys(getEventSourceMappingFunctions(compiledTemplate))) { + const config = configuredLambdaResources.alarmConfigurations[funcLogicalId] + if (config.enabled && config.IteratorAge.enabled) { + Object.assign(resources, createLambdaCfAlarm(config.IteratorAge, 'IteratorAge', funcLogicalId, compiledTemplate, alarmActionsConfig)) } } return resources diff --git a/core/alarms/sns.ts b/core/alarms/sns.ts index 1b38649e..4160a0af 100644 --- a/core/alarms/sns.ts +++ b/core/alarms/sns.ts @@ -1,10 +1,10 @@ import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createCfAlarms } from './alarm-utils' -export interface SlicWatchSnsAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchSnsAlarmsConfig = T & { 'NumberOfNotificationsFilteredOut-InvalidAttributes': T NumberOfNotificationsFailed: T } diff --git a/core/alarms/sqs.ts b/core/alarms/sqs.ts index 86adac6f..c07e46a7 100644 --- a/core/alarms/sqs.ts +++ b/core/alarms/sqs.ts @@ -2,11 +2,11 @@ import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createAlarm } from './alarm-utils' -import { getResourcesByType } from '../cf-template' +import { getResourceAlarmConfigurationsByType } from '../cf-template' -export interface SlicWatchSqsAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchSqsAlarmsConfig = T & { InFlightMessagesPc: T AgeOfOldestMessage: T } @@ -25,20 +25,22 @@ export default function createSQSAlarms ( sqsAlarmsConfig: SlicWatchSqsAlarmsConfig, alarmActionsConfig: AlarmActionsConfig, compiledTemplate: Template ): CloudFormationResources { const resources: CloudFormationResources = {} - const queueResources = getResourcesByType('AWS::SQS::Queue', compiledTemplate) + const configuredResources = getResourceAlarmConfigurationsByType('AWS::SQS::Queue', compiledTemplate, sqsAlarmsConfig) - for (const [queueLogicalId, queueResource] of Object.entries(queueResources)) { - if (sqsAlarmsConfig.enabled === false) continue - if (sqsAlarmsConfig.InFlightMessagesPc.enabled) { - // TODO: verify if there is a way to reference these hard limits directly as variables in the alarm - // so that in case AWS changes them, the rule will still be valid - const config = sqsAlarmsConfig.InFlightMessagesPc - const { enabled, ...rest } = config + for (const [queueLogicalId, queueResource] of Object.entries(configuredResources.resources)) { + const mergedConfig = configuredResources.alarmConfigurations[queueLogicalId] + if (!mergedConfig.enabled) { + continue + } + + const inFlightMessagesPcConfig = mergedConfig.InFlightMessagesPc + if (inFlightMessagesPcConfig.enabled) { + const { enabled, ...rest } = inFlightMessagesPcConfig const hardLimit = (queueResource.Properties?.FifoQueue != null) ? 20000 : 120000 - const thresholdValue = Math.floor(hardLimit * (config.Threshold as any) / 100) + const thresholdValue = Math.floor(hardLimit * (inFlightMessagesPcConfig.Threshold as any) / 100) const sqsAlarmProperties: AlarmProperties = { AlarmName: Fn.Sub(`SQS_ApproximateNumberOfMessagesNotVisible_\${${queueLogicalId}.QueueName}`, {}), - AlarmDescription: Fn.Sub(`SQS in-flight messages for \${${queueLogicalId}.QueueName} breaches ${thresholdValue} (${config.Threshold}% of the hard limit of ${hardLimit})`, {}), + AlarmDescription: Fn.Sub(`SQS in-flight messages for \${${queueLogicalId}.QueueName} breaches ${thresholdValue} (${inFlightMessagesPcConfig.Threshold}% of the hard limit of ${hardLimit})`, {}), MetricName: 'ApproximateNumberOfMessagesNotVisible', Namespace: 'AWS/SQS', Dimensions: [{ Name: 'QueueName', Value: Fn.GetAtt(`${queueLogicalId}`, 'QueueName') }], @@ -50,16 +52,16 @@ export default function createSQSAlarms ( resources[resourceName] = resource } - if (sqsAlarmsConfig.AgeOfOldestMessage.enabled) { - if (sqsAlarmsConfig.AgeOfOldestMessage.Threshold == null) { + const ageOfOldestMessageConfig = mergedConfig.AgeOfOldestMessage + if (ageOfOldestMessageConfig.enabled) { + if (ageOfOldestMessageConfig.Threshold == null) { throw new Error('SQS AgeOfOldestMessage alarm is enabled but `Threshold` is not specified. Please specify a threshold or disable the alarm.') } - const config = sqsAlarmsConfig.AgeOfOldestMessage - const { enabled, ...rest } = config + const { enabled, ...rest } = ageOfOldestMessageConfig const alarmProps = rest as AlarmProperties // All mandatory properties are set following cascading const sqsAlarmProperties: AlarmProperties = { AlarmName: Fn.Sub(`SQS_ApproximateAgeOfOldestMessage_\${${queueLogicalId}.QueueName}`, {}), - AlarmDescription: Fn.Sub(`SQS age of oldest message in the queue \${${queueLogicalId}.QueueName} breaches ${config.Threshold}`, {}), + AlarmDescription: Fn.Sub(`SQS age of oldest message in the queue \${${queueLogicalId}.QueueName} breaches ${ageOfOldestMessageConfig.Threshold as number}`, {}), MetricName: 'ApproximateAgeOfOldestMessage', Namespace: 'AWS/SQS', Dimensions: [{ Name: 'QueueName', Value: Fn.GetAtt(`${queueLogicalId}`, 'QueueName') }], diff --git a/core/alarms/step-functions.ts b/core/alarms/step-functions.ts index dae2b12c..1f775531 100644 --- a/core/alarms/step-functions.ts +++ b/core/alarms/step-functions.ts @@ -1,10 +1,10 @@ import type Template from 'cloudform-types/types/template' import { Fn } from 'cloudform' -import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchAlarmConfig, SlicWatchMergedConfig } from './alarm-types' +import type { AlarmActionsConfig, CloudFormationResources, InputOutput, SlicWatchMergedConfig } from './alarm-types' import { createCfAlarms } from './alarm-utils' -export interface SlicWatchSfAlarmsConfig extends SlicWatchAlarmConfig { +export type SlicWatchSfAlarmsConfig = T & { ExecutionThrottled: T ExecutionsFailed: T ExecutionsTimedOut: T diff --git a/core/alarms/tests/alarms.test.ts b/core/alarms/tests/alarms.test.ts index 00660688..e44f54dd 100644 --- a/core/alarms/tests/alarms.test.ts +++ b/core/alarms/tests/alarms.test.ts @@ -7,11 +7,7 @@ import { getResourcesByType } from '../../cf-template' test('Alarms create all service alarms', (t) => { const compiledTemplate = createTestCloudFormationTemplate() - const funcAlarmPropertiess = {} - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - funcAlarmPropertiess[funcLogicalId] = {} - } - addAlarms(defaultConfig.alarms, funcAlarmPropertiess, testAlarmActionsConfig, compiledTemplate) + addAlarms(defaultConfig.alarms, testAlarmActionsConfig, compiledTemplate) const namespaces = new Set() for (const resource of Object.values( getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) @@ -26,11 +22,7 @@ test('Alarms create all service alarms', (t) => { test('Alarms create all ALB service alarms', (t) => { const compiledTemplate = createTestCloudFormationTemplate(albCfTemplate) - const funcAlarmPropertiess = {} - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - funcAlarmPropertiess[funcLogicalId] = {} - } - addAlarms(defaultConfig.alarms, funcAlarmPropertiess, testAlarmActionsConfig, compiledTemplate) + addAlarms(defaultConfig.alarms, testAlarmActionsConfig, compiledTemplate) const namespaces = new Set() for (const resource of Object.values( getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) @@ -51,11 +43,7 @@ test('Alarms are not created when disabled globally', (t) => { } ) const compiledTemplate = createTestCloudFormationTemplate() - const funcAlarmPropertiess = {} - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - funcAlarmPropertiess[funcLogicalId] = {} - } - addAlarms(config, funcAlarmPropertiess, testAlarmActionsConfig, compiledTemplate) + addAlarms(config, testAlarmActionsConfig, compiledTemplate) const alarmsCreated = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) diff --git a/core/alarms/tests/alb-target-group.test.ts b/core/alarms/tests/alb-target-group.test.ts index dc549bf7..7a081098 100644 --- a/core/alarms/tests/alb-target-group.test.ts +++ b/core/alarms/tests/alb-target-group.test.ts @@ -1,5 +1,7 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' + import createAlbTargetAlarms, { findLoadBalancersForTargetGroup } from '../alb-target-group' import { defaultConfig } from '../../inputs/default-config' import { @@ -188,7 +190,7 @@ test('findLoadBalancersForTargetGroup', (t) => { }) test('ALB Target Group alarms are created', (t) => { - const AlarmPropertiesTargetGroup = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -210,13 +212,12 @@ test('ALB Target Group alarms are created', (t) => { } } } - ) - const albAlarmProperties = AlarmPropertiesTargetGroup.ApplicationELBTarget + const albAlarmConfig = testConfig.ApplicationELBTarget const compiledTemplate = createTestCloudFormationTemplate(albCfTemplate) - const targetGroupAlarmResources: ResourceType = createAlbTargetAlarms(albAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const targetGroupAlarmResources: ResourceType = createAlbTargetAlarms(albAlarmConfig, testAlarmActionsConfig, compiledTemplate) const expectedTypesTargetGroup = { LoadBalancer_HTTPCodeTarget5XXCountAlarm: 'HTTPCode_Target_5XX_Count', @@ -227,13 +228,13 @@ test('ALB Target Group alarms are created', (t) => { t.equal(Object.keys(targetGroupAlarmResources).length, Object.keys(expectedTypesTargetGroup).length) for (const alarmResource of Object.values(targetGroupAlarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) const expectedMetric = expectedTypesTargetGroup[alarmType] t.equal(al?.MetricName, expectedMetric) t.ok(al?.Statistic) - t.equal(al?.Threshold, albAlarmProperties[expectedMetric].Threshold) + t.equal(al?.Threshold, albAlarmConfig[expectedMetric].Threshold) t.equal(al?.EvaluationPeriods, 2) t.equal(al?.TreatMissingData, 'breaching') t.equal(al?.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -266,8 +267,45 @@ test('ALB Target Group alarms are created', (t) => { t.end() }) +test('ALB resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(albCfTemplate); + (template.Resources as ResourceType).AlbEventAlbTargetGrouphttpListener.Metadata = { + slicWatch: { + alarms: { + Period: 900, + HTTPCode_Target_5XX_Count: { + Threshold: 55 + }, + UnHealthyHostCount: { + Threshold: 56 + }, + LambdaInternalError: { + Threshold: 57 + }, + LambdaUserError: { + Threshold: 58, + enabled: false + } + } + } + } + + const targetGroupAlarmResources = createAlbTargetAlarms(testConfig.ApplicationELBTarget, testAlarmActionsConfig, template) + t.same(Object.keys(targetGroupAlarmResources).length, 3) + + const code5xxAlarm = Object.values(targetGroupAlarmResources).filter(a => a?.Properties?.MetricName === 'HTTPCode_Target_5XX_Count')[0] + const unHealthyHostCountAlarm = Object.values(targetGroupAlarmResources).filter(a => a?.Properties?.MetricName === 'UnHealthyHostCount')[0] + const lambdaInternalErrorAlarm = Object.values(targetGroupAlarmResources).filter(a => a?.Properties?.MetricName === 'LambdaInternalError')[0] + + t.equal(code5xxAlarm?.Properties?.Threshold, 55) + t.equal(unHealthyHostCountAlarm?.Properties?.Threshold, 56) + t.equal(lambdaInternalErrorAlarm?.Properties?.Threshold, 57) + t.end() +}) + test('ALB alarms are not created when disabled globally', (t) => { - const AlarmPropertiesTargetGroup = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { ApplicationELBTarget: { @@ -289,9 +327,9 @@ test('ALB alarms are not created when disabled globally', (t) => { } ) - const albAlarmProperties = AlarmPropertiesTargetGroup.ApplicationELBTarget + const albAlarmConfig = testConfig.ApplicationELBTarget const compiledTemplate = createTestCloudFormationTemplate(albCfTemplate) - const targetGroupAlarmResources = createAlbTargetAlarms(albAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const targetGroupAlarmResources = createAlbTargetAlarms(albAlarmConfig, testAlarmActionsConfig, compiledTemplate) t.same({}, targetGroupAlarmResources) t.end() diff --git a/core/alarms/tests/alb.test.ts b/core/alarms/tests/alb.test.ts index 056b9e6e..68624339 100644 --- a/core/alarms/tests/alb.test.ts +++ b/core/alarms/tests/alb.test.ts @@ -1,7 +1,8 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' + import createAlbAlarms from '../alb' -import type { SlicWatchAlbAlarmsConfig } from '../alb' import defaultConfig from '../../inputs/default-config' import { assertCommonAlarmProperties, @@ -12,10 +13,9 @@ import { testAlarmActionsConfig } from '../../tests/testing-utils' import type { ResourceType } from '../../cf-template' -import type { SlicWatchMergedConfig } from '../alarm-types' test('ALB alarms are created', (t) => { - const AlarmPropertiesELB = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -33,11 +33,8 @@ test('ALB alarms are created', (t) => { } ) - function createAlarmResources (elbAlarmProperties: SlicWatchAlbAlarmsConfig) { - const compiledTemplate = createTestCloudFormationTemplate(albCfTemplate) - return createAlbAlarms(elbAlarmProperties, testAlarmActionsConfig, compiledTemplate) - } - const albAlarmResources: ResourceType = createAlarmResources(AlarmPropertiesELB.ApplicationELB) + const compiledTemplate = createTestCloudFormationTemplate(albCfTemplate) + const albAlarmResources: ResourceType = createAlbAlarms(testConfig.ApplicationELB, testAlarmActionsConfig, compiledTemplate) const expectedTypesELB = { LoadBalancer_HTTPCodeELB5XXCountAlarm: 'HTTPCode_ELB_5XX_Count', @@ -46,13 +43,13 @@ test('ALB alarms are created', (t) => { t.equal(Object.keys(albAlarmResources).length, Object.keys(expectedTypesELB).length) for (const alarmResource of Object.values(albAlarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) const expectedMetric = expectedTypesELB[alarmType] t.equal(al?.MetricName, expectedMetric) t.ok(al?.Statistic) - t.equal(al?.Threshold, AlarmPropertiesELB.ApplicationELB[expectedMetric].Threshold) + t.equal(al?.Threshold, testConfig.ApplicationELB[expectedMetric].Threshold) t.equal(al?.EvaluationPeriods, 2) t.equal(al?.TreatMissingData, 'breaching') t.equal(al?.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -75,8 +72,36 @@ test('ALB alarms are created', (t) => { t.end() }) +test('ALB resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(albCfTemplate); + (template.Resources as ResourceType).alb.Metadata = { + slicWatch: { + alarms: { + Period: 900, + HTTPCode_ELB_5XX_Count: { + Threshold: 51, + enabled: false + }, + RejectedConnectionCount: { + Threshold: 52 + } + } + } + } + + const albAlarmResources: ResourceType = createAlbAlarms(testConfig.ApplicationELB, testAlarmActionsConfig, template) + + t.same(Object.keys(albAlarmResources).length, 1) + + const rejectedConnectionAlarm = Object.values(albAlarmResources).filter(a => a?.Properties?.MetricName === 'RejectedConnectionCount')[0] + + t.equal(rejectedConnectionAlarm?.Properties?.Threshold, 52) + t.end() +}) + test('ALB alarms are not created when disabled globally', (t) => { - const AlarmPropertiesELB = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { ApplicationELB: { @@ -96,7 +121,7 @@ test('ALB alarms are not created when disabled globally', (t) => { const compiledTemplate = createTestCloudFormationTemplate(albCfTemplate) return createAlbAlarms(elbAlarmProperties, testAlarmActionsConfig, compiledTemplate) } - const albAlarmResources = createAlarmResources(AlarmPropertiesELB.ApplicationELB) + const albAlarmResources = createAlarmResources(testConfig.ApplicationELB) t.same({}, albAlarmResources) t.end() diff --git a/core/alarms/tests/api-gateway.test.ts b/core/alarms/tests/api-gateway.test.ts index bb349342..311beac7 100644 --- a/core/alarms/tests/api-gateway.test.ts +++ b/core/alarms/tests/api-gateway.test.ts @@ -1,5 +1,7 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' + import createApiGatewayAlarms, { resolveRestApiNameAsCfn, resolveRestApiNameForSub } from '../api-gateway' import defaultConfig from '../../inputs/default-config' import { @@ -90,7 +92,7 @@ test('resolveRestApiNameForSub', (t) => { }) test('API Gateway alarms are created', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -110,17 +112,17 @@ test('API Gateway alarms are created', (t) => { } } ) - const apiGwAlarmProperties = AlarmProperties.ApiGateway + const apiGwAlarmConfig = testConfig.ApiGateway t.test('with full template', (t) => { const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources: ResourceType = createApiGatewayAlarms(apiGwAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createApiGatewayAlarms(apiGwAlarmConfig, testAlarmActionsConfig, compiledTemplate) const alarmsByType: AlarmsByType = {} t.equal(Object.keys(alarmResources).length, 3) for (const alarmResource of Object.values(alarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) alarmsByType[alarmType] = (alarmsByType[alarmType] === true) || new Set() @@ -136,7 +138,7 @@ test('API Gateway alarms are created', (t) => { for (const al of alarmsByType.ApiGW_5XXError) { t.equal(al.MetricName, '5XXError') t.equal(al.Statistic, 'Average') - t.equal(al.Threshold, apiGwAlarmProperties['5XXError'].Threshold) + t.equal(al.Threshold, apiGwAlarmConfig['5XXError'].Threshold) t.equal(al.EvaluationPeriods, 2) t.equal(al.TreatMissingData, 'breaching') t.equal(al.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -153,7 +155,7 @@ test('API Gateway alarms are created', (t) => { for (const al of alarmsByType.ApiGW_4XXError) { t.equal(al.MetricName, '4XXError') t.equal(al.Statistic, 'Average') - t.equal(al.Threshold, apiGwAlarmProperties['4XXError'].Threshold) + t.equal(al.Threshold, apiGwAlarmConfig['4XXError'].Threshold) t.equal(al.EvaluationPeriods, 2) t.equal(al.TreatMissingData, 'breaching') t.equal(al.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -170,7 +172,7 @@ test('API Gateway alarms are created', (t) => { for (const al of alarmsByType.ApiGW_Latency) { t.equal(al.MetricName, 'Latency') t.equal(al.ExtendedStatistic, 'p99') - t.equal(al.Threshold, apiGwAlarmProperties.Latency.Threshold) + t.equal(al.Threshold, apiGwAlarmConfig.Latency.Threshold) t.equal(al.EvaluationPeriods, 2) t.equal(al.TreatMissingData, 'breaching') t.equal(al.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -188,7 +190,7 @@ test('API Gateway alarms are created', (t) => { }) t.test('API Gateway alarms are not created when disabled globally', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { ApiGateway: { @@ -206,10 +208,10 @@ test('API Gateway alarms are created', (t) => { } } ) - const apiGwAlarmProperties = AlarmProperties.ApiGateway + const apiGwAlarmConfig = testConfig.ApiGateway const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources = createApiGatewayAlarms(apiGwAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources = createApiGatewayAlarms(apiGwAlarmConfig, testAlarmActionsConfig, compiledTemplate) t.same({}, alarmResources) t.end() @@ -226,7 +228,7 @@ test('API Gateway alarms are created', (t) => { } } }) - const alarmResources: ResourceType = createApiGatewayAlarms(apiGwAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createApiGatewayAlarms(apiGwAlarmConfig, testAlarmActionsConfig, compiledTemplate) t.same(Object.keys(alarmResources).sort(), [ 'slicWatchApi4XXErrorAlarmAWSStackName', 'slicWatchApi5XXErrorAlarmAWSStackName', @@ -237,8 +239,44 @@ test('API Gateway alarms are created', (t) => { t.end() }) +test('API Gateway resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(); + + (template.Resources as ResourceType).ApiGatewayRestApi.Metadata = { + slicWatch: { + alarms: { + Period: 900, + '5XXError': { + enabled: true, + Threshold: 9.9 + }, + '4XXError': { + enabled: false, + Threshold: 0.05 + }, + Latency: { + Threshold: 4321 + } + } + } + } + + const alarmResources = createApiGatewayAlarms(testConfig.ApiGateway, testAlarmActionsConfig, template) + t.same(Object.keys(alarmResources).length, 2) + + const code5xxAlarm = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === '5XXError')[0] + const latencyAlarm = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'Latency')[0] + + t.equal(code5xxAlarm?.Properties?.Threshold, 9.9) + t.equal(code5xxAlarm?.Properties?.Period, 900) + t.equal(latencyAlarm?.Properties?.Threshold, 4321) + t.equal(latencyAlarm?.Properties?.Period, 900) + t.end() +}) + test('API Gateway alarms are not created when disabled individually', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { ApiGateway: { @@ -259,10 +297,10 @@ test('API Gateway alarms are not created when disabled individually', (t) => { } } ) - const apiGwAlarmProperties = AlarmProperties.ApiGateway + const apiGwAlarmConfig = testConfig.ApiGateway const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources = createApiGatewayAlarms(apiGwAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources = createApiGatewayAlarms(apiGwAlarmConfig, testAlarmActionsConfig, compiledTemplate) t.same({}, alarmResources) t.end() }) diff --git a/core/alarms/tests/appsync.test.ts b/core/alarms/tests/appsync.test.ts index b0b99a83..7cead357 100644 --- a/core/alarms/tests/appsync.test.ts +++ b/core/alarms/tests/appsync.test.ts @@ -1,7 +1,7 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import createAppSyncAlarms from '../appsync' -import type { SlicWatchAppSyncAlarmsConfig } from '../appsync' import defaultConfig from '../../inputs/default-config' import { assertCommonAlarmProperties, @@ -12,10 +12,9 @@ import { testAlarmActionsConfig } from '../../tests/testing-utils' import type { ResourceType } from '../../cf-template' -import type { SlicWatchMergedConfig } from '../alarm-types' test('AppSync alarms are created', (t) => { - const AlarmPropertiesAppSync = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -31,13 +30,10 @@ test('AppSync alarms are created', (t) => { } } } - ) - function createAlarmResources (appSyncAlarmProperties: SlicWatchAppSyncAlarmsConfig) { - const compiledTemplate = createTestCloudFormationTemplate(appSyncCfTemplate) - return createAppSyncAlarms(appSyncAlarmProperties, testAlarmActionsConfig, compiledTemplate) - } - const appSyncAlarmResources: ResourceType = createAlarmResources(AlarmPropertiesAppSync.AppSync) + + const compiledTemplate = createTestCloudFormationTemplate(appSyncCfTemplate) + const appSyncAlarmResources: ResourceType = createAppSyncAlarms(testConfig.AppSync, testAlarmActionsConfig, compiledTemplate) const expectedTypesAppSync = { AppSync_5XXErrorAlarm: '5XXError', @@ -46,13 +42,13 @@ test('AppSync alarms are created', (t) => { t.equal(Object.keys(appSyncAlarmResources).length, Object.keys(expectedTypesAppSync).length) for (const alarmResource of Object.values(appSyncAlarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) const expectedMetric = expectedTypesAppSync[alarmType] t.equal(al?.MetricName, expectedMetric) t.ok(al?.Statistic) - t.equal(al?.Threshold, AlarmPropertiesAppSync.AppSync[expectedMetric].Threshold) + t.equal(al?.Threshold, testConfig.AppSync[expectedMetric].Threshold) t.equal(al?.EvaluationPeriods, 2) t.equal(al?.TreatMissingData, 'breaching') t.equal(al?.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -75,8 +71,37 @@ test('AppSync alarms are created', (t) => { t.end() }) +test('AppSync resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(appSyncCfTemplate); + + (template.Resources as ResourceType).AwesomeappsyncGraphQlApi.Metadata = { + slicWatch: { + alarms: { + Period: 900, + '5XXError': { + enabled: false, + Threshold: 9.9 + }, + Latency: { + Threshold: 4321 + } + } + } + } + + const alarmResources: ResourceType = createAppSyncAlarms(testConfig.AppSync, testAlarmActionsConfig, template) + t.same(Object.keys(alarmResources).length, 1) + + const latencyAlarm = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'Latency')[0] + + t.equal(latencyAlarm?.Properties?.Threshold, 4321) + t.equal(latencyAlarm?.Properties?.Period, 900) + t.end() +}) + test('AppSync alarms are not created when disabled globally', (t) => { - const AlarmPropertiesAppSync = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { AppSync: { @@ -92,11 +117,8 @@ test('AppSync alarms are not created when disabled globally', (t) => { } ) - function createAlarmResources (appSyncAlarmProperties) { - const compiledTemplate = createTestCloudFormationTemplate(appSyncCfTemplate) - return createAppSyncAlarms(appSyncAlarmProperties, testAlarmActionsConfig, compiledTemplate) - } - const appSyncAlarmResources = createAlarmResources(AlarmPropertiesAppSync.AppSync) + const compiledTemplate = createTestCloudFormationTemplate(appSyncCfTemplate) + const appSyncAlarmResources: ResourceType = createAppSyncAlarms(testConfig.AppSync, testAlarmActionsConfig, compiledTemplate) t.same({}, appSyncAlarmResources) t.end() diff --git a/core/alarms/tests/dynamodb.test.ts b/core/alarms/tests/dynamodb.test.ts index efae3ac6..c4631c9f 100644 --- a/core/alarms/tests/dynamodb.test.ts +++ b/core/alarms/tests/dynamodb.test.ts @@ -1,8 +1,9 @@ import { test } from 'tap' import _ from 'lodash' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import createDynamoDbAlarms from '../dynamodb' -import { addResource, getResourcesByType } from '../../cf-template' +import { type ResourceType, addResource, getResourcesByType } from '../../cf-template' import defaultConfig from '../../inputs/default-config' import { assertCommonAlarmProperties, @@ -13,7 +14,7 @@ import { defaultCfTemplate } from '../../tests/testing-utils' -const AlarmProperties = createTestConfig( +const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, EvaluationPeriods: 2, @@ -34,13 +35,15 @@ const AlarmProperties = createTestConfig( Threshold: 200 } } - }) -const dynamoDbAlarmProperties = AlarmProperties.DynamoDB + } +) + +const dynamoDbAlarmConfig = testConfig.DynamoDB ;[true, false].forEach(specifyTableName => { test(`DynamoDB alarms are created ${specifyTableName ? 'with' : 'without'} a table name property`, (t) => { const compiledTemplate = createTestCloudFormationTemplate() - const resources = createDynamoDbAlarms(dynamoDbAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const resources = createDynamoDbAlarms(dynamoDbAlarmConfig, testAlarmActionsConfig, compiledTemplate) for (const resourceName in resources) { addResource(resourceName, resources[resourceName], compiledTemplate) @@ -55,7 +58,7 @@ const dynamoDbAlarmProperties = AlarmProperties.DynamoDB const alarmsByType = {} t.equal(Object.keys(alarmResources).length, 6) for (const alarmResource of Object.values(alarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) alarmsByType[alarmType] = alarmsByType[alarmType] ?? new Set() @@ -76,7 +79,7 @@ const dynamoDbAlarmProperties = AlarmProperties.DynamoDB for (const al of alarmsByType[type]) { t.equal(al.Statistic, 'Sum') const metric = type.split('_')[1] - t.equal(al.Threshold, dynamoDbAlarmProperties[metric].Threshold) + t.equal(al.Threshold, dynamoDbAlarmConfig[metric].Threshold) t.equal(al.EvaluationPeriods, 2) t.equal(al.TreatMissingData, 'breaching') t.equal(al.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -97,11 +100,55 @@ const dynamoDbAlarmProperties = AlarmProperties.DynamoDB }) }) +test('Table resource configuration overrides take precedence', (t) => { + const template = createTestCloudFormationTemplate(); + + (template.Resources as ResourceType).dataTable.Metadata = { + slicWatch: { + alarms: { + Period: 900, + ReadThrottleEvents: { + Threshold: 11 + }, + WriteThrottleEvents: { + Threshold: 12 + }, + UserErrors: { + Threshold: 13, + enabled: false + }, + SystemErrors: { + Threshold: 14 + } + } + } + } + + const alarmResources = createDynamoDbAlarms(dynamoDbAlarmConfig, testAlarmActionsConfig, template) + t.same(Object.keys(alarmResources).length, 5) // 3 for the table, 2 for the GSI + + const readThrottleAlarms = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'ReadThrottleEvents') + const writeThrottleAlarms = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'WriteThrottleEvents') + const systemErrorsAlarm = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'SystemErrors')[0] + + for (const alarm of readThrottleAlarms) { + t.equal(alarm?.Properties?.Threshold, 11) + t.equal(alarm?.Properties?.Period, 900) + } + for (const alarm of writeThrottleAlarms) { + t.equal(alarm?.Properties?.Threshold, 12) + t.equal(alarm?.Properties?.Period, 900) + } + t.equal(systemErrorsAlarm?.Properties?.Threshold, 14) + t.equal(systemErrorsAlarm?.Properties?.Period, 900) + t.end() +}) + test('DynamoDB alarms are created without GSI', (t) => { const compiledTemplate = createTestCloudFormationTemplate() _.cloneDeep(defaultCfTemplate) delete compiledTemplate.Resources?.dataTable.Properties?.GlobalSecondaryIndexes - const resources = createDynamoDbAlarms(dynamoDbAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const resources = createDynamoDbAlarms(dynamoDbAlarmConfig, testAlarmActionsConfig, compiledTemplate) for (const resourceName in resources) { addResource(resourceName, resources[resourceName], compiledTemplate) } @@ -112,15 +159,15 @@ test('DynamoDB alarms are created without GSI', (t) => { }) test('DynamoDB alarms are not created when disabled', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const testConfig = createTestConfig(defaultConfig.alarms, { DynamoDB: { enabled: false } }) - const dynamoDbAlarmProperties = AlarmProperties.DynamoDB + const dynamoDbAlarmConfig = testConfig.DynamoDB const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources = createDynamoDbAlarms(dynamoDbAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources = createDynamoDbAlarms(dynamoDbAlarmConfig, testAlarmActionsConfig, compiledTemplate) t.same({}, alarmResources) t.end() diff --git a/core/alarms/tests/ecs.test.ts b/core/alarms/tests/ecs.test.ts index a5de1f81..98b23249 100644 --- a/core/alarms/tests/ecs.test.ts +++ b/core/alarms/tests/ecs.test.ts @@ -1,5 +1,7 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' + import createECSAlarms, { resolveEcsClusterNameAsCfn } from '../ecs' import defaultConfig from '../../inputs/default-config' import { @@ -31,7 +33,7 @@ test('resolveEcsClusterNameAsCfn', (t) => { }) test('ECS MemoryUtilization is created', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -48,10 +50,9 @@ test('ECS MemoryUtilization is created', (t) => { } } ) - const ecsAlarmProperties = AlarmProperties.ECS const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources: ResourceType = createECSAlarms(ecsAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createECSAlarms(testConfig.ECS, testAlarmActionsConfig, compiledTemplate) const expectedTypes = { ECS_MemoryAlarm: 'MemoryUtilization', @@ -60,13 +61,13 @@ test('ECS MemoryUtilization is created', (t) => { t.equal(Object.keys(alarmResources).length, Object.keys(expectedTypes).length) for (const alarmResource of Object.values(alarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) const expectedMetric = expectedTypes[alarmType] t.equal(al?.MetricName, expectedMetric) t.ok(al?.Statistic) - t.equal(al?.Threshold, ecsAlarmProperties[expectedMetric].Threshold) + t.equal(al?.Threshold, testConfig.ECS[expectedMetric].Threshold) t.equal(al?.EvaluationPeriods, 2) t.equal(al?.TreatMissingData, 'breaching') t.equal(al?.ComparisonOperator, 'LessThanThreshold') @@ -93,8 +94,37 @@ test('ECS MemoryUtilization is created', (t) => { t.end() }) +test('ECS resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(); + + (template.Resources as ResourceType).ecsService.Metadata = { + slicWatch: { + alarms: { + Period: 900, + MemoryUtilization: { + Threshold: 51, + enabled: false + }, + CPUUtilization: { + Threshold: 52 + } + } + } + } + + const alarmResources: ResourceType = createECSAlarms(testConfig.ECS, testAlarmActionsConfig, template) + t.same(Object.keys(alarmResources).length, 1) + + const cpuAlarm = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'CPUUtilization')[0] + + t.equal(cpuAlarm?.Properties?.Threshold, 52) + t.equal(cpuAlarm?.Properties?.Period, 900) + t.end() +}) + test('ECS alarms are not created when disabled globally', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { ECS: { @@ -109,10 +139,10 @@ test('ECS alarms are not created when disabled globally', (t) => { } } ) - const ecsAlarmProperties = AlarmProperties.ECS + const ecsAlarmConfig = testConfig.ECS const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources = createECSAlarms(ecsAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources = createECSAlarms(ecsAlarmConfig, testAlarmActionsConfig, compiledTemplate) t.same({}, alarmResources) t.end() diff --git a/core/alarms/tests/eventbridge.test.ts b/core/alarms/tests/eventbridge.test.ts index 06f82bd9..38f8ee28 100644 --- a/core/alarms/tests/eventbridge.test.ts +++ b/core/alarms/tests/eventbridge.test.ts @@ -1,5 +1,7 @@ import { test } from 'tap' +import type { AlarmProperties, Dimension } from 'cloudform-types/types/cloudWatch/alarm' + import createRuleAlarms from '../eventbridge' import { getResourcesByType } from '../../cf-template' import type { ResourceType } from '../../cf-template' @@ -13,7 +15,7 @@ import { } from '../../tests/testing-utils' test('Events alarms are created', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -30,9 +32,9 @@ test('Events alarms are created', (t) => { } } ) - const ruleAlarmProperties = AlarmProperties.Events + const ruleAlarmConfig = testConfig.Events const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources: ResourceType = createRuleAlarms(ruleAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createRuleAlarms(ruleAlarmConfig, testAlarmActionsConfig, compiledTemplate) const expectedTypes = { Events_FailedInvocations_Alarm: 'FailedInvocations', @@ -41,28 +43,59 @@ test('Events alarms are created', (t) => { t.equal(Object.keys(alarmResources).length, Object.keys(expectedTypes).length) for (const alarmResource of Object.values(alarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) const expectedMetric = expectedTypes[alarmType] t.equal(al?.MetricName, expectedMetric) t.ok(al?.Statistic) - t.equal(al?.Threshold, ruleAlarmProperties[expectedMetric].Threshold) + t.equal(al?.Threshold, ruleAlarmConfig[expectedMetric].Threshold) t.equal(al?.EvaluationPeriods, 2) t.equal(al?.TreatMissingData, 'breaching') t.equal(al?.ComparisonOperator, 'GreaterThanOrEqualToThreshold') t.equal(al?.Namespace, 'AWS/Events') t.equal(al?.Period, 120) - t.equal(al?.Dimensions.length, 1) - t.equal(al?.Dimensions[0].Name, 'RuleName') - t.ok(al?.Dimensions[0].Value) + const dims = al?.Dimensions as Dimension[] + t.equal(dims.length, 1) + const [dim] = dims + t.equal(dim.Name, 'RuleName') + t.ok(dim.Value) } t.end() }) +test('EventBridge Rule resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(); + + (template.Resources as ResourceType).ServerlesstestprojectdeveventsRulerule1EventBridgeRule.Metadata = { + slicWatch: { + alarms: { + Period: 900, + FailedInvocations: { + Threshold: 59 + }, + ThrottledRules: { + Threshold: 58, + enabled: false + } + } + } + } + + const alarmResources: ResourceType = createRuleAlarms(testConfig.Events, testAlarmActionsConfig, template) + t.same(Object.keys(alarmResources).length, 1) + + const failedInvocationsAlarm = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'FailedInvocations')[0] + + t.equal(failedInvocationsAlarm?.Properties?.Threshold, 59) + t.equal(failedInvocationsAlarm?.Properties?.Period, 900) + t.end() +}) + test('Events alarms are not created when disabled globally', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Events: { @@ -77,9 +110,9 @@ test('Events alarms are not created when disabled globally', (t) => { } } ) - const ruleAlarmProperties = AlarmProperties.Events + const ruleAlarmConfig = testConfig.Events const compiledTemplate = createTestCloudFormationTemplate() - createRuleAlarms(ruleAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createRuleAlarms(ruleAlarmConfig, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) diff --git a/core/alarms/tests/kinesis.test.ts b/core/alarms/tests/kinesis.test.ts index 0d2ca424..14a997e2 100644 --- a/core/alarms/tests/kinesis.test.ts +++ b/core/alarms/tests/kinesis.test.ts @@ -1,5 +1,8 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' +import { type Template } from 'cloudform' + import createKinesisAlarms from '../kinesis' import { getResourcesByType } from '../../cf-template' import type { ResourceType } from '../../cf-template' @@ -14,7 +17,7 @@ import { } from '../../tests/testing-utils' test('Kinesis data stream alarms are created', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -28,9 +31,9 @@ test('Kinesis data stream alarms are created', (t) => { } } ) - const kinesisAlarmProperties = AlarmProperties.Kinesis + const kinesisAlarmConfig = testConfig.Kinesis const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources: ResourceType = createKinesisAlarms(kinesisAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createKinesisAlarms(kinesisAlarmConfig, testAlarmActionsConfig, compiledTemplate) const expectedTypes = { Kinesis_StreamIteratorAge: 'GetRecords.IteratorAgeMilliseconds', @@ -43,13 +46,13 @@ test('Kinesis data stream alarms are created', (t) => { t.equal(Object.keys(alarmResources).length, Object.keys(expectedTypes).length) for (const alarmResource of Object.values(alarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) const expectedMetric = expectedTypes[alarmType] t.equal(al?.MetricName, expectedMetric) t.ok(al?.Statistic) - t.equal(al?.Threshold, kinesisAlarmProperties[expectedMetric].Threshold) + t.equal(al?.Threshold, kinesisAlarmConfig[expectedMetric].Threshold) t.equal(al?.EvaluationPeriods, 2) t.equal(al?.TreatMissingData, 'breaching') t.equal(al?.ComparisonOperator, 'LessThanThreshold') @@ -70,7 +73,7 @@ test('Kinesis data stream alarms are created', (t) => { }) test('Kinesis data stream alarms are not created when disabled globally', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Kinesis: { @@ -82,12 +85,43 @@ test('Kinesis data stream alarms are not created when disabled globally', (t) => } } ) - const kinesisAlarmProperties = AlarmProperties.Kinesis + const kinesisAlarmConfig = testConfig.Kinesis const compiledTemplate = createTestCloudFormationTemplate() - createKinesisAlarms(kinesisAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createKinesisAlarms(kinesisAlarmConfig, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) t.same({}, alarmResources) t.end() }) + +test('Kinesis data stream resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template: Template = { + Resources: { + Stream: { + Type: 'AWS::Kinesis::Stream', + Properties: { + Name: 'test-stream' + }, + Metadata: { + slicWatch: { + alarms: { + Period: 900, + 'GetRecords.IteratorAgeMilliseconds': { + Threshold: 9999 + } + } + } + } + } + } + } + + const alarmResources: ResourceType = createKinesisAlarms(testConfig.Kinesis, testAlarmActionsConfig, template) + + const alarmResource = alarmResources.slicWatchKinesisStreamIteratorAgeAlarmStream + t.same(alarmResource.Properties?.Threshold, 9999) + t.same(alarmResource.Properties?.Period, 900) + t.end() +}) diff --git a/core/alarms/tests/lambda.test.ts b/core/alarms/tests/lambda.test.ts index 4cfbf29a..35b56479 100644 --- a/core/alarms/tests/lambda.test.ts +++ b/core/alarms/tests/lambda.test.ts @@ -1,4 +1,5 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import createLambdaAlarms from '../lambda' import { getResourcesByType } from '../../cf-template' @@ -14,7 +15,6 @@ import { albCfTemplate, testAlarmActionsConfig } from '../../tests/testing-utils' -import { applyAlarmConfig } from '../../inputs/function-config' export interface AlarmsByType { Lambda_Duration? @@ -30,7 +30,7 @@ export interface MetricsById { } test('AWS Lambda alarms are created', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const alarmConfig = createTestConfig(defaultConfig.alarms, { Lambda: { Period: 120, EvaluationPeriods: 2, @@ -55,17 +55,12 @@ test('AWS Lambda alarms are created', (t) => { }) const compiledTemplate = createTestCloudFormationTemplate() - const FunctionAlarmProperties = AlarmProperties.Lambda - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - FunctionAlarmProperties[funcLogicalId] = AlarmProperties.Lambda - } - - const alarmResources: ResourceType = createLambdaAlarms(FunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) function getAlarmsByType (): AlarmsByType { const alarmsByType = {} for (const alarmResource of Object.values(alarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) alarmsByType[alarmType] = alarmsByType[alarmType] ?? new Set() @@ -86,7 +81,7 @@ test('AWS Lambda alarms are created', (t) => { for (const al of alarmsByType.Lambda_Errors) { t.equal(al.MetricName, 'Errors') t.equal(al.Statistic, 'Sum') - t.equal(al.Threshold, AlarmProperties.Lambda.Errors.Threshold) + t.equal(al.Threshold, alarmConfig.Lambda.Errors.Threshold) t.equal(al.EvaluationPeriods, 2) t.equal(al.TreatMissingData, 'breaching') t.equal(al.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -144,7 +139,7 @@ test('AWS Lambda alarms are created', (t) => { }) test('AWS Lambda alarms are created for ALB', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const alarmConfig = createTestConfig(defaultConfig.alarms, { Lambda: { Period: 120, EvaluationPeriods: 2, @@ -169,16 +164,12 @@ test('AWS Lambda alarms are created for ALB', (t) => { }) const compiledTemplate = createTestCloudFormationTemplate(albCfTemplate) - const albFunctionAlarmProperties = AlarmProperties.Lambda - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - albFunctionAlarmProperties[funcLogicalId] = AlarmProperties.Lambda - } - const albAlarmResources: ResourceType = createLambdaAlarms(albFunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const albAlarmResources: ResourceType = createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) function getAlarmsByType (): AlarmsByType { const albAlarmsByType: AlarmsByType = {} for (const alarmResource of Object.values(albAlarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType: any = alarmNameToType(al?.AlarmName) albAlarmsByType[alarmType] = albAlarmsByType[alarmType] ?? new Set() @@ -196,7 +187,7 @@ test('AWS Lambda alarms are created for ALB', (t) => { for (const al of albAlarmsByType.Lambda_Errors) { t.equal(al.MetricName, 'Errors') t.equal(al.Statistic, 'Sum') - t.equal(al.Threshold, AlarmProperties.Lambda.Errors.Threshold) + t.equal(al.Threshold, alarmConfig.Lambda.Errors.Threshold) t.equal(al.EvaluationPeriods, 2) t.equal(al.TreatMissingData, 'breaching') t.equal(al.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -241,7 +232,7 @@ test('AWS Lambda alarms are created for ALB', (t) => { }) test('Invocation alarms are created if configured', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const alarmConfig = createTestConfig(defaultConfig.alarms, { Lambda: { Period: 60, Errors: { @@ -264,12 +255,7 @@ test('Invocation alarms are created if configured', (t) => { }) const compiledTemplate = createTestCloudFormationTemplate() - const FunctionAlarmProperties = AlarmProperties.Lambda - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - FunctionAlarmProperties[funcLogicalId] = AlarmProperties.Lambda - } - - const alarmResources = createLambdaAlarms(FunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources = createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) const invocAlarmResources: ResourceType = filterObject( alarmResources, (res) => res.Properties.AlarmName.payload[0].startsWith('Lambda_Invocations') @@ -282,13 +268,13 @@ test('Invocation alarms are created if configured', (t) => { t.equal(al?.Threshold, 900) t.equal(al?.EvaluationPeriods, 1) t.equal(al?.Namespace, 'AWS/Lambda') - t.equal(al?.Period, AlarmProperties.Lambda.Period) + t.equal(al?.Period, alarmConfig.Lambda.Period) } t.end() }) test('Invocation alarms throws if misconfigured (enabled but no threshold set)', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const alarmConfig = createTestConfig(defaultConfig.alarms, { Lambda: { Period: 60, Errors: { @@ -311,11 +297,7 @@ test('Invocation alarms throws if misconfigured (enabled but no threshold set)', }) const compiledTemplate = createTestCloudFormationTemplate() - const FunctionAlarmProperties = AlarmProperties - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - FunctionAlarmProperties[funcLogicalId] = AlarmProperties.Lambda - } - createLambdaAlarms(FunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) t.end() }) @@ -333,7 +315,7 @@ test('Invocation alarms throws if misconfigured (enabled but no threshold set)', } ].forEach(({ functionName, reason }) => async () => { await test(`IteratorAge alarm is not created if function reference cannot be found due to ${reason}`, (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const alarmConfig = createTestConfig(defaultConfig.alarms, { Lambda: { Period: 60, Errors: { @@ -367,11 +349,7 @@ test('Invocation alarms throws if misconfigured (enabled but no threshold set)', } ) - const FunctionAlarmProperties = AlarmProperties.Lambda - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - FunctionAlarmProperties[funcLogicalId] = AlarmProperties.Lambda - } - createLambdaAlarms(FunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) t.equal(Object.keys(alarmResources).length, 0) t.end() @@ -379,7 +357,7 @@ test('Invocation alarms throws if misconfigured (enabled but no threshold set)', }) test('Lambda alarms are not created when disabled globally', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const alarmConfig = createTestConfig(defaultConfig.alarms, { Lambda: { enabled: false, // disabled globally Period: 60, @@ -402,11 +380,7 @@ test('Lambda alarms are not created when disabled globally', (t) => { }) const compiledTemplate = createTestCloudFormationTemplate() - const FunctionAlarmProperties = AlarmProperties.Lambda - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - FunctionAlarmProperties[funcLogicalId] = AlarmProperties.Lambda - } - createLambdaAlarms(FunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) t.same({}, alarmResources) @@ -414,7 +388,7 @@ test('Lambda alarms are not created when disabled globally', (t) => { }) test('Lambda alarms are not created when disabled individually', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const alarmConfig = createTestConfig(defaultConfig.alarms, { Lambda: { enabled: true, // enabled globally Period: 60, @@ -442,11 +416,7 @@ test('Lambda alarms are not created when disabled individually', (t) => { }) const compiledTemplate = createTestCloudFormationTemplate() - const FunctionAlarmProperties = AlarmProperties - for (const funcLogicalId of Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - FunctionAlarmProperties[funcLogicalId] = AlarmProperties.Lambda - } - createLambdaAlarms(FunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) @@ -455,7 +425,7 @@ test('Lambda alarms are not created when disabled individually', (t) => { }) test('AWS Lambda alarms are not created if disabled at function level', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, { + const alarmConfig = createTestConfig(defaultConfig.alarms, { Lambda: { Invocations: { enabled: true, @@ -469,6 +439,11 @@ test('AWS Lambda alarms are not created if disabled at function level', (t) => { Type: 'AWS::Lambda::Function', Properties: { FunctionName: 'serverless-test-project-dev-simpletest' + }, + Metadata: { + slicWatch: { + enabled: false + } } }, ESM: { @@ -479,34 +454,7 @@ test('AWS Lambda alarms are not created if disabled at function level', (t) => { } } }) - const disabledFunctionAlarmProperties = applyAlarmConfig( - AlarmProperties.Lambda, { - HelloLambdaFunction: { Lambda: { enabled: false } } - }) - createLambdaAlarms(disabledFunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) - - const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) - t.equal(Object.keys(alarmResources).length, 0) - t.end() -}) - -test('AWS Lambda alarms are not created if function configuration is not provided (e.g. Custom Resource injected functions)', (t) => { - const compiledTemplate = createTestCloudFormationTemplate({ - Resources: { - HelloLambdaFunction: { - Type: 'AWS::Lambda::Function', - Properties: { - FunctionName: 'serverless-test-project-dev-simpletest' - } - } - } - }) - const funcAlarmProperties = { Lambda: { enabled: false } } // No function configuration as in the case where functions are not defined in serverless.yml:functions - const disabledFunctionAlarmProperties = applyAlarmConfig( - funcAlarmProperties.Lambda, { - HelloLambdaFunction: { Lambda: { enabled: false } } - }) - createLambdaAlarms(disabledFunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) t.equal(Object.keys(alarmResources).length, 0) @@ -514,16 +462,14 @@ test('AWS Lambda alarms are not created if function configuration is not provide }) test('Duration alarms are created if no timeout is specified', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, {}) + const alarmConfig = createTestConfig(defaultConfig.alarms, {}) const compiledTemplate = createTestCloudFormationTemplate() - const FunctionAlarmProperties = AlarmProperties.Lambda - for (const [funcLogicalId, resource] of Object.entries(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { - FunctionAlarmProperties[funcLogicalId] = AlarmProperties.Lambda + for (const resource of Object.values(getResourcesByType('AWS::Lambda::Function', compiledTemplate))) { delete resource.Properties?.Timeout } - const alarmResources = createLambdaAlarms(FunctionAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources = createLambdaAlarms(alarmConfig.Lambda, testAlarmActionsConfig, compiledTemplate) const invocAlarmResources = filterObject( alarmResources, (res) => res.Properties.AlarmName.payload[0].startsWith('Lambda_Duration') @@ -531,14 +477,3 @@ test('Duration alarms are created if no timeout is specified', (t) => { t.equal(Object.keys(invocAlarmResources).length, 8) t.end() }) - -test('Lambda alarms are not created if the slic watch config does not exist', (t) => { - const AlarmProperties = createTestConfig(defaultConfig.alarms, {}) - const compiledTemplate = createTestCloudFormationTemplate() - const perLambdaConfig = AlarmProperties - perLambdaConfig.HelloLambdaFunction = AlarmProperties.Lambda - const createdAlarms = createLambdaAlarms(perLambdaConfig, testAlarmActionsConfig, compiledTemplate) - - t.same(Object.keys(createdAlarms), ['slicWatchLambdaErrorsAlarmHelloLambdaFunction', 'slicWatchLambdaThrottlesAlarmHelloLambdaFunction', 'slicWatchLambdaDurationAlarmHelloLambdaFunction']) - t.end() -}) diff --git a/core/alarms/tests/sns.test.ts b/core/alarms/tests/sns.test.ts index c7329c7c..55185809 100644 --- a/core/alarms/tests/sns.test.ts +++ b/core/alarms/tests/sns.test.ts @@ -1,4 +1,5 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' import createSnsAlarms from '../sns' import { getResourcesByType } from '../../cf-template' @@ -13,7 +14,7 @@ import { } from '../../tests/testing-utils' test('SNS alarms are created', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -30,10 +31,10 @@ test('SNS alarms are created', (t) => { } } ) - const snsAlarmProperties = AlarmProperties.SNS + const snsAlarmConfig = testConfig.SNS const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources: ResourceType = createSnsAlarms(snsAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createSnsAlarms(snsAlarmConfig, testAlarmActionsConfig, compiledTemplate) const expectedTypes = { SNS_NumberOfNotificationsFilteredOutInvalidAttributes_Alarm: 'NumberOfNotificationsFilteredOut-InvalidAttributes', SNS_NumberOfNotificationsFailed_Alarm: 'NumberOfNotificationsFailed' @@ -41,14 +42,14 @@ test('SNS alarms are created', (t) => { t.equal(Object.keys(alarmResources).length, Object.keys(expectedTypes).length) for (const alarmResource of Object.values(alarmResources)) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) const expectedMetric = expectedTypes[alarmType] t.ok(expectedMetric) t.equal(al?.MetricName, expectedMetric) t.ok(al?.Statistic) - t.equal(al?.Threshold, snsAlarmProperties[expectedMetric].Threshold) + t.equal(al?.Threshold, snsAlarmConfig[expectedMetric].Threshold) t.equal(al?.EvaluationPeriods, 2) t.equal(al?.TreatMissingData, 'breaching') t.equal(al?.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -71,8 +72,40 @@ test('SNS alarms are created', (t) => { t.end() }) +test('topic resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(); + + (template.Resources as ResourceType).topic.Metadata = { + slicWatch: { + alarms: { + Period: 900, + 'NumberOfNotificationsFilteredOut-InvalidAttributes': { + Threshold: 51 + }, + NumberOfNotificationsFailed: { + Threshold: 52, + Period: 3600 + } + } + } + } + + const alarmResources: ResourceType = createSnsAlarms(testConfig.SNS, testAlarmActionsConfig, template) + t.same(Object.keys(alarmResources).length, 2) + + const invalidAttrsAlarm = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'NumberOfNotificationsFilteredOut-InvalidAttributes')[0] + const failedAlarm = Object.values(alarmResources).filter(a => a?.Properties?.MetricName === 'NumberOfNotificationsFailed')[0] + t.equal(invalidAttrsAlarm?.Properties?.Threshold, 51) + t.equal(invalidAttrsAlarm?.Properties?.Period, 900) + t.equal(failedAlarm?.Properties?.Threshold, 52) + t.equal(failedAlarm?.Properties?.Period, 3600) + + t.end() +}) + test('SNS alarms are not created when disabled globally', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { SNS: { @@ -87,9 +120,9 @@ test('SNS alarms are not created when disabled globally', (t) => { } } ) - const snsAlarmProperties = AlarmProperties.SNS + const snsAlarmConfig = testConfig.SNS const compiledTemplate = createTestCloudFormationTemplate() - createSnsAlarms(snsAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createSnsAlarms(snsAlarmConfig, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) diff --git a/core/alarms/tests/sqs.test.ts b/core/alarms/tests/sqs.test.ts index 7b8ef9eb..c79e6d17 100644 --- a/core/alarms/tests/sqs.test.ts +++ b/core/alarms/tests/sqs.test.ts @@ -16,8 +16,9 @@ export interface AlarmsByType { SQS_ApproximateAgeOfOldestMessage SQS_ApproximateNumberOfMessagesNotVisible } + test('SQS alarms are created', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -36,10 +37,10 @@ test('SQS alarms are created', (t) => { } } }) - const sqsAlarmProperties = AlarmProperties.SQS + const sqsAlarmConfig = testConfig.SQS const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources: ResourceType = createSQSAlarms(sqsAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createSQSAlarms(sqsAlarmConfig, testAlarmActionsConfig, compiledTemplate) // We have 2 queues (a regular one and a fifo one) in our test stack // we expect 2 alarms per queue @@ -71,7 +72,7 @@ test('SQS alarms are created', (t) => { // regular queue t.equal(approximateAgeOfOldMessageAlarms[0].MetricName, 'ApproximateAgeOfOldestMessage') t.equal(approximateAgeOfOldMessageAlarms[0].Statistic, 'Maximum') - t.equal(approximateAgeOfOldMessageAlarms[0].Threshold, sqsAlarmProperties.AgeOfOldestMessage.Threshold) + t.equal(approximateAgeOfOldMessageAlarms[0].Threshold, sqsAlarmConfig.AgeOfOldestMessage.Threshold) t.equal(approximateAgeOfOldMessageAlarms[0].EvaluationPeriods, 2) t.equal(approximateAgeOfOldMessageAlarms[0].TreatMissingData, 'breaching') t.equal(approximateAgeOfOldMessageAlarms[0].ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -93,7 +94,7 @@ test('SQS alarms are created', (t) => { // fifo queue t.equal(approximateAgeOfOldMessageAlarms[1].MetricName, 'ApproximateAgeOfOldestMessage') t.equal(approximateAgeOfOldMessageAlarms[1].Statistic, 'Maximum') - t.equal(approximateAgeOfOldMessageAlarms[1].Threshold, sqsAlarmProperties.AgeOfOldestMessage.Threshold) + t.equal(approximateAgeOfOldMessageAlarms[1].Threshold, sqsAlarmConfig.AgeOfOldestMessage.Threshold) t.equal(approximateAgeOfOldMessageAlarms[1].EvaluationPeriods, 2) t.equal(approximateAgeOfOldMessageAlarms[1].TreatMissingData, 'breaching') t.equal(approximateAgeOfOldMessageAlarms[1].ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -160,8 +161,49 @@ test('SQS alarms are created', (t) => { t.end() }) +test('queue resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(); + + (template.Resources as ResourceType).regularQueue.Metadata = { + slicWatch: { + alarms: { + Period: 900, + AgeOfOldestMessage: { + Statistic: 'P99', + Threshold: 51, + enabled: true // this one is disabled by default + }, + InFlightMessagesPc: { + Statistic: 'Average', + Threshold: 52, + Period: 60 + } + } + } + } + + const alarmResources: ResourceType = createSQSAlarms(testConfig.SQS, testAlarmActionsConfig, template) + t.same(Object.keys(alarmResources).length, 3) // Two for standard queue, two for the FIFO queue + + const ageOfOldestMessageAlarm = Object.entries(alarmResources).filter(([key, value]) => key.includes('regular') && value?.Properties?.MetricName === 'ApproximateAgeOfOldestMessage')[0][1] + const inFlightMessagesFifoAlarm = Object.entries(alarmResources).filter(([key, value]) => key.includes('fifo') && value?.Properties?.MetricName === 'ApproximateNumberOfMessagesNotVisible')[0][1] + const inFlightMessagesRegularAlarm = Object.entries(alarmResources).filter(([key, value]) => key.includes('regular') && value?.Properties?.MetricName === 'ApproximateNumberOfMessagesNotVisible')[0][1] + t.equal(ageOfOldestMessageAlarm?.Properties?.Threshold, 51) + t.equal(ageOfOldestMessageAlarm?.Properties?.Statistic, 'P99') + t.equal(ageOfOldestMessageAlarm?.Properties?.Period, 900) + t.equal(inFlightMessagesFifoAlarm?.Properties?.Period, 60) + t.equal(inFlightMessagesFifoAlarm?.Properties?.Threshold, Math.floor(0.8 * 20000)) + t.equal(inFlightMessagesFifoAlarm?.Properties?.Statistic, 'Maximum') + t.equal(inFlightMessagesRegularAlarm?.Properties?.Period, 60) + t.equal(inFlightMessagesRegularAlarm?.Properties?.Threshold, Math.floor(0.52 * 120000)) + t.equal(inFlightMessagesRegularAlarm?.Properties?.Statistic, 'Average') + + t.end() +}) + test('SQS alarms are not created when disabled globally', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { SQS: { @@ -176,7 +218,7 @@ test('SQS alarms are not created when disabled globally', (t) => { } } }) - const sqsAlarmProperties = AlarmProperties.SQS + const sqsAlarmProperties = testConfig.SQS const compiledTemplate = createTestCloudFormationTemplate() createSQSAlarms(sqsAlarmProperties, testAlarmActionsConfig, compiledTemplate) @@ -187,7 +229,7 @@ test('SQS alarms are not created when disabled globally', (t) => { }) test('SQS alarms are not created when disabled individually', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { SQS: { @@ -204,9 +246,9 @@ test('SQS alarms are not created when disabled individually', (t) => { } } }) - const sqsAlarmProperties = AlarmProperties.SQS + const sqsAlarmConfig = testConfig.SQS const compiledTemplate = createTestCloudFormationTemplate() - createSQSAlarms(sqsAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createSQSAlarms(sqsAlarmConfig, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) @@ -215,7 +257,7 @@ test('SQS alarms are not created when disabled individually', (t) => { }) test('SQS AgeOfOldestMessage alarms throws if misconfigured (enabled but no threshold set)', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { SQS: { @@ -231,7 +273,7 @@ test('SQS AgeOfOldestMessage alarms throws if misconfigured (enabled but no thre } } }) - const sqsAlarmProperties = AlarmProperties.SQS + const sqsAlarmProperties = testConfig.SQS const compiledTemplate = createTestCloudFormationTemplate() t.throws(() => { createSQSAlarms(sqsAlarmProperties, testAlarmActionsConfig, compiledTemplate) }, { message: 'SQS AgeOfOldestMessage alarm is enabled but `Threshold` is not specified. Please specify a threshold or disable the alarm.' }) t.end() diff --git a/core/alarms/tests/step-functions.test.ts b/core/alarms/tests/step-functions.test.ts index fdf4325c..afa3483e 100644 --- a/core/alarms/tests/step-functions.test.ts +++ b/core/alarms/tests/step-functions.test.ts @@ -1,9 +1,12 @@ import { test } from 'tap' +import type { AlarmProperties } from 'cloudform-types/types/cloudWatch/alarm' + import createStatesAlarms from '../step-functions' import { getResourcesByType } from '../../cf-template' import type { ResourceType } from '../../cf-template' import defaultConfig from '../../inputs/default-config' + import { assertCommonAlarmProperties, alarmNameToType, @@ -13,7 +16,7 @@ import { } from '../../tests/testing-utils' test('Step Function alarms are created', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { Period: 120, @@ -34,16 +37,16 @@ test('Step Function alarms are created', (t) => { } } ) - const sfAlarmProperties = AlarmProperties.States + const sfAlarmConfig = testConfig.States const compiledTemplate = createTestCloudFormationTemplate() - const alarmResources: ResourceType = createStatesAlarms(sfAlarmProperties, testAlarmActionsConfig, compiledTemplate) + const alarmResources: ResourceType = createStatesAlarms(sfAlarmConfig, testAlarmActionsConfig, compiledTemplate) const alarmsByType = {} t.equal(Object.keys(alarmResources).length, 6) for (const [resourceName, alarmResource] of Object.entries(alarmResources)) { // Just test the standard workflow alarms if (!resourceName.endsWith('ExpressWorkflow')) { - const al = alarmResource.Properties + const al = alarmResource.Properties as AlarmProperties assertCommonAlarmProperties(t, al) const alarmType = alarmNameToType(al?.AlarmName) alarmsByType[alarmType] = alarmsByType[alarmType] ?? new Set() @@ -64,7 +67,7 @@ test('Step Function alarms are created', (t) => { for (const al of alarmsByType[type]) { t.equal(al.Statistic, 'Sum') const metric = type.split('_')[1].replace(/Alarm$/g, '') - t.equal(al.Threshold, sfAlarmProperties[metric].Threshold) + t.equal(al.Threshold, sfAlarmConfig[metric].Threshold) t.equal(al.EvaluationPeriods, 2) t.equal(al.TreatMissingData, 'breaching') t.equal(al.ComparisonOperator, 'GreaterThanOrEqualToThreshold') @@ -85,8 +88,42 @@ test('Step Function alarms are created', (t) => { t.end() }) +test('step function resource configuration overrides take precedence', (t) => { + const testConfig = createTestConfig(defaultConfig.alarms) + const template = createTestCloudFormationTemplate(); + + (template.Resources as ResourceType).Workflow.Metadata = { + slicWatch: { + alarms: { + Period: 900, + ExecutionThrottled: { + Threshold: 1 + }, + ExecutionsFailed: { + Threshold: 2, + enabled: false + }, + ExecutionsTimedOut: { + Threshold: 3 + } + } + } + } + + const alarmResources: ResourceType = createStatesAlarms(testConfig.States, testAlarmActionsConfig, template) + t.same(Object.keys(alarmResources).length, 5) // Two for standard workflow, three for the express workflow + + const throttledAlarm = Object.entries(alarmResources).filter(([key, value]) => !key.includes('xpress') && value?.Properties?.MetricName === 'ExecutionThrottled')[0][1] + const timedOutAlarm = Object.entries(alarmResources).filter(([key, value]) => !key.includes('xpress') && value?.Properties?.MetricName === 'ExecutionsTimedOut')[0][1] + t.equal(throttledAlarm?.Properties?.Threshold, 1) + t.equal(throttledAlarm?.Properties?.Period, 900) + t.equal(timedOutAlarm?.Properties?.Threshold, 3) + t.equal(timedOutAlarm?.Properties?.Period, 900) + + t.end() +}) test('Step function alarms are not created when disabled globally', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { States: { @@ -104,9 +141,9 @@ test('Step function alarms are not created when disabled globally', (t) => { } } ) - const sfAlarmProperties = AlarmProperties.States + const sfAlarmConfig = testConfig.States const compiledTemplate = createTestCloudFormationTemplate() - createStatesAlarms(sfAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createStatesAlarms(sfAlarmConfig, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) @@ -115,7 +152,7 @@ test('Step function alarms are not created when disabled globally', (t) => { }) test('Step function alarms are not created when disabled individually', (t) => { - const AlarmProperties = createTestConfig( + const testConfig = createTestConfig( defaultConfig.alarms, { States: { @@ -136,9 +173,9 @@ test('Step function alarms are not created when disabled individually', (t) => { } } ) - const sfAlarmProperties = AlarmProperties.States + const sfAlarmConfig = testConfig.States const compiledTemplate = createTestCloudFormationTemplate() - createStatesAlarms(sfAlarmProperties, testAlarmActionsConfig, compiledTemplate) + createStatesAlarms(sfAlarmConfig, testAlarmActionsConfig, compiledTemplate) const alarmResources = getResourcesByType('AWS::CloudWatch::Alarm', compiledTemplate) diff --git a/core/cf-template.ts b/core/cf-template.ts index fcddf343..226b722d 100644 --- a/core/cf-template.ts +++ b/core/cf-template.ts @@ -3,6 +3,10 @@ import type Template from 'cloudform-types/types/template' import { filterObject } from './filter-object' import { getLogger } from './logging' +import { cascade } from './inputs/cascading-config' +import { type SlicWatchMergedConfig } from './alarms/alarm-types' +import { type SlicWatchDashboardConfig, type WidgetMetricProperties } from './dashboards/dashboard-types' +import { merge } from 'lodash' const logger = getLogger() @@ -37,6 +41,62 @@ export function getResourcesByType (type: string, compiledTemplate: Template): R return filterObject(compiledTemplate.Resources ?? {}, (resource: { Type: string }) => resource.Type === type) } +export interface ResourceAlarmConfigurations { + resources: ResourceType + alarmConfigurations: Record +} + +export interface ResourceDashboardConfigurations { + resources: ResourceType + dashConfigurations: Record +} + +/** + * Find all resources of a given type and merge any resource-specific SLIC Watch configuration with + * the global alarm configuration for resources of that type + * + * @param type The CloudFormation resource type + * @param template The CloudFormation template + * @param config The global alarm configuration for resources of this type + * @returns The resources along with the merged configuration for each resource by logical ID + */ +export function getResourceAlarmConfigurationsByType ( + type: string, template: Template, config: M +): ResourceAlarmConfigurations { + const alarmConfigurations: Record = {} + const resources = getResourcesByType(type, template) + for (const [funcLogicalId, resource] of Object.entries(resources)) { + alarmConfigurations[funcLogicalId] = merge({}, config, cascade(resource?.Metadata?.slicWatch?.alarms ?? {}) as M) + } + return { + resources, + alarmConfigurations + } +} + +/** + * Find all resources of a given type and merge any resource-specific SLIC Watch configuration with + * the global dashboard configuration for resources of that type + * + * @param type The CloudFormation resource type + * @param template The CloudFormation template + * @param config The global dashboard configuration for resources of this type + * @returns The resources along with the merged configuration for each resource by logical ID + */ +export function getResourceDashboardConfigurationsByType ( + type: string, template: Template, config: T +): ResourceDashboardConfigurations { + const dashConfigurations: Record = {} + const resources = getResourcesByType(type, template) + for (const [logicalId, resource] of Object.entries(resources)) { + dashConfigurations[logicalId] = merge({}, config, cascade(resource?.Metadata?.slicWatch?.dashboard ?? {}) as SlicWatchDashboardConfig) + } + return { + resources, + dashConfigurations + } +} + export function getEventSourceMappingFunctions (compiledTemplate): ResourceType { const eventSourceMappings = getResourcesByType( 'AWS::Lambda::EventSourceMapping', compiledTemplate) diff --git a/core/dashboards/dashboard-types.ts b/core/dashboards/dashboard-types.ts index 8d627b27..4a9454f5 100644 --- a/core/dashboards/dashboard-types.ts +++ b/core/dashboards/dashboard-types.ts @@ -1,6 +1,6 @@ -import type FunctionProperties from 'cloudform-types/types/lambda/function' +import type { Widget } from 'cloudwatch-dashboard-types' -export type YAxis = 'left' | 'right' +export type YAxisPos = 'left' | 'right' interface TimeRange { start: string @@ -10,171 +10,131 @@ interface TimeRange { export interface MetricDefs { namespace: string metric: string - dimensions: object + dimensions: Record stat: string - yAxis?: YAxis -} -export interface Properties { - metrics: any[][] - title: string - view: string - region: string - period?: number - yAxis?: YAxis + yAxis?: YAxisPos } -export interface CreateMetricWidget { - type: string - properties: Properties +export interface WidgetWithSize extends Omit { width: number height: number - yAxis?: YAxis -} - -export interface Widgets { - enabled?: boolean - metricPeriod?: number - width?: number - height?: number - yAxis?: YAxis - Statistic?: string[] - Lambda?: LambdaDashboardBodyProperties - ApiGateway?: ApiGwDashboardBodyProperties - States?: SfDashboardBodyProperties - DynamoDB?: DynamoDbDashboardBodyProperties - Kinesis?: KinesisDashboardBodyProperties - SQS?: SqsDashboardBodyProperties - ECS?: EcsDashboardBodyProperties - SNS?: SnsDashboardBodyProperties - Events?: RuleDashboardBodyProperties - ApplicationELB?: AlbDashboardBodyProperties - ApplicationELBTarget?: AlbTargetDashboardBodyProperties - AppSync?: AppSyncDashboardBodyProperties -} - -export interface SlicWatchDashboardConfig { - enabled?: boolean - timeRange?: TimeRange - widgets: Widgets } -export interface DashboardBodyProperties { - enabled?: boolean - metricPeriod?: number - width?: number - height?: number - yAxis?: YAxis - Statistic?: string[] +export interface WidgetMetricProperties { + enabled: boolean + metricPeriod: number + width: number + height: number + yAxis: YAxisPos + Statistic: string[] } -export interface ServiceDashConfig { - DashboardBodyProperties?: DashboardBodyProperties - widgets?: Widgets +export interface Widgets extends WidgetMetricProperties { + Lambda: LambdaDashboardProperties + ApiGateway: ApiGwDashboardProperties + States: SfDashboardProperties + DynamoDB: DynamoDbDashboardProperties + Kinesis: KinesisDashboardProperties + SQS: SqsDashboardProperties + ECS: EcsDashboardProperties + SNS: SnsDashboardProperties + Events: RuleDashboardProperties + ApplicationELB: AlbDashboardProperties + ApplicationELBTarget: AlbTargetDashboardProperties + AppSync: AppSyncDashboardProperties } -export interface LambdaDashboardBodyProperties { - Errors: DashboardBodyProperties - Throttles: DashboardBodyProperties - Duration: DashboardBodyProperties - Invocations: DashboardBodyProperties - ConcurrentExecutions: DashboardBodyProperties - IteratorAge: DashboardBodyProperties +type NestedPartial = { + [K in keyof T]?: T[K] extends Array ? Array> : NestedPartial } -export interface ApiGwDashboardBodyProperties { - '5XXError': DashboardBodyProperties - '4XXError': DashboardBodyProperties - Latency: DashboardBodyProperties - Count: DashboardBodyProperties +export interface SlicWatchDashboardConfig extends WidgetMetricProperties { + timeRange: TimeRange + widgets: Widgets } -export interface SfDashboardBodyProperties { - ExecutionsFailed: DashboardBodyProperties - ExecutionThrottled: DashboardBodyProperties - ExecutionsTimedOut: DashboardBodyProperties -} +export type SlicWatchInputDashboardConfig = NestedPartial -export interface DynamoDbDashboardBodyProperties { - ReadThrottleEvents: DashboardBodyProperties - WriteThrottleEvents: DashboardBodyProperties +export interface LambdaDashboardProperties extends WidgetMetricProperties { + Errors: WidgetMetricProperties + Throttles: WidgetMetricProperties + Duration: WidgetMetricProperties + Invocations: WidgetMetricProperties + ConcurrentExecutions: WidgetMetricProperties + IteratorAge: WidgetMetricProperties } -export interface KinesisDashboardBodyProperties { - 'GetRecords.IteratorAgeMilliseconds': DashboardBodyProperties - ReadProvisionedThroughputExceeded: DashboardBodyProperties - WriteProvisionedThroughputExceeded: DashboardBodyProperties - 'PutRecord.Success': DashboardBodyProperties - 'PutRecords.Success': DashboardBodyProperties - 'GetRecords.Success': DashboardBodyProperties +export interface ApiGwDashboardProperties extends WidgetMetricProperties { + '5XXError': WidgetMetricProperties + '4XXError': WidgetMetricProperties + Latency: WidgetMetricProperties + Count: WidgetMetricProperties } -export interface SqsDashboardBodyProperties { - NumberOfMessagesSent: DashboardBodyProperties - NumberOfMessagesReceived: DashboardBodyProperties - NumberOfMessagesDeleted: DashboardBodyProperties - ApproximateAgeOfOldestMessage: DashboardBodyProperties - ApproximateNumberOfMessagesVisible: DashboardBodyProperties +export interface SfDashboardProperties extends WidgetMetricProperties { + ExecutionsFailed: WidgetMetricProperties + ExecutionThrottled: WidgetMetricProperties + ExecutionsTimedOut: WidgetMetricProperties } -export interface EcsDashboardBodyProperties { - enabled?: boolean - MemoryUtilization: DashboardBodyProperties - CPUUtilization: DashboardBodyProperties +export interface DynamoDbDashboardProperties extends WidgetMetricProperties { + ReadThrottleEvents: WidgetMetricProperties + WriteThrottleEvents: WidgetMetricProperties } -export interface SnsDashboardBodyProperties { - 'NumberOfNotificationsFilteredOut-InvalidAttributes': DashboardBodyProperties - NumberOfNotificationsFailed: DashboardBodyProperties +export interface KinesisDashboardProperties extends WidgetMetricProperties { + 'GetRecords.IteratorAgeMilliseconds': WidgetMetricProperties + ReadProvisionedThroughputExceeded: WidgetMetricProperties + WriteProvisionedThroughputExceeded: WidgetMetricProperties + 'PutRecord.Success': WidgetMetricProperties + 'PutRecords.Success': WidgetMetricProperties + 'GetRecords.Success': WidgetMetricProperties } -export interface RuleDashboardBodyProperties { - FailedInvocations: DashboardBodyProperties - ThrottledRules: DashboardBodyProperties - Invocations: DashboardBodyProperties +export interface SqsDashboardProperties extends WidgetMetricProperties { + NumberOfMessagesSent: WidgetMetricProperties + NumberOfMessagesReceived: WidgetMetricProperties + NumberOfMessagesDeleted: WidgetMetricProperties + ApproximateAgeOfOldestMessage: WidgetMetricProperties + ApproximateNumberOfMessagesVisible: WidgetMetricProperties } -export interface AlbDashboardBodyProperties { - HTTPCode_ELB_5XX_Count: DashboardBodyProperties - RejectedConnectionCount: DashboardBodyProperties +export interface EcsDashboardProperties extends WidgetMetricProperties { + MemoryUtilization: WidgetMetricProperties + CPUUtilization: WidgetMetricProperties } -export interface AlbTargetDashboardBodyProperties { - HTTPCode_Target_5XX_Count: DashboardBodyProperties - UnHealthyHostCount: DashboardBodyProperties - LambdaInternalError: DashboardBodyProperties - LambdaUserError: DashboardBodyProperties +export interface SnsDashboardProperties extends WidgetMetricProperties { + 'NumberOfNotificationsFilteredOut-InvalidAttributes': WidgetMetricProperties + NumberOfNotificationsFailed: WidgetMetricProperties } -export interface AppSyncDashboardBodyProperties { - '5XXError': DashboardBodyProperties - '4XXError': DashboardBodyProperties - Latency: DashboardBodyProperties - Requests: DashboardBodyProperties - ConnectServerError: DashboardBodyProperties - DisconnectServerError: DashboardBodyProperties - SubscribeServerError: DashboardBodyProperties - UnsubscribeServerError: DashboardBodyProperties - PublishDataMessageServerError: DashboardBodyProperties +export interface RuleDashboardProperties extends WidgetMetricProperties { + FailedInvocations: WidgetMetricProperties + ThrottledRules: WidgetMetricProperties + Invocations: WidgetMetricProperties } -// Lambda resources +export interface AlbDashboardProperties extends WidgetMetricProperties { + HTTPCode_ELB_5XX_Count: WidgetMetricProperties + RejectedConnectionCount: WidgetMetricProperties +} -export interface FunctionResources { - Type: string - Properties: FunctionProperties - DependsOn: string[] +export interface AlbTargetDashboardProperties extends WidgetMetricProperties { + HTTPCode_Target_5XX_Count: WidgetMetricProperties + UnHealthyHostCount: WidgetMetricProperties + LambdaInternalError: WidgetMetricProperties + LambdaUserError: WidgetMetricProperties } -export interface FunctionDashboardConfigs { - HelloLambdaFunction?: FunctionResources - PingLambdaFunction?: FunctionResources - ThrottlerLambdaFunction?: FunctionResources - DriveStreamLambdaFunction?: FunctionResources - DriveQueueLambdaFunction?: FunctionResources - DriveTableLambdaFunction?: FunctionResources - StreamProcessorLambdaFunction?: FunctionResources - HttpGetterLambdaFunction?: FunctionResources - SubscriptionHandlerLambdaFunction?: FunctionResources - EventsRuleLambdaFunction?: FunctionResources - AlbEventLambdaFunction?: FunctionResources +export interface AppSyncDashboardProperties extends WidgetMetricProperties { + '5XXError': WidgetMetricProperties + '4XXError': WidgetMetricProperties + Latency: WidgetMetricProperties + Requests: WidgetMetricProperties + ConnectServerError: WidgetMetricProperties + DisconnectServerError: WidgetMetricProperties + SubscribeServerError: WidgetMetricProperties + UnsubscribeServerError: WidgetMetricProperties + PublishDataMessageServerError: WidgetMetricProperties } diff --git a/core/dashboards/dashboard.ts b/core/dashboards/dashboard.ts index 204972c8..c3dd3703 100644 --- a/core/dashboards/dashboard.ts +++ b/core/dashboards/dashboard.ts @@ -1,33 +1,34 @@ -import type { Entries } from 'type-fest' import type Template from 'cloudform-types/types/template' +import { type Dashboard, type WidgetMetric, type Statistic, type YAxisPosition } from 'cloudwatch-dashboard-types' import { cascade } from '../inputs/cascading-config' -import { getResourcesByType, getEventSourceMappingFunctions, addResource } from '../cf-template' -import type { ResourceType } from '../cf-template' -import type { CreateMetricWidget, DashboardBodyProperties, FunctionDashboardConfigs, MetricDefs, ServiceDashConfig, SlicWatchDashboardConfig, Widgets } from './dashboard-types' +import { getEventSourceMappingFunctions, addResource, getResourceDashboardConfigurationsByType } from '../cf-template' +import type { + WidgetMetricProperties, MetricDefs, SlicWatchDashboardConfig, SlicWatchInputDashboardConfig, + Widgets, WidgetWithSize +} from './dashboard-types' import { findLoadBalancersForTargetGroup } from '../alarms/alb-target-group' import { resolveRestApiNameForSub } from '../alarms/api-gateway' -import { resolveEcsClusterNameForSub, resolveGraphQLId, resolveLoadBalancerFullNameForSub, resolveTargetGroupFullNameForSub } from './dashboard-utils' +import { + resolveEcsClusterNameForSub, resolveGraphQLId, + resolveLoadBalancerFullNameForSub, resolveTargetGroupFullNameForSub +} from './dashboard-utils' import { getLogger } from '../logging' -declare global { - interface ObjectConstructor { - // eslint-disable-next-line @typescript-eslint/method-signature-style - entries(obj: T): Entries - } -} - const MAX_WIDTH = 24 const logger = getLogger() /** + * Adds a dashboard to the specified CloudFormation template based on the resources provided in the template. + * + * A CloudFormation template + * * @param {*} dashboardConfig The global plugin dashboard configuration - * @param {*} functionDashboardConfigs The dashboard configuration override by function name * @param {*} compiledTemplate A CloudFormation template object */ -export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, functionDashboardConfigs: FunctionDashboardConfigs, compiledTemplate: Template) { +export default function addDashboard (dashboardConfig: SlicWatchInputDashboardConfig, compiledTemplate: Template) { const { timeRange, widgets: { @@ -46,39 +47,18 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, } } = cascade(dashboardConfig) as SlicWatchDashboardConfig - /** - * Adds a dashboard to the specified CloudFormation template - * based on the resources provided in the template. - * - * A CloudFormation template - */ - const apiResources = getResourcesByType('AWS::ApiGateway::RestApi', compiledTemplate) - const stateMachineResources = getResourcesByType('AWS::StepFunctions::StateMachine', compiledTemplate) - const lambdaResources = getResourcesByType('AWS::Lambda::Function', compiledTemplate) - const tableResources = getResourcesByType('AWS::DynamoDB::Table', compiledTemplate) - const streamResources = getResourcesByType('AWS::Kinesis::Stream', compiledTemplate) - const queueResources = getResourcesByType('AWS::SQS::Queue', compiledTemplate) - const ecsServiceResources = getResourcesByType('AWS::ECS::Service', compiledTemplate) - const topicResources = getResourcesByType('AWS::SNS::Topic', compiledTemplate) - const ruleResources = getResourcesByType('AWS::Events::Rule', compiledTemplate) - const loadBalancerResources = getResourcesByType('AWS::ElasticLoadBalancingV2::LoadBalancer', compiledTemplate) - const targetGroupResources = getResourcesByType('AWS::ElasticLoadBalancingV2::TargetGroup', compiledTemplate) - - const appSyncResources = getResourcesByType('AWS::AppSync::GraphQLApi', compiledTemplate) - - const eventSourceMappingFunctions = getEventSourceMappingFunctions(compiledTemplate) - const apiWidgets = createApiWidgets(apiResources) - const stateMachineWidgets = createStateMachineWidgets(stateMachineResources) - const dynamoDbWidgets = createDynamoDbWidgets(tableResources) - const lambdaWidgets = createLambdaWidgets(lambdaResources, Object.keys(eventSourceMappingFunctions)) - const streamWidgets = createStreamWidgets(streamResources) - const queueWidgets = createQueueWidgets(queueResources) - const ecsWidgets = createEcsWidgets(ecsServiceResources) - const topicWidgets = createTopicWidgets(topicResources) - const ruleWidgets = createRuleWidgets(ruleResources) - const loadBalancerWidgets = createLoadBalancerWidgets(loadBalancerResources) - const targetGroupWidgets = createTargetGroupWidgets(targetGroupResources, compiledTemplate) - const appSyncWidgets = createAppSyncWidgets(appSyncResources) + const apiWidgets = createApiWidgets() + const stateMachineWidgets = createStateMachineWidgets() + const dynamoDbWidgets = createDynamoDbWidgets() + const lambdaWidgets = createLambdaWidgets() + const streamWidgets = createStreamWidgets() + const queueWidgets = createQueueWidgets() + const ecsWidgets = createEcsWidgets() + const topicWidgets = createTopicWidgets() + const ruleWidgets = createRuleWidgets() + const loadBalancerWidgets = createLoadBalancerWidgets() + const targetGroupWidgets = createTargetGroupWidgets() + const appSyncWidgets = createAppSyncWidgets() const positionedWidgets = layOutWidgets([ ...apiWidgets, @@ -96,7 +76,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, ]) if (positionedWidgets.length > 0) { - const dash = { start: timeRange?.start, end: timeRange?.end, widgets: positionedWidgets } + const dash: Dashboard = { start: timeRange?.start, end: timeRange?.end, widgets: positionedWidgets } const dashboardResource = { Type: 'AWS::CloudWatch::Dashboard', Properties: { @@ -116,8 +96,8 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * @param {Array.} metricDefs The metric definitions to render * @param {Object} config Cascaded widget/metric configuration */ - function createMetricWidget (title: string, metricDefs: MetricDefs[], config: DashboardBodyProperties): CreateMetricWidget { - const metrics = metricDefs.map( + function createMetricWidget (title: string, metricDefs: MetricDefs[], config: WidgetMetricProperties): WidgetWithSize { + const metrics: WidgetMetric[] = metricDefs.map( ({ namespace, metric, dimensions, stat, yAxis }) => [ namespace, metric, @@ -125,7 +105,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, (acc: string[], [name, value]) => [...acc, name, value], [] ), - { stat, yAxis } + { stat: stat as Statistic, yAxis: yAxis as YAxisPosition } ] ) return { @@ -137,68 +117,68 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, region: '${AWS::Region}', period: config.metricPeriod }, - width: config.width as number, - height: config.height as number + width: config.width, + height: config.height } } /** - * Create a set of CloudWatch Dashboard widgets for the Lambda - * CloudFormation resources provided + * Create a set of CloudWatch Dashboard widgets for the Lambda Functions in the specified template * - * Object with CloudFormation Lambda Function resources by resource name - * eventSourceMappingFunctionResourceNames Names of Lambda function resources that are linked to EventSourceMappings + * @return * Object with CloudFormation Lambda Function resources by resource name */ - function createLambdaWidgets (functionResources: FunctionDashboardConfigs, eventSourceMappingFunctionResourceNames: string[]): CreateMetricWidget[] { + function createLambdaWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::Lambda::Function', compiledTemplate, lambdaDashConfig) + const eventSourceMappingFunctions = getEventSourceMappingFunctions(compiledTemplate) + const lambdaWidgets: any = [] - if (Object.keys(functionResources).length > 0) { + + if (Object.keys(configuredResources.resources).length > 0) { for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(lambdaDashConfig))) { - if (metricConfig?.enabled !== false) { - if (metric !== 'IteratorAge' as any) { - for (const stat of metricConfig?.Statistic ?? []) { - const metricDefs: MetricDefs[] = [] - for (const logicalId of Object.keys(functionResources)) { - const functionConfig = functionDashboardConfigs[logicalId] ?? {} - const functionMetricConfig = functionConfig[metric] ?? {} - if (functionConfig.enabled !== false && (functionMetricConfig.enabled !== false)) { - metricDefs.push({ - namespace: 'AWS/Lambda', - metric, - dimensions: { FunctionName: `\${${logicalId}}` }, - stat - }) - } + if (metric !== 'IteratorAge' as any) { + for (const stat of metricConfig.Statistic) { + const metricDefs: MetricDefs[] = [] + for (const funcLogicalId of Object.keys(configuredResources.resources)) { + const funcConfig = configuredResources.dashConfigurations[funcLogicalId] + const metricConfig = funcConfig[metric] + if (metricConfig.enabled !== false) { + metricDefs.push({ + namespace: 'AWS/Lambda', + metric, + dimensions: { FunctionName: `\${${funcLogicalId}}` }, + stat: stat as Statistic + }) } + } - if (metricDefs.length > 0) { - const metricStatWidget = createMetricWidget( - `Lambda ${metric} ${stat} per Function`, - metricDefs, - metricConfig as Widgets - ) - lambdaWidgets.push(metricStatWidget) - } + if (metricDefs.length > 0) { + const metricStatWidget = createMetricWidget( + `Lambda ${metric} ${stat} per Function`, + metricDefs, + metricConfig as Widgets + ) + lambdaWidgets.push(metricStatWidget) } - } else { - for (const logicalId of eventSourceMappingFunctionResourceNames) { - // Add IteratorAge alarm if the Lambda function has an EventSourceMapping trigger - const functionConfig = functionDashboardConfigs[logicalId] ?? {} - const functionMetricConfig = functionConfig[metric] ?? {} - if (functionConfig.enabled !== false && (functionMetricConfig.enabled !== false)) { - const stats: string[] = [] - metricConfig?.Statistic?.forEach(a => stats.push(a)) - const iteratorAgeWidget = createMetricWidget( - `Lambda IteratorAge \${${logicalId}} ${stats?.join(',')}`, - stats.map(stat => ({ - namespace: 'AWS/Lambda', - metric: 'IteratorAge', - dimensions: { FunctionName: `\${${logicalId}}` }, - stat - })), - metricConfig as Widgets - ) - lambdaWidgets.push(iteratorAgeWidget) - } + } + } else { + for (const funcLogicalId of Object.keys(eventSourceMappingFunctions)) { + // Add IteratorAge alarm if the Lambda function has an EventSourceMapping trigger + const funcConfig = configuredResources.dashConfigurations[funcLogicalId] + const functionMetricConfig = funcConfig[metric] + if (functionMetricConfig.enabled !== false) { + const stats: string[] = [] + metricConfig?.Statistic?.forEach(a => stats.push(a)) + const iteratorAgeWidget = createMetricWidget( + `Lambda IteratorAge \${${funcLogicalId}} ${stats?.join(',')}`, + stats.map(stat => ({ + namespace: 'AWS/Lambda', + metric: 'IteratorAge', + dimensions: { FunctionName: `\${${funcLogicalId}}` }, + stat: stat as Statistic + })), + metricConfig as Widgets + ) + lambdaWidgets.push(iteratorAgeWidget) } } } @@ -212,11 +192,13 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * These config objects mix cascaded config literals (like `alarmPeriod: 300`) and metric * configurations (like `Errors: { Statistic: ['Sum'] }`) so here we extract the latter. * - * @param serviceDashConfig t The config object for a specific service within the dashboard - * @returns {Iterable} An iterable over the alarm-config Object entries + * @param serviceDashConfig The config object for a specific service within the dashboard + * @returns An object with the metric's properties by metric name */ - function getConfiguredMetrics (serviceDashConfig): ServiceDashConfig { - return Object.fromEntries(Object.entries(serviceDashConfig).filter((_, metricConfig) => typeof metricConfig !== 'object')) + function getConfiguredMetrics (serviceDashConfig: WidgetMetricProperties): Record { + return Object.fromEntries(Object.entries(serviceDashConfig).filter( + ([_, metricConfig]) => typeof metricConfig === 'object') + ) as unknown as Record } /** @@ -226,14 +208,16 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * Object of CloudFormation RestApi resources by resource name */ - function createApiWidgets (apiResources: ResourceType): CreateMetricWidget[] { - const apiWidgets: CreateMetricWidget[] = [] - for (const [resourceName, res] of Object.entries(apiResources)) { - const apiName: string = resolveRestApiNameForSub(res, resourceName) // e.g., ${AWS::Stack} (Ref), ${OtherResource.Name} (GetAtt) + function createApiWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::ApiGateway::RestApi', compiledTemplate, apiGwDashConfig) + const apiWidgets: WidgetWithSize[] = [] + for (const [logicalId, res] of Object.entries(configuredResources.resources)) { + const apiName: string = resolveRestApiNameForSub(res, logicalId) // e.g., ${AWS::Stack} (Ref), ${OtherResource.Name} (GetAtt) const widgetMetrics: MetricDefs[] = [] - for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(apiGwDashConfig))) { - if (metricConfig?.enabled !== false) { - for (const stat of metricConfig?.Statistic ?? []) { + const mergedConfig = configuredResources.dashConfigurations[logicalId] + for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(mergedConfig))) { + if (metricConfig.enabled) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/ApiGateway', metric, @@ -248,7 +232,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `${metric} API ${apiName}`, widgetMetrics, - apiGwDashConfig as Widgets + apiGwDashConfig ) apiWidgets.push(metricStatWidget) } @@ -263,13 +247,15 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * * Object of Step Function State Machine resources by resource name */ - function createStateMachineWidgets (smResources: ResourceType): CreateMetricWidget[] { - const smWidgets: CreateMetricWidget[] = [] - for (const [logicalId] of Object.entries(smResources)) { + function createStateMachineWidgets (): WidgetWithSize[] { + const stateMachineResources = getResourceDashboardConfigurationsByType('AWS::StepFunctions::StateMachine', compiledTemplate, sfDashConfig) + const smWidgets: WidgetWithSize[] = [] + for (const [logicalId] of Object.entries(stateMachineResources.resources)) { + const mergedConfig = stateMachineResources.dashConfigurations[logicalId] const widgetMetrics: MetricDefs[] = [] - for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(sfDashConfig))) { - if (metricConfig?.enabled !== false) { - for (const stat of metricConfig?.Statistic ?? []) { + for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(mergedConfig))) { + if (metricConfig.enabled) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/States', metric, @@ -285,7 +271,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `\${${logicalId}.Name} Step Function Executions`, widgetMetrics, - sfDashConfig as Widgets + sfDashConfig ) smWidgets.push(metricStatWidget) } @@ -298,13 +284,15 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * * Object of DynamoDB table resources by resource name */ - function createDynamoDbWidgets (tableResources: ResourceType): CreateMetricWidget[] { - const ddbWidgets: CreateMetricWidget[] = [] - for (const [logicalId, res] of Object.entries(tableResources)) { + function createDynamoDbWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::DynamoDB::Table', compiledTemplate, dynamoDbDashConfig) + const ddbWidgets: WidgetWithSize[] = [] + for (const [logicalId, res] of Object.entries(configuredResources.resources)) { + const mergedConfig = configuredResources.dashConfigurations[logicalId] const widgetMetrics: MetricDefs[] = [] - for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(dynamoDbDashConfig))) { - if (metricConfig?.enabled !== false) { - for (const stat of metricConfig?.Statistic ?? []) { + for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(mergedConfig))) { + if (metricConfig.enabled) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/DynamoDB', metric, @@ -318,13 +306,13 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `${metric} Table $\{${logicalId}}`, widgetMetrics, - dynamoDbDashConfig as Widgets + dynamoDbDashConfig ) ddbWidgets.push(metricStatWidget) } for (const gsi of res.Properties?.GlobalSecondaryIndexes ?? []) { const gsiName: string = gsi.IndexName - for (const stat of metricConfig?.Statistic ?? []) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/DynamoDB', metric, @@ -339,7 +327,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `${metric} GSI ${gsiName} in \${${logicalId}}`, widgetMetrics, - dynamoDbDashConfig as Widgets + dynamoDbDashConfig ) ddbWidgets.push(metricStatWidget) } @@ -355,22 +343,23 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * * Object with CloudFormation Kinesis Data Stream resources by resource name */ - function createStreamWidgets (streamResources: ResourceType): CreateMetricWidget[] { - const streamWidgets: CreateMetricWidget[] = [] + function createStreamWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::Kinesis::Stream', compiledTemplate, kinesisDashConfig) + const streamWidgets: WidgetWithSize[] = [] const metricGroups = { IteratorAge: ['GetRecords.IteratorAgeMilliseconds'], 'Get/Put Success': ['PutRecord.Success', 'PutRecords.Success', 'GetRecords.Success'], 'Provisioned Throughput': ['ReadProvisionedThroughputExceeded', 'WriteProvisionedThroughputExceeded'] } - const metricConfigs = getConfiguredMetrics(kinesisDashConfig) - for (const [logicalId] of Object.entries(streamResources)) { + for (const [logicalId] of Object.entries(configuredResources.resources)) { + const streamConfig = configuredResources.dashConfigurations[logicalId] for (const [group, metrics] of Object.entries(metricGroups)) { const widgetMetrics: MetricDefs[] = [] for (const metric of metrics) { - const metricConfig = metricConfigs[metric] - if (metricConfig.enabled as boolean) { + const metricConfig = streamConfig[metric] + if (metricConfig.enabled !== false) { for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/Kinesis', @@ -385,7 +374,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, streamWidgets.push(createMetricWidget( `${group} $\{${logicalId}} Kinesis`, widgetMetrics, - kinesisDashConfig as Widgets + kinesisDashConfig )) } } @@ -397,21 +386,21 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * Create a set of CloudWatch Dashboard widgets for the SQS resources provided * Object with CloudFormation SQS resources by resource name */ - function createQueueWidgets (queueResources: ResourceType): CreateMetricWidget[] { - const queueWidgets: CreateMetricWidget[] = [] + function createQueueWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::SQS::Queue', compiledTemplate, sqsDashConfig) + const queueWidgets: WidgetWithSize[] = [] const metricGroups = { Messages: ['NumberOfMessagesSent', 'NumberOfMessagesReceived', 'NumberOfMessagesDeleted'], 'Oldest Message age': ['ApproximateAgeOfOldestMessage'], 'Messages in queue': ['ApproximateNumberOfMessagesVisible'] } - const metricConfigs = getConfiguredMetrics(sqsDashConfig) - - for (const [logicalId] of Object.entries(queueResources)) { + for (const [logicalId] of Object.entries(configuredResources.resources)) { + const mergedConfig = configuredResources.dashConfigurations[logicalId] for (const [group, metrics] of Object.entries(metricGroups)) { const widgetMetrics: MetricDefs[] = [] for (const metric of metrics) { - const metricConfig = metricConfigs[metric] + const metricConfig = mergedConfig[metric] if (metricConfig.enabled !== false) { for (const stat of metricConfig.Statistic) { widgetMetrics.push({ @@ -429,7 +418,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, queueWidgets.push(createMetricWidget( `${group} \${${logicalId}.QueueName} SQS`, widgetMetrics, - sqsDashConfig as Widgets + sqsDashConfig )) } } @@ -443,15 +432,16 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * * Object of ECS Service resources by resource name */ - function createEcsWidgets (ecsServiceResources: ResourceType): CreateMetricWidget[] { - const ecsWidgets: CreateMetricWidget[] = [] - for (const [logicalId, res] of Object.entries(ecsServiceResources)) { + function createEcsWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::ECS::Service', compiledTemplate, ecsDashConfig) + const ecsWidgets: WidgetWithSize[] = [] + for (const [logicalId, res] of Object.entries(configuredResources.resources)) { const clusterName = resolveEcsClusterNameForSub(res.Properties?.Cluster) - + const mergedConfig = configuredResources.dashConfigurations[logicalId] const widgetMetrics: MetricDefs[] = [] - for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(ecsDashConfig))) { - if (metricConfig?.enabled !== false) { - for (const stat of metricConfig?.Statistic ?? []) { + for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(mergedConfig))) { + if (metricConfig.enabled) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/ECS', metric, @@ -468,7 +458,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `ECS Service \${${logicalId}.Name}`, widgetMetrics, - ecsDashConfig as Widgets + ecsDashConfig ) ecsWidgets.push(metricStatWidget) } @@ -478,16 +468,16 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, /** * Create a set of CloudWatch Dashboard widgets for SNS services. - * - * Object of SNS Service resources by resource name */ - function createTopicWidgets (topicResources: ResourceType): CreateMetricWidget[] { - const topicWidgets: CreateMetricWidget[] = [] - for (const logicalId of Object.keys(topicResources)) { + function createTopicWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::SNS::Topic', compiledTemplate, snsDashConfig) + const topicWidgets: WidgetWithSize[] = [] + for (const logicalId of Object.keys(configuredResources.resources)) { const widgetMetrics: MetricDefs[] = [] - for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(snsDashConfig))) { - if (metricConfig?.enabled !== false) { - for (const stat of metricConfig?.Statistic ?? []) { + const mergedConfig = configuredResources.dashConfigurations[logicalId] + for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(mergedConfig))) { + if (metricConfig.enabled) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/SNS', metric, @@ -503,7 +493,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `SNS Topic \${${logicalId}.TopicName}`, widgetMetrics, - snsDashConfig as Widgets + snsDashConfig ) topicWidgets.push(metricStatWidget) } @@ -516,13 +506,15 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * * Object of EventBridge Service resources by resource name */ - function createRuleWidgets (ruleResources: ResourceType): CreateMetricWidget[] { - const ruleWidgets: CreateMetricWidget[] = [] - for (const [logicalId] of Object.entries(ruleResources)) { + function createRuleWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::Events::Rule', compiledTemplate, ruleDashConfig) + const ruleWidgets: WidgetWithSize[] = [] + for (const [logicalId] of Object.entries(configuredResources.resources)) { const widgetMetrics: MetricDefs[] = [] - for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(ruleDashConfig))) { - if (metricConfig?.enabled !== false) { - for (const stat of metricConfig?.Statistic ?? []) { + const mergedConfig = configuredResources.dashConfigurations[logicalId] + for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(mergedConfig))) { + if (metricConfig.enabled) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/Events', metric, @@ -536,7 +528,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `EventBridge Rule \${${logicalId}}`, widgetMetrics, - ruleDashConfig as Widgets + mergedConfig ) ruleWidgets.push(metricStatWidget) } @@ -546,19 +538,19 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, /** * Create a set of CloudWatch Dashboard widgets for Application Load Balancer services. - * - * Object of Application Load Balancer Service resources by resource name */ - function createLoadBalancerWidgets (loadBalancerResources: ResourceType): CreateMetricWidget[] { - const loadBalancerWidgets: CreateMetricWidget[] = [] - for (const [logicalId] of Object.entries(loadBalancerResources)) { + function createLoadBalancerWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::ElasticLoadBalancingV2::LoadBalancer', compiledTemplate, albDashConfig) + const loadBalancerWidgets: WidgetWithSize[] = [] + for (const [logicalId] of Object.entries(configuredResources.resources)) { const loadBalancerName = `\${${logicalId}.LoadBalancerName}` + const mergedConfig = configuredResources.dashConfigurations[logicalId] const loadBalancerFullName = resolveLoadBalancerFullNameForSub(logicalId) const widgetMetrics: MetricDefs[] = [] - for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(albDashConfig))) { - if (metricConfig?.enabled !== false) { - for (const stat of metricConfig?.Statistic ?? []) { + for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(mergedConfig))) { + if (metricConfig.enabled) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/ApplicationELB', metric, @@ -574,7 +566,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `ALB ${loadBalancerName}`, widgetMetrics, - albDashConfig as Widgets + mergedConfig ) loadBalancerWidgets.push(metricStatWidget) } @@ -588,19 +580,22 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * Object of Application Load Balancer Service Target Group resources by resource name * The full CloudFormation template instance used to look up associated listener and ALB resources */ - function createTargetGroupWidgets (targetGroupResources: ResourceType, compiledTemplate: Template): CreateMetricWidget[] { - const targetGroupWidgets: CreateMetricWidget[] = [] - for (const [tgLogicalId, targetGroupResource] of Object.entries(targetGroupResources)) { + function createTargetGroupWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::ElasticLoadBalancingV2::TargetGroup', compiledTemplate, albTargetDashConfig) + + const targetGroupWidgets: WidgetWithSize[] = [] + for (const [tgLogicalId, targetGroupResource] of Object.entries(configuredResources.resources)) { + const mergedConfig = configuredResources.dashConfigurations[tgLogicalId] const loadBalancerLogicalIds = findLoadBalancersForTargetGroup(tgLogicalId, compiledTemplate) for (const loadBalancerLogicalId of loadBalancerLogicalIds) { const targetGroupFullName = resolveTargetGroupFullNameForSub(tgLogicalId) const loadBalancerFullName = `\${${loadBalancerLogicalId}.LoadBalancerFullName}` const widgetMetrics: MetricDefs[] = [] - for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(albTargetDashConfig))) { - if ((metricConfig?.enabled !== false) && + for (const [metric, metricConfig] of Object.entries(getConfiguredMetrics(mergedConfig))) { + if (metricConfig.enabled && (targetGroupResource.Properties?.TargetType === 'lambda' || !['LambdaUserError', 'LambdaInternalError'].includes(metric)) ) { - for (const stat of metricConfig?.Statistic ?? []) { + for (const stat of metricConfig.Statistic) { widgetMetrics.push({ namespace: 'AWS/ApplicationELB', metric, @@ -617,7 +612,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, const metricStatWidget = createMetricWidget( `Target Group \${${loadBalancerLogicalId}.LoadBalancerName}/\${${tgLogicalId}.TargetGroupName}`, widgetMetrics, - albTargetDashConfig as Widgets + mergedConfig ) targetGroupWidgets.push(metricStatWidget) } @@ -631,42 +626,42 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * * Object of AppSync Service resources by resource name */ - function createAppSyncWidgets (appSyncResources: ResourceType): CreateMetricWidget[] { - const appSyncWidgets: CreateMetricWidget[] = [] + function createAppSyncWidgets (): WidgetWithSize[] { + const configuredResources = getResourceDashboardConfigurationsByType('AWS::AppSync::GraphQLApi', compiledTemplate, appSyncDashConfig) + + const appSyncWidgets: WidgetWithSize[] = [] const metricGroups = { API: ['5XXError', '4XXError', 'Latency', 'Requests'], 'Real-time Subscriptions': ['ConnectServerError', 'DisconnectServerError', 'SubscribeServerError', 'UnsubscribeServerError', 'PublishDataMessageServerError'] } - const metricConfigs = getConfiguredMetrics(appSyncDashConfig) - for (const res of Object.values(appSyncResources)) { + for (const [logicalId, res] of Object.entries(configuredResources.resources)) { const appSyncResourceName: string = res.Properties?.Name - for (const [logicalId] of Object.entries(appSyncResources)) { - const graphQLAPIId = resolveGraphQLId(logicalId) - for (const [group, metrics] of Object.entries(metricGroups)) { - const widgetMetrics: MetricDefs[] = [] - for (const metric of metrics) { - const metricConfig: DashboardBodyProperties | Widgets = metricConfigs[metric] - if (metricConfig?.enabled !== false) { - const stats: string[] = [] - metricConfig?.Statistic?.forEach(stat => stats.push(stat)) - for (const stat of stats) { - widgetMetrics.push({ - namespace: 'AWS/AppSync', - metric, - dimensions: { GraphQLAPIId: graphQLAPIId }, - stat, - yAxis: metricConfig.yAxis - }) - } + const mergedConfig = configuredResources.dashConfigurations[logicalId] + const graphQLAPIId = resolveGraphQLId(logicalId) + for (const [group, metrics] of Object.entries(metricGroups)) { + const widgetMetrics: MetricDefs[] = [] + for (const metric of metrics) { + const metricConfig = mergedConfig[metric] + if (metricConfig.enabled !== false) { + const stats: string[] = [] + metricConfig?.Statistic?.forEach(stat => stats.push(stat)) + for (const stat of stats) { + widgetMetrics.push({ + namespace: 'AWS/AppSync', + metric, + dimensions: { GraphQLAPIId: graphQLAPIId }, + stat: stat as Statistic, + yAxis: metricConfig.yAxis as YAxisPosition + }) } } - if (widgetMetrics.length > 0) { - appSyncWidgets.push(createMetricWidget( - `AppSync ${group} ${appSyncResourceName}`, - widgetMetrics, - sqsDashConfig as Widgets - )) - } + } + if (widgetMetrics.length > 0) { + appSyncWidgets.push(createMetricWidget( + `AppSync ${group} ${appSyncResourceName}`, + widgetMetrics, + mergedConfig + )) } } } @@ -679,7 +674,7 @@ export default function addDashboard (dashboardConfig: SlicWatchDashboardConfig, * A set of dashboard widgets * A set of dashboard widgets with layout properties set */ - function layOutWidgets (widgets: CreateMetricWidget[]) { + function layOutWidgets (widgets: WidgetWithSize[]) { let x = 0 let y = 0 diff --git a/core/dashboards/tests/dashboard.test.ts b/core/dashboards/tests/dashboard.test.ts index b9be9499..07f08b68 100644 --- a/core/dashboards/tests/dashboard.test.ts +++ b/core/dashboards/tests/dashboard.test.ts @@ -6,14 +6,14 @@ import addDashboard from '../dashboard' import defaultConfig from '../../inputs/default-config' import { createTestCloudFormationTemplate, defaultCfTemplate, albCfTemplate, appSyncCfTemplate } from '../../tests/testing-utils' -import { getResourcesByType } from '../../cf-template' +import { type ResourceType, getResourcesByType } from '../../cf-template' +import { type Widgets } from '../dashboard-types' const lambdaMetrics = ['Errors', 'Duration', 'IteratorAge', 'Invocations', 'ConcurrentExecutions', 'Throttles'] -const emptyFuncConfigs = {} test('An empty template creates no dashboard', (t) => { const compiledTemplate = createTestCloudFormationTemplate({ Resources: {} }) - addDashboard(defaultConfig.dashboard, emptyFuncConfigs, compiledTemplate) + addDashboard(defaultConfig.dashboard, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.equal(Object.keys(dashResources).length, 0) @@ -22,7 +22,7 @@ test('An empty template creates no dashboard', (t) => { test('A dashboard includes metrics', (t) => { const compiledTemplate = createTestCloudFormationTemplate() - addDashboard(defaultConfig.dashboard, emptyFuncConfigs, compiledTemplate) + addDashboard(defaultConfig.dashboard, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.equal(Object.keys(dashResources).length, 1) const [, dashResource] = Object.entries(dashResources)[0] @@ -259,7 +259,7 @@ test('A dashboard includes metrics', (t) => { test('A dashboard includes metrics for ALB', (t) => { const compiledTemplate = createTestCloudFormationTemplate((albCfTemplate)) - addDashboard(defaultConfig.dashboard, emptyFuncConfigs, compiledTemplate) + addDashboard(defaultConfig.dashboard, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.equal(Object.keys(dashResources).length, 1) const [, dashResource] = Object.entries(dashResources)[0] @@ -314,10 +314,10 @@ test('A dashboard includes metrics for ALB', (t) => { const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] const dashConfig = _.cloneDeep(defaultConfig.dashboard) for (const service of services) { - dashConfig.widgets[service].enabled = false + (dashConfig.widgets as Widgets)[service].enabled = false } const compiledTemplate = createTestCloudFormationTemplate((appSyncCfTemplate)) - addDashboard(dashConfig, emptyFuncConfigs, compiledTemplate) + addDashboard(dashConfig, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.same(dashResources, {}) t.end() @@ -327,10 +327,10 @@ test('A dashboard includes metrics for ALB', (t) => { const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] const dashConfig = _.cloneDeep(defaultConfig.dashboard) for (const service of services) { - dashConfig.widgets[service].enabled = false + (dashConfig.widgets as Widgets)[service].enabled = false } const compiledTemplate = createTestCloudFormationTemplate((albCfTemplate)) - addDashboard(dashConfig, emptyFuncConfigs, compiledTemplate) + addDashboard(dashConfig, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.same(dashResources, {}) t.end() @@ -362,7 +362,7 @@ test('A dashboard includes metrics for ALB', (t) => { } } }) - addDashboard(defaultConfig.dashboard, emptyFuncConfigs, compiledTemplate) + addDashboard(defaultConfig.dashboard, compiledTemplate) const tgDashResource = Object.values(getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate))[0] const tgDashBody = JSON.parse(tgDashResource.Properties?.DashboardBody['Fn::Sub']) @@ -380,7 +380,7 @@ test('A dashboard includes metrics for ALB', (t) => { test('A dashboard includes metrics for AppSync', (t) => { const compiledTemplate = createTestCloudFormationTemplate((appSyncCfTemplate)) - addDashboard(defaultConfig.dashboard, emptyFuncConfigs, compiledTemplate) + addDashboard(defaultConfig.dashboard, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.equal(Object.keys(dashResources).length, 1) const [, dashResource] = Object.entries(dashResources)[0] @@ -416,10 +416,10 @@ test('A dashboard includes metrics for ALB', (t) => { const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] const dashConfig = _.cloneDeep(defaultConfig.dashboard) for (const service of services) { - dashConfig.widgets[service].enabled = false + (dashConfig.widgets as Widgets)[service].enabled = false } const compiledTemplate = createTestCloudFormationTemplate((appSyncCfTemplate)) - addDashboard(dashConfig, emptyFuncConfigs, compiledTemplate) + addDashboard(dashConfig, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.same(dashResources, {}) t.end() @@ -441,7 +441,7 @@ test('DynamoDB widgets are created without GSIs', (t) => { } const compiledTemplate = createTestCloudFormationTemplate(compTemplates) - addDashboard(defaultConfig.dashboard, emptyFuncConfigs, compiledTemplate) + addDashboard(defaultConfig.dashboard, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) const [, dashResource] = Object.entries(dashResources)[0] @@ -465,10 +465,10 @@ test('No dashboard is created if all widgets are disabled', (t) => { const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] const dashConfig = _.cloneDeep(defaultConfig.dashboard) for (const service of services) { - dashConfig.widgets[service].enabled = false + (dashConfig.widgets as Widgets)[service].enabled = false } const compiledTemplate = createTestCloudFormationTemplate() - addDashboard(dashConfig, emptyFuncConfigs, compiledTemplate) + addDashboard(dashConfig, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.same(dashResources, {}) t.end() @@ -478,25 +478,25 @@ test('No dashboard is created if all metrics are disabled', (t) => { const services = ['Lambda', 'ApiGateway', 'States', 'DynamoDB', 'SQS', 'Kinesis', 'ECS', 'SNS', 'Events', 'ApplicationELB', 'ApplicationELBTarget', 'AppSync'] const dashConfig = _.cloneDeep(defaultConfig.dashboard) for (const service of services) { - dashConfig.widgets[service].enabled = false + (dashConfig.widgets as Widgets)[service].enabled = false } const compiledTemplate = createTestCloudFormationTemplate() - addDashboard(dashConfig, emptyFuncConfigs, compiledTemplate) + addDashboard(dashConfig, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) t.same(dashResources, {}) t.end() }) test('A widget is not created for Lambda if disabled at a function level', (t) => { - const disabledFunctionName = 'serverless-test-project-dev-hello' for (const metric of lambdaMetrics) { - const funcConfigs: any = { - [disabledFunctionName]: { - [metric]: { enabled: false } + const compiledTemplate = createTestCloudFormationTemplate(); + (compiledTemplate.Resources as ResourceType).HelloLambdaFunction.Metadata = { + slicWatch: { + dashboard: { enabled: false } } } - const compiledTemplate = createTestCloudFormationTemplate() - addDashboard(defaultConfig.dashboard, funcConfigs, compiledTemplate) + + addDashboard(defaultConfig.dashboard, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) const [, dashResource] = Object.entries(dashResources)[0] const dashBody = JSON.parse(dashResource.Properties?.DashboardBody['Fn::Sub']) @@ -507,22 +507,52 @@ test('A widget is not created for Lambda if disabled at a function level', (t) = const widgetMetrics = widgets[0].properties.metrics const functionNames = widgetMetrics.map(widgetMetric => widgetMetric[3]) t.ok(functionNames.length > 0) - t.equal(functionNames.indexOf(disabledFunctionName), -1, `${metric} is disabled`) + t.equal(functionNames.indexOf('serverless-test-project-dev-hello'), -1, `${metric} is disabled`) + } + t.end() +}) + +test('A widget is not created for Lambda if disabled at a function level for each metric', (t) => { + for (const metric of lambdaMetrics) { + const compiledTemplate = createTestCloudFormationTemplate(); + (compiledTemplate.Resources as ResourceType).HelloLambdaFunction.Metadata = { + slicWatch: { + dashboard: Object.fromEntries(lambdaMetrics.map((metric) => ([ + metric, { enabled: false } + ]))) + } + } + + addDashboard(defaultConfig.dashboard, compiledTemplate) + const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) + const [, dashResource] = Object.entries(dashResources)[0] + const dashBody = JSON.parse(dashResource.Properties?.DashboardBody['Fn::Sub']) + + const widgets = dashBody.widgets.filter(({ properties: { title } }) => + title.startsWith(`Lambda ${metric}`) + ) + const widgetMetrics = widgets[0].properties.metrics + const functionNames = widgetMetrics.map(widgetMetric => widgetMetric[3]) + t.ok(functionNames.length > 0) + t.equal(functionNames.indexOf('serverless-test-project-dev-hello'), -1, `${metric} is disabled`) } t.end() }) test('No Lambda widgets are created if all metrics for functions are disabled', (t) => { - const funcConfigs = {} const compiledTemplate = createTestCloudFormationTemplate() const allFunctionLogicalIds = Object.keys(getResourcesByType('AWS::Lambda::Function', compiledTemplate)) for (const funcLogicalId of allFunctionLogicalIds) { - funcConfigs[funcLogicalId] = {} - for (const metric of lambdaMetrics) { - funcConfigs[funcLogicalId][metric] = { enabled: false } + (compiledTemplate.Resources as ResourceType)[funcLogicalId].Metadata = { + slicWatch: { + dashboard: Object.fromEntries(lambdaMetrics.map((metric) => ([ + metric, { enabled: false } + ]))) + } } } - addDashboard(defaultConfig.dashboard, funcConfigs, compiledTemplate) + + addDashboard(defaultConfig.dashboard, compiledTemplate) const dashResources = getResourcesByType('AWS::CloudWatch::Dashboard', compiledTemplate) const [, dashResource] = Object.entries(dashResources)[0] const dashBody = JSON.parse(dashResource.Properties?.DashboardBody['Fn::Sub']) diff --git a/core/inputs/default-config.ts b/core/inputs/default-config.ts index 2ee79443..bba15c5c 100644 --- a/core/inputs/default-config.ts +++ b/core/inputs/default-config.ts @@ -1,9 +1,9 @@ import type { SlicWatchCascadedAlarmsConfig, SlicWatchAlarmConfig } from '../alarms/alarm-types' -import type { SlicWatchDashboardConfig } from '../dashboards/dashboard-types' +import type { SlicWatchInputDashboardConfig } from '../dashboards/dashboard-types' export interface DefaultConfig { alarms: SlicWatchCascadedAlarmsConfig - dashboard: SlicWatchDashboardConfig + dashboard: SlicWatchInputDashboardConfig } /** diff --git a/core/inputs/function-config.ts b/core/inputs/function-config.ts deleted file mode 100644 index fb95c318..00000000 --- a/core/inputs/function-config.ts +++ /dev/null @@ -1,35 +0,0 @@ - -import { get, merge } from 'lodash' - -import { cascade } from './cascading-config' -import defaultConfig from './default-config' -import type { SlicWatchLambdaAlarmsConfig } from '../alarms/lambda' -import { type SlicWatchMergedConfig } from '../alarms/alarm-types' - -/** - * Merges the global Lambda alarm configuration with any function-specific overrides, ensuring - * that function overrides take precedence over any global configuration - * - * cascadedLambdaAlarmConfig - * functionAlarmConfig An object per function name specifying any function-specific alarm configuration overrides - * A per-function configuration consolidating all inputs - */ -function applyAlarmConfig (cascadedLambdaAlarmConfig, functionAlarmConfigs): SlicWatchLambdaAlarmsConfig { - // Add all alarm properties to functionAlarmConfig so we can cascade top-level configuration down - const mergedFuncAlarmConfigs = {} - for (const func of Object.keys(functionAlarmConfigs)) { - const funcConfig = { ...(functionAlarmConfigs[func].Lambda ?? {}) } - for (const metric of Object.keys({ ...defaultConfig.alarms.Lambda })) { - funcConfig[metric] = get(functionAlarmConfigs, [func, 'Lambda', metric], {}) - } - mergedFuncAlarmConfigs[func] = merge({}, cascadedLambdaAlarmConfig, cascade(funcConfig)) - } - return mergedFuncAlarmConfigs as SlicWatchLambdaAlarmsConfig -} - -/** - * Support for function-specific configuration for AWS Lambda overrides at a function level - */ -export { - applyAlarmConfig -} diff --git a/core/inputs/general-config.ts b/core/inputs/general-config.ts index 0150192a..12866628 100644 --- a/core/inputs/general-config.ts +++ b/core/inputs/general-config.ts @@ -28,9 +28,9 @@ export class ConfigError extends Error { } /** - * Validates and transforms the user-provided top-configuration for SLIC Watch. The configuration - * is validated accoring to the config schema. Defaults are applied where not provided by the user. - * Finally, the alarm actions are addded. + * Validates and transforms the user-provided top-level configuration for SLIC Watch. + * The configuration is validated accoring to the config schema. Defaults are applied + * where not provided by the user. Finally, the alarm actions are addded. * * @param slicWatchConfig The user-provided configuration * @returns Resolved configuration diff --git a/core/package.json b/core/package.json index 2dceba1d..ff856bd8 100644 --- a/core/package.json +++ b/core/package.json @@ -36,6 +36,7 @@ "yaml": "^1.10.2" }, "devDependencies": { + "cloudwatch-dashboard-types": "^1.0.1-rc2", "ts-node": "^10.9.1" } } diff --git a/core/tests/testing-utils.ts b/core/tests/testing-utils.ts index 24d2bf50..be159e87 100644 --- a/core/tests/testing-utils.ts +++ b/core/tests/testing-utils.ts @@ -35,7 +35,7 @@ function alarmNameToType (alarmName) { return components.join('_') } -function createTestConfig (from, cascadingChanges): any { +function createTestConfig (from, cascadingChanges = {}): any { return cascade( _.merge( {}, diff --git a/package-lock.json b/package-lock.json index d8bec7f1..c9005df9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,6 +89,7 @@ "yaml": "^1.10.2" }, "devDependencies": { + "cloudwatch-dashboard-types": "^1.0.1-rc2", "ts-node": "^10.9.1" } }, @@ -5531,6 +5532,12 @@ "node": ">=4.2.0" } }, + "node_modules/cloudwatch-dashboard-types": { + "version": "1.0.1-rc2", + "resolved": "https://registry.npmjs.org/cloudwatch-dashboard-types/-/cloudwatch-dashboard-types-1.0.1-rc2.tgz", + "integrity": "sha512-8pdwBjVhaGFmEbqtfiqaZeUuf2yRb0oCaYOvHzea+0uFKH9nwVtXwB6R4GNffPk59zpZZ2kIrF9W/V581wnRjQ==", + "dev": true + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -17725,6 +17732,12 @@ "cloudform-types": { "version": "7.4.2" }, + "cloudwatch-dashboard-types": { + "version": "1.0.1-rc2", + "resolved": "https://registry.npmjs.org/cloudwatch-dashboard-types/-/cloudwatch-dashboard-types-1.0.1-rc2.tgz", + "integrity": "sha512-8pdwBjVhaGFmEbqtfiqaZeUuf2yRb0oCaYOvHzea+0uFKH9nwVtXwB6R4GNffPk59zpZZ2kIrF9W/V581wnRjQ==", + "dev": true + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -22165,6 +22178,7 @@ "ajv": "^8.11.0", "case": "^1.6.3", "cloudform": "^7.4.2", + "cloudwatch-dashboard-types": "^1.0.1-rc2", "lodash": "^4.17.21", "pino": "^8.4.2", "ts-node": "^10.9.1", diff --git a/sam-test-project/template.yaml b/sam-test-project/template.yaml index c59475a4..ce186a11 100755 --- a/sam-test-project/template.yaml +++ b/sam-test-project/template.yaml @@ -9,11 +9,10 @@ Transform: Metadata: slicWatch: enabled: true - alarmActionsConfig: { - alarmActions: [!Ref MonitoringTopic], - okActions: [!Ref MonitoringTopic], + alarmActionsConfig: + alarmActions: [!Ref MonitoringTopic] + okActions: [!Ref MonitoringTopic] actionsEnabled: true - } alarms: Lambda: Invocations: diff --git a/serverless-plugin/serverless-plugin.ts b/serverless-plugin/serverless-plugin.ts index 455cc9c1..fe095c14 100644 --- a/serverless-plugin/serverless-plugin.ts +++ b/serverless-plugin/serverless-plugin.ts @@ -2,7 +2,8 @@ import { merge } from 'lodash' import type Serverless from 'serverless' import ServerlessError from 'serverless/lib/serverless-error' import type Hooks from 'serverless-hooks-plugin' - +import { type Template } from 'cloudform-types' +import type Resource from 'cloudform-types/types/resource' import { type ResourceType } from '../core/index' import { addAlarms, addDashboard, pluginConfigSchema, functionConfigSchema } from '../core/index' import { resolveSlicWatchConfig, type ResolvedConfiguration, type SlicWatchConfig } from '../core/inputs/general-config' @@ -56,22 +57,27 @@ class ServerlessPlugin { if (config.enabled) { const awsProvider = this.serverless.getProvider('aws') - const functionAlarmConfigs = {} - const functionDashboardConfigs = {} + const compiledTemplate = this.serverless.service.provider.compiledCloudFormationTemplate as Template + const additionalResources = this.serverless.service.resources as ResourceType + + // Each Lambda Function declared in serverless.yml may have a slicWatch configuration + // to set configuration overrides for the specific function. We transform those into + // CloudFormation Metadata on the generate AWS::Lambda::Function resource for (const funcName of this.serverless.service.getAllFunctions()) { - const func = this.serverless.service.getFunction(funcName) as any // check why they don't return slicWatch - const functionResName = awsProvider.naming.getLambdaLogicalId(funcName) + const func = this.serverless.service.getFunction(funcName) as any const funcConfig = func.slicWatch ?? {} - functionAlarmConfigs[functionResName] = funcConfig.alarms ?? {} - functionDashboardConfigs[functionResName] = funcConfig.dashboard - } + const functionLogicalId = awsProvider.naming.getLambdaLogicalId(funcName) - const compiledTemplate = this.serverless.service.provider.compiledCloudFormationTemplate - const additionalResources = this.serverless.service.resources as ResourceType + const templateResources = compiledTemplate.Resources as Record + templateResources[functionLogicalId].Metadata = { + ...templateResources[functionLogicalId].Metadata ?? {}, + slicWatch: funcConfig + } + } merge(compiledTemplate, additionalResources) - addDashboard(config.dashboard, functionDashboardConfigs, compiledTemplate) - addAlarms(config.alarms, functionAlarmConfigs, config.alarmActionsConfig, compiledTemplate) + addDashboard(config.dashboard, compiledTemplate) + addAlarms(config.alarms, config.alarmActionsConfig, compiledTemplate) } } } diff --git a/serverless-plugin/tests/index.test.ts b/serverless-plugin/tests/index.test.ts index dddcd764..d24129ed 100644 --- a/serverless-plugin/tests/index.test.ts +++ b/serverless-plugin/tests/index.test.ts @@ -73,23 +73,6 @@ const mockServerless = { } } -function compileServerlessFunctionsToCloudformation (functions: Record, provider: () => { - naming: { getLambdaLogicalId: (funcName: string) => string } -}) { - const compiledCloudFormationTemplate = Object.keys(functions).map(lambda => { - const compiledLambdaLogicalId = provider().naming.getLambdaLogicalId(lambda) - const result = {} - result[`${compiledLambdaLogicalId}`] = { - Type: 'AWS::Lambda::Function', - MemorySize: 256, - Runtime: 'nodejs12', - Timeout: 60 - } - return result - }).reduce((accum, currentValue) => ({ ...accum, ...currentValue })) - return { Resources: compiledCloudFormationTemplate } -} - test('index', t => { t.test('plugin uses v3 logger', t => { // Since v3, Serverless Framework provides a logger that we must use to log output @@ -219,77 +202,5 @@ test('index', t => { plugin.createSlicWatchResources() t.end() }) - - t.test('should create only the dashboard when a lambda is not referenced in the serverless functions config', t => { - const mockServerless = { - getProvider: () => { - }, - service: { - getAllFunctions: () => [], - provider: { - name: 'aws', - compiledCloudFormationTemplate: { - Resources: { - HelloTestLambda: { - Type: 'AWS::Lambda::Function', - MemorySize: 256, - Runtime: 'nodejs12', - Timeout: 60 - } - } - } - }, - custom: { - slicWatch: { - enabled: true - } - } - } - } - const plugin = new ServerlessPlugin(mockServerless, null, pluginUtils) - plugin.createSlicWatchResources() - t.same(Object.keys(mockServerless.service.provider.compiledCloudFormationTemplate.Resources), ['HelloTestLambda', 'slicWatchDashboard']) - t.end() - }) - - t.test('should create only the dashboard and lambda alarm when the lambda is referenced in the serverless functions config', t => { - const functions = { MyServerlessFunction: {} } - const provider = () => ({ - naming: { - getLambdaLogicalId: (funcName: string) => { - return funcName[0].toUpperCase() + funcName.slice(1) + 'LambdaFunction' - } - } - }) - const compiledCloudFormationTemplate = compileServerlessFunctionsToCloudformation(functions, provider) - - const mockServerless = { - getProvider: provider, - service: { - getAllFunctions: () => Object.keys(functions), - provider: { - name: 'aws', - compiledCloudFormationTemplate - }, - custom: { - slicWatch: { - enabled: true - } - }, - getFunction: (funcRef) => functions[funcRef] - } - } - const plugin = new ServerlessPlugin(mockServerless, null, pluginUtils) - plugin.createSlicWatchResources() - t.same(Object.keys(mockServerless.service.provider.compiledCloudFormationTemplate.Resources), - [ - 'MyServerlessFunctionLambdaFunction', - 'slicWatchDashboard', - 'slicWatchLambdaErrorsAlarmMyServerlessFunctionLambdaFunction', - 'slicWatchLambdaThrottlesAlarmMyServerlessFunctionLambdaFunction', - 'slicWatchLambdaDurationAlarmMyServerlessFunctionLambdaFunction' - ]) - t.end() - }) t.end() }) diff --git a/tsconfig.json b/tsconfig.json index c026ead6..0cdde55f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "resolveJsonModule": true, "target": "ESNext", "allowSyntheticDefaultImports": true, - "esModuleInterop": true + "esModuleInterop": true, }, "ts-node": { "swc": true,