From e39ab1fba386cda3e884c5fc1c067d2a3fb6a25c Mon Sep 17 00:00:00 2001 From: Mikhael Rakauskas Date: Mon, 21 Jul 2025 13:11:53 -0400 Subject: [PATCH] First pass CDK stack --- cdk_deploy/.gitignore | 5 + cdk_deploy/.npmignore | 3 + cdk_deploy/README.md | 153 +++++++++ cdk_deploy/app.js | 109 +++++++ cdk_deploy/cdk.context.json | 82 +++++ cdk_deploy/cdk.json | 64 ++++ cdk_deploy/lib/cp-docuseal-stack.js | 382 +++++++++++++++++++++++ cdk_deploy/package-lock.json | 468 ++++++++++++++++++++++++++++ cdk_deploy/package.json | 34 ++ cdk_deploy/userdata.txt | 39 +++ 10 files changed, 1339 insertions(+) create mode 100644 cdk_deploy/.gitignore create mode 100644 cdk_deploy/.npmignore create mode 100644 cdk_deploy/README.md create mode 100644 cdk_deploy/app.js create mode 100644 cdk_deploy/cdk.context.json create mode 100644 cdk_deploy/cdk.json create mode 100644 cdk_deploy/lib/cp-docuseal-stack.js create mode 100644 cdk_deploy/package-lock.json create mode 100644 cdk_deploy/package.json create mode 100644 cdk_deploy/userdata.txt diff --git a/cdk_deploy/.gitignore b/cdk_deploy/.gitignore new file mode 100644 index 00000000..21dc7626 --- /dev/null +++ b/cdk_deploy/.gitignore @@ -0,0 +1,5 @@ +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/cdk_deploy/.npmignore b/cdk_deploy/.npmignore new file mode 100644 index 00000000..5de422a0 --- /dev/null +++ b/cdk_deploy/.npmignore @@ -0,0 +1,3 @@ +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/cdk_deploy/README.md b/cdk_deploy/README.md new file mode 100644 index 00000000..73d56f5b --- /dev/null +++ b/cdk_deploy/README.md @@ -0,0 +1,153 @@ +# CP Docuseal CDK Infrastructure + +This directory contains AWS CDK v2 infrastructure code for deploying the CP Docuseal app. At present it only deploys to development and staging; production will follow. + +## Architecture Overview + +This infrastructure is basically nicked wholesale from the Integration Station application, just dialed down a notch as our needs are a bit less than theirs. + +- **Internal Application Load Balancer (ALB)** - Routes traffic to ECS services within the VPC +- **Amazon ECS Cluster** - Runs containerized applications on EC2 instances (ARM64 t4g.small) +- **ECS Service** - Manages container deployment and scaling +- **ECS Task Definition** - Defines container configuration and resource requirements +- **CloudWatch Logging** - Centralized logging for application monitoring +- **Security Groups** - Network security controls +- **ECR Integration** - Uses existing "integration-station" ECR repository + +## Prerequisites + +1. **AWS CLI** configured with appropriate permissions +2. **Node.js** (version 22 or later) +3. **AWS CDK v2** installed globally: `npm install -g aws-cdk` +4. **Existing AWS Infrastructure**: + - VPC with public and private subnets + - ECR repository named "cp-docuseal" + - Appropriate IAM permissions for CDK deployment + +## Setup + +1. **Install dependencies**: + ```bash + cd cdk_deploy + npm install + ``` + +2. **Bootstrap CDK** (application setup ONLY): + ```bash + npm run bootstrap + ``` + +3. **Update VPC and Subnet IDs**: + Edit `app.js` and replace the placeholder IDs with your actual VPC and subnet IDs: + ```javascript + vpcId: 'vpc-your-actual-vpc-id', + privateSubnetIds: ['subnet-your-private-1', 'subnet-your-private-2'], + publicSubnetIds: ['subnet-your-public-1', 'subnet-your-public-2'] + ``` + +## Environment Configuration + +The infrastructure supports three environments with different resource allocations: + +### Development +- **Instances**: 1 ECS instance +- **CPU**: 512 units +- **Memory**: 1024 MB + +### Staging +- **Instances**: 1 ECS instance +- **CPU**: 512 units +- **Memory**: 1024 MB + +### Production +- N/A + + +## Deployment + +### Deploy to Development +```bash +npm run deploy:dev +``` + +### Deploy to Staging +```bash +npm run deploy:staging +``` + +### Deploy to Production - NOT YET SUPPORTED +```bash +npm run deploy:prod +``` + +### View CloudFormation Template +```bash +npm run synth +``` + +### Compare Changes +```bash +npm run diff +``` + +## Cleanup + +### Destroy Development Environment +```bash +npm run destroy:dev +``` + +### Destroy Staging Environment +```bash +npm run destroy:staging +``` + +### Destroy Production Environment - NOT YET SUPPORTED +```bash +npm run destroy:prod +``` + +## Important Notes + +1. **Internal ALB**: The Application Load Balancer is configured as internal-only and deployed in private subnets for security. + +2. **ARM64 Instances**: The ECS cluster uses t4g.small ARM64 instances for cost efficiency. Ensure your container images are built for ARM64 architecture. + +3. **Health Checks**: The ALB target group is configured to perform health checks on `/health` endpoint. Make sure your application responds to this endpoint. + +4. **Logging**: All ECS tasks automatically log to CloudWatch under `/ecs/cp-docuseal-{environment}` log groups. + +5. **Security**: Security groups are configured to allow: + - ALB: HTTP (80) and HTTPS (443) traffic + - ECS: Traffic from ALB on port 3000 + +## Troubleshooting + +1. **VPC Lookup Issues**: Ensure the VPC IDs and subnet IDs in `app.js` are correct and exist in your AWS account. + +2. **ECR Repository**: Verify that the "cp-docuseal" ECR repository exists and contains the required Docker images. + +3. **Permissions**: Ensure your AWS credentials have sufficient permissions for: + - EC2 (VPC, Security Groups, Launch Templates) + - ECS (Clusters, Services, Tasks) + - ELB (Application Load Balancers, Target Groups) + - CloudWatch (Log Groups) + - IAM (Roles and Policies) + +4. **Container Health**: If services fail to start, check CloudWatch logs for container startup issues. + +## Customization + +You can modify the following aspects: + +- **Instance Types**: Change `ec2.InstanceClass.T4G` and `ec2.InstanceSize.SMALL` in the stack +- **Container Port**: Update port mappings if your application uses a different port +- **Resource Limits**: Adjust CPU and memory allocations in the environment configurations +- **Auto Scaling**: Modify `minCapacity` and `maxCapacity` for different scaling behaviors + +## Stack Outputs + +After deployment, the stack provides: +- **ALB DNS Name**: Internal DNS name for the Application Load Balancer +- **ECS Cluster Name**: Name of the created ECS cluster +- **ECS Service Name**: Name of the ECS service diff --git a/cdk_deploy/app.js b/cdk_deploy/app.js new file mode 100644 index 00000000..5aee4459 --- /dev/null +++ b/cdk_deploy/app.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +const { App } = require('aws-cdk-lib'); +const { CPDocusealStack } = require('./lib/cp-docuseal-stack'); +const fs = require('fs'); + +const app = new App(); + +const userDataScript = fs.readFileSync('./userdata.txt', 'utf8'); + +// Get staging number from context if provided +const stagingNumber = app.node.tryGetContext('stagingNumber'); + +// Function to get certificate ARN based on staging number +function getStagingCertificateArn(stagingNumber) { + if (!stagingNumber) { + // Default certificate for staging when no staging number is provided + return 'arn:aws:acm:us-east-1:788066832395:certificate/5b1f59b9-ab27-4056-a5e2-0d89554e5f35'; + } + + const num = parseInt(stagingNumber); + + if (num >= 1 && num <= 11) { + return 'arn:aws:acm:us-east-1:788066832395:certificate/d3ae2320-6da3-4a6f-a3d9-0f00f85033cb'; + } else if (num >= 12 && num <= 22) { + return 'arn:aws:acm:us-east-1:788066832395:certificate/5b1f59b9-ab27-4056-a5e2-0d89554e5f35'; + } else if (num >= 23 && num <= 24) { + return 'arn:aws:acm:us-east-1:788066832395:certificate/69a8fe61-f12f-4251-9e55-d68c21553388'; + } else if (num >= 25 && num <= 27) { + return 'arn:aws:acm:us-east-1:788066832395:certificate/3ce231d1-b2ec-4013-80db-b231db5a1e02'; + } else { + // Default certificate for staging numbers outside defined ranges + return 'arn:aws:acm:us-east-1:788066832395:certificate/5b1f59b9-ab27-4056-a5e2-0d89554e5f35'; + } +} + +// Environment configurations +const environments = { + dev: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION || 'us-east-1', + vpcId: 'vpc-95c19df2', + publicSubnetIds: ['subnet-ff02dbb6', 'subnet-69fe1132', 'subnet-cb8e63f7' ], + instanceCount: 1, + instanceSize: 'SMALL', + cpu: 512, + memory: 1024, + securityGroupIds: ["sg-0f0da2fa2d6088742", "sg-006e8df67aec60469"], + userDataScript: userDataScript, + certificateArn: 'arn:aws:acm:us-east-1:788066832395:certificate/5b1f59b9-ab27-4056-a5e2-0d89554e5f35', + }, + staging: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION || 'us-east-1', + vpcId: 'vpc-95c19df2', + publicSubnetIds: ['subnet-ff02dbb6', 'subnet-69fe1132', 'subnet-cb8e63f7'], + instanceCount: 1, + instanceSize: 'SMALL', + apiCpu: 512, + apiMemory: 1024, + securityGroupIds: ["sg-0f0da2fa2d6088742", "sg-006e8df67aec60469"], + userDataScript: userDataScript, + certificateArn: getStagingCertificateArn(stagingNumber), + }, + // production: { + // account: process.env.CDK_DEFAULT_ACCOUNT, + // region: process.env.CDK_DEFAULT_REGION || 'us-east-1', + // vpcId: 'vpc-95c19df2', + // publicSubnetIds: ['subnet-ff02dbb6', 'subnet-69fe1132', 'subnet-cb8e63f7' ], + // instanceCount: 2, + // instanceSize: 'LARGE', + // apiCpu: 900, + // apiMemory: 3000, + // sidekiqCpu: 900, + // sidekiqMemory: 3000, + // securityGroupIds: ["sg-09fa17711757036e6", "sg-006e8df67aec60469"], + // userDataScript: userDataScript, + // certificateArn: 'arn:aws:acm:us-east-1:788066832395:certificate/05fa2bb8-5589-4425-9f28-427f1082a64b', + // } +}; + +// Create stacks for each environment +Object.entries(environments).forEach(([envName, config]) => { + // Construct stack name with staging number if provided + let stackName = `CPDocusealStack-${envName}`; + let environmentName = envName; + + // Append staging number for staging environments + if (envName === 'staging' && stagingNumber) { + stackName += `-${stagingNumber}`; + environmentName += `-${stagingNumber}`; + } + + new CPDocusealStack(app, stackName, { + env: { + account: config.account, + region: config.region + }, + environment: envName, + vpcConfig: { + vpcId: config.vpcId, + publicSubnetIds: config.publicSubnetIds + }, + ecsConfig: config, + certificateArn: config.certificateArn, + securityGroupIds: config.securityGroupIds, + userDataScript: config.userDataScript + }); +}); diff --git a/cdk_deploy/cdk.context.json b/cdk_deploy/cdk.context.json new file mode 100644 index 00000000..a31bc0da --- /dev/null +++ b/cdk_deploy/cdk.context.json @@ -0,0 +1,82 @@ +{ + "vpc-provider:account=788066832395:filter.vpc-id=vpc-95c19df2:region=us-east-1:returnAsymmetricSubnets=true": { + "vpcId": "vpc-95c19df2", + "vpcCidrBlock": "172.30.0.0/16", + "ownerAccountId": "788066832395", + "availabilityZones": [], + "subnetGroups": [ + { + "name": "Public", + "type": "Public", + "subnets": [ + { + "subnetId": "subnet-ff02dbb6", + "cidr": "172.30.0.0/24", + "availabilityZone": "us-east-1a", + "routeTableId": "rtb-27219141" + }, + { + "subnetId": "subnet-69fe1132", + "cidr": "172.30.1.0/24", + "availabilityZone": "us-east-1b", + "routeTableId": "rtb-27219141" + }, + { + "subnetId": "subnet-95927bb8", + "cidr": "172.30.2.0/24", + "availabilityZone": "us-east-1d", + "routeTableId": "rtb-27219141" + }, + { + "subnetId": "subnet-0a1feb6c1333efbab", + "cidr": "172.30.8.0/24", + "availabilityZone": "us-east-1d", + "routeTableId": "rtb-27219141" + }, + { + "subnetId": "subnet-cb8e63f7", + "cidr": "172.30.3.0/24", + "availabilityZone": "us-east-1e", + "routeTableId": "rtb-27219141" + }, + { + "subnetId": "subnet-0f5743e7967df31ef", + "cidr": "172.30.4.0/24", + "availabilityZone": "us-east-1f", + "routeTableId": "rtb-27219141" + }, + { + "subnetId": "subnet-0118637ce4cf80f0a", + "cidr": "172.30.10.0/24", + "availabilityZone": "us-east-1f", + "routeTableId": "rtb-27219141" + } + ] + }, + { + "name": "Private", + "type": "Private", + "subnets": [ + { + "subnetId": "subnet-0a263997449735cfd", + "cidr": "172.30.5.0/24", + "availabilityZone": "us-east-1a", + "routeTableId": "rtb-07ee1e39f005b06f2" + }, + { + "subnetId": "subnet-05e98535001316fe8", + "cidr": "172.30.6.0/24", + "availabilityZone": "us-east-1b", + "routeTableId": "rtb-07ee1e39f005b06f2" + }, + { + "subnetId": "subnet-0bcf29b2a2a47fbb7", + "cidr": "172.30.9.0/24", + "availabilityZone": "us-east-1e", + "routeTableId": "rtb-07ee1e39f005b06f2" + } + ] + } + ] + } +} diff --git a/cdk_deploy/cdk.json b/cdk_deploy/cdk.json new file mode 100644 index 00000000..11b217a0 --- /dev/null +++ b/cdk_deploy/cdk.json @@ -0,0 +1,64 @@ +{ + "app": "node app.js", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.js", + "package*.json", + "yarn.lock", + "node_modules" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableLoggingConfiguration": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableLogging": true, + "@aws-cdk/aws-noralization:emptyOperationContext": true, + "@aws-cdk/aws-lambda:codecommitPolicyDefaultTargetsBranch": true, + "@aws-cdk/aws-lambda:recognizeVersionProps": true, + "@aws-cdk/aws-cloudformation:parseParamsAndSecretsConfiguration": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-ec2:disableSubnetCreateDefaultRouteToIgw": true, + "@aws-cdk/aws-ec2:ipv4IpamPoolPublicIpSource": true + } +} diff --git a/cdk_deploy/lib/cp-docuseal-stack.js b/cdk_deploy/lib/cp-docuseal-stack.js new file mode 100644 index 00000000..40857a9f --- /dev/null +++ b/cdk_deploy/lib/cp-docuseal-stack.js @@ -0,0 +1,382 @@ +const { + Stack, + Duration, + aws_ec2: ec2, + aws_ecs: ecs, + aws_elasticloadbalancingv2: elbv2, + aws_logs: logs, + aws_iam: iam, + aws_ecr: ecr, + aws_secretsmanager: secretsmanager, + aws_certificatemanager: acm, + aws_route53: route53, + aws_route53_targets: targets, + Aspects, + RemovalPolicy +} = require('aws-cdk-lib'); +const { Construct } = require('constructs'); + +class CPDocusealStack extends Stack { + constructor(scope, id, props) { + super(scope, id, props); + + const { environment, vpcConfig, ecsConfig, securityGroupIds, userDataScript } = props; + + // Get the image tag from CDK context, default to 'latest' if not provided + const imageTag = this.node.tryGetContext('imageTag') || 'latest'; + const stagingNumber = this.node.tryGetContext('stagingNumber') || null; + const envDescription = stagingNumber ? `${environment}-${stagingNumber}` : environment; + + // Import existing VPC + const vpc = ec2.Vpc.fromLookup(this, 'VPC', { + vpcId: vpcConfig.vpcId + }); + + // Import existing subnets + const publicSubnets = vpcConfig.publicSubnetIds.map((subnetId, index) => + ec2.Subnet.fromSubnetId(this, `PublicSubnet${index}`, subnetId) + ); + + // Security Group for ALB + const albSecurityGroup = new ec2.SecurityGroup(this, 'ALBSecurityGroup', { + vpc, + description: `ALB Security Group for ${envDescription}`, + allowAllOutbound: true + }); + + // we may not need this... + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(80), + 'Allow HTTP traffic' + ); + + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(443), + 'Allow HTTPS traffic' + ); + + // Security Group for ECS Tasks + const ecsSecurityGroup = new ec2.SecurityGroup(this, 'ECSSecurityGroup', { + vpc, + description: `ECS Security Group for ${envDescription}`, + allowAllOutbound: true + }); + + ecsSecurityGroup.addIngressRule( + albSecurityGroup, + ec2.Port.tcp(3001), + 'Allow traffic from ALB' + ); + + // Internal Application Load Balancer + const alb = new elbv2.ApplicationLoadBalancer(this, 'InternalALB', { + vpc, + internetFacing: true, + vpcSubnets: { + subnets: publicSubnets + }, + securityGroup: albSecurityGroup, + loadBalancerName: `cpd-alb-${envDescription}` + }); + + // Target Group for ECS Service + const targetGroup = new elbv2.ApplicationTargetGroup(this, 'ECSTargetGroup', { + vpc, + port: 3001, + protocol: elbv2.ApplicationProtocol.HTTP, + targetType: elbv2.TargetType.INSTANCE, + healthCheck: { + enabled: true, + healthyHttpCodes: '200', + path: '/up', + protocol: elbv2.Protocol.HTTP, + interval: Duration.seconds(30), + timeout: Duration.seconds(5), + healthyThresholdCount: 2, + unhealthyThresholdCount: 5 + }, + deregistrationDelay: Duration.seconds(20), + targetGroupName: `cpd-tg-${envDescription}` + }); + + // ALB Listener + const listener = alb.addListener('ALBListener', { + port: 80, + protocol: elbv2.ApplicationProtocol.HTTP, + defaultTargetGroups: [targetGroup] + }); + + // Accept certificateArn as input (default to provided value if not in props) + const certificateArn = props.certificateArn || 'arn:aws:acm:us-east-1:788066832395:certificate/05fa2bb8-5589-4425-9f28-427f1082a64b'; + + // Add HTTPS Listener with ACM certificate + const certificate = acm.Certificate.fromCertificateArn(this, 'ALBCertificate', certificateArn); + alb.addListener('ALBListenerHTTPS', { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + certificates: [certificate], + defaultTargetGroups: [targetGroup] + }); + + // Create Route53 A record for staging and production environments + let hostedZoneDomain = null; + + if (environment === 'staging' && stagingNumber) { + hostedZoneDomain = `cpstaging${stagingNumber}.name`; + // } else if (environment === 'production') { + // hostedZoneDomain = 'careerplug.com'; + } + + if (hostedZoneDomain) { + const recordName = 'cpd'; + + // Import the existing hosted zone + const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', { + domainName: hostedZoneDomain + }); + + // Create A record pointing to ALB + new route53.ARecord(this, 'ALBARecord', { + zone: hostedZone, + recordName: recordName, + target: route53.RecordTarget.fromAlias(new targets.LoadBalancerTarget(alb)), + ttl: Duration.minutes(1), + comment: `A record for CP Docuseal ${envDescription}` + }); + } + + // ECS Cluster + const cluster = new ecs.Cluster(this, 'ECSCluster', { + vpc, + clusterName: `cp-docuseal-${envDescription}`, + containerInsightsV2: ecs.ContainerInsights.ENABLED + }); + + // Determine instance size based on configuration + const instanceSize = ecsConfig.instanceSize || 'SMALL'; + + // Import additional existing security groups by ID from props + const importedSecurityGroups = (securityGroupIds || []).map((sgId, idx) => + ec2.SecurityGroup.fromSecurityGroupId(this, `ImportedSG${idx + 1}`, sgId) + ); + + // Auto Scaling Group for ECS with ARM64 instances + const autoScalingGroup = cluster.addCapacity('ECSAutoScalingGroup', { + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize[instanceSize]), // ARM64 + machineImage: ecs.EcsOptimizedImage.amazonLinux2(ecs.AmiHardwareType.ARM), + minCapacity: ecsConfig.instanceCount, + maxCapacity: ecsConfig.instanceCount, + vpcSubnets: { + subnets: publicSubnets + } + }); + + // Attach imported security groups to the EC2 instances + importedSecurityGroups.forEach((sg, idx) => { + autoScalingGroup.addSecurityGroup(sg); + }); + + // Add security group rule to EC2 instances for HOST network mode + // Allow ALB to reach containers on port 3001 + autoScalingGroup.addSecurityGroup(ecsSecurityGroup); + + if (userDataScript) { + autoScalingGroup.addUserData(userDataScript); + } + + // CloudWatch Log Groups + const apiLogGroup = new logs.LogGroup(this, 'APILogGroup', { + logGroupName: `/ecs/cp-docuseal-api-${envDescription}`, + retention: logs.RetentionDays.ONE_WEEK + }); + apiLogGroup.applyRemovalPolicy(RemovalPolicy.DESTROY); + + // const sidekiqLogGroup = new logs.LogGroup(this, 'SidekiqLogGroup', { + // logGroupName: `/ecs/cp-docuseal-sidekiq-${envDescription}`, + // retention: logs.RetentionDays.ONE_WEEK + // }); + // sidekiqLogGroup.applyRemovalPolicy(RemovalPolicy.DESTROY); + + // Import ECR Repository + const ecrRepository = ecr.Repository.fromRepositoryName( + this, + 'ECRRepository', + 'cp-docuseal' + ); + + // Task Definition + const taskDefinition = new ecs.Ec2TaskDefinition(this, 'TaskDefinition', { + family: `cp-docuseal-task-${envDescription}`, + networkMode: ecs.NetworkMode.HOST + }); + + // Base environment variables + const containerEnvironment = { + NODE_ENV: environment, + PORT: '3001', + RAILS_ENV: environment + }; + + // Add environment-specific variables + if (environment === 'staging') { // || environment === 'production') { + // For staging and production, we'll use Secrets Manager + containerEnvironment.AWS_REGION = this.region; + // Map production environment to prod for secret naming + const secretEnvironment = environment; // === 'production' ? 'prod' : environment; + containerEnvironment.DB_SECRETS_NAME = `${secretEnvironment}/db_creds`; + + // set DB name + containerEnvironment.DB_NAME = `cpdocuseal${stagingNumber ? `${stagingNumber}` : ''}`; + } + + // Check if we need multi-container setup (staging and production) + const isMultiContainer = environment === 'staging'; // || environment === 'production'; + + if (isMultiContainer) { + // API Container Definition + const apiContainerConfig = { + image: ecs.ContainerImage.fromEcrRepository(ecrRepository, imageTag), + cpu: ecsConfig.apiCpu, + memoryLimitMiB: ecsConfig.apiMemory, + essential: true, + logging: ecs.LogDrivers.awsLogs({ + streamPrefix: 'api', + logGroup: apiLogGroup + }), + environment: containerEnvironment, + healthCheck: { + command: ['CMD-SHELL', 'curl -f http://localhost:3001/up || exit 1'], + interval: Duration.seconds(30), + timeout: Duration.seconds(5), + retries: 3, + startPeriod: Duration.seconds(60) + } + }; + + // Set the appropriate startup script for staging and production + if (environment === 'staging') { + apiContainerConfig.command = ['./bin/start_staging', 'api']; + } + // } else if (environment === 'production') { + // apiContainerConfig.command = ['./bin/start_production', 'api']; + // } + + const apiContainer = taskDefinition.addContainer('APIContainer', apiContainerConfig); + + // Add port mapping for HOST network mode + apiContainer.addPortMappings({ + containerPort: 3001, + protocol: ecs.Protocol.TCP + }); + + // Sidekiq Container Definition + // const sidekiqContainerConfig = { + // image: ecs.ContainerImage.fromEcrRepository(ecrRepository, imageTag), + // cpu: ecsConfig.sidekiqCpu, + // memoryLimitMiB: ecsConfig.sidekiqMemory, + // essential: true, + // logging: ecs.LogDrivers.awsLogs({ + // streamPrefix: 'sidekiq', + // logGroup: sidekiqLogGroup + // }), + // environment: containerEnvironment, + // }; + + // Set the appropriate startup script for staging and production + // if (environment === 'staging') { + // sidekiqContainerConfig.command = ['./bin/start_staging', 'sidekiq']; + // } else if (environment === 'production') { + // sidekiqContainerConfig.command = ['./bin/start_production', 'sidekiq']; + // } + + // const sidekiqContainer = taskDefinition.addContainer('SidekiqContainer', sidekiqContainerConfig); + + // // Make Sidekiq container depend on API container + // sidekiqContainer.addContainerDependencies({ + // container: apiContainer, + // condition: ecs.ContainerDependencyCondition.HEALTHY + // }); + + } else { + // Single container setup for dev environment + const containerConfig = { + image: ecs.ContainerImage.fromEcrRepository(ecrRepository, imageTag), + cpu: ecsConfig.cpu, + memoryLimitMiB: ecsConfig.memory, + essential: true, + logging: ecs.LogDrivers.awsLogs({ + streamPrefix: 'ecs', + logGroup: apiLogGroup + }), + environment: containerEnvironment + }; + + const container = taskDefinition.addContainer('CPDocusealContainer', containerConfig); + + // Add port mapping for HOST network mode + container.addPortMappings({ + containerPort: 3001, + protocol: ecs.Protocol.TCP + }); + } + + // ECS Service + const service = new ecs.Ec2Service(this, 'ECSService', { + cluster, + taskDefinition, + serviceName: `cp-docuseal-service-${envDescription}`, + desiredCount: ecsConfig.instanceCount, + deploymentConfiguration: { + maximumPercent: 200, + minimumHealthyPercent: 50 + }, + enableExecuteCommand: true // For debugging + // vpcSubnets and securityGroups removed - HOST network mode uses EC2 instance network configuration + }); + + // Attach the service to the target group + service.attachToApplicationTargetGroup(targetGroup); + + // Task Role for CloudWatch logging and ECR access + taskDefinition.taskRole.addManagedPolicy( + iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy') + ); + + // Add Secrets Manager permissions for staging and production environments + if (environment === 'staging') { // || environment === 'production') { + // Map production environment to prod for secret naming + const secretEnvironment = environment; // === 'production' ? 'prod' : environment; + const secretsManagerPolicy = new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + 'secretsmanager:GetSecretValue', + 'secretsmanager:DescribeSecret' + ], + resources: [ + `arn:aws:secretsmanager:${this.region}:${this.account}:secret:${secretEnvironment}/db_creds*`, + // `arn:aws:secretsmanager:${this.region}:${this.account}:secret:integration_station/encryption_key*` + ] + }); + + taskDefinition.taskRole.addToPolicy(secretsManagerPolicy); + } + + // Output important values + this.albDnsName = alb.loadBalancerDnsName; + this.clusterName = cluster.clusterName; + this.serviceName = service.serviceName; + + // Output Route53 record name for environments that create DNS records + if (hostedZoneDomain) { + if (environment === 'staging' && stagingNumber) { + this.route53RecordName = `cpd.cpstaging${stagingNumber}.name`; + // } else if (environment === 'production') { + // this.route53RecordName = 'cpd.careerplug.com'; + } + } + } +} + +module.exports = { CPDocusealStack }; diff --git a/cdk_deploy/package-lock.json b/cdk_deploy/package-lock.json new file mode 100644 index 00000000..40c56a5b --- /dev/null +++ b/cdk_deploy/package-lock.json @@ -0,0 +1,468 @@ +{ + "name": "cdk_deploy", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cdk_deploy", + "version": "0.1.0", + "dependencies": { + "aws-cdk-lib": "^2.205.0", + "cdk-nag": "^2.36.22", + "constructs": "^10.3.0" + }, + "bin": { + "cdk_deploy": "bin/cdk_deploy.js" + }, + "devDependencies": { + "aws-cdk": "2.1021.0" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.242", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.242.tgz", + "integrity": "sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.0.tgz", + "integrity": "sha512-7bY3J8GCVxLupn/kNmpPc5VJz8grx+4RKfnnJiO1LG+uxkZfANZG3RMHhE+qQxxwkyQ9/MfPtTpf748UhR425A==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "45.2.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-45.2.0.tgz", + "integrity": "sha512-5TTUkGHQ+nfuUGwKA8/Yraxb+JdNUh4np24qk/VHXmrCMq+M6HfmGWfhcg/QlHA2S5P3YIamfYHdQAB4uSNLAg==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.2" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.7.2", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk": { + "version": "2.1021.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1021.0.tgz", + "integrity": "sha512-kE557b4N9UFWax+7km3R6D56o4tGhpzOks/lRDugaoC8su3mocLCXJhb954b/IRl0ipnbZnY/Sftq+RQ/sxivg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.205.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.205.0.tgz", + "integrity": "sha512-GZHy/F8jql+1aFlgIhGQuLl9zHvceHL0VRuBgaYngwWrflHc+ZN3eCEtfzCblA2bXmi4NbLljpSUBIGBVx2EEQ==", + "bundleDependencies": [ + "@balena/dockerignore", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.242", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", + "@aws-cdk/cloud-assembly-schema": "^45.0.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.0", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^3.1.2", + "punycode": "^2.3.1", + "semver": "^7.7.2", + "table": "^6.9.0", + "yaml": "1.10.2" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "constructs": "^10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.17.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "1.1.12", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/concat-map": { + "version": "0.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.0.6", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "3.1.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.7.2", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cdk-nag": { + "version": "2.36.40", + "resolved": "https://registry.npmjs.org/cdk-nag/-/cdk-nag-2.36.40.tgz", + "integrity": "sha512-8ZmVuGMAfwJcz88uGBj0plGi7qNN4jTmlj/td7jzNcaBoPx4ZGq/hresTTY8uvg26apjxgSZgZswzLoWGyMfOg==", + "license": "Apache-2.0", + "peerDependencies": { + "aws-cdk-lib": "^2.156.0", + "constructs": "^10.0.5" + } + }, + "node_modules/constructs": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.2.tgz", + "integrity": "sha512-wsNxBlAott2qg8Zv87q3eYZYgheb9lchtBfjHzzLHtXbttwSrHPs1NNQbBrmbb1YZvYg2+Vh0Dor76w4mFxJkA==", + "license": "Apache-2.0" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + } + } +} diff --git a/cdk_deploy/package.json b/cdk_deploy/package.json new file mode 100644 index 00000000..417e1173 --- /dev/null +++ b/cdk_deploy/package.json @@ -0,0 +1,34 @@ +{ + "name": "cdk_deploy", + "version": "0.1.0", + "bin": { + "cdk_deploy": "bin/cdk_deploy.js" + }, + "scripts": { + "cdk": "cdk", + "deploy:dev": "cdk deploy CPDocusealStack-dev --profile default", + "deploy:staging": "cdk deploy CPDocusealStack-staging --profile default", + "deploy:prod": "cdk deploy CPDocusealStack-production --profile default", + "destroy:dev": "cdk destroy CPDocusealStack-dev --profile default", + "destroy:staging": "cdk destroy CPDocusealStack-staging --profile default", + "destroy:prod": "cdk destroy CPDocusealStack-production --profile default", + "synth": "cdk synth", + "diff": "cdk diff", + "bootstrap": "cdk bootstrap" + }, + "devDependencies": { + "aws-cdk": "2.1021.0" + }, + "dependencies": { + "aws-cdk-lib": "^2.205.0", + "cdk-nag": "^2.36.22", + "constructs": "^10.3.0" + }, + "keywords": [ + "aws", + "cdk", + "infrastructure", + "ecs", + "alb" + ] +} diff --git a/cdk_deploy/userdata.txt b/cdk_deploy/userdata.txt new file mode 100644 index 00000000..27010abd --- /dev/null +++ b/cdk_deploy/userdata.txt @@ -0,0 +1,39 @@ +sudo yum install -y ec2-instance-connect +cat >/home/ec2-user/.bashrc <<'EOF' + # .bashrc + + # Source global definitions + if [ -f /etc/bashrc ]; then + . /etc/bashrc + fi + + # User specific aliases and functions + function setContainer { + CONTAINER_ID=`sudo docker ps | grep "rails/bin" | awk '{ print $1; }' | head -1` + } + function execDocker { + setContainer + sudo docker exec -u rails -i -t $CONTAINER_ID $@ + } + function copy_from { + setContainer + sudo docker cp "$CONTAINER_ID:/rails/$1" "$2" + } + function copy_to { + setContainer + sudo docker cp "$1" "$CONTAINER_ID:/rails/$2" + sudo docker exec -u root -t $CONTAINER_ID /bin/sh -c 'chown rails:rails /rails/$2' + } + + function dangerConsole { + echo + echo "********** Accessing the WRITE & READ console **********" + echo "********** THIS IS DANGEROUS AND CHANGES YOU MAKE WILL IMPACT THE DATABASE **********" + echo + sleep 3 + execDocker bin/rails console + } + + alias console='dangerConsole' + alias shell='execDocker /bin/bash' +EOF