constructor()

in cdk/lib/recipes-backend.ts [28:389]


	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 });
	}