cdk/lib/alarms-handler.ts (147 lines of code) (raw):
import { GuAlarm } from '@guardian/cdk/lib/constructs/cloudwatch';
import type { GuStackProps } from '@guardian/cdk/lib/constructs/core';
import { GuStack, GuStringParameter } from '@guardian/cdk/lib/constructs/core';
import { GuLambdaFunction } from '@guardian/cdk/lib/constructs/lambda';
import type { App } from 'aws-cdk-lib';
import { Duration } from 'aws-cdk-lib';
import { ComparisonOperator } from 'aws-cdk-lib/aws-cloudwatch';
import {
AnyPrincipal,
Effect,
Policy,
PolicyStatement,
} from 'aws-cdk-lib/aws-iam';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
import { Topic } from 'aws-cdk-lib/aws-sns';
import { SqsSubscription } from 'aws-cdk-lib/aws-sns-subscriptions';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { nodeVersion } from './node-version';
export class AlarmsHandler extends GuStack {
constructor(scope: App, id: string, props: GuStackProps) {
super(scope, id, props);
const app = 'alarms-handler';
const deadLetterQueue = new Queue(this, `dead-letters-${app}-queue`, {
queueName: `dead-letters-${app}-queue-${this.stage}`,
retentionPeriod: Duration.days(14),
});
const queue = new Queue(this, `${app}-queue`, {
queueName: `${app}-queue-${this.stage}`,
deadLetterQueue: {
queue: deadLetterQueue,
maxReceiveCount: 3,
},
});
const buildWebhookParameter = (team: string): GuStringParameter =>
new GuStringParameter(this, `${app}-${team}-webhook`, {
description: `${team} Team Google Chat webhook URL`,
});
const mobileAccountId = new GuStringParameter(
this,
`${app}-mobile-aws-account`,
{
description: 'ID of the mobile aws account',
},
);
const mobileAccountRoleArn = new GuStringParameter(
this,
`${app}-mobile-account-role-arn`,
{
description:
'ARN of role in the mobile account which allows cloudwatch:ListTagsForResource',
},
);
const targetingAccountId = new GuStringParameter(
this,
`${app}-targeting-aws-account`,
{
description: 'ID of the targeting aws account',
},
);
const targetingAccountRoleArn = new GuStringParameter(
this,
`${app}-targeting-account-role-arn`,
{
description:
'ARN of role in the targeting account which allows cloudwatch:ListTagsForResource',
},
);
const lambda = new GuLambdaFunction(this, `${app}-lambda`, {
app,
memorySize: 1024,
fileName: `${app}.zip`,
runtime: nodeVersion,
timeout: Duration.seconds(15),
handler: 'index.handler',
functionName: `${app}-${this.stage}`,
events: [new SqsEventSource(queue)],
environment: {
APP: app,
STACK: this.stack,
STAGE: this.stage,
GROWTH_WEBHOOK: buildWebhookParameter('GROWTH').valueAsString,
PORTFOLIO_WEBHOOK: buildWebhookParameter('PORTFOLIO').valueAsString,
PLATFORM_WEBHOOK: buildWebhookParameter('PLATFORM').valueAsString,
VALUE_WEBHOOK: buildWebhookParameter('VALUE').valueAsString,
SRE_WEBHOOK: buildWebhookParameter('SRE').valueAsString,
// The lambda uses the mobile account role if it needs to fetch tags cross-account
MOBILE_AWS_ACCOUNT_ID: mobileAccountId.valueAsString,
MOBILE_ROLE_ARN: mobileAccountRoleArn.valueAsString,
TARGETING_AWS_ACCOUNT_ID: targetingAccountId.valueAsString,
TARGETING_ROLE_ARN: targetingAccountRoleArn.valueAsString,
},
});
lambda.role?.attachInlinePolicy(
new Policy(this, `${app}-cloudwatch-policy`, {
statements: [
new PolicyStatement({
actions: ['cloudwatch:ListTagsForResource'],
resources: ['*'],
}),
],
}),
);
// Allow the lambda to assume the roles that allow cross-account fetching of tags
lambda.addToRolePolicy(
new PolicyStatement({
actions: ['sts:AssumeRole'],
effect: Effect.ALLOW,
resources: [
mobileAccountRoleArn.valueAsString,
targetingAccountRoleArn.valueAsString,
],
}),
);
const snsTopic = new Topic(this, `${app}-topic`, {
topicName: `${app}-topic-${this.stage}`,
});
snsTopic.addSubscription(new SqsSubscription(queue));
// Allow cross-account publishing to the topic
snsTopic.addToResourcePolicy(
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['sns:Publish'],
// Setting principal to mobileAccountId doesn't work, so we have to restrict the account in the conditions below
principals: [new AnyPrincipal()],
resources: [snsTopic.topicArn],
conditions: {
ArnLike: {
'aws:SourceArn': [
`arn:aws:cloudwatch:eu-west-1:${mobileAccountId.valueAsString}:alarm:*`,
`arn:aws:cloudwatch:eu-west-1:${targetingAccountId.valueAsString}:alarm:*`,
`arn:aws:cloudwatch:eu-west-1:${this.account}:alarm:*`,
],
},
},
}),
);
new GuAlarm(this, `${app}-alarm`, {
app: app,
snsTopicName: snsTopic.topicName,
alarmName: `${this.stage}: Failed to handle CloudWatch alarm`,
alarmDescription: `There was an error in the lambda function that handles CloudWatch alarms.`,
metric: deadLetterQueue
.metric('ApproximateNumberOfMessagesVisible')
.with({ statistic: 'Sum', period: Duration.minutes(1) }),
comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
threshold: 0,
evaluationPeriods: 24,
actionsEnabled: this.stage === 'PROD',
});
}
}