cdk/lib/stack.ts (711 lines of code) (raw):

import { App, aws_apigateway as apiGateway, aws_autoscaling as autoscaling, aws_certificatemanager as acm, aws_cloudwatch as cloudwatch, aws_ec2 as ec2, aws_events as events, aws_events_targets as eventsTargets, aws_iam as iam, aws_lambda as lambda, aws_rds as rds, aws_s3 as S3, aws_ssm as ssm, aws_ses as ses, CfnOutput, Duration, Fn, RemovalPolicy, Stack, Tags, Size, } from "aws-cdk-lib"; import * as appsync from "@aws-cdk/aws-appsync-alpha"; import { join } from "path"; import { APP, DATABASE_BRIDGE_LAMBDA_BASENAME, getDatabaseBridgeLambdaFunctionName, getEmailLambdaFunctionName, getNotificationsLambdaFunctionName, getWorkflowBridgeLambdaFunctionName, NOTIFICATIONS_LAMBDA_BASENAME, WORKFLOW_BRIDGE_LAMBDA_BASENAME, } from "shared/constants"; import crypto from "crypto"; import { ENVIRONMENT_VARIABLE_KEYS } from "shared/environmentVariables"; import { DATABASE_NAME, DATABASE_PORT, DATABASE_USERNAME, getDatabaseJumpHostAsgName, getDatabaseProxyName, } from "shared/database/database"; import { Stage } from "shared/types/stage"; import { MUTATIONS, QUERIES } from "shared/graphql/operations"; import { GuAmiParameter, GuStack, GuStackProps, } from "@guardian/cdk/lib/constructs/core"; import { GuVpc, SubnetType } from "@guardian/cdk/lib/constructs/ec2"; import { GuAutoScalingGroup, GuUserData, } from "@guardian/cdk/lib/constructs/autoscaling"; import { GuAlarm } from "@guardian/cdk/lib/constructs/cloudwatch"; import { GuScheduledLambda } from "@guardian/cdk"; import { EmailIdentity } from "aws-cdk-lib/aws-ses"; import { GuCname } from "@guardian/cdk/lib/constructs/dns"; import { GuLambdaFunction } from "@guardian/cdk/lib/constructs/lambda"; // if changing should also change .nvmrc (at the root of repo) const LAMBDA_NODE_VERSION = lambda.Runtime.NODEJS_22_X; const ALARM_SNS_TOPIC_NAME = "Cloudwatch-Alerts"; interface PinBoardStackProps extends GuStackProps { domainName: string; } export class PinBoardStack extends GuStack { constructor( scope: App, id: string, { domainName, ...props }: PinBoardStackProps ) { super(scope, id, { ...props, app: APP }); const context = Stack.of(this); const account = context.account; const region = context.region; const isPROD = this.stage === "PROD"; const accountVpc = GuVpc.fromIdParameter(this, "AccountVPC", { availabilityZones: Fn.getAzs(region), privateSubnetIds: GuVpc.subnetsFromParameter(this, { app: APP, type: SubnetType.PRIVATE, }).map((subnet) => subnet.subnetId), }); const database = new rds.DatabaseInstance(this, "Database", { instanceIdentifier: `${APP}-db-${this.stage}`, engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_13_7, // RDS Proxy fails to create with a Postgres 14 instance (comment on 22 Aug 2022) }), vpc: accountVpc, port: DATABASE_PORT, databaseName: DATABASE_NAME, credentials: rds.Credentials.fromGeneratedSecret(DATABASE_USERNAME), iamAuthentication: true, storageType: rds.StorageType.GP2, // SSD allocatedStorage: 20, // minimum for GP2 storageEncrypted: true, autoMinorVersionUpgrade: true, instanceType: ec2.InstanceType.of( ec2.InstanceClass.T4G, isPROD ? ec2.InstanceSize.SMALL : ec2.InstanceSize.MICRO ), multiAz: isPROD, publiclyAccessible: false, deleteAutomatedBackups: false, deletionProtection: true, removalPolicy: RemovalPolicy.RETAIN, }); Tags.of(database).add("devx-backup-enabled", "true"); const roleToInvokeLambdaFromRDS = new iam.Role( this, "RoleToInvokeLambdaFromRDS", { assumedBy: new iam.ServicePrincipal("rds.amazonaws.com"), roleName: `${APP}-invoke-lambda-from-RDS-database-${this.stage}`, description: `Give ${APP} RDS Postgres instance permission to invoke certain lambdas`, } ); (database.node.defaultChild as rds.CfnDBInstance).associatedRoles = [ { featureName: "Lambda", roleArn: roleToInvokeLambdaFromRDS.roleArn, }, ]; const databaseProxy = database.addProxy("DatabaseProxy", { dbProxyName: getDatabaseProxyName(this.stage as Stage), vpc: accountVpc, secrets: [database.secret!], iamAuth: true, requireTLS: true, }); const cfnDatabaseProxy = databaseProxy.node.defaultChild as rds.CfnDBProxy; const databaseHostname = databaseProxy.endpoint; const deployBucket = S3.Bucket.fromBucketName( this, "workflow-dist", "workflow-dist" ); const readPinboardParamStorePolicyStatement = new iam.PolicyStatement({ actions: ["ssm:GetParameter"], effect: iam.Effect.ALLOW, resources: [`arn:aws:ssm:${region}:${account}:parameter/${APP}/*`], }); const permissionsFilePolicyStatement = new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["s3:GetObject"], resources: [`arn:aws:s3:::permissions-cache/${this.stage}/*`], //TODO when we guCDK the bootstrapping-lambda, tighten this up and use constants from 'shared/permissions.ts' }); const pandaConfigAndKeyPolicyStatement = new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["s3:GetObject"], resources: [`arn:aws:s3:::pan-domain-auth-settings/*`], //TODO when we guCDK the bootstrapping-lambda, tighten this up and use constants from 'shared/panDomainAuth.ts' (ideally we could limit to the stage specific settings file and anything in stage specific directory }); const workflowDatastoreVpcId = Fn.importValue( `WorkflowDatastoreLoadBalancerSecurityGroupVpcId-${this.stage}` ); const workflowDatastoreVPC = ec2.Vpc.fromVpcAttributes( this, "workflow-datastore-vpc", { vpcId: workflowDatastoreVpcId, availabilityZones: Fn.getAzs(region), privateSubnetIds: Fn.split( ",", Fn.importValue(`WorkflowPrivateSubnetIds-${this.stage}`) ), } ); const pinboardWorkflowBridgeLambda = new lambda.Function( this, WORKFLOW_BRIDGE_LAMBDA_BASENAME, { runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, memorySize: 128, timeout: Duration.seconds(5), handler: "index.handler", environment: { STAGE: this.stage, STACK: this.stack, APP, [ENVIRONMENT_VARIABLE_KEYS.workflowDnsName]: Fn.importValue( `WorkflowDatastoreLoadBalancerDNSName-${this.stage}` ), }, functionName: getWorkflowBridgeLambdaFunctionName(this.stage as Stage), code: lambda.Code.fromBucket( deployBucket, `${this.stack}/${this.stage}/${WORKFLOW_BRIDGE_LAMBDA_BASENAME}/${WORKFLOW_BRIDGE_LAMBDA_BASENAME}.zip` ), role: new iam.Role(this, `${WORKFLOW_BRIDGE_LAMBDA_BASENAME}-role`, { assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), managedPolicies: [ iam.ManagedPolicy.fromAwsManagedPolicyName( "service-role/AWSLambdaBasicExecutionRole" ), iam.ManagedPolicy.fromAwsManagedPolicyName( "service-role/AWSLambdaVPCAccessExecutionRole" ), ], }), vpc: workflowDatastoreVPC, securityGroups: [ ec2.SecurityGroup.fromSecurityGroupId( this, "workflow-datastore-load-balancer-security-group", Fn.importValue( `WorkflowDatastoreLoadBalancerSecurityGroupId-${this.stage}` ) ), ], } ); const gridBridgeLambdaBasename = "pinboard-grid-bridge-lambda"; const pinboardGridBridgeLambda = new lambda.Function( this, gridBridgeLambdaBasename, { runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, memorySize: 128, timeout: Duration.seconds(5), handler: "index.handler", environment: { STAGE: this.stage, STACK: this.stack, APP, }, functionName: `${gridBridgeLambdaBasename}-${this.stage}`, code: lambda.Code.fromBucket( deployBucket, `${this.stack}/${this.stage}/${gridBridgeLambdaBasename}/${gridBridgeLambdaBasename}.zip` ), initialPolicy: [readPinboardParamStorePolicyStatement], } ); const databaseSecurityGroupName = `PinboardDatabaseSecurityGroup${this.stage}`; const databaseSecurityGroup = new ec2.SecurityGroup( this, "DatabaseSecurityGroup", { vpc: accountVpc, allowAllOutbound: true, securityGroupName: databaseSecurityGroupName, } ); databaseSecurityGroup.addIngressRule( ec2.Peer.ipv4("77.91.248.0/21"), ec2.Port.tcp(22), "Allow SSH for tunneling purposes when this security group is reused for database jump host." ); ec2.SecurityGroup.fromSecurityGroupId( this, "databaseProxySecurityGroup", Fn.select(0, cfnDatabaseProxy!.vpcSecurityGroupIds!) ).addIngressRule( ec2.Peer.securityGroupId(databaseSecurityGroup.securityGroupId), ec2.Port.tcp(DATABASE_PORT), `Allow ${databaseSecurityGroupName} to connect to the ${databaseProxy.dbProxyName}` ); const pinboardDatabaseBridgeLambda = new lambda.Function( this, DATABASE_BRIDGE_LAMBDA_BASENAME, { runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, memorySize: 128, timeout: Duration.seconds(30), handler: "index.handler", environment: { STAGE: this.stage, STACK: this.stack, APP, [ENVIRONMENT_VARIABLE_KEYS.databaseHostname]: databaseHostname, }, functionName: getDatabaseBridgeLambdaFunctionName(this.stage as Stage), code: lambda.Code.fromBucket( deployBucket, `${this.stack}/${this.stage}/${DATABASE_BRIDGE_LAMBDA_BASENAME}/${DATABASE_BRIDGE_LAMBDA_BASENAME}.zip` ), initialPolicy: [], vpc: accountVpc, securityGroups: [databaseSecurityGroup], } ); databaseProxy.grantConnect(pinboardDatabaseBridgeLambda); const databaseJumpHostASGName = getDatabaseJumpHostAsgName( this.stage as Stage ); const databaseJumpHostASG = new GuAutoScalingGroup( this, "DatabaseJumpHostASG", { vpc: accountVpc, app: APP, autoScalingGroupName: databaseJumpHostASGName, instanceType: ec2.InstanceType.of( ec2.InstanceClass.T4G, ec2.InstanceSize.NANO ), groupMetrics: [ new autoscaling.GroupMetrics( autoscaling.GroupMetric.IN_SERVICE_INSTANCES ), ], allowAllOutbound: false, minimumInstances: 0, maximumInstances: 1, additionalSecurityGroups: [databaseSecurityGroup], imageId: new GuAmiParameter(this, { app: APP }), userData: new GuUserData(this, { app: APP, distributable: { fileName: "startup.sh", executionStatement: `bash /${APP}/startup.sh ${databaseJumpHostASGName} ${region}`, }, }).userData, } ); databaseProxy.grantConnect(databaseJumpHostASG); databaseJumpHostASG.addToRolePolicy( // allow the instance to effectively terminate itself by reducing the capacity of the ASG that controls it new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["autoscaling:SetDesiredCapacity"], resources: [ `arn:aws:autoscaling:${region}:${account}:*/${databaseJumpHostASGName}`, // unfortunately can't use the databaseJumpHostASG.autoScalingGroupArn property as it's circular ], }) ); new GuAlarm(this, "DatabaseJumpHostOverrunningAlarm", { app: APP, snsTopicName: ALARM_SNS_TOPIC_NAME, alarmName: `${databaseJumpHostASG.autoScalingGroupName} instance running for more than 12 hours`, alarmDescription: `The ${APP} database 'jump host' should not run for more than 12 hours as it suggests the mechanism to shut it down when it's idle looks to be broken`, metric: new cloudwatch.Metric({ metricName: "GroupInServiceInstances", namespace: "AWS/AutoScaling", dimensionsMap: { AutoScalingGroupName: databaseJumpHostASG.autoScalingGroupName, }, period: Duration.hours(1), }), comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, threshold: 0, evaluationPeriods: 12, actionsEnabled: true, okAction: true, }); const pinboardNotificationsLambda = new lambda.Function( this, NOTIFICATIONS_LAMBDA_BASENAME, { vpc: accountVpc, runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, memorySize: 128, timeout: Duration.seconds(30), handler: "index.handler", environment: { STAGE: this.stage, STACK: this.stack, APP, }, functionName: getNotificationsLambdaFunctionName(this.stage as Stage), code: lambda.Code.fromBucket( deployBucket, `${this.stack}/${this.stage}/${NOTIFICATIONS_LAMBDA_BASENAME}/${NOTIFICATIONS_LAMBDA_BASENAME}.zip` ), initialPolicy: [readPinboardParamStorePolicyStatement], } ); pinboardNotificationsLambda.grantInvoke(roleToInvokeLambdaFromRDS); const pinboardAuthLambdaBasename = "pinboard-auth-lambda"; const pinboardAuthLambda = new lambda.Function( this, pinboardAuthLambdaBasename, { runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, memorySize: 128, timeout: Duration.seconds(11), handler: "index.handler", environment: { STAGE: this.stage, STACK: this.stack, APP, }, functionName: `${pinboardAuthLambdaBasename}-${this.stage}`, code: lambda.Code.fromBucket( deployBucket, `${this.stack}/${this.stage}/${pinboardAuthLambdaBasename}/${pinboardAuthLambdaBasename}.zip` ), initialPolicy: [pandaConfigAndKeyPolicyStatement], } ); const gqlSchema = appsync.Schema.fromAsset( join(__dirname, "../../shared/graphql/schema.graphql") ); const pinboardAppsyncApiBaseName = "pinboard-appsync-api"; const pinboardAppsyncApi = new appsync.GraphqlApi( this, pinboardAppsyncApiBaseName, { name: `${pinboardAppsyncApiBaseName}-${this.stage}`, schema: gqlSchema, authorizationConfig: { defaultAuthorization: { authorizationType: appsync.AuthorizationType.LAMBDA, lambdaAuthorizerConfig: { handler: pinboardAuthLambda, resultsCacheTtl: Duration.seconds(30), }, }, }, xrayEnabled: true, } ); const pinboardWorkflowBridgeLambdaDataSource = pinboardAppsyncApi.addLambdaDataSource( `${WORKFLOW_BRIDGE_LAMBDA_BASENAME.replace("pinboard-", "") .split("-") .join("_")}_ds`, pinboardWorkflowBridgeLambda ); const pinboardGridBridgeLambdaDataSource = pinboardAppsyncApi.addLambdaDataSource( `${gridBridgeLambdaBasename .replace("pinboard-", "") .split("-") .join("_")}_ds`, pinboardGridBridgeLambda ); const pinboardDatabaseBridgeLambdaDataSource = pinboardAppsyncApi.addLambdaDataSource( `${DATABASE_BRIDGE_LAMBDA_BASENAME.replace("pinboard-", "") .split("-") .join("_")}_ds`, pinboardDatabaseBridgeLambda ); const gqlSchemaChecksum = crypto .createHash("md5") .update(gqlSchema.definition, "utf8") .digest("hex"); // workaround for resolvers sometimes getting disconnected // see https://github.com/aws/aws-appsync-community/issues/146 const resolverBugWorkaround = (mappingTemplate: appsync.MappingTemplate) => appsync.MappingTemplate.fromString( `## schema checksum : ${gqlSchemaChecksum}\n${mappingTemplate.renderTemplate()}` ); const createLambdaResolver = (lambdaDS: appsync.LambdaDataSource, typeName: "Query" | "Mutation") => (fieldName: string) => { lambdaDS.createResolver({ typeName, fieldName, responseMappingTemplate: resolverBugWorkaround( appsync.MappingTemplate.lambdaResult() ), }); }; QUERIES.database.forEach( createLambdaResolver(pinboardDatabaseBridgeLambdaDataSource, "Query") ); MUTATIONS.database.forEach( createLambdaResolver(pinboardDatabaseBridgeLambdaDataSource, "Mutation") ); QUERIES.workflow.forEach( createLambdaResolver(pinboardWorkflowBridgeLambdaDataSource, "Query") ); QUERIES.grid.forEach( createLambdaResolver(pinboardGridBridgeLambdaDataSource, "Query") ); const usersRefresherLambdaBasename = "pinboard-users-refresher-lambda"; const usersRefresherLambdaFunction = new lambda.Function( this, usersRefresherLambdaBasename, { runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, memorySize: 512, timeout: Duration.minutes(15), handler: "index.handler", environment: { STAGE: this.stage, STACK: this.stack, APP, [ENVIRONMENT_VARIABLE_KEYS.databaseHostname]: databaseHostname, }, functionName: `${usersRefresherLambdaBasename}-${this.stage}`, code: lambda.Code.fromBucket( deployBucket, `${this.stack}/${this.stage}/${usersRefresherLambdaBasename}/${usersRefresherLambdaBasename}.zip` ), initialPolicy: [ permissionsFilePolicyStatement, readPinboardParamStorePolicyStatement, ], vpc: accountVpc, securityGroups: [databaseSecurityGroup], } ); databaseProxy.grantConnect(usersRefresherLambdaFunction); new events.Rule( this, `${usersRefresherLambdaBasename}-schedule-isProcessPermissionChangesOnly`, { description: `Runs the ${usersRefresherLambdaFunction.functionName} every minute, with 'isProcessPermissionChangesOnly: true'.`, enabled: true, targets: [ new eventsTargets.LambdaFunction(usersRefresherLambdaFunction, { event: events.RuleTargetInput.fromObject({ isProcessPermissionChangesOnly: true, }), }), ], schedule: events.Schedule.rate(Duration.minutes(1)), } ); new events.Rule(this, `${usersRefresherLambdaBasename}-schedule-FULL-RUN`, { description: `Runs the ${usersRefresherLambdaFunction.functionName} every 24 hours, which should be a FULL RUN.`, enabled: true, targets: [new eventsTargets.LambdaFunction(usersRefresherLambdaFunction)], schedule: events.Schedule.rate(Duration.days(1)), }); const archiverLambda = new GuScheduledLambda(this, "ArchiverLambda", { app: APP, vpc: accountVpc, securityGroups: [databaseSecurityGroup], functionName: `pinboard-archiver-lambda-${this.stage}`, runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, handler: "index.handler", environment: { [ENVIRONMENT_VARIABLE_KEYS.databaseHostname]: databaseHostname, }, monitoringConfiguration: { toleratedErrorPercentage: 0, snsTopicName: ALARM_SNS_TOPIC_NAME, okAction: true, }, fileName: "pinboard-archiver-lambda.zip", rules: [ { schedule: events.Schedule.rate(Duration.hours(6)), description: "Run every 6 hours to ensure pinboards get cleaned out regularly", }, ], }); pinboardWorkflowBridgeLambda.grantInvoke(archiverLambda); databaseProxy.grantConnect(archiverLambda); const sesVerifiedIdentity = new EmailIdentity(this, "EmailIdentity", { identity: ses.Identity.domain(domainName), }); sesVerifiedIdentity.dkimRecords.forEach(({ name, value }, index) => { new GuCname(this, `EmailIdentityDkim${index}`, { app: APP, domainName: name, resourceRecord: value, ttl: Duration.hours(1), }); }); const emailLambda = new GuLambdaFunction(this, "EmailLambda", { app: APP, vpc: accountVpc, securityGroups: [databaseSecurityGroup], functionName: getEmailLambdaFunctionName(this.stage as Stage), runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, handler: "index.handler", environment: { [ENVIRONMENT_VARIABLE_KEYS.databaseHostname]: databaseHostname, }, errorPercentageMonitoring: { toleratedErrorPercentage: 0, snsTopicName: ALARM_SNS_TOPIC_NAME, okAction: true, }, fileName: "pinboard-email-lambda.zip", initialPolicy: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["ses:SendEmail"], resources: [ `arn:aws:ses:${this.region}:${this.account}:identity/${sesVerifiedIdentity.emailIdentityName}`, ], }), ], }); pinboardWorkflowBridgeLambda.grantInvoke(emailLambda); emailLambda.grantInvoke(roleToInvokeLambdaFromRDS); databaseProxy.grantConnect(emailLambda); const bootstrappingLambdaBasename = "pinboard-bootstrapping-lambda"; const bootstrappingLambdaApiBaseName = `${bootstrappingLambdaBasename}-api`; const bootstrappingLambdaFunctionName = `${bootstrappingLambdaBasename}-${this.stage}`; const bootstrappingLambdaFunction = new lambda.Function( this, bootstrappingLambdaBasename, { runtime: LAMBDA_NODE_VERSION, architecture: lambda.Architecture.ARM_64, memorySize: 256, timeout: Duration.seconds(5), handler: "index.handler", environment: { STAGE: this.stage, STACK: this.stack, APP, [ENVIRONMENT_VARIABLE_KEYS.graphqlEndpoint]: pinboardAppsyncApi.graphqlUrl, [ENVIRONMENT_VARIABLE_KEYS.sentryDSN]: ssm.StringParameter.valueForStringParameter( this, "/pinboard/sentryDSN" ), }, functionName: bootstrappingLambdaFunctionName, code: lambda.Code.fromBucket( deployBucket, `${this.stack}/${this.stage}/${bootstrappingLambdaApiBaseName}/${bootstrappingLambdaApiBaseName}.zip` ), initialPolicy: [ pandaConfigAndKeyPolicyStatement, permissionsFilePolicyStatement, new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["lambda:InvokeFunction"], resources: [ `arn:aws:lambda:${region}:${account}:function:${DATABASE_BRIDGE_LAMBDA_BASENAME}-${ isPROD ? "*" : this.stage }`, ], }), ], } ); const bootstrappingApiGatewayName = `${bootstrappingLambdaApiBaseName}-${this.stage}`; const bootstrappingApiGateway = new apiGateway.LambdaRestApi( this, bootstrappingLambdaApiBaseName, { restApiName: bootstrappingApiGatewayName, handler: bootstrappingLambdaFunction, endpointTypes: [apiGateway.EndpointType.REGIONAL], policy: new iam.PolicyDocument({ statements: [ new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ["execute-api:Invoke"], resources: [`arn:aws:execute-api:${region}:*`], principals: [new iam.AnyPrincipal()], }), ], }), defaultMethodOptions: { apiKeyRequired: false, }, deployOptions: { stageName: "api", }, minCompressionSize: Size.bytes(0), // gzip responses where the client (i.e. browser) supports it (via 'Accept-Encoding' header) } ); new GuAlarm(this, `${bootstrappingLambdaApiBaseName}Alarm`, { app: APP, snsTopicName: ALARM_SNS_TOPIC_NAME, alarmName: `${bootstrappingApiGatewayName} 5XX errors`, alarmDescription: `The ${bootstrappingApiGatewayName} gateway is experiencing 5XX errors`, metric: new cloudwatch.Metric({ metricName: "5XXError", namespace: "AWS/ApiGateway", dimensionsMap: { ApiName: bootstrappingApiGatewayName, }, period: Duration.minutes(5), }), comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, threshold: 0.05, evaluationPeriods: isPROD ? 2 : 6, // CODE invocations are fewer and more sporadic so let's have more tolerance there actionsEnabled: true, okAction: true, }); const bootstrappingApiCertificate = new acm.Certificate( this, `${bootstrappingLambdaApiBaseName}-certificate`, { domainName, validation: acm.CertificateValidation.fromDns(), } ); const bootstrappingApiDomainName = new apiGateway.DomainName( this, `${bootstrappingLambdaApiBaseName}-domain-name`, { domainName, certificate: bootstrappingApiCertificate, endpointType: apiGateway.EndpointType.REGIONAL, } ); bootstrappingApiDomainName.addBasePathMapping(bootstrappingApiGateway, { basePath: "", }); new CfnOutput(this, `${bootstrappingLambdaApiBaseName}-hostname`, { description: `${bootstrappingLambdaApiBaseName}-hostname`, value: `${bootstrappingApiDomainName.domainNameAliasDomainName}`, }); new CfnOutput(this, `BootstrappingLambdaFunctionName`, { exportName: `${bootstrappingLambdaFunctionName}-function-name`, description: `${bootstrappingLambdaFunctionName} function name`, value: `${bootstrappingLambdaFunction.functionName}`, }); } }