in cdk/lib/gatehouse.ts [63:557]
constructor(scope: App, id: string, props: GatehouseStackProps) {
super(scope, id, props);
const {
stack,
stage,
database: {
minCapacity: serverlessV2MinCapacity,
maxCapacity: serverlessV2MaxCapacity,
},
} = props;
const ec2App = 'gatehouse';
const databasePort = 5432;
const distBucket =
GuDistributionBucketParameter.getInstance(this).valueAsString;
const artifactPath = [
distBucket,
stack,
stage,
ec2App,
`${ec2App}.deb`,
].join('/');
const readAppSsmParamsPolicy = new GuPolicy(
this,
'ReadAppSsmParamsPolicy',
{
statements: [new ReadParametersByName(this, { app: ec2App })],
},
);
// See https://aws-otel.github.io/docs/setup/permissions
const xrayTelemetryPolicy = new GuPolicy(this, 'XrayTelemetryPolicy', {
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'logs:PutLogEvents',
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:DescribeLogStreams',
'logs:DescribeLogGroups',
'logs:PutRetentionPolicy',
'xray:PutTraceSegments',
'xray:PutTelemetryRecords',
'xray:GetSamplingRules',
'xray:GetSamplingTargets',
'xray:GetSamplingStatisticSummaries',
],
resources: ['*'],
}),
],
});
const rdsSecurityGroupId = new GuStringParameter(
this,
'rdsSecurityGroupId',
{
fromSSM: true,
default: `/${stage}/${stack}/${ec2App}/rdsSecurityGroup/id`,
description: 'ID of database security group.',
},
);
const app = new GuPlayApp(this, {
app: ec2App,
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MICRO),
access: { scope: AccessScope.PUBLIC },
userData: UserData.custom(
[
'#!/bin/bash -ev',
// See https://github.com/aws-observability/aws-otel-collector/blob/main/docs/developers/linux-rpm-demo.md
'# Install X-Ray Collector',
'wget -P /tmp https://aws-otel-collector.s3.amazonaws.com/ubuntu/arm64/latest/aws-otel-collector.deb',
'dpkg -i /tmp/aws-otel-collector.deb',
'cat << EOF > /opt/aws/aws-otel-collector/etc/config.yaml',
'# Prepares collector to receive OTLP traces',
'# See https://github.com/open-telemetry/opentelemetry-collector/tree/main/receiver/otlpreceiver#otlp-receiver',
'receivers:',
' otlp:',
' protocols:',
' grpc:',
'processors:',
' # Collects EC2 metadata. In particular, we need the Stage tag to distinguish between prod and non-prod environments',
' # See https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/resourcedetectionprocessor#resource-detection-processor',
' resourcedetection/ec2:',
' detectors:',
' - ec2',
' ec2:',
' tags:',
' - Stage',
' timeout: 2s',
' override: false',
' # Keeps the collector from using more than 20 MiB of memory',
' # See https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/memorylimiterprocessor#memory-limiter-processor',
' memory_limiter:',
' check_interval: 1s',
' limit_mib: 20',
' # Sends batches of up to 50 traces every second',
' # https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/batchprocessor#batch-processor',
' batch/traces:',
' timeout: 1s',
' send_batch_size: 50',
'# Exports traces to AWS X-Ray, allowing Stage to be indexed for filtering',
'# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/awsxrayexporter',
'exporters:',
' awsxray:',
' indexed_attributes:',
' - otel.resource.ec2.tag.Stage',
'# Allows access to AWS APIs',
'# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/awsproxy',
'extensions:',
' awsproxy:',
'# Wires all the resources defined above together',
'service:',
' extensions:',
' - awsproxy',
' pipelines:',
' traces:',
' receivers:',
' - otlp',
' processors:',
' - resourcedetection/ec2',
' - memory_limiter',
' - batch/traces',
' exporters:',
' - awsxray',
'EOF',
// 'echo "loggingLevel=DEBUG" | sudo tee -a /opt/aws/aws-otel-collector/etc/extracfg.txt',
'sudo /opt/aws/aws-otel-collector/bin/aws-otel-collector-ctl -a start',
// See https://aws-otel.github.io/docs/getting-started/java-sdk/auto-instr
'# Install X-Ray Agent',
'sudo mkdir /opt/aws-opentelemetry-agent',
'chmod +rx /opt/aws-opentelemetry-agent',
'wget -P /opt/aws-opentelemetry-agent https://github.com/aws-observability/aws-otel-java-instrumentation/releases/latest/download/aws-opentelemetry-agent.jar',
'# Install app',
`aws --region ${props.env?.region} s3 cp s3://${artifactPath} /tmp/${ec2App}.deb`,
`dpkg -i /tmp/${ec2App}.deb`,
].join('\n'),
),
certificateProps: {
domainName: props.domainName,
},
monitoringConfiguration: { noMonitoring: true },
scaling: {
minimumInstances: 0,
maximumInstances: 2,
},
applicationLogging: {
enabled: true,
systemdUnitName: 'gatehouse',
},
imageRecipe: 'arm-identity-base-jammy-java21-cdk-base',
roleConfiguration: {
additionalPolicies: [readAppSsmParamsPolicy, xrayTelemetryPolicy],
},
});
app.autoScalingGroup.connections.addSecurityGroup(
SecurityGroup.fromSecurityGroupId(
this,
'rdsSecurityGroup',
rdsSecurityGroupId.valueAsString,
{
mutable: false,
},
),
);
// This parameter is used by https://github.com/guardian/waf
new StringParameter(this, 'AlbSsmParam', {
parameterName: `/infosec/waf/services/${stage}/gatehouse-alb-arn`,
description: `The ARN of the ALB for identity-${stage}-gatehouse. N.B. This parameter is created via CDK.`,
simpleName: false,
stringValue: app.loadBalancer.loadBalancerArn,
tier: ParameterTier.STANDARD,
dataType: ParameterDataType.TEXT,
});
new GuCname(this, 'EC2AppDNS', {
app: ec2App,
ttl: Duration.hours(1),
domainName: props.domainName,
resourceRecord: app.loadBalancer.loadBalancerDnsName,
});
const vpc = GuVpc.fromIdParameter(this, 'IdentityVPC');
const rdsSecurityGroupRules = new SecurityGroup(
this,
'RDSSecurityGroupRules',
{
vpc: vpc,
allowAllOutbound: false,
},
);
const rdsSecurityGroupClients = new SecurityGroup(
this,
'RDSSecurityGroupClients',
{
vpc: vpc,
allowAllOutbound: false,
description: 'Allow access to Gatehouse DB from Clients',
},
);
rdsSecurityGroupRules.addIngressRule(
rdsSecurityGroupClients,
Port.tcp(databasePort),
);
const subnets = GuVpc.subnetsFromParameterFixedNumber(
this,
{
type: SubnetType.PRIVATE,
},
3,
);
const cluster = new DatabaseCluster(this, 'GatehouseDb', {
engine: DatabaseClusterEngine.auroraPostgres({
version: AuroraPostgresEngineVersion.VER_16_6,
}),
writer: ClusterInstance.serverlessV2('writer'),
readers: [
// Scale reader instance with writer so that it can deal with immediate traffic spike during failover
ClusterInstance.serverlessV2('reader', { scaleWithWriter: true }),
],
credentials: Credentials.fromPassword(
'postgres',
// This value will be replaced by the escape hatch to use ManagerMasterUserPassword below
SecretValue.secretsManager('uselessWorkaroundSecret'),
),
storageEncrypted: true,
deletionProtection: true,
iamAuthentication: true,
enableDataApi: true,
enableClusterLevelEnhancedMonitoring: true,
enablePerformanceInsights: true,
// Under some scenarios AWS can upgrade the database version without downtime
// However all upgrades, including minor ones, can result in downtime in certain scenarios.
// Our maintenance window during which we can apply upgrades is set to a time when traffic is low.
// https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/USER_UpgradeDBInstance.PostgreSQL.MinorUpgrade.html
autoMinorVersionUpgrade: true,
preferredMaintenanceWindow: 'Wed:04:30-Wed:05:00',
backup: {
retention: Duration.days(14),
},
performanceInsightRetention: PerformanceInsightRetention.DEFAULT,
monitoringInterval: Duration.minutes(1),
defaultDatabaseName: 'gatehouse',
port: databasePort,
serverlessV2MinCapacity,
serverlessV2MaxCapacity,
securityGroups: [rdsSecurityGroupRules],
vpcSubnets: {
subnets,
},
vpc,
parameters: {
// Require verifying SSL before connecting to DB
'rds.force_ssl': '1',
},
});
// Resources tagged with devx-backup-enabled=true will be backed up by the DevX backup service
// https://github.com/guardian/aws-account-setup/blob/42885f5d22dbee137950d4e7500bbb1d7cc1bf77/packages/cdk/lib/aws-backup.ts#L72-L76
Tags.of(cluster).add('devx-backup-enabled', 'true');
// CDK currently does not support ManagerMasterUserPassword
// See https://github.com/aws/aws-cdk/issues/29239
const defaultChild = cluster.node.defaultChild as CfnDBCluster;
defaultChild.addOverride('Properties.ManageMasterUserPassword', true);
defaultChild.addOverride('Properties.MasterUserPassword', undefined);
// Output RDS Client security group as SSM parameter to be used in other stacks.
new StringParameter(this, 'ClientSecurityGroupOutputParameter', {
parameterName: `/${stage}/${stack}/${ec2App}/db-clients-security-group`,
stringValue: rdsSecurityGroupClients.securityGroupId,
});
// Output DB Identifier as SSM parameter to be used in other stacks.
new StringParameter(this, 'DatabaseClusterIdentifierOutputParameter', {
parameterName: `/${stage}/${stack}/${ec2App}/db-identifier`,
stringValue: cluster.clusterIdentifier,
});
new StringParameter(
this,
'DatabaseClusterResourceIdentifierOutputParameter',
{
parameterName: `/${stage}/${stack}/${ec2App}/db-resource-identifier`,
stringValue: cluster.clusterResourceIdentifier,
},
);
const dmsSourceCredentials = new Secret(this, 'DMSSourceCredentials', {});
const dmsTargetCredentials = new Secret(this, 'DMSTargetCredentials', {});
const dmsSourceIamRole = new GuRole(this, 'DMSSourceIamRole', {
assumedBy: new CompositePrincipal(
new ServicePrincipal('dms.amazonaws.com', {
conditions: {
StringEquals: {
'aws:SourceAccount': this.account,
},
},
}),
new ServicePrincipal('dms.eu-west-1.amazonaws.com', {
conditions: {
StringEquals: {
'aws:SourceAccount': this.account,
},
},
}),
new ServicePrincipal('dms-data-migrations.amazonaws.com', {
conditions: {
StringEquals: {
'aws:SourceAccount': this.account,
},
},
}),
),
inlinePolicies: {
retrieveCredentials: new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'secretsmanager:DescribeSecret',
'secretsmanager:GetSecretValue',
],
resources: [dmsSourceCredentials.secretArn],
}),
],
}),
},
});
const dmsMigrationSourceEndpoint = new CfnEndpoint(
this,
'DMSIdentitySourceEndpoint',
{
endpointType: 'source',
engineName: 'postgres',
databaseName: 'identitydb',
sslMode: 'require',
postgreSqlSettings: {
secretsManagerAccessRoleArn: dmsSourceIamRole.roleArn,
secretsManagerSecretId: dmsSourceCredentials.secretArn,
},
},
);
const dmsTargetIamRole = new GuRole(this, 'DMSTargetIamRole', {
assumedBy: new CompositePrincipal(
new ServicePrincipal('dms.amazonaws.com', {
conditions: {
StringEquals: {
'aws:SourceAccount': this.account,
},
},
}),
new ServicePrincipal('dms.eu-west-1.amazonaws.com', {
conditions: {
StringEquals: {
'aws:SourceAccount': this.account,
},
},
}),
new ServicePrincipal('dms-data-migrations.amazonaws.com', {
conditions: {
StringEquals: {
'aws:SourceAccount': this.account,
},
},
}),
),
inlinePolicies: {
retrieveCredentials: new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: [
'secretsmanager:DescribeSecret',
'secretsmanager:GetSecretValue',
],
resources: [dmsTargetCredentials.secretArn],
}),
],
}),
},
});
const dmsMigrationTargetEndpoint = new CfnEndpoint(
this,
'DMSGatehouseTargetEndpoint',
{
endpointType: 'target',
engineName: 'postgres',
databaseName: 'gatehouse',
sslMode: 'require',
postgreSqlSettings: {
secretsManagerAccessRoleArn: dmsTargetIamRole.roleArn,
secretsManagerSecretId: dmsTargetCredentials.secretArn,
},
},
);
const dmsSubnetGroup = new CfnReplicationSubnetGroup(
this,
'DMSReplicationSubnetGroup',
{
replicationSubnetGroupDescription: 'DMS Replication Subnet Group',
subnetIds: subnets.map((subnet) => subnet.subnetId),
},
);
new CfnReplicationConfig(this, 'DMSReplicationConfig', {
replicationConfigIdentifier: `${ec2App}-${stage}`,
replicationSettings: {
Logging: {
EnableLogging: true,
},
},
computeConfig: {
minCapacityUnits: 1,
maxCapacityUnits: 16,
replicationSubnetGroupId: dmsSubnetGroup.ref,
vpcSecurityGroupIds: [
rdsSecurityGroupClients.securityGroupId,
rdsSecurityGroupId.valueAsString,
],
},
replicationType: 'full-load',
sourceEndpointArn: dmsMigrationSourceEndpoint.ref,
targetEndpointArn: dmsMigrationTargetEndpoint.ref,
tableMappings: {
rules: [
{
'rule-id': '1',
'rule-name': '1',
'rule-type': 'selection',
'rule-action': 'include',
'object-locator': {
'schema-name': 'public',
'table-name': 'clients',
},
filters: [],
},
{
'rule-id': '2',
'rule-name': '2',
'rule-type': 'selection',
'rule-action': 'include',
'object-locator': {
'schema-name': 'public',
'table-name': 'clientaccesstokens',
},
filters: [],
},
{
'rule-id': '3',
'rule-name': '3',
'rule-type': 'transformation',
'rule-target': 'table',
'rule-action': 'add-prefix',
'object-locator': {
'schema-name': 'public',
'table-name': 'clients',
},
value: 'identity_',
},
{
'rule-id': '4',
'rule-name': '4',
'rule-type': 'transformation',
'rule-target': 'table',
'rule-action': 'add-prefix',
'object-locator': {
'schema-name': 'public',
'table-name': 'clientaccesstokens',
},
value: 'identity_',
},
],
},
});
}