mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 01:53:12 +00:00
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:
parent
3d050bcf8e
commit
bbd185b280
7 changed files with 178 additions and 85 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
]
|
||||
},
|
||||
"context": {
|
||||
"env": "prod",
|
||||
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
|
||||
"@aws-cdk/core:checkSecretUsage": true,
|
||||
"@aws-cdk/core:target-partitions": ["aws"]
|
||||
|
|
|
|||
|
|
@ -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
80
infra/lib/config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue