mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 20:33:14 +00:00
* fix(infra): give the Aurora cluster a distinct construct ID The RDS->Aurora swap (PR3 #125) kept construct ID 'Database', so CloudFormation saw the same logical ID change from AWS::RDS::DBInstance to AWS::RDS::DBCluster and rejected the changeset ('Update of resource type is not permitted'). Renaming the construct to 'AuroraCluster' gives the cluster a new logical ID, so CFN does a clean replace (remove old DBInstance, add new DBCluster) instead of an in-place type change. * chore(deps): group Dependabot minor/patch updates per ecosystem Add a group to each update entry so weekly minor/patch bumps land as a single PR per ecosystem/directory instead of one PR per package. Major bumps remain individual PRs so breaking changes get isolated review. Grouping takes effect when open-pull-requests-limit is raised above 0 (version updates are still paused during development, #109). * chore(deps): unpause Dependabot version updates Raise open-pull-requests-limit from 0 to 10 across all ecosystems, re-enabling weekly version updates (paused during development, #109). With grouping now in place, minor/patch bumps land as one grouped PR per ecosystem; the limit caps outstanding major-bump PRs.
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
import * as cdk from 'aws-cdk-lib';
|
|
import * as ec2 from 'aws-cdk-lib/aws-ec2';
|
|
import * as rds from 'aws-cdk-lib/aws-rds';
|
|
import * as s3 from 'aws-cdk-lib/aws-s3';
|
|
import * as sqs from 'aws-cdk-lib/aws-sqs';
|
|
import * as cognito from 'aws-cdk-lib/aws-cognito';
|
|
import * as logs from 'aws-cdk-lib/aws-logs';
|
|
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
|
|
import * as sns from 'aws-cdk-lib/aws-sns';
|
|
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;
|
|
public readonly lambdaSecurityGroup: ec2.ISecurityGroup;
|
|
public readonly dbSecret: secretsmanager.ISecret;
|
|
public readonly dbCluster: rds.IDatabaseCluster;
|
|
public readonly uploadsBucket: s3.IBucket;
|
|
public readonly generatedBucket: s3.IBucket;
|
|
public readonly libraryBucket: s3.IBucket;
|
|
public readonly jobsQueue: sqs.IQueue;
|
|
public readonly userPool: cognito.IUserPool;
|
|
public readonly alarmTopic: sns.ITopic;
|
|
public readonly webClientId: string;
|
|
public readonly mobileClientId: string;
|
|
|
|
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${config.stackSuffix}`,
|
|
maxAzs: 2,
|
|
natGateways: 1,
|
|
subnetConfiguration: [
|
|
{
|
|
name: 'public',
|
|
subnetType: ec2.SubnetType.PUBLIC,
|
|
cidrMask: 24,
|
|
},
|
|
{
|
|
name: 'private',
|
|
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
|
|
cidrMask: 24,
|
|
},
|
|
],
|
|
});
|
|
|
|
// VPC Endpoints
|
|
this.vpc.addGatewayEndpoint('S3Endpoint', {
|
|
service: ec2.GatewayVpcEndpointAwsService.S3,
|
|
});
|
|
|
|
this.vpc.addInterfaceEndpoint('SecretsManagerEndpoint', {
|
|
service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
|
|
});
|
|
|
|
// Security Groups
|
|
this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSg', {
|
|
vpc: this.vpc,
|
|
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${config.stackSuffix}`,
|
|
description: 'Security group for proposal system RDS instance',
|
|
allowAllOutbound: false,
|
|
});
|
|
|
|
rdsSg.addIngressRule(
|
|
this.lambdaSecurityGroup,
|
|
ec2.Port.tcp(5432),
|
|
'Allow PostgreSQL from Lambda SG'
|
|
);
|
|
|
|
// Aurora PostgreSQL Serverless v2 — pgvector store for the Bedrock Knowledge Base.
|
|
// PR3: replaced the RDS instance + OpenSearch Serverless with Aurora + pgvector
|
|
// (kills the AOSS OCU floor; scales toward 0 ACU when idle). Data API is required
|
|
// by Bedrock Knowledge Bases to query the vector table.
|
|
// Construct ID is 'AuroraCluster' (not 'Database') so CloudFormation gets a NEW
|
|
// logical ID for the cluster — the old RDS DBInstance shared logical ID 'Database*'
|
|
// and CFN forbids changing a resource's type in place ("Update of resource type is
|
|
// not permitted"). A distinct ID makes it a clean replace instead.
|
|
const dbCluster = new rds.DatabaseCluster(this, 'AuroraCluster', {
|
|
clusterIdentifier: `proposal-system-db${config.stackSuffix}`,
|
|
engine: rds.DatabaseClusterEngine.auroraPostgres({
|
|
version: rds.AuroraPostgresEngineVersion.VER_15_4,
|
|
}),
|
|
vpc: this.vpc,
|
|
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
|
|
securityGroups: [rdsSg],
|
|
writer: rds.ClusterInstance.serverlessV2('writer'),
|
|
serverlessV2MinCapacity: 0.5,
|
|
serverlessV2MaxCapacity: 4,
|
|
enableDataApi: true,
|
|
storageEncrypted: true,
|
|
backup: { retention: cdk.Duration.days(7) },
|
|
deletionProtection: config.retainData,
|
|
removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
|
|
defaultDatabaseName: 'proposals',
|
|
credentials: rds.Credentials.fromGeneratedSecret('proposalsadmin', {
|
|
secretName: `proposal-system/db-credentials${config.stackSuffix}`,
|
|
}),
|
|
});
|
|
|
|
this.dbSecret = dbCluster.secret!;
|
|
this.dbCluster = dbCluster;
|
|
|
|
// 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}${config.stackSuffix}`,
|
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
enforceSSL: true,
|
|
versioned: true,
|
|
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
lifecycleRules: [
|
|
{
|
|
transitions: [
|
|
{
|
|
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
|
|
transitionAfter: cdk.Duration.days(90),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
cors: [
|
|
{
|
|
allowedMethods: [s3.HttpMethods.PUT, s3.HttpMethods.POST],
|
|
allowedOrigins: config.s3CorsOrigins,
|
|
allowedHeaders: ['*'],
|
|
maxAge: 3600,
|
|
},
|
|
],
|
|
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}${config.stackSuffix}`,
|
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
enforceSSL: true, // Fix: INF-M5
|
|
versioned: true,
|
|
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
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}${config.stackSuffix}`,
|
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
enforceSSL: true, // Fix: INF-M5
|
|
versioned: true,
|
|
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
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${config.stackSuffix}`,
|
|
retentionPeriod: cdk.Duration.days(14),
|
|
});
|
|
|
|
this.jobsQueue = new sqs.Queue(this, 'JobsQueue', {
|
|
queueName: `proposal-system-jobs${config.stackSuffix}`,
|
|
visibilityTimeout: cdk.Duration.seconds(720),
|
|
deadLetterQueue: {
|
|
queue: dlq,
|
|
maxReceiveCount: 3,
|
|
},
|
|
});
|
|
|
|
// Cognito User Pool
|
|
const userPool = new cognito.UserPool(this, 'UserPool', {
|
|
userPoolName: `proposal-system-auth${config.stackSuffix}`,
|
|
selfSignUpEnabled: false,
|
|
signInAliases: { email: true },
|
|
standardAttributes: {
|
|
email: { required: true, mutable: true },
|
|
fullname: { required: true, mutable: true },
|
|
},
|
|
passwordPolicy: {
|
|
minLength: 12,
|
|
requireUppercase: true,
|
|
requireLowercase: true,
|
|
requireDigits: true,
|
|
requireSymbols: false,
|
|
},
|
|
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
|
|
removalPolicy: config.retainData ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY,
|
|
});
|
|
|
|
this.userPool = userPool;
|
|
|
|
// Cognito Groups
|
|
new cognito.CfnUserPoolGroup(this, 'DispatchersGroup', {
|
|
userPoolId: userPool.userPoolId,
|
|
groupName: 'dispatchers',
|
|
description: 'Dispatchers who submit proposal requests',
|
|
});
|
|
|
|
new cognito.CfnUserPoolGroup(this, 'AdminsGroup', {
|
|
userPoolId: userPool.userPoolId,
|
|
groupName: 'admins',
|
|
description: 'Admins who review and approve proposals',
|
|
});
|
|
|
|
new cognito.CfnUserPoolGroup(this, 'SysadminsGroup', {
|
|
userPoolId: userPool.userPoolId,
|
|
groupName: 'sysadmins',
|
|
description: 'System administrators',
|
|
});
|
|
|
|
// Cognito Domain
|
|
userPool.addDomain('CognitoDomain', {
|
|
cognitoDomain: { domainPrefix: config.cognitoDomainPrefix },
|
|
});
|
|
|
|
// Web App Client (PKCE)
|
|
const webClient = userPool.addClient('WebClient', {
|
|
userPoolClientName: 'proposal-system-web',
|
|
generateSecret: false,
|
|
authFlows: {
|
|
userSrp: true,
|
|
},
|
|
oAuth: {
|
|
flows: { authorizationCodeGrant: true },
|
|
scopes: [
|
|
cognito.OAuthScope.OPENID,
|
|
cognito.OAuthScope.EMAIL,
|
|
cognito.OAuthScope.PROFILE,
|
|
],
|
|
callbackUrls: config.webCallbackUrls,
|
|
logoutUrls: config.webLogoutUrls,
|
|
},
|
|
});
|
|
|
|
this.webClientId = webClient.userPoolClientId;
|
|
|
|
// Mobile App Client (PKCE)
|
|
const mobileClient = userPool.addClient('MobileClient', {
|
|
userPoolClientName: 'proposal-system-mobile',
|
|
generateSecret: false,
|
|
authFlows: {
|
|
userSrp: true,
|
|
},
|
|
oAuth: {
|
|
flows: { authorizationCodeGrant: true },
|
|
scopes: [
|
|
cognito.OAuthScope.OPENID,
|
|
cognito.OAuthScope.EMAIL,
|
|
cognito.OAuthScope.PROFILE,
|
|
],
|
|
callbackUrls: ['com.seahavenind.proposals://auth/callback'],
|
|
logoutUrls: ['com.seahavenind.proposals://auth/logout'],
|
|
},
|
|
});
|
|
|
|
this.webClientId = webClient.userPoolClientId;
|
|
this.mobileClientId = mobileClient.userPoolClientId;
|
|
|
|
// SNS Alarm Topic
|
|
const alarmTopic = new sns.Topic(this, 'AlarmTopic', {
|
|
topicName: `proposal-system-alarms${config.stackSuffix}`,
|
|
displayName: 'Proposal System Alarms',
|
|
});
|
|
alarmTopic.addSubscription(
|
|
new snsSubscriptions.EmailSubscription(config.alarmsEmail),
|
|
);
|
|
this.alarmTopic = alarmTopic;
|
|
|
|
const alarmAction = new cloudwatchActions.SnsAction(alarmTopic);
|
|
|
|
// DLQ Alarm: any message landing in DLQ indicates a processing failure
|
|
const dlqAlarm = new cloudwatch.Alarm(this, 'DlqDepthAlarm', {
|
|
alarmName: `proposal-system-dlq-depth${config.stackSuffix}`,
|
|
alarmDescription: 'Messages in DLQ — SQS processing failures',
|
|
metric: dlq.metricApproximateNumberOfMessagesVisible({
|
|
period: cdk.Duration.minutes(1),
|
|
}),
|
|
threshold: 0,
|
|
comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
|
|
evaluationPeriods: 1,
|
|
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
});
|
|
dlqAlarm.addAlarmAction(alarmAction);
|
|
|
|
// RDS Alarms
|
|
const rdsAlarms = [
|
|
new cloudwatch.Alarm(this, 'RdsCpuAlarm', {
|
|
alarmName: `proposal-system-rds-cpu${config.stackSuffix}`,
|
|
alarmDescription: 'RDS CPU utilization above 80%',
|
|
metric: dbCluster.metricCPUUtilization({ period: cdk.Duration.minutes(5) }),
|
|
threshold: 80,
|
|
evaluationPeriods: 3,
|
|
treatMissingData: cloudwatch.TreatMissingData.BREACHING,
|
|
}),
|
|
new cloudwatch.Alarm(this, 'RdsConnectionsAlarm', {
|
|
alarmName: `proposal-system-rds-connections${config.stackSuffix}`,
|
|
alarmDescription: 'RDS database connections above 80',
|
|
metric: dbCluster.metricDatabaseConnections({ period: cdk.Duration.minutes(5) }),
|
|
threshold: 80,
|
|
evaluationPeriods: 2,
|
|
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
}),
|
|
// Aurora storage auto-scales (no FreeStorageSpace); freeable memory is the
|
|
// meaningful health signal for a Serverless v2 cluster.
|
|
new cloudwatch.Alarm(this, 'RdsLowMemoryAlarm', {
|
|
alarmName: `proposal-system-rds-low-memory${config.stackSuffix}`,
|
|
alarmDescription: 'Aurora freeable memory below 256 MB',
|
|
metric: dbCluster.metricFreeableMemory({ period: cdk.Duration.minutes(5) }),
|
|
threshold: 256_000_000,
|
|
comparisonOperator: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
|
|
evaluationPeriods: 3,
|
|
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
|
|
}),
|
|
];
|
|
for (const alarm of rdsAlarms) {
|
|
alarm.addAlarmAction(alarmAction);
|
|
}
|
|
|
|
// CloudWatch Log Groups
|
|
const logGroupNames = [
|
|
'proposal-system-api',
|
|
'proposal-system-pdf-extract',
|
|
'proposal-system-pdf-generate',
|
|
'proposal-system-library-ingest',
|
|
];
|
|
|
|
for (const name of logGroupNames) {
|
|
new logs.LogGroup(this, `LogGroup-${name}`, {
|
|
logGroupName: `/aws/lambda/${name}${config.stackSuffix}`,
|
|
retention: logs.RetentionDays.TWO_MONTHS,
|
|
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
|
});
|
|
}
|
|
|
|
// Outputs
|
|
new cdk.CfnOutput(this, 'VpcId', { value: this.vpc.vpcId });
|
|
new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
|
|
new cdk.CfnOutput(this, 'UserPoolArn', { value: userPool.userPoolArn });
|
|
new cdk.CfnOutput(this, 'UploadsBucketName', { value: this.uploadsBucket.bucketName });
|
|
new cdk.CfnOutput(this, 'GeneratedBucketName', { value: this.generatedBucket.bucketName });
|
|
new cdk.CfnOutput(this, 'LibraryBucketName', { value: this.libraryBucket.bucketName });
|
|
new cdk.CfnOutput(this, 'JobsQueueUrl', { value: this.jobsQueue.queueUrl });
|
|
new cdk.CfnOutput(this, 'DbSecretArn', { value: this.dbSecret.secretArn });
|
|
new cdk.CfnOutput(this, 'WebClientId', { value: webClient.userPoolClientId });
|
|
new cdk.CfnOutput(this, 'MobileClientId', { value: mobileClient.userPoolClientId });
|
|
new cdk.CfnOutput(this, 'AlarmTopicArn', { value: alarmTopic.topicArn });
|
|
}
|
|
}
|