cdk/lib/discount-expiry-notifier.ts (371 lines of code) (raw):
import type { GuStackProps } from '@guardian/cdk/lib/constructs/core';
import { GuStack } from '@guardian/cdk/lib/constructs/core';
import { GuLambdaFunction } from '@guardian/cdk/lib/constructs/lambda';
import type { App } from 'aws-cdk-lib';
import { aws_cloudwatch, Duration } from 'aws-cdk-lib';
import {
Alarm,
Metric,
Stats,
TreatMissingData,
} from 'aws-cdk-lib/aws-cloudwatch';
import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions';
import { Rule, Schedule } from 'aws-cdk-lib/aws-events';
import { SfnStateMachine } from 'aws-cdk-lib/aws-events-targets';
import {
Effect,
Policy,
PolicyStatement,
Role,
ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { Architecture } from 'aws-cdk-lib/aws-lambda';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Topic } from 'aws-cdk-lib/aws-sns';
import {
DefinitionBody,
JsonPath,
Map,
StateMachine,
} from 'aws-cdk-lib/aws-stepfunctions';
import { LambdaInvoke } from 'aws-cdk-lib/aws-stepfunctions-tasks';
import { nodeVersion } from './node-version';
export class DiscountExpiryNotifier extends GuStack {
constructor(scope: App, id: string, props: GuStackProps) {
super(scope, id, props);
const appName = 'discount-expiry-notifier';
const role = new Role(this, 'query-lambda-role', {
// Set the name of the role rather than using an autogenerated name.
// This is because if the ARN is too long then it breaks the authentication request to GCP
roleName: `disc-exp-alert-${this.stage}`,
assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
});
role.addToPolicy(
new PolicyStatement({
actions: ['ssm:GetParameter'],
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter/discount-expiry-notifier/${this.stage}/gcp-credentials-config`,
],
}),
);
role.addToPolicy(
new PolicyStatement({
actions: [
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:PutLogEvents',
],
resources: ['*'],
}),
);
const allowPutMetric = new PolicyStatement({
effect: Effect.ALLOW,
actions: ['cloudwatch:PutMetricData'],
resources: ['*'],
});
const bucket = new Bucket(this, 'Bucket', {
bucketName: `${appName}-${this.stage.toLowerCase()}`,
});
const getExpiringDiscountsLambda = new GuLambdaFunction(
this,
'get-expiring-discounts-lambda',
{
app: appName,
functionName: `${appName}-get-expiring-discounts-${this.stage}`,
runtime: nodeVersion,
environment: {
Stage: this.stage,
DAYS_UNTIL_DISCOUNT_EXPIRY_DATE: '32',
},
handler: 'getExpiringDiscounts.handler',
fileName: `${appName}.zip`,
architecture: Architecture.ARM_64,
initialPolicy: [allowPutMetric],
timeout: Duration.seconds(300),
role,
},
);
const filterRecordsLambda = new GuLambdaFunction(
this,
'filter-records-lambda',
{
app: appName,
functionName: `${appName}-filter-records-${this.stage}`,
runtime: nodeVersion,
environment: {
Stage: this.stage,
FILTER_BY_REGIONS: 'US,USA,United States,United States of America',
},
handler: 'filterRecords.handler',
fileName: `${appName}.zip`,
architecture: Architecture.ARM_64,
initialPolicy: [allowPutMetric],
},
);
const getSubStatusLambda = new GuLambdaFunction(
this,
'get-sub-status-lambda',
{
app: appName,
functionName: `${appName}-get-sub-status-${this.stage}`,
runtime: nodeVersion,
environment: {
Stage: this.stage,
},
handler: 'getSubStatus.handler',
fileName: `${appName}.zip`,
architecture: Architecture.ARM_64,
initialPolicy: [
new PolicyStatement({
actions: ['secretsmanager:GetSecretValue'],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:${this.stage}/Zuora-OAuth/SupportServiceLambdas-*`,
],
}),
],
},
);
const getOldPaymentAmountLambda = new GuLambdaFunction(
this,
'get-old-payment-amount-lambda',
{
app: appName,
functionName: `${appName}-get-old-payment-amount-${this.stage}`,
runtime: nodeVersion,
environment: {
Stage: this.stage,
},
handler: 'getOldPaymentAmount.handler',
fileName: `${appName}.zip`,
architecture: Architecture.ARM_64,
initialPolicy: [
new PolicyStatement({
actions: ['secretsmanager:GetSecretValue'],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:${this.stage}/Zuora-OAuth/SupportServiceLambdas-*`,
],
}),
],
},
);
const getNewPaymentAmountLambda = new GuLambdaFunction(
this,
'get-new-payment-amount-lambda',
{
app: appName,
functionName: `${appName}-get-new-payment-amount-${this.stage}`,
runtime: nodeVersion,
environment: {
Stage: this.stage,
},
handler: 'getNewPaymentAmount.handler',
fileName: `${appName}.zip`,
architecture: Architecture.ARM_64,
initialPolicy: [
new PolicyStatement({
actions: ['secretsmanager:GetSecretValue'],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:${this.stage}/Zuora-OAuth/SupportServiceLambdas-*`,
],
}),
],
},
);
const sendEmailLambda = new GuLambdaFunction(this, 'send-email-lambda', {
app: appName,
functionName: `${appName}-send-email-${this.stage}`,
runtime: nodeVersion,
environment: {
Stage: this.stage,
S3_BUCKET: bucket.bucketName,
},
handler: 'sendEmail.handler',
fileName: `${appName}.zip`,
architecture: Architecture.ARM_64,
});
const saveResultsLambda = new GuLambdaFunction(
this,
'save-results-lambda',
{
app: appName,
functionName: `${appName}-save-results-${this.stage}`,
runtime: nodeVersion,
environment: {
Stage: this.stage,
S3_BUCKET: bucket.bucketName,
},
handler: 'saveResults.handler',
fileName: `${appName}.zip`,
architecture: Architecture.ARM_64,
initialPolicy: [
new PolicyStatement({
actions: ['s3:GetObject', 's3:PutObject'],
resources: [bucket.arnForObjects('*')],
}),
allowPutMetric,
],
},
);
const alarmOnFailuresLambda = new GuLambdaFunction(
this,
'alarm-on-failures-lambda',
{
app: appName,
functionName: `${appName}-alarm-on-failures-${this.stage}`,
runtime: nodeVersion,
environment: {
Stage: this.stage,
},
handler: 'alarmOnFailures.handler',
fileName: `${appName}.zip`,
architecture: Architecture.ARM_64,
initialPolicy: [allowPutMetric],
},
);
const getExpiringDiscountsLambdaTask = new LambdaInvoke(
this,
'Get expiring discounts',
{
lambdaFunction: getExpiringDiscountsLambda,
outputPath: '$.Payload',
},
).addRetry({
errors: ['States.ALL'],
interval: Duration.seconds(10),
maxAttempts: 2, // Retry only once (1 initial attempt + 1 retry)
});
const filterRecordsLambdaTask = new LambdaInvoke(
this,
'Filter records by region',
{
lambdaFunction: filterRecordsLambda,
outputPath: '$.Payload',
},
);
const getSubStatusLambdaTask = new LambdaInvoke(this, 'Get sub status', {
lambdaFunction: getSubStatusLambda,
outputPath: '$.Payload',
});
const getOldPaymentAmountLambdaTask = new LambdaInvoke(
this,
'Get old payment amount',
{
lambdaFunction: getOldPaymentAmountLambda,
outputPath: '$.Payload',
},
);
const getNewPaymentAmountLambdaTask = new LambdaInvoke(
this,
'Get new payment amount',
{
lambdaFunction: getNewPaymentAmountLambda,
outputPath: '$.Payload',
},
);
const saveResultsLambdaTask = new LambdaInvoke(this, 'Save results', {
lambdaFunction: saveResultsLambda,
outputPath: '$.Payload',
});
const alarmOnFailuresLambdaTask = new LambdaInvoke(
this,
'Alarm on errors',
{
lambdaFunction: alarmOnFailuresLambda,
outputPath: '$.Payload',
},
);
const sendEmailLambdaTask = new LambdaInvoke(this, 'Send email', {
lambdaFunction: sendEmailLambda,
outputPath: '$.Payload',
});
const subStatusFetcherMap = new Map(this, 'Sub status fetcher map', {
maxConcurrency: 10,
itemsPath: JsonPath.stringAt('$.recordsForEmailSend'),
resultPath: '$.discountProcessingAttempts',
});
const expiringDiscountProcessorMap = new Map(
this,
'Expiring discount processor map',
{
maxConcurrency: 10,
itemsPath: JsonPath.stringAt('$.discountProcessingAttempts'),
resultPath: '$.discountProcessingAttempts',
},
);
subStatusFetcherMap.iterator(
getSubStatusLambdaTask
.next(getOldPaymentAmountLambdaTask)
.next(getNewPaymentAmountLambdaTask),
);
expiringDiscountProcessorMap.iterator(sendEmailLambdaTask);
const definitionBody = DefinitionBody.fromChainable(
getExpiringDiscountsLambdaTask
.next(filterRecordsLambdaTask)
.next(subStatusFetcherMap)
.next(expiringDiscountProcessorMap)
.next(saveResultsLambdaTask)
.next(alarmOnFailuresLambdaTask),
);
const sqsInlinePolicy: Policy = new Policy(this, 'sqs-inline-policy', {
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['sqs:GetQueueUrl', 'sqs:SendMessage'],
resources: [
`arn:aws:sqs:${this.region}:${this.account}:braze-emails-${this.stage}`,
],
}),
],
});
sendEmailLambda.role?.attachInlinePolicy(sqsInlinePolicy);
const stateMachine = new StateMachine(
this,
`${appName}-state-machine-${this.stage}`,
{
stateMachineName: `${appName}-${this.stage}`,
definitionBody: definitionBody,
},
);
const cronEveryDayAtNoon = { minute: '0', hour: '12' };
const cronOncePerYear = { minute: '0', hour: '0', day: '1', month: '1' };
const executionFrequency =
this.stage === 'PROD' ? cronEveryDayAtNoon : cronOncePerYear;
new Rule(this, 'ScheduleStateMachineRule', {
schedule: Schedule.cron(executionFrequency),
targets: [new SfnStateMachine(stateMachine)],
enabled: true,
});
const topic = Topic.fromTopicArn(
this,
'Topic',
`arn:aws:sns:${this.region}:${this.account}:alarms-handler-topic-${this.stage}`,
);
const lambdaFunctionsToAlarmOn = [
getExpiringDiscountsLambda,
filterRecordsLambda,
alarmOnFailuresLambda,
];
lambdaFunctionsToAlarmOn.forEach((lambdaFunction, index) => {
const alarm = new Alarm(this, `alarm-${index}`, {
alarmName: `Discount Expiry Notifier - ${lambdaFunction.functionName} - something went wrong - ${this.stage}`,
alarmDescription:
'Something went wrong when executing the Discount Expiry Notifier. See Cloudwatch logs for more information on the error.',
datapointsToAlarm: 1,
evaluationPeriods: 1,
actionsEnabled: true,
comparisonOperator:
aws_cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
metric: new Metric({
metricName: 'Errors',
namespace: 'AWS/Lambda',
statistic: Stats.SUM,
period: Duration.seconds(60),
dimensionsMap: {
FunctionName: lambdaFunction.functionName,
},
}),
threshold: 0,
treatMissingData: TreatMissingData.MISSING,
});
alarm.addAlarmAction(new SnsAction(topic));
});
}
}