cdk/lib/dotcom-components.ts (268 lines of code) (raw):
import { GuEc2App } from '@guardian/cdk';
import { AccessScope } from '@guardian/cdk/lib/constants';
import type { NoMonitoring } from '@guardian/cdk/lib/constructs/cloudwatch';
import { GuAlarm } from '@guardian/cdk/lib/constructs/cloudwatch';
import type { GuStackProps } from '@guardian/cdk/lib/constructs/core';
import {
GuDistributionBucketParameter,
GuStack,
GuStringParameter,
} from '@guardian/cdk/lib/constructs/core';
import {
GuAllowPolicy,
GuDynamoDBReadPolicy,
GuGetS3ObjectsPolicy,
GuPutCloudwatchMetricsPolicy,
} from '@guardian/cdk/lib/constructs/iam';
import type { Alarms } from '@guardian/cdk/lib/patterns/ec2-app/base';
import type { GuAsgCapacity } from '@guardian/cdk/lib/types';
import type { App } from 'aws-cdk-lib';
import { Duration } from 'aws-cdk-lib';
import {
ComparisonOperator,
Metric,
TreatMissingData,
} from 'aws-cdk-lib/aws-cloudwatch';
import {
InstanceClass,
InstanceSize,
InstanceType,
UserData,
} from 'aws-cdk-lib/aws-ec2';
import type { Policy } from 'aws-cdk-lib/aws-iam';
interface DotcomComponentsProps extends GuStackProps {
domainName: string;
}
export class DotcomComponents extends GuStack {
constructor(scope: App, id: string, props: DotcomComponentsProps) {
super(scope, id, props);
const appName = 'dotcom-components';
const baseUrl = new GuStringParameter(this, 'BaseUrl', {
description: 'Base URL of the service.',
});
const elkStream = new GuStringParameter(this, 'ELKStream', {
description:
'Name of the Kinesis stream used to send logs to the central ELK stack.',
});
// Cloudwatch alarms
const snsTopicName = 'alarms-handler-topic-PROD';
const namespace = `support-${appName}-${this.stage}`;
new GuAlarm(this, 'SuperModeAlarm', {
app: appName,
alarmName: `support-${appName}: Super Mode error - ${this.stage}`,
alarmDescription:
'Error fetching Epic Super Mode data from Dynamodb',
snsTopicName,
metric: new Metric({
metricName: 'super-mode-error',
namespace,
period: Duration.minutes(60),
statistic: 'sum',
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator:
ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: TreatMissingData.NOT_BREACHING,
});
new GuAlarm(this, 'ChannelTestsAlarm', {
app: appName,
alarmName: `support-${appName}: Channel Tests error - ${this.stage}`,
alarmDescription: 'Error fetching channel tests data from Dynamodb',
snsTopicName,
metric: new Metric({
metricName: 'channel-tests-error',
namespace,
period: Duration.minutes(60),
statistic: 'sum',
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator:
ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: TreatMissingData.NOT_BREACHING,
});
new GuAlarm(this, 'LoadBannerDesignsAlarm', {
app: appName,
alarmName: `support-${appName}: Banner Designs loading error - ${this.stage}`,
alarmDescription: 'Error fetching banner designs from Dynamodb',
snsTopicName,
metric: new Metric({
metricName: 'banner-designs-load-error',
namespace,
period: Duration.minutes(60),
statistic: 'sum',
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator:
ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: TreatMissingData.NOT_BREACHING,
});
new GuAlarm(this, 'BanditDataLoadError', {
app: appName,
alarmName: `support-${appName}: Bandit Data loading error - ${this.stage}`,
alarmDescription:
'Error fetching bandit samples data from Dynamodb',
snsTopicName,
metric: new Metric({
metricName: 'bandit-data-load-error',
namespace,
period: Duration.minutes(60),
statistic: 'sum',
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator:
ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: TreatMissingData.NOT_BREACHING,
});
new GuAlarm(this, 'BanditDataSelectionError', {
app: appName,
alarmName: `support-${appName}: Bandit Data selection error - ${this.stage}`,
alarmDescription: 'Error selecting variant for bandit test',
snsTopicName,
metric: new Metric({
metricName: 'bandit-selection-error',
namespace,
period: Duration.minutes(60),
statistic: 'sum',
}),
threshold: 1,
evaluationPeriods: 1,
comparisonOperator:
ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treatMissingData: TreatMissingData.NOT_BREACHING,
});
const userData = UserData.custom(`#!/bin/bash
groupadd support
useradd -r -m -s /usr/bin/nologin -g support dotcom-components
cd /home/dotcom-components
aws --region eu-west-1 s3 cp s3://${
GuDistributionBucketParameter.getInstance(this).valueAsString
}/support/${this.stage}/${appName}/${appName}.tar ./
mkdir ${appName}
tar -xvf ${appName}.tar --directory ${appName}
chown -R dotcom-components:support ${appName}
cd ${appName}
export TERM=xterm-256color
export NODE_ENV=production
export stage=${this.stage}
export base_url=${baseUrl.valueAsString}
mkdir /var/log/dotcom-components
chown -R dotcom-components:support /var/log/dotcom-components
cat > /opt/aws/amazon-cloudwatch-agent/bin/config.json <<'EOF'
{
"metrics": {
"metrics_collected": {
"mem": {
"measurement": [
"mem_available_percent",
"mem_available",
"mem_used_percent",
"mem_used"
],
"metrics_collection_interval": 60
}
},
"aggregation_dimensions" : [["AutoScalingGroupName"]],
"append_dimensions": {
"App": "${appName}",
"Stack": "${this.stack}",
"Stage": "${this.stage}",
"AutoScalingGroupName": "\${aws:AutoScalingGroupName}",
"InstanceId": "\${aws:InstanceId}"
}
}
}
EOF
sudo amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:/opt/aws/amazon-cloudwatch-agent/bin/config.json -s
/usr/local/node/pm2 start --uid dotcom-components --gid support server.js
/opt/aws-kinesis-agent/configure-aws-kinesis-agent ${this.region} ${
elkStream.valueAsString
} /var/log/dotcom-components/dotcom-components.log`);
const policies: Policy[] = [
new GuGetS3ObjectsPolicy(this, 'S3ReadPolicySupportAdminConsole', {
bucketName: 'support-admin-console',
paths: [
`${this.stage}/banner-deploy/*`,
`${this.stage}/channel-switches.json`,
`${this.stage}/configured-amounts-v3.json`,
`${this.stage}/guardian-weekly-propensity-test/*`,
`PROD/auxia-credentials.json`,
],
}),
new GuGetS3ObjectsPolicy(
this,
'S3ReadPolicyGuContributionsPublic',
{
bucketName: 'gu-contributions-public',
paths: [
`epic/${this.stage}/*`,
`header/${this.stage}/*`,
`banner/${this.stage}/*`,
],
},
),
new GuDynamoDBReadPolicy(this, 'DynamoReadPolicy', {
tableName: `super-mode-calculator-${this.stage}`,
}),
// TODO: remove when secondary indexes are included in GuDynamoDBRead
new GuDynamoDBReadPolicy(this, 'DynamoReadPolicySecondaryIndex', {
tableName: `super-mode-calculator-${this.stage}/index/*`,
}),
new GuPutCloudwatchMetricsPolicy(this),
new GuDynamoDBReadPolicy(this, 'DynamoTestsReadPolicy', {
tableName: `support-admin-console-channel-tests-${this.stage}`,
}),
new GuDynamoDBReadPolicy(this, 'DynamoBannerDesignsReadPolicy', {
tableName: `support-admin-console-banner-designs-${this.stage}`,
}),
new GuDynamoDBReadPolicy(this, 'DynamoBanditReadPolicy', {
tableName: `support-bandit-${this.stage}`,
}),
new GuAllowPolicy(this, 'SSMGet', {
actions: ['ssm:GetParameter'],
resources: ['*'],
}),
];
const scaling: GuAsgCapacity = {
minimumInstances: this.stage === 'CODE' ? 1 : 3,
maximumInstances: this.stage === 'CODE' ? 2 : 18,
};
const monitoringConfiguration: Alarms | NoMonitoring =
this.stage === 'PROD'
? {
http5xxAlarm: {
tolerated5xxPercentage: 0.5,
numberOfMinutesAboveThresholdBeforeAlarm: 1,
alarmName: `URGENT 9-5 - high 5XX error rate on ${this.stage} support-dotcom-components`,
},
unhealthyInstancesAlarm: true,
snsTopicName,
}
: { noMonitoring: true };
const ec2App = new GuEc2App(this, {
instanceType: InstanceType.of(
InstanceClass.T4G,
InstanceSize.SMALL,
),
applicationPort: 3030,
app: appName,
access: { scope: AccessScope.PUBLIC },
certificateProps: {
domainName: props.domainName,
},
monitoringConfiguration,
userData,
roleConfiguration: {
additionalPolicies: policies,
},
scaling,
});
ec2App.autoScalingGroup.scaleOnRequestCount(
'RequestCountScalingPolicy',
{
targetRequestsPerMinute: 20000,
},
);
}
}