mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 05:23:14 +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 { 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;
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
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 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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue