From bbd185b2802380e97c08ad91104bbf0169c2b4a0 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:04:53 -0400 Subject: [PATCH] feat(infra): parameterize stacks for multi-env (prod/staging) (#123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- infra/bin/app.ts | 26 +++++++----- infra/cdk.json | 1 + infra/lib/compute-stack.ts | 61 +++++++++++++------------- infra/lib/config.ts | 80 +++++++++++++++++++++++++++++++++++ infra/lib/foundation-stack.ts | 73 +++++++++++++++----------------- infra/lib/frontend-stack.ts | 12 ++++-- scripts/post-deploy.sh | 10 +++-- 7 files changed, 178 insertions(+), 85 deletions(-) create mode 100644 infra/lib/config.ts diff --git a/infra/bin/app.ts b/infra/bin/app.ts index 271e5fa..fb1405b 100644 --- a/infra/bin/app.ts +++ b/infra/bin/app.ts @@ -2,23 +2,26 @@ import * as cdk from 'aws-cdk-lib'; import { FoundationStack } from '../lib/foundation-stack'; import { ComputeStack } from '../lib/compute-stack'; import { FrontendStack } from '../lib/frontend-stack'; +import { resolveConfig } from '../lib/config'; const app = new cdk.App(); -const env: cdk.Environment = { - account: '328440206208', - region: 'us-east-1', -}; +// Multi-env (PR2): `-c env=staging` (default prod). prod keeps the exact construct IDs +// and stack names of the deployed stacks (stackSuffix=''); only staging is suffixed. +const config = resolveConfig(app); +const { env, stackSuffix } = config; -const foundation = new FoundationStack(app, 'proposal-system-foundation', { - stackName: 'proposal-system-foundation', +const foundation = new FoundationStack(app, `proposal-system-foundation${stackSuffix}`, { + stackName: `proposal-system-foundation${stackSuffix}`, env, + config, description: 'Proposal System - VPC, RDS, S3, SQS, Cognito', }); -const compute = new ComputeStack(app, 'proposal-system-compute', { - stackName: 'proposal-system-compute', +const compute = new ComputeStack(app, `proposal-system-compute${stackSuffix}`, { + stackName: `proposal-system-compute${stackSuffix}`, env, + config, description: 'Proposal System - API Lambda, Python Lambdas, Bedrock KB', vpc: foundation.vpc, lambdaSecurityGroup: foundation.lambdaSecurityGroup, @@ -33,8 +36,11 @@ const compute = new ComputeStack(app, 'proposal-system-compute', { mobileClientId: foundation.mobileClientId, }); -new FrontendStack(app, 'proposal-system-frontend', { - stackName: 'proposal-system-frontend', +new FrontendStack(app, `proposal-system-frontend${stackSuffix}`, { + stackName: `proposal-system-frontend${stackSuffix}`, env, + config, description: 'Proposal System - CloudFront + S3 web hosting', }); + +void compute; diff --git a/infra/cdk.json b/infra/cdk.json index 72066ba..01977b2 100644 --- a/infra/cdk.json +++ b/infra/cdk.json @@ -14,6 +14,7 @@ ] }, "context": { + "env": "prod", "@aws-cdk/aws-lambda:recognizeLayerVersion": true, "@aws-cdk/core:checkSecretUsage": true, "@aws-cdk/core:target-partitions": ["aws"] diff --git a/infra/lib/compute-stack.ts b/infra/lib/compute-stack.ts index a1faff9..48a0455 100644 --- a/infra/lib/compute-stack.ts +++ b/infra/lib/compute-stack.ts @@ -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 cr from 'aws-cdk-lib/custom-resources'; import { Construct } from 'constructs'; +import { EnvConfig } from './config'; export interface ComputeStackProps extends cdk.StackProps { + config: EnvConfig; vpc: ec2.IVpc; lambdaSecurityGroup: ec2.ISecurityGroup; dbSecret: secretsmanager.ISecret; @@ -36,12 +38,13 @@ export interface ComputeStackProps extends cdk.StackProps { export class ComputeStack extends cdk.Stack { constructor(scope: Construct, id: string, props: ComputeStackProps) { super(scope, id, props); + const { config } = props; const privateSubnets = { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }; // Internal API key for Lambda-to-API calls (stored in Secrets Manager) const internalApiKeySecret = new secretsmanager.Secret(this, 'InternalApiKeySecret', { - secretName: 'proposal-system/internal-api-key', + secretName: `proposal-system/internal-api-key${config.stackSuffix}`, generateSecretString: { excludePunctuation: true, passwordLength: 48, @@ -50,10 +53,10 @@ export class ComputeStack extends cdk.Stack { // OpenSearch Serverless collection for Bedrock KB vector store const ossEncryptionPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssEncryptionPolicy', { - name: 'proposal-system-kb-enc', + name: `proposal-system-kb-enc${config.stackSuffix}`, type: 'encryption', policy: JSON.stringify({ - Rules: [{ ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }], + Rules: [{ ResourceType: 'collection', Resource: [`collection/proposal-system-kb${config.stackSuffix}`] }], AWSOwnedKey: true, }), }); @@ -61,18 +64,18 @@ export class ComputeStack extends cdk.Stack { // Fix: INF-H3 — restrict OpenSearch Serverless to VPC (was AllowFromPublic: true). // Create a VPC endpoint so Lambdas in private subnets can reach the collection. const ossVpcEndpoint = new opensearchserverless.CfnVpcEndpoint(this, 'OssVpcEndpoint', { - name: 'proposal-system-kb-vpce', + name: `proposal-system-kb-vpce${config.stackSuffix}`, vpcId: props.vpc.vpcId, subnetIds: props.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds, securityGroupIds: [props.lambdaSecurityGroup.securityGroupId], }); const ossNetworkPolicy = new opensearchserverless.CfnSecurityPolicy(this, 'OssNetworkPolicy', { - name: 'proposal-system-kb-net', + name: `proposal-system-kb-net${config.stackSuffix}`, type: 'network', policy: JSON.stringify([{ Rules: [ - { ResourceType: 'collection', Resource: ['collection/proposal-system-kb'] }, + { ResourceType: 'collection', Resource: [`collection/proposal-system-kb${config.stackSuffix}`] }, ], AllowFromPublic: false, SourceVPCEs: [ossVpcEndpoint.attrId], @@ -81,7 +84,7 @@ export class ComputeStack extends cdk.Stack { ossNetworkPolicy.addDependency(ossVpcEndpoint); const ossCollection = new opensearchserverless.CfnCollection(this, 'OssCollection', { - name: 'proposal-system-kb', + name: `proposal-system-kb${config.stackSuffix}`, type: 'VECTORSEARCH', }); ossCollection.addDependency(ossEncryptionPolicy); @@ -89,7 +92,7 @@ export class ComputeStack extends cdk.Stack { // Bedrock KB execution role 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'), }); @@ -110,7 +113,7 @@ export class ComputeStack extends cdk.Stack { // Lambda to pre-create the vector index (retries until AOSS access policy propagates) 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, architecture: lambda.Architecture.ARM_64, 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 // needs create/describe for bootstrapping the vector index. const ossDataAccessPolicy = new opensearchserverless.CfnAccessPolicy(this, 'OssDataAccessPolicy', { - name: 'proposal-system-kb-access', + name: `proposal-system-kb-access${config.stackSuffix}`, type: 'data', policy: JSON.stringify([ { @@ -144,7 +147,7 @@ export class ComputeStack extends cdk.Stack { Rules: [ { ResourceType: 'collection', - Resource: ['collection/proposal-system-kb'], + Resource: [`collection/proposal-system-kb${config.stackSuffix}`], Permission: [ 'aoss:DescribeCollectionItems', 'aoss:CreateCollectionItems', @@ -153,7 +156,7 @@ export class ComputeStack extends cdk.Stack { }, { ResourceType: 'index', - Resource: ['index/proposal-system-kb/*'], + Resource: [`index/proposal-system-kb${config.stackSuffix}/*`], Permission: [ 'aoss:DescribeIndex', 'aoss:ReadDocument', @@ -168,7 +171,7 @@ export class ComputeStack extends cdk.Stack { Rules: [ { ResourceType: 'collection', - Resource: ['collection/proposal-system-kb'], + Resource: [`collection/proposal-system-kb${config.stackSuffix}`], Permission: [ 'aoss:DescribeCollectionItems', 'aoss:CreateCollectionItems', @@ -176,7 +179,7 @@ export class ComputeStack extends cdk.Stack { }, { ResourceType: 'index', - Resource: ['index/proposal-system-kb/*'], + Resource: [`index/proposal-system-kb${config.stackSuffix}/*`], Permission: [ 'aoss:CreateIndex', 'aoss:DescribeIndex', @@ -207,7 +210,7 @@ export class ComputeStack extends cdk.Stack { ossIndex.node.addDependency(ossDataAccessPolicy); const knowledgeBase = new bedrock.CfnKnowledgeBase(this, 'KnowledgeBase', { - name: 'proposal-system-kb', + name: `proposal-system-kb${config.stackSuffix}`, roleArn: kbRole.roleArn, knowledgeBaseConfiguration: { type: 'VECTOR', @@ -232,7 +235,7 @@ export class ComputeStack extends cdk.Stack { // KB Data Source (S3 library bucket) const dataSource = new bedrock.CfnDataSource(this, 'KbDataSource', { - name: 'proposal-system-library', + name: `proposal-system-library${config.stackSuffix}`, knowledgeBaseId: knowledgeBase.attrKnowledgeBaseId, dataSourceConfiguration: { type: 'S3', @@ -253,7 +256,7 @@ export class ComputeStack extends cdk.Stack { // .NET 8 API Lambda const apiFunction = new lambda.Function(this, 'ApiFunction', { - functionName: 'proposal-system-api', + functionName: `proposal-system-api${config.stackSuffix}`, runtime: lambda.Runtime.DOTNET_8, architecture: lambda.Architecture.ARM_64, handler: 'ProposalSystem.Api', @@ -273,7 +276,7 @@ export class ComputeStack extends cdk.Stack { INTERNAL_API_KEY_SECRET_ARN: internalApiKeySecret.secretArn, Auth__Authority: `https://cognito-idp.${this.region}.amazonaws.com/${props.userPool.userPoolId}`, 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_MOBILE_CLIENT_ID: props.mobileClientId, }, @@ -303,13 +306,9 @@ export class ComputeStack extends cdk.Stack { // API Gateway HTTP API const httpApi = new apigatewayv2.HttpApi(this, 'HttpApi', { - apiName: 'proposal-system-gateway', + apiName: `proposal-system-gateway${config.stackSuffix}`, corsPreflight: { - allowOrigins: [ - 'https://proposals.seahaven.com', - 'https://d2yevct5e5uuz5.cloudfront.net', - 'http://localhost:5173', - ], + allowOrigins: config.apiCorsOrigins, allowMethods: [ apigatewayv2.CorsHttpMethod.GET, apigatewayv2.CorsHttpMethod.POST, @@ -323,7 +322,7 @@ export class ComputeStack extends cdk.Stack { }); const apiAccessLogGroup = new logs.LogGroup(this, 'ApiAccessLogs', { - logGroupName: '/aws/apigateway/proposal-system', + logGroupName: `/aws/apigateway/proposal-system${config.stackSuffix}`, retention: logs.RetentionDays.TWO_MONTHS, removalPolicy: cdk.RemovalPolicy.DESTROY, }); @@ -408,7 +407,7 @@ export class ComputeStack extends cdk.Stack { // Python Lambda: Suggestions Engine const suggestionsFunction = new lambda.Function(this, 'SuggestionsFunction', { - functionName: 'proposal-system-suggestions', + functionName: `proposal-system-suggestions${config.stackSuffix}`, runtime: lambda.Runtime.PYTHON_3_12, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', @@ -446,7 +445,7 @@ export class ComputeStack extends cdk.Stack { // Python Lambda: PDF Extract 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, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', @@ -480,7 +479,7 @@ export class ComputeStack extends cdk.Stack { // Python Lambda: PDF Generate 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, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', @@ -505,7 +504,7 @@ export class ComputeStack extends cdk.Stack { // Python Lambda: Library Ingest 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, architecture: lambda.Architecture.ARM_64, handler: 'app.handler', @@ -588,7 +587,7 @@ export class ComputeStack extends cdk.Stack { for (const { fn, name } of lambdaFunctions) { 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}`, metric: fn.metricErrors({ period: cdk.Duration.minutes(5) }), threshold: 1, @@ -599,7 +598,7 @@ export class ComputeStack extends cdk.Stack { } const api5xxAlarm = new cloudwatch.Alarm(this, 'Api5xxAlarm', { - alarmName: 'proposal-system-api-5xx', + alarmName: `proposal-system-api-5xx${config.stackSuffix}`, alarmDescription: 'API Gateway 5xx errors', metric: new cloudwatch.Metric({ namespace: 'AWS/ApiGateway', diff --git a/infra/lib/config.ts b/infra/lib/config.ts new file mode 100644 index 0000000..2bb0d62 --- /dev/null +++ b/infra/lib/config.ts @@ -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 = { 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; +} diff --git a/infra/lib/foundation-stack.ts b/infra/lib/foundation-stack.ts index 8237b52..a35ea52 100644 --- a/infra/lib/foundation-stack.ts +++ b/infra/lib/foundation-stack.ts @@ -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 cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions'; import { Construct } from 'constructs'; +import { EnvConfig } from './config'; + +export interface FoundationStackProps extends cdk.StackProps { + config: EnvConfig; +} export class FoundationStack extends cdk.Stack { public readonly vpc: ec2.IVpc; @@ -25,12 +30,13 @@ export class FoundationStack extends cdk.Stack { public readonly webClientId: string; public readonly mobileClientId: string; - constructor(scope: Construct, id: string, props?: cdk.StackProps) { + constructor(scope: Construct, id: string, props: FoundationStackProps) { super(scope, id, props); + const { config } = props; // VPC: 2 AZs, public + private subnets, single NAT Gateway this.vpc = new ec2.Vpc(this, 'Vpc', { - vpcName: 'proposal-system-vpc', + vpcName: `proposal-system-vpc${config.stackSuffix}`, maxAzs: 2, natGateways: 1, subnetConfiguration: [ @@ -59,14 +65,14 @@ export class FoundationStack extends cdk.Stack { // Security Groups this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSg', { vpc: this.vpc, - securityGroupName: 'proposal-system-lambda-sg', + securityGroupName: `proposal-system-lambda-sg${config.stackSuffix}`, description: 'Security group for proposal system Lambda functions', allowAllOutbound: true, }); const rdsSg = new ec2.SecurityGroup(this, 'RdsSg', { vpc: this.vpc, - securityGroupName: 'proposal-system-rds-sg', + securityGroupName: `proposal-system-rds-sg${config.stackSuffix}`, description: 'Security group for proposal system RDS instance', allowAllOutbound: false, }); @@ -79,7 +85,7 @@ export class FoundationStack extends cdk.Stack { // RDS PostgreSQL 15 const dbInstance = new rds.DatabaseInstance(this, 'Database', { - instanceIdentifier: 'proposal-system-db', + instanceIdentifier: `proposal-system-db${config.stackSuffix}`, engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_15, }), @@ -95,11 +101,11 @@ export class FoundationStack extends cdk.Stack { maxAllocatedStorage: 100, storageEncrypted: true, backupRetention: cdk.Duration.days(7), - deletionProtection: true, - removalPolicy: cdk.RemovalPolicy.RETAIN, + deletionProtection: config.retainData, + removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, databaseName: 'proposals', credentials: rds.Credentials.fromGeneratedSecret('proposalsadmin', { - secretName: 'proposal-system/db-credentials', + secretName: `proposal-system/db-credentials${config.stackSuffix}`, }), publiclyAccessible: false, }); @@ -109,7 +115,7 @@ export class FoundationStack extends cdk.Stack { // S3 Buckets // Fix: INF-M5 — enforce HTTPS-only access on all S3 buckets 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, enforceSSL: true, versioned: true, @@ -127,49 +133,46 @@ export class FoundationStack extends cdk.Stack { cors: [ { allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST], - allowedOrigins: [ - 'https://proposals.seahaven.com', - 'http://localhost:5173', - ], + allowedOrigins: config.s3CorsOrigins, allowedHeaders: ['*'], 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('ManagedBy', 'proposal-system'); 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, enforceSSL: true, // Fix: INF-M5 versioned: true, 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('ManagedBy', 'proposal-system'); 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, enforceSSL: true, // Fix: INF-M5 versioned: true, 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('ManagedBy', 'proposal-system'); // SQS Queue + DLQ const dlq = new sqs.Queue(this, 'JobsDlq', { - queueName: 'proposal-system-jobs-dlq', + queueName: `proposal-system-jobs-dlq${config.stackSuffix}`, retentionPeriod: cdk.Duration.days(14), }); this.jobsQueue = new sqs.Queue(this, 'JobsQueue', { - queueName: 'proposal-system-jobs', + queueName: `proposal-system-jobs${config.stackSuffix}`, visibilityTimeout: cdk.Duration.seconds(720), deadLetterQueue: { queue: dlq, @@ -179,7 +182,7 @@ export class FoundationStack extends cdk.Stack { // Cognito User Pool const userPool = new cognito.UserPool(this, 'UserPool', { - userPoolName: 'proposal-system-auth', + userPoolName: `proposal-system-auth${config.stackSuffix}`, selfSignUpEnabled: false, signInAliases: { email: true }, standardAttributes: { @@ -194,7 +197,7 @@ export class FoundationStack extends cdk.Stack { requireSymbols: false, }, accountRecovery: cognito.AccountRecovery.EMAIL_ONLY, - removalPolicy: cdk.RemovalPolicy.RETAIN, + removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, }); this.userPool = userPool; @@ -220,7 +223,7 @@ export class FoundationStack extends cdk.Stack { // Cognito Domain userPool.addDomain('CognitoDomain', { - cognitoDomain: { domainPrefix: 'proposal-system-seahaven' }, + cognitoDomain: { domainPrefix: config.cognitoDomainPrefix }, }); // Web App Client (PKCE) @@ -237,14 +240,8 @@ export class FoundationStack extends cdk.Stack { cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE, ], - callbackUrls: [ - 'https://proposals.seahaven.com/callback', - 'http://localhost:5173/callback', - ], - logoutUrls: [ - 'https://proposals.seahaven.com', - 'http://localhost:5173', - ], + callbackUrls: config.webCallbackUrls, + logoutUrls: config.webLogoutUrls, }, }); @@ -274,11 +271,11 @@ export class FoundationStack extends cdk.Stack { // SNS Alarm Topic const alarmTopic = new sns.Topic(this, 'AlarmTopic', { - topicName: 'proposal-system-alarms', + topicName: `proposal-system-alarms${config.stackSuffix}`, displayName: 'Proposal System Alarms', }); alarmTopic.addSubscription( - new snsSubscriptions.EmailSubscription('adam@seahavenind.com'), + new snsSubscriptions.EmailSubscription(config.alarmsEmail), ); this.alarmTopic = alarmTopic; @@ -286,7 +283,7 @@ export class FoundationStack extends cdk.Stack { // DLQ Alarm: any message landing in DLQ indicates a processing failure 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', metric: dlq.metricApproximateNumberOfMessagesVisible({ period: cdk.Duration.minutes(1), @@ -301,7 +298,7 @@ export class FoundationStack extends cdk.Stack { // RDS Alarms const rdsAlarms = [ new cloudwatch.Alarm(this, 'RdsCpuAlarm', { - alarmName: 'proposal-system-rds-cpu', + alarmName: `proposal-system-rds-cpu${config.stackSuffix}`, alarmDescription: 'RDS CPU utilization above 80%', metric: dbInstance.metricCPUUtilization({ period: cdk.Duration.minutes(5) }), threshold: 80, @@ -309,7 +306,7 @@ export class FoundationStack extends cdk.Stack { treatMissingData: cloudwatch.TreatMissingData.BREACHING, }), new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', { - alarmName: 'proposal-system-rds-connections', + alarmName: `proposal-system-rds-connections${config.stackSuffix}`, alarmDescription: 'RDS database connections above 80', metric: dbInstance.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }), threshold: 80, @@ -317,7 +314,7 @@ export class FoundationStack extends cdk.Stack { treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }), 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', metric: dbInstance.metricFreeStorageSpace({ period: cdk.Duration.minutes(5) }), threshold: 2_000_000_000, @@ -340,7 +337,7 @@ export class FoundationStack extends cdk.Stack { for (const name of logGroupNames) { new logs.LogGroup(this, `LogGroup-${name}`, { - logGroupName: `/aws/lambda/${name}`, + logGroupName: `/aws/lambda/${name}${config.stackSuffix}`, retention: logs.RetentionDays.TWO_MONTHS, removalPolicy: cdk.RemovalPolicy.DESTROY, }); diff --git a/infra/lib/frontend-stack.ts b/infra/lib/frontend-stack.ts index f193d67..37e32c0 100644 --- a/infra/lib/frontend-stack.ts +++ b/infra/lib/frontend-stack.ts @@ -3,14 +3,20 @@ import * as s3 from 'aws-cdk-lib/aws-s3'; import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins'; import { Construct } from 'constructs'; +import { EnvConfig } from './config'; + +export interface FrontendStackProps extends cdk.StackProps { + config: EnvConfig; +} 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); + const { config } = props; // Fix: INF-M5 — enforce HTTPS-only access on S3 bucket 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, enforceSSL: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, @@ -19,7 +25,7 @@ export class FrontendStack extends cdk.Stack { }); const distribution = new cloudfront.Distribution(this, 'Distribution', { - comment: 'proposal-system-web', + comment: `proposal-system-web${config.stackSuffix}`, defaultBehavior: { origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(siteBucket), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, diff --git a/scripts/post-deploy.sh b/scripts/post-deploy.sh index ef0cbc6..dae6a6d 100755 --- a/scripts/post-deploy.sh +++ b/scripts/post-deploy.sh @@ -1,26 +1,30 @@ #!/usr/bin/env bash 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 npm ci npm run build cd .. BUCKET=$(aws cloudformation describe-stacks \ - --stack-name proposal-system-frontend \ + --stack-name "proposal-system-frontend${SUFFIX}" \ --query "Stacks[0].Outputs[?OutputKey=='SiteBucketName'].OutputValue" \ --output text) aws s3 sync web/dist "s3://$BUCKET" --delete DIST_ID=$(aws cloudformation describe-stacks \ - --stack-name proposal-system-frontend \ + --stack-name "proposal-system-frontend${SUFFIX}" \ --query "Stacks[0].Outputs[?OutputKey=='DistributionId'].OutputValue" \ --output text) aws cloudfront create-invalidation --distribution-id "$DIST_ID" --paths "/*" # Health check: verify API is reachable API_URL=$(aws cloudformation describe-stacks \ - --stack-name proposal-system-compute \ + --stack-name "proposal-system-compute${SUFFIX}" \ --query "Stacks[0].Outputs[?OutputKey=='ApiEndpoint'].OutputValue" \ --output text)