cdk/lib/manage-frontend.ts (194 lines of code) (raw):
import { readFileSync } from 'fs';
import { join } from 'path';
import { GuEc2App } from '@guardian/cdk';
import { AccessScope } from '@guardian/cdk/lib/constants';
import type { GuStackProps } from '@guardian/cdk/lib/constructs/core';
import { GuStack, GuStringParameter } from '@guardian/cdk/lib/constructs/core';
import {
GuAllowPolicy,
GuGetS3ObjectsPolicy,
GuPutCloudwatchMetricsPolicy,
} from '@guardian/cdk/lib/constructs/iam';
import type { GuAsgCapacity } from '@guardian/cdk/lib/types';
import type { App } from 'aws-cdk-lib';
import { Duration } from 'aws-cdk-lib';
import { CfnDashboard } from 'aws-cdk-lib/aws-cloudwatch';
import {
InstanceClass,
InstanceSize,
InstanceType,
UserData,
} from 'aws-cdk-lib/aws-ec2';
import type { CfnListener } from "aws-cdk-lib/aws-elasticloadbalancingv2";
import { Protocol, SslPolicy } from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
import { CfnRecordSet } from 'aws-cdk-lib/aws-route53';
interface ManageGuStackProps extends GuStackProps {
scaling: GuAsgCapacity;
domain: string;
}
export class ManageFrontend extends GuStack {
constructor(scope: App, id: string, props: ManageGuStackProps) {
super(scope, id, props);
const app = 'manage-frontend';
const hostedZoneId = new GuStringParameter(this, 'hostedZoneId', {
fromSSM: true,
default: '/account/route53/membership/hostedZoneId',
}).valueAsString;
const clientRavenDSN = new GuStringParameter(this, 'clientRavenDSN', {
description: 'the DSN to use with Sentry on the client',
fromSSM: true,
default: `/${this.stage}/${this.stack}/${app}/clientRavenDSN`,
});
const serverRavenDSN = new GuStringParameter(this, 'serverRavenDSN', {
description: 'the DSN to use with Sentry on the server',
fromSSM: true,
default: `/${this.stage}/${this.stack}/${app}/serverRavenDSN`,
});
const userData = UserData.forLinux({ shebang: '#!/bin/bash -ev' });
userData.addCommands(
`# get runnable tar from S3
aws --region ${this.region} s3 cp s3://membership-dist/${this.stack}/${this.stage}/${app}/manage-frontend.zip /tmp
mkdir /etc/gu
unzip /tmp/manage-frontend.zip -d /etc/gu/dist/
# add user
groupadd manage-frontend
useradd -r -s /usr/bin/nologin -g manage-frontend manage-frontend
touch /var/log/manage-frontend.log
chown -R manage-frontend:manage-frontend /etc/gu
chown manage-frontend:manage-frontend /var/log/manage-frontend.log
# write out systemd file
cat >/etc/systemd/system/manage-frontend.service <<EOL
[Service]
ExecStart=/usr/bin/node /etc/gu/dist/server.js
Restart=always
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=manage-frontend
User=manage-frontend
Group=manage-frontend
Environment=STAGE=${this.stage}
Environment=CLIENT_DSN=${clientRavenDSN.valueAsString}
Environment=SERVER_DSN=${serverRavenDSN.valueAsString}
[Install]
WantedBy=multi-user.target
EOL
# RUN
systemctl enable manage-frontend
systemctl start manage-frontend
/opt/cloudwatch-logs/configure-logs application ${this.stack} ${this.stage} ${app} /var/log/manage-frontend.log`,
);
const logGroup = new LogGroup(this, 'ManageFrontendLogGroup', {
logGroupName: `support-manage-frontend-${this.stage}`,
retention: RetentionDays.TWO_WEEKS,
});
this.overrideLogicalId(logGroup, {
logicalId: 'ManageFrontendLogGroup',
reason: 'Retain logicalId previously defined in yaml',
});
// docs https://guardian.github.io/cdk/classes/patterns.GuEc2App.html
const nodeApp = new GuEc2App(this, {
access: { scope: AccessScope.PUBLIC },
applicationPort: 9233, // TODO: why has this number been choosen?
app,
certificateProps: {
domainName: props.domain,
// `dig NS manage.theguardian.com.origin.membership.guardianapis.com` shows the nameserver as `ns-1529.awsdns-63.org`, which is Route53
// https://prism.gutools.co.uk/route53-zones tells us the zone id for 'membership.guardianapis.com'
hostedZoneId: 'Z1E4V12LQGXFEC',
},
instanceType: InstanceType.of(
InstanceClass.T4G,
InstanceSize.SMALL,
),
monitoringConfiguration: {
noMonitoring: true,
},
scaling: props.scaling,
userData,
roleConfiguration: {
additionalPolicies: [
new GuAllowPolicy(this, 'PushLogs', {
actions: [
'logs:CreateLogGroup',
'logs:CreateLogStream',
'logs:PutLogEvents',
],
resources: [logGroup.logGroupArn],
}),
new GuPutCloudwatchMetricsPolicy(this),
// TODO: whats this bucket used for and are we doing the right thing?
new GuGetS3ObjectsPolicy(this, 'ReadPrivateCredentials', {
bucketName: 'gu-reader-revenue-private',
paths: [`${app}/${this.stage}/*`],
}),
// we're using a wild card for the help center content as the bucket only contains public json files needed to display the help center pages
new GuGetS3ObjectsPolicy(this, 'ReadManageHelpContent', {
bucketName: 'manage-help-content',
paths: [`${this.stage}/*`],
}),
new GuGetS3ObjectsPolicy(
this,
'ReadFulfilmentDateCalculatorOutput',
{
bucketName: `fulfilment-date-calculator-${this.stage.toLowerCase()}`,
paths: ['*'],
},
),
new GuAllowPolicy(this, 'DiscoverApiGatewayLambdas', {
actions: ['cloudformation:ListStackResources'],
resources: [
`arn:aws:cloudformation:${this.region}:${this.account}:stack/membership-CODE-*`,
`arn:aws:cloudformation:${this.region}:${this.account}:stack/support-CODE-*`,
`arn:aws:cloudformation:${this.region}:${this.account}:stack/membership-${this.stage}-*`,
`arn:aws:cloudformation:${this.region}:${this.account}:stack/support-${this.stage}-*`,
],
/*
* NOTE: PROD currently requires access to CODE lambdas see here:
* https://github.com/guardian/manage-frontend/wiki/test-users
* and here:
* https://github.com/guardian/manage-frontend/wiki/Proxying-API-Gateway-Lambdas
*
* TODO: Does this provide us with any real benefit (testing code resources in prod)?
*/
}),
new GuAllowPolicy(this, 'DiscoverApiGatewayApiKeys', {
actions: ['apigateway:GET'],
resources: [
`arn:aws:apigateway:${this.region}::/apikeys/*`,
],
}),
new GuAllowPolicy(this, 'InvokeApiGateway', {
actions: ['execute-api:Invoke'],
resources: [
`arn:aws:execute-api:${this.region}:${this.account}:*/CODE/*`,
`arn:aws:execute-api:${this.region}:${this.account}:*/${this.stage}/*`,
],
/*
* NOTE: PROD currently requires access to CODE lambdas see here:
* https://github.com/guardian/manage-frontend/wiki/test-users
* and here:
* https://github.com/guardian/manage-frontend/wiki/Proxying-API-Gateway-Lambdas
*
* TODO: Does this provide us with any real benefit (testing code resources in prod)?
*/
}),
],
},
});
nodeApp.targetGroup.configureHealthCheck({
path: '/_healthcheck',
healthyThresholdCount: 5,
unhealthyThresholdCount: 2,
interval: Duration.seconds(10),
timeout: Duration.seconds(5),
protocol: Protocol.HTTP,
});
(nodeApp.listener.node.defaultChild as CfnListener).sslPolicy = SslPolicy.TLS13_RES;
new CfnRecordSet(this, 'AliasRecord', {
name: props.domain,
type: 'A',
hostedZoneId,
aliasTarget: {
dnsName: nodeApp.loadBalancer.loadBalancerDnsName,
hostedZoneId:
nodeApp.loadBalancer.loadBalancerCanonicalHostedZoneId,
},
});
if (this.stage === 'PROD') {
// TODO: It might be better to understand the shorthand properties of the existing
// dashboard and recreate it using level 2 constructs (cdk/guCDK)
try {
const jsonFilePath = join(__dirname, 'dashboard.json');
const dashboardBody = readFileSync(jsonFilePath, 'utf8');
new CfnDashboard(this, 'CriticalPathsCloudWatchDashboard', {
dashboardBody,
dashboardName: 'manage-frontend',
});
} catch (err: unknown) {
const errorToString =
err instanceof Error ? err.message : String(err);
throw new Error(
`Could not load the dashboard.json file: ${errorToString}`,
);
}
}
}
}