packages/cdk/lib/pressreader.ts (210 lines of code) (raw):

import { GuCertificate } from '@guardian/cdk/lib/constructs/acm'; import { GuAlarm } from '@guardian/cdk/lib/constructs/cloudwatch/alarm'; import type { GuStackProps } from '@guardian/cdk/lib/constructs/core'; import { GuStack } from '@guardian/cdk/lib/constructs/core'; import { GuCname } from '@guardian/cdk/lib/constructs/dns/'; import { GuS3Bucket } from '@guardian/cdk/lib/constructs/s3'; import { GuScheduledLambda } from '@guardian/cdk/lib/patterns/scheduled-lambda'; import type { App } from 'aws-cdk-lib'; import { Duration } from 'aws-cdk-lib'; import type { DomainName } from 'aws-cdk-lib/aws-apigateway'; import { AwsIntegration, RestApi } from 'aws-cdk-lib/aws-apigateway'; import { Metric } from 'aws-cdk-lib/aws-cloudwatch'; import type { Schedule } from 'aws-cdk-lib/aws-events'; import { Effect, PolicyStatement, Role, ServicePrincipal, } from 'aws-cdk-lib/aws-iam'; import { LoggingFormat, Runtime } from 'aws-cdk-lib/aws-lambda'; import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; import { Topic } from 'aws-cdk-lib/aws-sns'; import { EmailSubscription } from 'aws-cdk-lib/aws-sns-subscriptions'; import type { EditionKey } from 'packages/shared-types'; export interface PressReaderProps extends GuStackProps { lambdaConfigs: Array<{ editionKey: EditionKey; s3PrefixPath: string[]; schedule: Schedule; }>; domainName: string; /** * If false, no notifications will be sent for this stack. Should be `true` * for 'INFRA' stage, but it can be useful to set to `false` for 'CODE' stage * to reduce noise. Does not affect alarms for regular monitoring of success * or failure for the lambda. */ enableNotifications: boolean; } export class PressReader extends GuStack { constructor(scope: App, id: string, props: PressReaderProps) { super(scope, id, props); const appName = 'pressreader'; const domainName = props.domainName; const enableNotifications = props.enableNotifications; // S3 Bucket const dataBucket = new GuS3Bucket(this, 'PressreaderDataBucket', { app: appName, bucketName: `gu-pressreader-data-${this.stage.toLowerCase()}`, }); // ACM Certificate const certificate = new GuCertificate(this, { app: appName, domainName, }); // API Gateway const apiGateway = new RestApi(this, 'PressReaderAPI', { restApiName: 'Press Reader API', description: 'Serves data to Press Reader from an S3 bucket.', domainName: { domainName, certificate, }, }); const executeRole = new Role(this, 'ApiGatewayS3AssumeRole', { assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), roleName: `APIGatewayS3IntegrationRole${this.stage}`, }); props.lambdaConfigs.forEach((lambdaConfig) => { executeRole.addToPolicy( new PolicyStatement({ resources: [ [dataBucket.bucketArn, ...lambdaConfig.s3PrefixPath, '*'].join('/'), ], actions: ['s3:GetObject'], }), ); const s3Integration = new AwsIntegration({ service: 's3', integrationHttpMethod: 'GET', path: [ dataBucket.bucketName, ...lambdaConfig.s3PrefixPath, '{key}.json', ].join('/'), options: { credentialsRole: executeRole, integrationResponses: [ { statusCode: '200', responseParameters: { 'method.response.header.Content-Type': 'integration.response.header.Content-Type', }, }, ], requestParameters: { 'integration.request.path.key': 'method.request.path.key', }, }, }); apiGateway.root .addResource(lambdaConfig.editionKey) .addResource('{key}') .addMethod('GET', s3Integration, { methodResponses: [ { statusCode: '200', responseParameters: { 'method.response.header.Content-Length': true, 'method.response.header.Content-Type': true, }, }, ], requestParameters: { 'method.request.path.key': true, 'method.request.header.Content-Type': true, }, apiKeyRequired: true, }); }); // create usage plan const usagePlan = apiGateway.addUsagePlan('PressReaderAPIUsagePlan', { throttle: { // Maximum expected average requests per second rateLimit: 10, burstLimit: 10, }, }); // create api key const pressReaderClientApiKey = apiGateway.addApiKey( 'PressReaderClientApiKey', ); // associate api key to plan usagePlan.addApiKey(pressReaderClientApiKey); // associate stage with plan usagePlan.addApiStage({ stage: apiGateway.deploymentStage }); // domain name const apiDomainName = apiGateway.domainName as DomainName; new GuCname(this, 'cname', { app: appName, domainName: domainName, ttl: Duration.days(1), resourceRecord: apiDomainName.domainNameAliasDomainName, }); // metrics const collectionLookupFailureMetric = new Metric({ namespace: 'AWS/Lambda', metricName: `CollectionLookupFailure-${this.stage}`, }); // non-critical alarms if (enableNotifications) { const notificationsSnsTopic = new Topic( this, `${appName}-${this.stage}-email-notifications-topic`, ); const notificationsEmail = `newsroom.resilience+notifications@guardian.co.uk`; notificationsSnsTopic.addSubscription( new EmailSubscription(notificationsEmail), ); new GuAlarm(this, 'CollectionLookupFailureAlarm', { app: appName, metric: collectionLookupFailureMetric, threshold: 1, evaluationPeriods: 1, datapointsToAlarm: 1, snsTopicName: notificationsSnsTopic.topicName, okAction: true, }); } // alarm for GuScheduledLambda built-in monitoring const alarmSnsTopic = new Topic( this, `${appName}-${this.stage}-email-alarm-topic`, ); const alertEmail = `newsroom.resilience+alerts@guardian.co.uk`; alarmSnsTopic.addSubscription(new EmailSubscription(alertEmail)); props.lambdaConfigs.forEach((config) => { const lambdaSuffix = config.editionKey; const capiSecret = new Secret(this, `CapiTokenSecret${lambdaSuffix}`, { secretName: `/${this.stage}/${this.stack}/${appName}/capiToken${lambdaSuffix}`, description: 'The CAPI token used to retrieve content', }); const capiSecretGetPolicyStatement = new PolicyStatement({ effect: Effect.ALLOW, actions: ['secretsmanager:GetSecretValue'], resources: [capiSecret.secretArn], }); const s3PutPolicyStatement = new PolicyStatement({ effect: Effect.ALLOW, actions: ['s3:PutObject'], resources: [ [dataBucket.bucketArn, ...config.s3PrefixPath, '*'].join('/'), ], }); const scheduledLambda = new GuScheduledLambda( this, `${appName}-${this.stage}-${lambdaSuffix}`, { // The riff-raff.yaml auto-generation incorporated // by using GuRoot and outputting to // cdk/cdk.out/riff-raff.yaml when the synth task is // run uses this value to identify what to deploy. // // This value must match one of the contentDirectories // identified in .github/workflows/ci.yml app: `${appName}-${lambdaSuffix}`, runtime: Runtime.NODEJS_20_X, memorySize: 512, handler: 'handler.main', environment: { BUCKET_NAME: dataBucket.bucketName, CAPI_SECRET_LOCATION: capiSecret.secretName, EDITION_KEY: config.editionKey, FAILURE_METRIC_NAME: collectionLookupFailureMetric.metricName, PREFIX_PATH: config.s3PrefixPath.join('/'), }, fileName: `pressreader.zip`, monitoringConfiguration: { snsTopicName: alarmSnsTopic.topicName, toleratedErrorPercentage: 1, lengthOfEvaluationPeriod: Duration.minutes(15), numberOfEvaluationPeriodsAboveThresholdBeforeAlarm: 3, }, loggingFormat: LoggingFormat.TEXT, rules: [{ schedule: config.schedule }], timeout: Duration.seconds(300), }, ); scheduledLambda.addToRolePolicy(capiSecretGetPolicyStatement); scheduledLambda.addToRolePolicy(s3PutPolicyStatement); Metric.grantPutMetricData(scheduledLambda); }); } }