diff --git a/README.md b/README.md index 551ed50c4..9f41e1bc7 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,14 @@ This repository uses 3 webhooks to publish to 3 different channels all in CMS Sl - Secrets are added to GitHub secrets by GitHub Admins - Development secrets are maintained in a 1Password vault +## Deployment + +While application deployment is generally handled by Github Actions, when you initially set up a new AWS account to host this application, you'll need to deploy a prerequisite stack like so: +```bash +./run deploy-prerequisites +``` +That will create a stack called `seds-prerequisites` which will contain resources needed by any application stacks. + ## License [![License](https://img.shields.io/badge/License-CC0--1.0--Universal-blue.svg)](https://creativecommons.org/publicdomain/zero/1.0/legalcode) diff --git a/deployment/deployment-config.ts b/deployment/deployment-config.ts index be6ef597f..f81a1be2c 100644 --- a/deployment/deployment-config.ts +++ b/deployment/deployment-config.ts @@ -33,7 +33,7 @@ export const determineDeploymentConfig = async (stage: string) => { return config; }; -const loadDefaultSecret = async (project: string) => { +export const loadDefaultSecret = async (project: string) => { return JSON.parse((await getSecret(`${project}-default`))!); }; diff --git a/deployment/prerequisites.ts b/deployment/prerequisites.ts new file mode 100644 index 000000000..6f4fcdb58 --- /dev/null +++ b/deployment/prerequisites.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import "source-map-support/register"; +import { + aws_apigateway as apigateway, + aws_iam as iam, + App, + DefaultStackSynthesizer, + Stack, + StackProps, + Tags, +} from "aws-cdk-lib"; +import { CloudWatchLogsResourcePolicy } from "./constructs/cloudwatch-logs-resource-policy"; +import { loadDefaultSecret } from "./deployment-config"; +import { getSecret } from "./utils/secrets-manager"; +import { Construct } from "constructs"; + +interface PrerequisiteConfigProps { + project: string; + iamPath: string; + iamPermissionsBoundaryArn: string; +} + +export class PrerequisiteStack extends Stack { + constructor( + scope: Construct, + id: string, + props: StackProps & PrerequisiteConfigProps + ) { + super(scope, id, props); + + const { + project, + iamPermissionsBoundaryArn, + iamPath, + } = props; + + new CloudWatchLogsResourcePolicy(this, "logPolicy", { project }); + + const cloudWatchRole = new iam.Role( + this, + "ApiGatewayRestApiCloudWatchRole", + { + assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"), + permissionsBoundary: iam.ManagedPolicy.fromManagedPolicyArn( + this, + "iamPermissionsBoundary", + iamPermissionsBoundaryArn + ), + path: iamPath, + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AmazonAPIGatewayPushToCloudWatchLogs" + ), + ], + } + ); + + new apigateway.CfnAccount( + this, + "ApiGatewayRestApiAccount", + { + cloudWatchRoleArn: cloudWatchRole.roleArn, + } + ); + } +} + +async function main() { + const app = new App({ + defaultStackSynthesizer: new DefaultStackSynthesizer( + JSON.parse((await getSecret("cdkSynthesizerConfig"))!) + ), + }); + + Tags.of(app).add("PROJECT", "SEDS"); + + const project = process.env.PROJECT!; + new PrerequisiteStack(app, "seds-prerequisites", { + project, + ...(await loadDefaultSecret(project)), + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, + }); +} + +main(); diff --git a/deployment/stacks/api.ts b/deployment/stacks/api.ts index 4455df074..793bf839c 100644 --- a/deployment/stacks/api.ts +++ b/deployment/stacks/api.ts @@ -110,31 +110,6 @@ export function createApiComponents(props: CreateApiComponentsProps) { }, }); - const cloudWatchRole = new iam.Role( - scope, - "ApiGatewayRestApiCloudWatchRole", - { - assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"), - permissionsBoundary: props.iamPermissionsBoundary, - path: props.iamPath, - managedPolicies: [ - iam.ManagedPolicy.fromAwsManagedPolicyName( - "service-role/AmazonAPIGatewayPushToCloudWatchLogs" - ), - ], - } - ); - cloudWatchRole.applyRemovalPolicy(RemovalPolicy.RETAIN); - - const apiGatewayRestApiAccount = new apigateway.CfnAccount( - scope, - "ApiGatewayRestApiAccount", - { - cloudWatchRoleArn: cloudWatchRole.roleArn, - } - ); - apiGatewayRestApiAccount.applyRemovalPolicy(RemovalPolicy.RETAIN); - const environment = { BOOTSTRAP_BROKER_STRING_TLS: brokerString, stage, @@ -480,8 +455,7 @@ export function createApiComponents(props: CreateApiComponentsProps) { webAclArn: waf.webAcl.attrArn, }); - - if (!isDev) { // resources that must be in AWS account + if (!isDev) { const logBucket = new s3.Bucket(scope, "WafLogBucket", { encryption: s3.BucketEncryption.S3_MANAGED, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, diff --git a/deployment/stacks/parent.ts b/deployment/stacks/parent.ts index 15c54bf4c..c555b3d14 100644 --- a/deployment/stacks/parent.ts +++ b/deployment/stacks/parent.ts @@ -7,7 +7,6 @@ import { Stack, StackProps, } from "aws-cdk-lib"; -import { CloudWatchLogsResourcePolicy } from "../constructs/cloudwatch-logs-resource-policy"; import { DeploymentConfigProperties } from "../deployment-config"; import { createDataComponents } from "./data"; import { createUiAuthComponents } from "./ui-auth"; @@ -54,10 +53,6 @@ export class ParentStack extends Stack { const vpc = ec2.Vpc.fromLookup(this, "Vpc", { vpcName }); const privateSubnets = sortSubnets(vpc.privateSubnets).slice(0, 3); - if (!isDev) { // resources that must be in AWS account - new CloudWatchLogsResourcePolicy(this, "logPolicy", { project }); - } - const { customResourceRole } = createCustomResourceRole({ ...commonProps }); const { tables } = createDataComponents({ diff --git a/deployment/stacks/ui.ts b/deployment/stacks/ui.ts index 1b2bf00a8..89e174a51 100644 --- a/deployment/stacks/ui.ts +++ b/deployment/stacks/ui.ts @@ -91,6 +91,9 @@ export function createUiComponents(props: CreateUiComponentsProps) { }, } ); + securityHeadersPolicy.applyRemovalPolicy( + isDev ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN + ) const distribution = new cloudfront.Distribution( scope, @@ -129,6 +132,9 @@ export function createUiComponents(props: CreateUiComponentsProps) { ], } ); + distribution.applyRemovalPolicy( + isDev ? RemovalPolicy.DESTROY : RemovalPolicy.RETAIN + ) const applicationEndpointUrl = `https://${distribution.distributionDomainName}/`; diff --git a/src/run.ts b/src/run.ts index 5aaba3c91..440eb7833 100644 --- a/src/run.ts +++ b/src/run.ts @@ -6,6 +6,7 @@ import readline from "node:readline"; import { CloudFormationClient, DeleteStackCommand, + DescribeStacksCommand, waitUntilStackDeleteComplete, } from "@aws-sdk/client-cloudformation"; import { writeUiEnvFile } from "./write-ui-env-file.js"; @@ -126,12 +127,35 @@ async function prepare_services(runner: LabeledProcessRunner) { } } +async function deploy_prerequisites() { + const runner = new LabeledProcessRunner(); + await prepare_services(runner); + const deployPrequisitesCmd = ["cdk", "deploy", "--app", "\"npx tsx deployment/prerequisites.ts\""]; + await runner.run_command_and_output("CDK prerequisite deploy", deployPrequisitesCmd, "."); +} + +const stackExists = async (stackName: string): Promise => { + const client = new CloudFormationClient({ region }); + try { + await client.send( + new DescribeStacksCommand({ StackName: stackName }) + ); + return true; + } catch (error: any) { + return false; + } +}; + async function deploy(options: { stage: string }) { const stage = options.stage; const runner = new LabeledProcessRunner(); await prepare_services(runner); - const deployCmd = ["cdk", "deploy", "--context", `stage=${stage}`, "--all"]; - await runner.run_command_and_output("CDK deploy", deployCmd, "."); + if (await stackExists("seds-prerequisites")) { + const deployCmd = ["cdk", "deploy", "--context", `stage=${stage}`, "--all"]; + await runner.run_command_and_output("CDK deploy", deployCmd, "."); + } else { + console.error("MISSING PREREQUISITE STACK! Must deploy it before attempting to deploy the application.") + } } const waitForStackDeleteComplete = async ( @@ -201,12 +225,10 @@ yargs(process.argv.slice(2)) run_local ) .command( - "test", - "run all tests", + "deploy-prerequisites", + "deploy the app's AWS account prerequisites with cdk to the cloud", () => {}, - () => { - console.log("Testing 1. 2. 3."); - } + deploy_prerequisites ) .command( "deploy",