cdk/lib/recipes-backend.ts (360 lines of code) (raw):
import { GuScheduledLambda } from '@guardian/cdk';
import type { GuStackProps } from '@guardian/cdk/lib/constructs/core';
import { GuParameter, GuStack } from '@guardian/cdk/lib/constructs/core';
import { GuLambdaFunction } from '@guardian/cdk/lib/constructs/lambda';
import { type App, aws_events_targets, aws_sns, Duration } from 'aws-cdk-lib';
import {
Alarm,
ComparisonOperator,
TreatMissingData,
Unit,
} from 'aws-cdk-lib/aws-cloudwatch';
import { SnsAction } from 'aws-cdk-lib/aws-cloudwatch-actions';
import { EventBus, Rule, Schedule } from 'aws-cdk-lib/aws-events';
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda';
import { LambdaDestination } from 'aws-cdk-lib/aws-s3-notifications';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { DataStore } from './datastore';
import { DynamicFronts } from './dynamic-fronts';
import { ExternalParameters } from './external_parameters';
import { FaciaConnection } from './facia-connection';
import { PrintableRecipeGenerator } from './printable-recipe-generator';
import { RecipesReindex } from './recipes-reindex';
import { RestEndpoints } from './rest-endpoints';
import { StaticServing } from './static-serving';
export class RecipesBackend extends GuStack {
constructor(scope: App, id: string, props: GuStackProps) {
super(scope, id, props);
const app = 'recipes-responder';
const serving = new StaticServing(this, 'static');
const store = new DataStore(this, 'store');
const lambdaTimeout = Duration.seconds(30);
new GuLambdaFunction(this, 'testIndexLambda', {
fileName: 'test-indexbuild-lambda.zip',
runtime: Runtime.NODEJS_20_X,
architecture: Architecture.ARM_64,
app: 'recipes-backend-testindex',
handler: 'main.handler',
timeout: lambdaTimeout,
environment: {
STATIC_BUCKET: serving.staticBucket.bucketName,
INDEX_TABLE: store.table.tableName,
LAST_UPDATED_INDEX: store.lastUpdatedIndexName,
},
initialPolicy: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['s3:PutObject', 's3:DeleteObject'],
resources: [serving.staticBucket.bucketArn + '/*'],
}),
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['dynamodb:Scan', 'dynamodb:Query'],
resources: [store.table.tableArn, store.table.tableArn + '/index/*'],
}),
],
});
const externalParameters = new ExternalParameters(this, 'externals');
const nonUrgentAlarmTopic = aws_sns.Topic.fromTopicArn(
this,
'nonurgent-alarm',
externalParameters.nonUrgentAlarmTopicArn.stringValue,
);
const capiKeyParam = new GuParameter(this, 'capiKey', {
fromSSM: true,
default: `/${this.stage}/${this.stack}/${app}/capi-key`,
});
const fastlyKeyParam = new GuParameter(this, 'fastlyKey', {
fromSSM: true,
default: `/${this.stage}/${this.stack}/${app}/fastly-key`,
});
const telemetryTopic = new GuParameter(this, 'TelemetryTopic', {
fromSSM: true,
default: `/${this.stage}/feast/recipe-structuriser/telemetryTopic`,
description:
'ARN of the SNS topic to use for data submissions (shared with structuriser)',
});
const eventBusParam = new GuParameter(this, 'EventBus', {
fromSSM: true,
default: `/${this.stage}/feast/feast-shared-infra/crier-event-bus`,
});
const faciaSNSTopicARNParam = new GuParameter(this, 'faciaSNSTopicParam', {
default: `/${this.stage}/${this.stack}/${app}/facia-sns-topic-arn`,
fromSSM: true,
description:
'The ARN of the facia-tool SNS topic that emits curation notifications',
});
const faciaPublishStatusSNSTopicParam = new GuParameter(
this,
'faciaPublishStatusSNSTopicParam',
{
default: `/${this.stage}/${this.stack}/${app}/facia-status-sns-topic-arn`,
fromSSM: true,
type: 'String',
description:
'The ARN of the facia-tool SNS topic that receives publication status messages',
},
);
const faciaPublishStatusSNSRoleARNParam = new GuParameter(
this,
'faciaPublishStatusSNSTopicRoleParam',
{
default: `/${this.stage}/${this.stack}/${app}/facia-status-sns-topic-role-arn`,
fromSSM: true,
type: 'String',
description:
'The ARN of role that permits us to write to faciaPublishStatusSNSTopic',
},
);
const reindexBatchSizeParam = new GuParameter(
this,
'reindexBatchSizeParam',
{
default: 100,
type: 'Number',
description: 'The size of the batches to write to the reindex stream',
},
);
const reindexWaitTimeParam = new GuParameter(this, 'reindexWaitTimeParam', {
default: 10,
type: 'Number',
description:
'The time to wait between sending batches of reindex messages',
});
const contentUrlBase =
this.stage === 'CODE'
? 'recipes.code.dev-guardianapis.com'
: 'recipes.guardianapis.com';
const capiUrlBase =
this.stage === 'CODE'
? 'content.code.dev-guardianapis.com'
: 'content.guardianapis.com';
const eventBus = EventBus.fromEventBusName(
this,
'CrierEventBus',
eventBusParam.valueAsString,
);
const updaterLambda = new GuLambdaFunction(this, 'updaterLambda', {
functionName: `recipe-responder-${this.stack}-${this.stage}`,
environment: {
CAPI_KEY: capiKeyParam.valueAsString,
INDEX_TABLE: store.table.tableName,
LAST_UPDATED_INDEX: store.lastUpdatedIndexName,
CONTENT_URL_BASE: contentUrlBase,
DEBUG_LOGS: 'true',
FASTLY_API_KEY: fastlyKeyParam.valueAsString,
STATIC_BUCKET: serving.staticBucket.bucketName,
TELEMETRY_TOPIC: telemetryTopic.valueAsString,
OUTGOING_EVENT_BUS: eventBus.eventBusName,
CAPI_BASE_URL:
this.stage === 'PROD'
? 'https://content.guardianapis.com'
: 'https://content.code.dev-guardianapis.com',
},
initialPolicy: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['s3:PutObject', 's3:DeleteObject'],
resources: [serving.staticBucket.bucketArn + '/*'],
}),
new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'dynamodb:Scan',
'dynamodb:Query',
'dynamodb:BatchWriteItem',
'dynamodb:DeleteItem',
'dynamodb:PutItem',
],
resources: [store.table.tableArn, store.table.tableArn + '/index/*'],
}),
new PolicyStatement({
effect: Effect.ALLOW,
resources: ['*'],
actions: ['cloudwatch:PutMetricData'],
}),
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['events:PutEvents'],
resources: [eventBus.eventBusArn],
}),
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['sns:Publish'],
resources: [telemetryTopic.valueAsString],
}),
],
runtime: Runtime.NODEJS_20_X,
app,
handler: 'main.handler',
fileName: `${app}.zip`,
timeout: lambdaTimeout,
});
const responderDLQ = new Queue(this, 'RecipeResponderDLQ', {
queueName: `recipe-responder-${this.stage}-DLQ`,
});
new Rule(this, 'CrierConnection', {
eventBus,
description: `Connect recipe responder ${this.stage} to Crier`,
eventPattern: {
source: ['crier'],
detail: {
channels: ['feast'],
},
},
targets: [
new aws_events_targets.LambdaFunction(updaterLambda, {
deadLetterQueue: responderDLQ,
maxEventAge: Duration.minutes(30),
retryAttempts: 5,
}),
],
});
new Rule(this, 'ReindexConnection', {
eventBus,
description: `Connect recipe responder ${this.stage} to recipes-reindex`,
eventPattern: {
source: ['recipes-reindex'],
},
targets: [
new aws_events_targets.LambdaFunction(updaterLambda, {
deadLetterQueue: responderDLQ,
maxEventAge: Duration.minutes(30),
retryAttempts: 5,
}),
],
});
new FaciaConnection(this, 'RecipesFacia', {
fastlyKeyParam,
serving,
externalParameters,
faciaPublishSNSTopicARN: faciaSNSTopicARNParam.valueAsString,
faciaPublishStatusSNSTopicARN:
faciaPublishStatusSNSTopicParam.valueAsString,
faciaPublishStatusSNSRoleARN:
faciaPublishStatusSNSRoleARNParam.valueAsString,
contentUrlBase,
});
new RestEndpoints(this, 'RestEndpoints', {
servingBucket: serving.staticBucket,
fastlyKey: fastlyKeyParam.valueAsString,
contentUrlBase,
dataStore: store,
});
new RecipesReindex(this, 'RecipeReindex', {
dataStore: store,
contentUrlBase,
reindexBatchSize: reindexBatchSizeParam.valueAsNumber,
reindexWaitTime: reindexWaitTimeParam.valueAsNumber,
eventBus,
});
const durationAlarm = new Alarm(this, 'DurationRuntimeAlarm', {
alarmDescription:
'Recipe backend ingest lambda at 75% of allowed duration',
actionsEnabled: true,
threshold: lambdaTimeout.toMilliseconds() * 0.75,
treatMissingData: TreatMissingData.IGNORE,
comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
metric: updaterLambda.metricDuration({
period: Duration.minutes(3),
statistic: 'Maximum',
unit: Unit.MILLISECONDS,
}),
evaluationPeriods: 3, // when happens at least 3 times
});
durationAlarm.addAlarmAction(new SnsAction(nonUrgentAlarmTopic));
const publishTodaysCurationLambda = new GuScheduledLambda(
this,
'PublishTodaysCuration',
{
app: 'recipes-publish-todays-curation',
architecture: Architecture.ARM_64,
fileName: 'publish-todays-curation.zip',
functionName: `PublishTodaysCuration-${props.stage}`,
handler: 'main.handler',
initialPolicy: [
new PolicyStatement({
effect: Effect.DENY,
actions: ['*'],
resources: [serving.staticBucket.bucketArn + '/content/*'],
}),
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['s3:PutObject', 's3:GetObject'],
resources: [serving.staticBucket.bucketArn + '/*'],
}),
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['s3:ListBucket'],
resources: [serving.staticBucket.bucketArn],
}),
],
memorySize: 256,
monitoringConfiguration: {
noMonitoring: true,
},
rules: [
{
schedule: Schedule.cron({ hour: '0', minute: '1' }),
description: 'Update Feast app daily curation at midnight',
},
],
runtime: Runtime.NODEJS_20_X,
timeout: Duration.seconds(10),
environment: {
STATIC_BUCKET: serving.staticBucket.bucketName,
FASTLY_API_KEY: fastlyKeyParam.valueAsString,
CONTENT_URL_BASE: contentUrlBase,
},
},
);
serving.staticBucket.addObjectCreatedNotification(
new LambdaDestination(publishTodaysCurationLambda),
{ suffix: 'curation.json' },
);
new GuScheduledLambda(this, 'PublishContributors', {
app: 'recipes-publish-contributor-information',
architecture: Architecture.ARM_64,
fileName: 'profile-cache-rebuild.zip',
functionName: `PublishRecipeContributors-${this.stage}`,
handler: 'main.handler',
initialPolicy: [
new PolicyStatement({
effect: Effect.DENY,
actions: ['*'],
resources: [serving.staticBucket.bucketArn + '/content/*'],
}),
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['s3:PutObject', 's3:GetObject'],
resources: [serving.staticBucket.bucketArn + '/*'],
}),
],
memorySize: 256,
monitoringConfiguration: {
noMonitoring: true, //TBD
},
rules: [
{
schedule: Schedule.cron({ minute: '16' }),
description:
'Update cache of contributor information for Feast at 16 minutes past every hour',
},
],
runtime: Runtime.NODEJS_20_X,
timeout: Duration.seconds(30),
environment: {
STATIC_BUCKET: serving.staticBucket.bucketName,
FASTLY_API_KEY: fastlyKeyParam.valueAsString,
CONTENT_URL_BASE: contentUrlBase,
CAPI_BASE_URL: capiUrlBase,
CAPI_KEY: capiKeyParam.valueAsString,
},
});
new DynamicFronts(this, 'DynamicFronts', {
destBucket: serving.staticBucket,
});
new PrintableRecipeGenerator(this, 'PrintableRecipes', { eventBus });
}
}