feat(infra): parameterize stacks for multi-env (prod/staging) (#123)

Adds an env config layer resolved from CDK context (`-c env=staging`, default prod)
and threads it through all three stacks so a fully isolated staging environment can
be deployed in the same AWS account.

prod is byte-identical: the prod config reproduces the deployed values exactly and
stackSuffix='' keeps every construct ID, stack name, and physical resource name
unchanged. Verified via synth — prod foundation keeps proposal-system-db /
-uploads / db-credentials / -auth / seahaven; staging suffixes all of them.

- config.ts: EnvConfig (prod + staging, same account) + resolveConfig
- app.ts: env-aware stack naming + config passthrough
- foundation/compute/frontend: ~40 physical names suffixed with config.stackSuffix;
  CORS, Cognito domain/callbacks, alarms email from config; RETAIN / deletionProtection
  gated on config.retainData so staging can be torn down
- cdk.json: register `env` context (default prod)
- post-deploy.sh: STACK_SUFFIX for dynamic stack-name lookup (default prod)

Note: automated staging CI deploy needs a one-line `cdk-context` input added to the
org reusable cd-cdk.yaml (companion change). prod deploy is unaffected (default prod).
This commit is contained in:
Adam Moussa 2026-06-12 18:04:53 -04:00 • committed by GitHub
parent 3d050bcf8e
commit bbd185b280
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 178 additions and 85 deletions

View file

@ -2,23 +2,26 @@ import * as cdk from 'aws-cdk-lib';
import { FoundationStack } from '../lib/foundation-stack'; import { FoundationStack } from '../lib/foundation-stack';
import { ComputeStack } from '../lib/compute-stack'; import { ComputeStack } from '../lib/compute-stack';
import { FrontendStack } from '../lib/frontend-stack'; import { FrontendStack } from '../lib/frontend-stack';
import { resolveConfig } from '../lib/config';
const app = new cdk.App(); const app = new cdk.App();
const env: cdk.Environment = { // Multi-env (PR2): `-c env=staging` (default prod). prod keeps the exact construct IDs
account: '328440206208', // and stack names of the deployed stacks (stackSuffix=''); only staging is suffixed.
region: 'us-east-1', const config = resolveConfig(app);
}; const { env, stackSuffix } = config;
const foundation = new FoundationStack(app, 'proposal-system-foundation', { const foundation = new FoundationStack(app, `proposal-system-foundation${stackSuffix}`, {
stackName: 'proposal-system-foundation', stackName: `proposal-system-foundation${stackSuffix}`,
env, env,
config,
description: 'Proposal System - VPC, RDS, S3, SQS, Cognito', description: 'Proposal System - VPC, RDS, S3, SQS, Cognito',
}); });
const compute = new ComputeStack(app, 'proposal-system-compute', { const compute = new ComputeStack(app, `proposal-system-compute${stackSuffix}`, {
stackName: 'proposal-system-compute', stackName: `proposal-system-compute${stackSuffix}`,
env, env,
config,
description: 'Proposal System - API Lambda, Python Lambdas, Bedrock KB', description: 'Proposal System - API Lambda, Python Lambdas, Bedrock KB',
vpc: foundation.vpc, vpc: foundation.vpc,
lambdaSecurityGroup: foundation.lambdaSecurityGroup, lambdaSecurityGroup: foundation.lambdaSecurityGroup,
@ -33,8 +36,11 @@ const compute = new ComputeStack(app, 'proposal-system-compute', {
mobileClientId: foundation.mobileClientId, mobileClientId: foundation.mobileClientId,
}); });
new FrontendStack(app, 'proposal-system-frontend', { new FrontendStack(app, `proposal-system-frontend${stackSuffix}`, {
stackName: 'proposal-system-frontend', stackName: `proposal-system-frontend${stackSuffix}`,
env, env,
config,
description: 'Proposal System - CloudFront + S3 web hosting', description: 'Proposal System - CloudFront + S3 web hosting',
}); });
void compute;

View file

@ -14,6 +14,7 @@
] ]
}, },
"context": { "context": {
"env": "prod",
"@aws-cdk/aws-lambda:recognizeLayerVersion": true, "@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true, "@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": ["aws"] "@aws-cdk/core:target-partitions": ["aws"]

View file

@ -18,8 +18,10 @@ import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions'; import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
import * as cr from 'aws-cdk-lib/custom-resources'; import * as cr from 'aws-cdk-lib/custom-resources';
import { Construct } from 'constructs'; import { Construct } from 'constructs';
import { EnvConfig } from './config';
export interface ComputeStackProps extends cdk.StackProps { export interface ComputeStackProps extends cdk.StackProps {
config: EnvConfig;
vpc: ec2.IVpc; vpc: ec2.IVpc;
lambdaSecurityGroup: ec2.ISecurityGroup; lambdaSecurityGroup: ec2.ISecurityGroup;
dbSecret: secretsmanager.ISecret; dbSecret: secretsmanager.ISecret;
@ -36,12 +38,13 @@ export interface ComputeStackProps extends cdk.StackProps {
export class ComputeStack extends cdk.Stack { export class ComputeStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: ComputeStackProps) { constructor(scope: Construct, id: string, props: ComputeStackProps) {
super(scope, id, props); super(scope, id, props);
const { config } = props;
const privateSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }; const privateSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS };
// Internal API key for Lambda-to-API calls (stored in Secrets Manager) // Internal API key for Lambda-to-API calls (stored in Secrets Manager)
const internalApiKeySecret = new secretsmanager.Secret(this, 'InternalApiKeySecret', { const internalApiKeySecret = new secretsmanager.Secret(this, 'InternalApiKeySecret', {
secretName: 'proposal-system/internal-api-key', secretName: `proposal-system/internal-api-key${config.stackSuffix}`,
generateSecretString: { generateSecretString: {
excludePunctuation: true, excludePunctuation: true,
passwordLength: 48, passwordLength: 48,
@ -50,10 +53,10 @@ export class ComputeStack extends cdk.Stack {
// OpenSearch Serverless collection for Bedrock KB vector store // OpenSearch Serverless collection for Bedrock KB vector store
const ossEncryptionPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssEncryptionPolicy', { const ossEncryptionPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssEncryptionPolicy', {
name: 'proposal-system-kb-enc', name: `proposal-system-kb-enc${config.stackSuffix}`,
type: 'encryption', type: 'encryption',
policy: JSON.stringify({ policy: JSON.stringify({
Rules: [{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }], Rules: [{ ResourceType: 'collection', Resource: [`collection/proposal-system-kb${config.stackSuffix}`] }],
AWSOwnedKey: true, AWSOwnedKey: true,
}), }),
}); });
@ -61,18 +64,18 @@ export class ComputeStack extends cdk.Stack {
// Fix: INF-H3 — restrict OpenSearch Serverless to VPC (was AllowFromPublic: true). // Fix: INF-H3 — restrict OpenSearch Serverless to VPC (was AllowFromPublic: true).
// Create a VPC endpoint so Lambdas in private subnets can reach the collection. // Create a VPC endpoint so Lambdas in private subnets can reach the collection.
const ossVpcEndpoint = new opensearchserverless.CfnVpcEndpoint(this, 'OssVpcEndpoint', { const ossVpcEndpoint = new opensearchserverless.CfnVpcEndpoint(this, 'OssVpcEndpoint', {
name: 'proposal-system-kb-vpce', name: `proposal-system-kb-vpce${config.stackSuffix}`,
vpcId: props.vpc.vpcId, vpcId: props.vpc.vpcId,
subnetIds: props.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds, subnetIds: props.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds,
securityGroupIds: [props.lambdaSecurityGroup.securityGroupId], securityGroupIds: [props.lambdaSecurityGroup.securityGroupId],
}); });
const ossNetworkPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssNetworkPolicy', { const ossNetworkPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssNetworkPolicy', {
name: 'proposal-system-kb-net', name: `proposal-system-kb-net${config.stackSuffix}`,
type: 'network', type: 'network',
policy: JSON.stringify([{ policy: JSON.stringify([{
Rules: [ Rules: [
{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }, { ResourceType: 'collection', Resource: [`collection/proposal-system-kb${config.stackSuffix}`] },
], ],
AllowFromPublic: false, AllowFromPublic: false,
SourceVPCEs: [ossVpcEndpoint.attrId], SourceVPCEs: [ossVpcEndpoint.attrId],
@ -81,7 +84,7 @@ export class ComputeStack extends cdk.Stack {
ossNetworkPolicy.addDependency(ossVpcEndpoint); ossNetworkPolicy.addDependency(ossVpcEndpoint);
const ossCollection = new opensearchserverless.CfnCollection(this, 'OssCollection', { const ossCollection = new opensearchserverless.CfnCollection(this, 'OssCollection', {
name: 'proposal-system-kb', name: `proposal-system-kb${config.stackSuffix}`,
type: 'VECTORSEARCH', type: 'VECTORSEARCH',
}); });
ossCollection.addDependency(ossEncryptionPolicy); ossCollection.addDependency(ossEncryptionPolicy);
@ -89,7 +92,7 @@ export class ComputeStack extends cdk.Stack {
// Bedrock KB execution role // Bedrock KB execution role
const kbRole = new iam.Role(this, 'KnowledgeBaseRole', { const kbRole = new iam.Role(this, 'KnowledgeBaseRole', {
roleName: 'proposal-system-kb-role', roleName: `proposal-system-kb-role${config.stackSuffix}`,
assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'), assumedBy: new iam.ServicePrincipal('bedrock.amazonaws.com'),
}); });
@ -110,7 +113,7 @@ export class ComputeStack extends cdk.Stack {
// Lambda to pre-create the vector index (retries until AOSS access policy propagates) // Lambda to pre-create the vector index (retries until AOSS access policy propagates)
const indexCreatorFn = new lambda.Function(this, 'OssIndexCreator', { const indexCreatorFn = new lambda.Function(this, 'OssIndexCreator', {
functionName: 'proposal-system-oss-index-creator', functionName: `proposal-system-oss-index-creator${config.stackSuffix}`,
runtime: lambda.Runtime.PYTHON_3_12, runtime: lambda.Runtime.PYTHON_3_12,
architecture: lambda.Architecture.ARM_64, architecture: lambda.Architecture.ARM_64,
handler: 'app.handler', handler: 'app.handler',
@ -136,7 +139,7 @@ export class ComputeStack extends cdk.Stack {
// collection and index). KB role needs read/write for embeddings. Index creator // collection and index). KB role needs read/write for embeddings. Index creator
// needs create/describe for bootstrapping the vector index. // needs create/describe for bootstrapping the vector index.
const ossDataAccessPolicy = new opensearchserverless.CfnAccessPolicy(this, 'OssDataAccessPolicy', { const ossDataAccessPolicy = new opensearchserverless.CfnAccessPolicy(this, 'OssDataAccessPolicy', {
name: 'proposal-system-kb-access', name: `proposal-system-kb-access${config.stackSuffix}`,
type: 'data', type: 'data',
policy: JSON.stringify([ policy: JSON.stringify([
{ {
@ -144,7 +147,7 @@ export class ComputeStack extends cdk.Stack {
Rules: [ Rules: [
{ {
ResourceType: 'collection', ResourceType: 'collection',
Resource: ['collection/proposal-system-kb'], Resource: [`collection/proposal-system-kb${config.stackSuffix}`],
Permission: [ Permission: [
'aoss:DescribeCollectionItems', 'aoss:DescribeCollectionItems',
'aoss:CreateCollectionItems', 'aoss:CreateCollectionItems',
@ -153,7 +156,7 @@ export class ComputeStack extends cdk.Stack {
}, },
{ {
ResourceType: 'index', ResourceType: 'index',
Resource: ['index/proposal-system-kb/*'], Resource: [`index/proposal-system-kb${config.stackSuffix}/*`],
Permission: [ Permission: [
'aoss:DescribeIndex', 'aoss:DescribeIndex',
'aoss:ReadDocument', 'aoss:ReadDocument',
@ -168,7 +171,7 @@ export class ComputeStack extends cdk.Stack {
Rules: [ Rules: [
{ {
ResourceType: 'collection', ResourceType: 'collection',
Resource: ['collection/proposal-system-kb'], Resource: [`collection/proposal-system-kb${config.stackSuffix}`],
Permission: [ Permission: [
'aoss:DescribeCollectionItems', 'aoss:DescribeCollectionItems',
'aoss:CreateCollectionItems', 'aoss:CreateCollectionItems',
@ -176,7 +179,7 @@ export class ComputeStack extends cdk.Stack {
}, },
{ {
ResourceType: 'index', ResourceType: 'index',
Resource: ['index/proposal-system-kb/*'], Resource: [`index/proposal-system-kb${config.stackSuffix}/*`],
Permission: [ Permission: [
'aoss:CreateIndex', 'aoss:CreateIndex',
'aoss:DescribeIndex', 'aoss:DescribeIndex',
@ -207,7 +210,7 @@ export class ComputeStack extends cdk.Stack {
ossIndex.node.addDependency(ossDataAccessPolicy); ossIndex.node.addDependency(ossDataAccessPolicy);
const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', { const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', {
name: 'proposal-system-kb', name: `proposal-system-kb${config.stackSuffix}`,
roleArn: kbRole.roleArn, roleArn: kbRole.roleArn,
knowledgeBaseConfiguration: { knowledgeBaseConfiguration: {
type: 'VECTOR', type: 'VECTOR',
@ -232,7 +235,7 @@ export class ComputeStack extends cdk.Stack {
// KB Data Source (S3 library bucket) // KB Data Source (S3 library bucket)
const dataSource = new bedrock.CfnDataSource(this, 'KbDataSource', { const dataSource = new bedrock.CfnDataSource(this, 'KbDataSource', {
name: 'proposal-system-library', name: `proposal-system-library${config.stackSuffix}`,
knowledgeBaseId: knowledgeBase.attrKnowledgeBaseId, knowledgeBaseId: knowledgeBase.attrKnowledgeBaseId,
dataSourceConfiguration: { dataSourceConfiguration: {
type: 'S3', type: 'S3',
@ -253,7 +256,7 @@ export class ComputeStack extends cdk.Stack {
// .NET 8 API Lambda // .NET 8 API Lambda
const apiFunction = new lambda.Function(this, 'ApiFunction', { const apiFunction = new lambda.Function(this, 'ApiFunction', {
functionName: 'proposal-system-api', functionName: `proposal-system-api${config.stackSuffix}`,
runtime: lambda.Runtime.DOTNET_8, runtime: lambda.Runtime.DOTNET_8,
architecture: lambda.Architecture.ARM_64, architecture: lambda.Architecture.ARM_64,
handler: 'ProposalSystem.Api', handler: 'ProposalSystem.Api',
@ -273,7 +276,7 @@ export class ComputeStack extends cdk.Stack {
INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn,
Auth__Authority: `https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`, Auth__Authority: `https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`,
Auth__ClientId: props.webClientId, Auth__ClientId: props.webClientId,
Auth__CognitoDomain: `proposal-system-seahaven.auth.${this.region}.amazoncognito.com`, Auth__CognitoDomain: `${config.cognitoDomainPrefix}.auth.${this.region}.amazoncognito.com`,
COGNITO_WEB_CLIENT_ID: props.webClientId, COGNITO_WEB_CLIENT_ID: props.webClientId,
COGNITO_MOBILE_CLIENT_ID: props.mobileClientId, COGNITO_MOBILE_CLIENT_ID: props.mobileClientId,
}, },
@ -303,13 +306,9 @@ export class ComputeStack extends cdk.Stack {
// API Gateway HTTP API // API Gateway HTTP API
const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', { const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', {
apiName: 'proposal-system-gateway', apiName: `proposal-system-gateway${config.stackSuffix}`,
corsPreflight: { corsPreflight: {
allowOrigins: [ allowOrigins: config.apiCorsOrigins,
'https://proposals.seahaven.com',
'https://d2yevct5e5uuz5.cloudfront.net',
'http://localhost:5173',
],
allowMethods: [ allowMethods: [
apigatewayv2.CorsHttpMethod.GET, apigatewayv2.CorsHttpMethod.GET,
apigatewayv2.CorsHttpMethod.POST, apigatewayv2.CorsHttpMethod.POST,
@ -323,7 +322,7 @@ export class ComputeStack extends cdk.Stack {
}); });
const apiAccessLogGroup = new logs.LogGroup(this, 'ApiAccessLogs', { const apiAccessLogGroup = new logs.LogGroup(this, 'ApiAccessLogs', {
logGroupName: '/aws/apigateway/proposal-system', logGroupName: `/aws/apigateway/proposal-system${config.stackSuffix}`,
retention: logs.RetentionDays.TWO_MONTHS, retention: logs.RetentionDays.TWO_MONTHS,
removalPolicy: cdk.RemovalPolicy.DESTROY, removalPolicy: cdk.RemovalPolicy.DESTROY,
}); });
@ -408,7 +407,7 @@ export class ComputeStack extends cdk.Stack {
// Python Lambda: Suggestions Engine // Python Lambda: Suggestions Engine
const suggestionsFunction = new lambda.Function(this, 'SuggestionsFunction', { const suggestionsFunction = new lambda.Function(this, 'SuggestionsFunction', {
functionName: 'proposal-system-suggestions', functionName: `proposal-system-suggestions${config.stackSuffix}`,
runtime: lambda.Runtime.PYTHON_3_12, runtime: lambda.Runtime.PYTHON_3_12,
architecture: lambda.Architecture.ARM_64, architecture: lambda.Architecture.ARM_64,
handler: 'app.handler', handler: 'app.handler',
@ -446,7 +445,7 @@ export class ComputeStack extends cdk.Stack {
// Python Lambda: PDF Extract // Python Lambda: PDF Extract
const pdfExtractFunction = new lambda.Function(this, 'PdfExtractFunction', { const pdfExtractFunction = new lambda.Function(this, 'PdfExtractFunction', {
functionName: 'proposal-system-pdf-extract', functionName: `proposal-system-pdf-extract${config.stackSuffix}`,
runtime: lambda.Runtime.PYTHON_3_12, runtime: lambda.Runtime.PYTHON_3_12,
architecture: lambda.Architecture.ARM_64, architecture: lambda.Architecture.ARM_64,
handler: 'app.handler', handler: 'app.handler',
@ -480,7 +479,7 @@ export class ComputeStack extends cdk.Stack {
// Python Lambda: PDF Generate // Python Lambda: PDF Generate
const pdfGenerateFunction = new lambda.Function(this, 'PdfGenerateFunction', { const pdfGenerateFunction = new lambda.Function(this, 'PdfGenerateFunction', {
functionName: 'proposal-system-pdf-generate', functionName: `proposal-system-pdf-generate${config.stackSuffix}`,
runtime: lambda.Runtime.PYTHON_3_12, runtime: lambda.Runtime.PYTHON_3_12,
architecture: lambda.Architecture.ARM_64, architecture: lambda.Architecture.ARM_64,
handler: 'app.handler', handler: 'app.handler',
@ -505,7 +504,7 @@ export class ComputeStack extends cdk.Stack {
// Python Lambda: Library Ingest // Python Lambda: Library Ingest
const libraryIngestFunction = new lambda.Function(this, 'LibraryIngestFunction', { const libraryIngestFunction = new lambda.Function(this, 'LibraryIngestFunction', {
functionName: 'proposal-system-library-ingest', functionName: `proposal-system-library-ingest${config.stackSuffix}`,
runtime: lambda.Runtime.PYTHON_3_12, runtime: lambda.Runtime.PYTHON_3_12,
architecture: lambda.Architecture.ARM_64, architecture: lambda.Architecture.ARM_64,
handler: 'app.handler', handler: 'app.handler',
@ -588,7 +587,7 @@ export class ComputeStack extends cdk.Stack {
for (const { fn, name } of lambdaFunctions) { for (const { fn, name } of lambdaFunctions) {
const alarm = new cloudwatch.Alarm(this, `LambdaErrors-${name}`, { const alarm = new cloudwatch.Alarm(this, `LambdaErrors-${name}`, {
alarmName: `proposal-system-${name}-errors`, alarmName: `proposal-system-${name}-errors${config.stackSuffix}`,
alarmDescription: `Lambda errors for ${name}`, alarmDescription: `Lambda errors for ${name}`,
metric: fn.metricErrors({ period: cdk.Duration.minutes(5) }), metric: fn.metricErrors({ period: cdk.Duration.minutes(5) }),
threshold: 1, threshold: 1,
@ -599,7 +598,7 @@ export class ComputeStack extends cdk.Stack {
} }
const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', { const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', {
alarmName: 'proposal-system-api-5xx', alarmName: `proposal-system-api-5xx${config.stackSuffix}`,
alarmDescription: 'API Gateway 5xx errors', alarmDescription: 'API Gateway 5xx errors',
metric: new cloudwatch.Metric({ metric: new cloudwatch.Metric({
namespace: 'AWS/ApiGateway', namespace: 'AWS/ApiGateway',

80
infra/lib/config.ts Normal file
View file

@ -0,0 +1,80 @@
import * as cdk from 'aws-cdk-lib';
// Multi-environment configuration (PR2). Resolved from CDK context: `-c env=staging`,
// default `prod`. CRITICAL: the prod config MUST keep the exact construct IDs, stack
// names, and resource settings the deployed stacks already use — renaming a deployed
// CloudFormation stack triggers replace-and-delete, which would destroy the RDS instance.
// Only non-prod environments take a stack-name suffix.
export type EnvName = 'prod' | 'staging';
export interface EnvConfig {
readonly envName: EnvName;
readonly env: cdk.Environment;
/** Suffix for stack names + construct IDs. '' for prod so deployed stacks are untouched. */
readonly stackSuffix: string;
/** S3 bucket CORS allowed origins. */
readonly s3CorsOrigins: string[];
/** API Gateway CORS allowed origins. */
readonly apiCorsOrigins: string[];
/** Cognito web-client callback / logout URLs. */
readonly webCallbackUrls: string[];
readonly webLogoutUrls: string[];
/** Cognito hosted-UI domain prefix (must be globally unique). */
readonly cognitoDomainPrefix: string;
/** Email subscribed to the CloudWatch alarm SNS topic. */
readonly alarmsEmail: string;
/** Retain stateful resources (RDS, buckets) on stack deletion. */
readonly retainData: boolean;
}
const ACCOUNT = '328440206208';
const REGION = 'us-east-1';
// Prod values reproduce the currently-deployed stacks EXACTLY (verified against
// foundation-stack.ts / compute-stack.ts) so `cdk diff` shows no prod changes.
const PROD: EnvConfig = {
envName: 'prod',
env: { account: ACCOUNT, region: REGION },
stackSuffix: '',
s3CorsOrigins: ['https://proposals.seahaven.com', 'http://localhost:5173'],
apiCorsOrigins: [
'https://proposals.seahaven.com',
'https://d2yevct5e5uuz5.cloudfront.net',
'http://localhost:5173',
],
webCallbackUrls: [
'https://proposals.seahaven.com/callback',
'http://localhost:5173/callback',
],
webLogoutUrls: ['https://proposals.seahaven.com', 'http://localhost:5173'],
cognitoDomainPrefix: 'proposal-system-seahaven',
alarmsEmail: 'adam@seahavenind.com',
retainData: true,
};
// Staging: same AWS account (Adam, 2026-06-12), suffixed stacks, no permanent domain
// yet (CloudFront default URL + localhost), data not retained.
const STAGING: EnvConfig = {
envName: 'staging',
env: { account: ACCOUNT, region: REGION },
stackSuffix: '-staging',
s3CorsOrigins: ['http://localhost:5173'],
apiCorsOrigins: ['http://localhost:5173'],
webCallbackUrls: ['http://localhost:5173/callback'],
webLogoutUrls: ['http://localhost:5173'],
cognitoDomainPrefix: 'proposal-system-seahaven-staging',
alarmsEmail: 'adam@seahavenind.com',
retainData: false,
};
const CONFIGS: Record<EnvName, EnvConfig> = { prod: PROD, staging: STAGING };
export function resolveConfig(app: cdk.App): EnvConfig {
const name = (app.node.tryGetContext('env') as EnvName | undefined) ?? 'prod';
const config = CONFIGS[name];
if (!config) {
throw new Error(`Unknown env '${name}'. Use -c env=prod (default) or -c env=staging.`);
}
return config;
}

View file

@ -11,6 +11,11 @@ import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions'; import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
import { Construct } from 'constructs'; import { Construct } from 'constructs';
import { EnvConfig } from './config';
export interface FoundationStackProps extends cdk.StackProps {
config: EnvConfig;
}
export class FoundationStack extends cdk.Stack { export class FoundationStack extends cdk.Stack {
public readonly vpc: ec2.IVpc; public readonly vpc: ec2.IVpc;
@ -25,12 +30,13 @@ export class FoundationStack extends cdk.Stack {
public readonly webClientId: string; public readonly webClientId: string;
public readonly mobileClientId: string; public readonly mobileClientId: string;
constructor(scope: Construct, id: string, props?: cdk.StackProps) { constructor(scope: Construct, id: string, props: FoundationStackProps) {
super(scope, id, props); super(scope, id, props);
const { config } = props;
// VPC: 2 AZs, public + private subnets, single NAT Gateway // VPC: 2 AZs, public + private subnets, single NAT Gateway
this.vpc = new ec2.Vpc(this, 'Vpc', { this.vpc = new ec2.Vpc(this, 'Vpc', {
vpcName: 'proposal-system-vpc', vpcName: `proposal-system-vpc${config.stackSuffix}`,
maxAzs: 2, maxAzs: 2,
natGateways: 1, natGateways: 1,
subnetConfiguration: [ subnetConfiguration: [
@ -59,14 +65,14 @@ export class FoundationStack extends cdk.Stack {
// Security Groups // Security Groups
this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSg', { this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSg', {
vpc: this.vpc, vpc: this.vpc,
securityGroupName: 'proposal-system-lambda-sg', securityGroupName: `proposal-system-lambda-sg${config.stackSuffix}`,
description: 'Security group for proposal system Lambda functions', description: 'Security group for proposal system Lambda functions',
allowAllOutbound: true, allowAllOutbound: true,
}); });
const rdsSg = new ec2.SecurityGroup(this, 'RdsSg', { const rdsSg = new ec2.SecurityGroup(this, 'RdsSg', {
vpc: this.vpc, vpc: this.vpc,
securityGroupName: 'proposal-system-rds-sg', securityGroupName: `proposal-system-rds-sg${config.stackSuffix}`,
description: 'Security group for proposal system RDS instance', description: 'Security group for proposal system RDS instance',
allowAllOutbound: false, allowAllOutbound: false,
}); });
@ -79,7 +85,7 @@ export class FoundationStack extends cdk.Stack {
// RDS PostgreSQL 15 // RDS PostgreSQL 15
const dbInstance = new rds.DatabaseInstance(this, 'Database', { const dbInstance = new rds.DatabaseInstance(this, 'Database', {
instanceIdentifier: 'proposal-system-db', instanceIdentifier: `proposal-system-db${config.stackSuffix}`,
engine: rds.DatabaseInstanceEngine.postgres({ engine: rds.DatabaseInstanceEngine.postgres({
version: rds.PostgresEngineVersion.VER_15, version: rds.PostgresEngineVersion.VER_15,
}), }),
@ -95,11 +101,11 @@ export class FoundationStack extends cdk.Stack {
maxAllocatedStorage: 100, maxAllocatedStorage: 100,
storageEncrypted: true, storageEncrypted: true,
backupRetention: cdk.Duration.days(7), backupRetention: cdk.Duration.days(7),
deletionProtection: true, deletionProtection: config.retainData,
removalPolicy: cdk.RemovalPolicy.RETAIN, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
databaseName: 'proposals', databaseName: 'proposals',
credentials: rds.Credentials.fromGeneratedSecret('proposalsadmin', { credentials: rds.Credentials.fromGeneratedSecret('proposalsadmin', {
secretName: 'proposal-system/db-credentials', secretName: `proposal-system/db-credentials${config.stackSuffix}`,
}), }),
publiclyAccessible: false, publiclyAccessible: false,
}); });
@ -109,7 +115,7 @@ export class FoundationStack extends cdk.Stack {
// S3 Buckets // S3 Buckets
// Fix: INF-M5 — enforce HTTPS-only access on all S3 buckets // Fix: INF-M5 — enforce HTTPS-only access on all S3 buckets
this.uploadsBucket = new s3.Bucket(this, 'UploadsBucket', { this.uploadsBucket = new s3.Bucket(this, 'UploadsBucket', {
bucketName: `proposal-system-uploads-${this.account}`, bucketName: `proposal-system-uploads-${this.account}${config.stackSuffix}`,
encryption: s3.BucketEncryption.S3_MANAGED, encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true, enforceSSL: true,
versioned: true, versioned: true,
@ -127,49 +133,46 @@ export class FoundationStack extends cdk.Stack {
cors: [ cors: [
{ {
allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST], allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST],
allowedOrigins: [ allowedOrigins: config.s3CorsOrigins,
'https://proposals.seahaven.com',
'http://localhost:5173',
],
allowedHeaders: ['*'], allowedHeaders: ['*'],
maxAge: 3600, maxAge: 3600,
}, },
], ],
removalPolicy: cdk.RemovalPolicy.RETAIN, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
}); });
cdk.Tags.of(this.uploadsBucket).add('Purpose', 'Vendor PDFs and dispatcher attachments'); cdk.Tags.of(this.uploadsBucket).add('Purpose', 'Vendor PDFs and dispatcher attachments');
cdk.Tags.of(this.uploadsBucket).add('ManagedBy', 'proposal-system'); cdk.Tags.of(this.uploadsBucket).add('ManagedBy', 'proposal-system');
this.generatedBucket = new s3.Bucket(this, 'GeneratedBucket', { this.generatedBucket = new s3.Bucket(this, 'GeneratedBucket', {
bucketName: `proposal-system-generated-${this.account}`, bucketName: `proposal-system-generated-${this.account}${config.stackSuffix}`,
encryption: s3.BucketEncryption.S3_MANAGED, encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true, // Fix: INF-M5 enforceSSL: true, // Fix: INF-M5
versioned: true, versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
}); });
cdk.Tags.of(this.generatedBucket).add('Purpose', 'Generated proposal PDFs'); cdk.Tags.of(this.generatedBucket).add('Purpose', 'Generated proposal PDFs');
cdk.Tags.of(this.generatedBucket).add('ManagedBy', 'proposal-system'); cdk.Tags.of(this.generatedBucket).add('ManagedBy', 'proposal-system');
this.libraryBucket = new s3.Bucket(this, 'LibraryBucket', { this.libraryBucket = new s3.Bucket(this, 'LibraryBucket', {
bucketName: `proposal-system-library-${this.account}`, bucketName: `proposal-system-library-${this.account}${config.stackSuffix}`,
encryption: s3.BucketEncryption.S3_MANAGED, encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true, // Fix: INF-M5 enforceSSL: true, // Fix: INF-M5
versioned: true, versioned: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
removalPolicy: cdk.RemovalPolicy.RETAIN, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
}); });
cdk.Tags.of(this.libraryBucket).add('Purpose', 'Historical proposal library for RAG'); cdk.Tags.of(this.libraryBucket).add('Purpose', 'Historical proposal library for RAG');
cdk.Tags.of(this.libraryBucket).add('ManagedBy', 'proposal-system'); cdk.Tags.of(this.libraryBucket).add('ManagedBy', 'proposal-system');
// SQS Queue + DLQ // SQS Queue + DLQ
const dlq = new sqs.Queue(this, 'JobsDlq', { const dlq = new sqs.Queue(this, 'JobsDlq', {
queueName: 'proposal-system-jobs-dlq', queueName: `proposal-system-jobs-dlq${config.stackSuffix}`,
retentionPeriod: cdk.Duration.days(14), retentionPeriod: cdk.Duration.days(14),
}); });
this.jobsQueue = new sqs.Queue(this, 'JobsQueue', { this.jobsQueue = new sqs.Queue(this, 'JobsQueue', {
queueName: 'proposal-system-jobs', queueName: `proposal-system-jobs${config.stackSuffix}`,
visibilityTimeout: cdk.Duration.seconds(720), visibilityTimeout: cdk.Duration.seconds(720),
deadLetterQueue: { deadLetterQueue: {
queue: dlq, queue: dlq,
@ -179,7 +182,7 @@ export class FoundationStack extends cdk.Stack {
// Cognito User Pool // Cognito User Pool
const userPool = new cognito.UserPool(this, 'UserPool', { const userPool = new cognito.UserPool(this, 'UserPool', {
userPoolName: 'proposal-system-auth', userPoolName: `proposal-system-auth${config.stackSuffix}`,
selfSignUpEnabled: false, selfSignUpEnabled: false,
signInAliases: { email: true }, signInAliases: { email: true },
standardAttributes: { standardAttributes: {
@ -194,7 +197,7 @@ export class FoundationStack extends cdk.Stack {
requireSymbols: false, requireSymbols: false,
}, },
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY, accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: cdk.RemovalPolicy.RETAIN, removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
}); });
this.userPool = userPool; this.userPool = userPool;
@ -220,7 +223,7 @@ export class FoundationStack extends cdk.Stack {
// Cognito Domain // Cognito Domain
userPool.addDomain('CognitoDomain', { userPool.addDomain('CognitoDomain', {
cognitoDomain: { domainPrefix: 'proposal-system-seahaven' }, cognitoDomain: { domainPrefix: config.cognitoDomainPrefix },
}); });
// Web App Client (PKCE) // Web App Client (PKCE)
@ -237,14 +240,8 @@ export class FoundationStack extends cdk.Stack {
cognito.OAuthScope.EMAIL, cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE, cognito.OAuthScope.PROFILE,
], ],
callbackUrls: [ callbackUrls: config.webCallbackUrls,
'https://proposals.seahaven.com/callback', logoutUrls: config.webLogoutUrls,
'http://localhost:5173/callback',
],
logoutUrls: [
'https://proposals.seahaven.com',
'http://localhost:5173',
],
}, },
}); });
@ -274,11 +271,11 @@ export class FoundationStack extends cdk.Stack {
// SNS Alarm Topic // SNS Alarm Topic
const alarmTopic = new sns.Topic(this, 'AlarmTopic', { const alarmTopic = new sns.Topic(this, 'AlarmTopic', {
topicName: 'proposal-system-alarms', topicName: `proposal-system-alarms${config.stackSuffix}`,
displayName: 'Proposal System Alarms', displayName: 'Proposal System Alarms',
}); });
alarmTopic.addSubscription( alarmTopic.addSubscription(
new snsSubscriptions.EmailSubscription('adam@seahavenind.com'), new snsSubscriptions.EmailSubscription(config.alarmsEmail),
); );
this.alarmTopic = alarmTopic; this.alarmTopic = alarmTopic;
@ -286,7 +283,7 @@ export class FoundationStack extends cdk.Stack {
// DLQ Alarm: any message landing in DLQ indicates a processing failure // DLQ Alarm: any message landing in DLQ indicates a processing failure
const dlqAlarm = new cloudwatch.Alarm(this, 'DlqDepthAlarm', { const dlqAlarm = new cloudwatch.Alarm(this, 'DlqDepthAlarm', {
alarmName: 'proposal-system-dlq-depth', alarmName: `proposal-system-dlq-depth${config.stackSuffix}`,
alarmDescription: 'Messages in DLQ — SQS processing failures', alarmDescription: 'Messages in DLQ — SQS processing failures',
metric: dlq.metricApproximateNumberOfMessagesVisible({ metric: dlq.metricApproximateNumberOfMessagesVisible({
period: cdk.Duration.minutes(1), period: cdk.Duration.minutes(1),
@ -301,7 +298,7 @@ export class FoundationStack extends cdk.Stack {
// RDS Alarms // RDS Alarms
const rdsAlarms = [ const rdsAlarms = [
new cloudwatch.Alarm(this, 'RdsCpuAlarm', { new cloudwatch.Alarm(this, 'RdsCpuAlarm', {
alarmName: 'proposal-system-rds-cpu', alarmName: `proposal-system-rds-cpu${config.stackSuffix}`,
alarmDescription: 'RDS CPU utilization above 80%', alarmDescription: 'RDS CPU utilization above 80%',
metric: dbInstance.metricCPUUtilization({ period: cdk.Duration.minutes(5) }), metric: dbInstance.metricCPUUtilization({ period: cdk.Duration.minutes(5) }),
threshold: 80, threshold: 80,
@ -309,7 +306,7 @@ export class FoundationStack extends cdk.Stack {
treatMissingData: cloudwatch.TreatMissingData.BREACHING, treatMissingData: cloudwatch.TreatMissingData.BREACHING,
}), }),
new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', { new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', {
alarmName: 'proposal-system-rds-connections', alarmName: `proposal-system-rds-connections${config.stackSuffix}`,
alarmDescription: 'RDS database connections above 80', alarmDescription: 'RDS database connections above 80',
metric: dbInstance.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }), metric: dbInstance.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }),
threshold: 80, threshold: 80,
@ -317,7 +314,7 @@ export class FoundationStack extends cdk.Stack {
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
}), }),
new cloudwatch.Alarm(this, 'RdsFreeStorageAlarm', { new cloudwatch.Alarm(this, 'RdsFreeStorageAlarm', {
alarmName: 'proposal-system-rds-free-storage', alarmName: `proposal-system-rds-free-storage${config.stackSuffix}`,
alarmDescription: 'RDS free storage below 2 GB', alarmDescription: 'RDS free storage below 2 GB',
metric: dbInstance.metricFreeStorageSpace({ period: cdk.Duration.minutes(5) }), metric: dbInstance.metricFreeStorageSpace({ period: cdk.Duration.minutes(5) }),
threshold: 2_000_000_000, threshold: 2_000_000_000,
@ -340,7 +337,7 @@ export class FoundationStack extends cdk.Stack {
for (const name of logGroupNames) { for (const name of logGroupNames) {
new logs.LogGroup(this, `LogGroup-${name}`, { new logs.LogGroup(this, `LogGroup-${name}`, {
logGroupName: `/aws/lambda/${name}`, logGroupName: `/aws/lambda/${name}${config.stackSuffix}`,
retention: logs.RetentionDays.TWO_MONTHS, retention: logs.RetentionDays.TWO_MONTHS,
removalPolicy: cdk.RemovalPolicy.DESTROY, removalPolicy: cdk.RemovalPolicy.DESTROY,
}); });

View file

@ -3,14 +3,20 @@ import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins'; import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins';
import { Construct } from 'constructs'; import { Construct } from 'constructs';
import { EnvConfig } from './config';
export interface FrontendStackProps extends cdk.StackProps {
config: EnvConfig;
}
export class FrontendStack extends cdk.Stack { export class FrontendStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) { constructor(scope: Construct, id: string, props: FrontendStackProps) {
super(scope, id, props); super(scope, id, props);
const { config } = props;
// Fix: INF-M5 — enforce HTTPS-only access on S3 bucket // Fix: INF-M5 — enforce HTTPS-only access on S3 bucket
const siteBucket = new s3.Bucket(this, 'SiteBucket', { const siteBucket = new s3.Bucket(this, 'SiteBucket', {
bucketName: `proposal-system-web-${this.account}`, bucketName: `proposal-system-web-${this.account}${config.stackSuffix}`,
encryption: s3.BucketEncryption.S3_MANAGED, encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true, enforceSSL: true,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
@ -19,7 +25,7 @@ export class FrontendStack extends cdk.Stack {
}); });
const distribution = new cloudfront.Distribution(this, 'Distribution', { const distribution = new cloudfront.Distribution(this, 'Distribution', {
comment: 'proposal-system-web', comment: `proposal-system-web${config.stackSuffix}`,
defaultBehavior: { defaultBehavior: {
origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(siteBucket), origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(siteBucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,

View file

@ -1,26 +1,30 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Multi-env (PR2): STACK_SUFFIX is '' for prod (default) and '-staging' for staging,
# matching the CDK stack-name suffix. Prod runs with no suffix -> names unchanged.
SUFFIX="${STACK_SUFFIX:-}"
cd web cd web
npm ci npm ci
npm run build npm run build
cd .. cd ..
BUCKET=$(aws cloudformation describe-stacks \ BUCKET=$(aws cloudformation describe-stacks \
--stack-name proposal-system-frontend \ --stack-name "proposal-system-frontend${SUFFIX}" \
--query "Stacks[0].Outputs[?OutputKey=='SiteBucketName'].OutputValue" \ --query "Stacks[0].Outputs[?OutputKey=='SiteBucketName'].OutputValue" \
--output text) --output text)
aws s3 sync web/dist "s3://$BUCKET" --delete aws s3 sync web/dist "s3://$BUCKET" --delete
DIST_ID=$(aws cloudformation describe-stacks \ DIST_ID=$(aws cloudformation describe-stacks \
--stack-name proposal-system-frontend \ --stack-name "proposal-system-frontend${SUFFIX}" \
--query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" \ --query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" \
--output text) --output text)
aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*" aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*"
# Health check: verify API is reachable # Health check: verify API is reachable
API_URL=$(aws cloudformation describe-stacks \ API_URL=$(aws cloudformation describe-stacks \
--stack-name proposal-system-compute \ --stack-name "proposal-system-compute${SUFFIX}" \
--query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \ --query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \
--output text) --output text)