cdk/lib/index.ts (281 lines of code) (raw):

import { App, Duration, RemovalPolicy, SecretValue, } from "aws-cdk-lib"; import { Certificate } from "aws-cdk-lib/aws-certificatemanager"; import type { GuStackProps } from "@guardian/cdk/lib/constructs/core/stack"; import { GuDnsRecordSet, RecordType, } from "@guardian/cdk/lib/constructs/dns/dns-records"; import { GuStack } from "@guardian/cdk/lib/constructs/core/stack"; import { GuPlayApp } from "@guardian/cdk"; import { GuGetS3ObjectsPolicy, GuPutCloudwatchMetricsPolicy, } from "@guardian/cdk/lib/constructs/iam"; import { GuSecurityGroup, GuVpc } from "@guardian/cdk/lib/constructs/ec2"; import { InstanceType, Port, SubnetType } from "aws-cdk-lib/aws-ec2"; import { GuS3Bucket } from "@guardian/cdk/lib/constructs/s3"; import { AllowedMethods, CachePolicy, Distribution, OriginProtocolPolicy, OriginRequestPolicy, } from "aws-cdk-lib/aws-cloudfront"; import { LoadBalancerV2Origin } from "aws-cdk-lib/aws-cloudfront-origins"; import { Alarm, ComparisonOperator, Metric, TreatMissingData, } from "aws-cdk-lib/aws-cloudwatch"; import { GuDatabaseInstance } from "@guardian/cdk/lib/constructs/rds"; import { Credentials, DatabaseInstanceEngine, PostgresEngineVersion, StorageType, SubnetGroup, } from "aws-cdk-lib/aws-rds"; import { GuArnParameter, GuParameter } from "@guardian/cdk/lib/constructs/core"; import { AccessScope } from "@guardian/cdk/lib/constants/access"; import { Secret } from "aws-cdk-lib/aws-secretsmanager"; export interface TyperighterStackProps extends GuStackProps { domainSuffix: string; instanceCount: number; } export class Typerighter extends GuStack { constructor(scope: App, id: string, props: TyperighterStackProps) { super(scope, id, props); const parameters = { MasterDBUsername: new GuParameter(this, "MasterDBUsername", { description: "Master DB username", default: "rule_manager", type: "String", }), CheckerCertificate: new GuArnParameter( this, "CheckerCloudfrontCertificate", { description: "The ARN of the certificate for the checker service Cloudfront distribution", } ), }; const pandaAuthPolicy = new GuGetS3ObjectsPolicy(this, "PandaAuthPolicy", { bucketName: "pan-domain-auth-settings", }); const permissionsFilePolicyStatement = new GuGetS3ObjectsPolicy(this, "PermissionsPolicy", { bucketName: "permissions-cache", paths: [`${this.stage}/*`] }); const lowercaseStage = this.stage.toLowerCase(); const typerighterBucketName = `typerighter-app-${lowercaseStage}`; // Checker app const checkerAppName = "typerighter-checker"; const checkerDomain = `checker.${props.domainSuffix}` const checkerApp = new GuPlayApp(this, { app: checkerAppName, instanceType: new InstanceType("t4g.small"), userData: `#!/bin/bash -ev mkdir /etc/gu cat > /etc/gu/tags << 'EOF' Stage=${this.stage} Stack=${this.stack} App=${checkerAppName} EOF cat > /etc/gu/${checkerAppName}.conf << 'EOF' include "application" typerighter.ngramPath="/opt/ngram-data" EOF aws --quiet --region ${this.region} s3 cp s3://composer-dist/${this.stack}/${this.stage}/${checkerAppName}/${checkerAppName}.deb /tmp/package.deb dpkg -i /tmp/package.deb chown ${checkerAppName} /usr/share/${checkerAppName}/conf/resources/dictionary`, access: { scope: AccessScope.PUBLIC, }, certificateProps: { domainName: checkerDomain }, monitoringConfiguration: { noMonitoring: true, }, roleConfiguration: { additionalPolicies: [ pandaAuthPolicy, new GuPutCloudwatchMetricsPolicy(this), ], }, scaling: { minimumInstances: props.instanceCount, }, applicationLogging: { enabled: true, systemdUnitName: "typerighter-checker" } }); // Rule manager app const dbPort = 5432; const ruleManagerAppName = "typerighter-rule-manager"; const ruleManagerDomain = `manager.${props.domainSuffix}` const ruleManagerApp = new GuPlayApp(this, { app: ruleManagerAppName, instanceType: new InstanceType("t4g.small"), userData: `#!/bin/bash -ev aws --quiet --region ${this.region} s3 cp s3://composer-dist/${this.stack}/${this.stage}/typerighter-rule-manager/typerighter-rule-manager.deb /tmp/package.deb dpkg -i /tmp/package.deb mkdir /etc/gu cat > /etc/gu/typerighter-rule-manager.conf << 'EOF' typerighter.checkerServiceUrl = "https://${checkerDomain}" EOF `, access: { scope: AccessScope.PUBLIC, }, certificateProps: { domainName: ruleManagerDomain, }, monitoringConfiguration: { noMonitoring: true, }, roleConfiguration: { additionalPolicies: [ pandaAuthPolicy, permissionsFilePolicyStatement ], }, scaling: { minimumInstances: props.instanceCount, }, applicationLogging: { enabled: true, systemdUnitName: "typerighter-rule-manager" } }); const ruleManagerDnsRecord = new GuDnsRecordSet( this, "manager-dns-records", { name: ruleManagerDomain, recordType: RecordType.CNAME, resourceRecords: [ruleManagerApp.loadBalancer.loadBalancerDnsName], ttl: Duration.minutes(60), } ); const typerighterBucket = new GuS3Bucket(this, "typerighter-bucket", { bucketName: typerighterBucketName, app: ruleManagerAppName }); typerighterBucket.grantReadWrite(checkerApp.autoScalingGroup); typerighterBucket.grantReadWrite(ruleManagerApp.autoScalingGroup); const cloudfrontBucket = new GuS3Bucket(this, "cloudfront-bucket", { app: ruleManagerAppName, lifecycleRules: [ { expiration: Duration.days(90), }, ], }); const checkerCertificate = Certificate.fromCertificateArn( this, "CheckerCertificate", parameters.CheckerCertificate.valueAsString ); const checkerOrigin = new LoadBalancerV2Origin(checkerApp.loadBalancer, { protocolPolicy: OriginProtocolPolicy.HTTPS_ONLY, }); const checkerCloudFrontDistro = new Distribution( this, "typerighter-cloudfront", { defaultBehavior: { origin: checkerOrigin, allowedMethods: AllowedMethods.ALLOW_ALL, cachePolicy: CachePolicy.CACHING_DISABLED, originRequestPolicy: OriginRequestPolicy.ALL_VIEWER }, domainNames: [checkerDomain], logBucket: cloudfrontBucket, certificate: checkerCertificate, } ); const checkerDnsRecord = new GuDnsRecordSet(this, "checker-dns-records", { name: checkerDomain, recordType: RecordType.CNAME, resourceRecords: [checkerCloudFrontDistro.domainName], ttl: Duration.minutes(60), }); const ruleMetric = new Metric({ metricName: "RulesNotFound", namespace: "Typerighter", period: Duration.minutes(60), statistic: "Sum", }); const ruleProvisionerAlarm = new Alarm(this, "rule-provisioner-alarm", { alarmName: `Typerighter - ${this.stage} - issue provisioning rules`, alarmDescription: "There was a problem getting rules for Typerighter. Rules might not be present, or might be out of date.", threshold: 0, evaluationPeriods: 3, comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, treatMissingData: TreatMissingData.NOT_BREACHING, metric: ruleMetric, }); // Box-to-box communication const hmacSecret = new Secret(this, "hmacSecret", { description: "Shared secret for HMAC-based communication between manager and checker services", secretName: `/${this.stage}/flexible/typerighter/hmacSecretKey` }); hmacSecret.grantRead(checkerApp.autoScalingGroup.role); hmacSecret.grantRead(ruleManagerApp.autoScalingGroup.role); // Database const dbAppName = "rule-manager-db"; const dbAccessSecurityGroup = new GuSecurityGroup(this, "DBSecurityGroup", { app: ruleManagerAppName, description: "Allow traffic from EC2 instances to DB", vpc: ruleManagerApp.vpc, allowAllOutbound: false, }); dbAccessSecurityGroup.connections.allowFrom( ruleManagerApp.autoScalingGroup, Port.tcp(dbPort), "Allow connection from EC2 instances to DB" ); const ruleDB = new GuDatabaseInstance(this, "RuleManagerRDS", { app: dbAppName, vpc: ruleManagerApp.vpc, vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS }, allocatedStorage: 50, allowMajorVersionUpgrade: false, autoMinorVersionUpgrade: true, deleteAutomatedBackups: false, engine: DatabaseInstanceEngine.postgres({ version: PostgresEngineVersion.VER_13, }), instanceType: "db.t4g.micro", instanceIdentifier: `typerighter-rule-manager-store-${this.stage}`, subnetGroup: new SubnetGroup(this, "DBSubnetGroup", { vpc: ruleManagerApp.vpc, vpcSubnets: { subnets: GuVpc.subnetsFromParameter(this), }, description: "Subnet for typerighter rule-manager database", }), credentials: Credentials.fromPassword( parameters.MasterDBUsername.valueAsString, SecretValue.ssmSecure( `/${this.stage}/${this.stack}/typerighter-rule-manager/db.default.password`, "1" ) ), multiAz: this.stage === "PROD", port: dbPort, preferredMaintenanceWindow: "Mon:06:30-Mon:07:00", securityGroups: [dbAccessSecurityGroup], storageEncrypted: true, storageType: StorageType.GP2, removalPolicy: RemovalPolicy.SNAPSHOT, devXBackups: { enabled: true } }); } }