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 { 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;

View file

@ -14,6 +14,7 @@
]
},
"context": {
"env": "prod",
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@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 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',

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 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,
});

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 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,

View file

@ -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)