diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ceb0202b..1cde93f6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: needs: [lint, test] strategy: matrix: - app: [gateway, web, websockets, workers] + app: [gateway, websockets, workers] uses: ./.github/workflows/deploy-app.yml with: app-name: ${{ matrix.app }} diff --git a/apps/infra/src/deployments/production/gateway.ts b/apps/infra/src/deployments/production/gateway.ts new file mode 100644 index 000000000..e7a29dd11 --- /dev/null +++ b/apps/infra/src/deployments/production/gateway.ts @@ -0,0 +1,153 @@ +import * as aws from '@pulumi/aws' +import { Cluster } from '@pulumi/aws/ecs' +import * as docker from '@pulumi/docker' +import * as pulumi from '@pulumi/pulumi' + +import { + ecsSecurityGroup, + ecsTaskExecutionRole, + privateSubnets, + resolve, + vpcId, +} from '../../shared' +import { coreStack, environment } from '../shared' + +const DNS_ADDRESS = 'gateway.latitude.so' + +// Create an ECR repository +const repo = new aws.ecr.Repository('latitude-llm-gateway-repo') + +// Build and push the Docker image +const token = await aws.ecr.getAuthorizationToken() +const image = new docker.Image('LatitudeLLMGatewayImage', { + build: { + platform: 'linux/amd64', + context: resolve('../../../'), + dockerfile: resolve('../../../apps/gateway/docker/Dockerfile'), + cacheFrom: { + images: [pulumi.interpolate`${repo.repositoryUrl}:latest`], + }, + }, + imageName: pulumi.interpolate`${repo.repositoryUrl}:latest`, + registry: { + server: repo.repositoryUrl, + username: token.userName, + password: pulumi.secret(token.password), + }, +}) + +// Create a Fargate task definition +const containerName = 'LatitudeLLMGatewayContainer' +// Create the log group +const logGroup = new aws.cloudwatch.LogGroup('LatitudeLLMGatewayLogGroup', { + name: '/ecs/LatitudeLLMGateway', + retentionInDays: 7, +}) + +const taskDefinition = pulumi + .all([logGroup.name, image.imageName, environment]) + .apply( + ([logGroupName, imageName, environment]) => + new aws.ecs.TaskDefinition('LatitudeLLMGatewayTaskDefinition', { + family: 'LatitudeLLMTaskFamily', + cpu: '256', + memory: '512', + networkMode: 'awsvpc', + requiresCompatibilities: ['FARGATE'], + executionRoleArn: ecsTaskExecutionRole, + taskRoleArn: ecsTaskExecutionRole, + containerDefinitions: JSON.stringify([ + { + name: containerName, + image: imageName, + essential: true, + portMappings: [ + { containerPort: 8080, hostPort: 8080, protocol: 'tcp' }, + ], + environment, + healthCheck: { + command: [ + 'CMD-SHELL', + 'curl -f http://localhost:8080/health || exit 1', + ], + interval: 10, + timeout: 5, + retries: 3, + startPeriod: 60, + }, + logConfiguration: { + logDriver: 'awslogs', + options: { + 'awslogs-group': logGroupName, + 'awslogs-region': 'eu-central-1', + 'awslogs-stream-prefix': 'ecs', + }, + }, + }, + ]), + }), + ) + +const targetGroup = new aws.lb.TargetGroup('LatitudeLLMGatewayTg', { + port: 8080, + vpcId, + protocol: 'HTTP', + targetType: 'ip', + healthCheck: { + path: '/health', + interval: 5, + timeout: 2, + healthyThreshold: 2, + unhealthyThreshold: 2, + }, + deregistrationDelay: 5, +}) + +const defaultListenerArn = coreStack.requireOutput('defaultListenerArn') + +new aws.lb.ListenerRule('LatitudeLLMGatewayListenerRule', { + listenerArn: defaultListenerArn, + actions: [ + { + type: 'forward', + targetGroupArn: targetGroup.arn, + }, + ], + conditions: [ + { + hostHeader: { + values: [DNS_ADDRESS], + }, + }, + ], +}) + +const cluster = coreStack.requireOutput('cluster') as pulumi.Output +new aws.ecs.Service('LatitudeLLMGateway', { + cluster: cluster.arn, + taskDefinition: taskDefinition.arn, + desiredCount: 2, + launchType: 'FARGATE', + forceNewDeployment: true, + enableExecuteCommand: true, + networkConfiguration: { + subnets: privateSubnets.ids, + assignPublicIp: false, + securityGroups: [ecsSecurityGroup], + }, + loadBalancers: [ + { + targetGroupArn: targetGroup.arn, + containerName, + containerPort: 8080, + }, + ], + tags: { + digest: image.repoDigest, + }, + triggers: { + diggest: image.repoDigest, + }, +}) + +export const serviceUrl = pulumi.interpolate`https://${DNS_ADDRESS}` diff --git a/apps/infra/src/deployments/production/web.ts b/apps/infra/src/deployments/production/web.ts new file mode 100644 index 000000000..f2df41963 --- /dev/null +++ b/apps/infra/src/deployments/production/web.ts @@ -0,0 +1,251 @@ +import * as aws from '@pulumi/aws' +import { Cluster } from '@pulumi/aws/ecs' +import * as docker from '@pulumi/docker' +import * as pulumi from '@pulumi/pulumi' + +import { + ecsSecurityGroup, + ecsTaskExecutionRole, + privateSubnets, + resolve, + vpcId, +} from '../../shared' +import { + coreStack, + environment, + postHogApiKey, + sentryDsn, + sentryOrg, + sentryProject, +} from '../shared' + +const DNS_ADDRESS = 'app.latitude.so' + +// Create an ECR repository +const repo = new aws.ecr.Repository('latitude-llm-app-repo') +const coreRepo = new aws.ecr.Repository('latitude-llm-core-repo') + +// Build and push the Docker image +const token = await aws.ecr.getAuthorizationToken() + +const image = pulumi + .all([sentryDsn, sentryOrg, sentryProject, postHogApiKey]) + .apply( + ([sentryDsn, sentryOrg, sentryProject, postHogApiKey]) => + new docker.Image('LatitudeLLMAppImage', { + build: { + platform: 'linux/amd64', + context: resolve('../../../'), + dockerfile: resolve('../../../apps/web/docker/Dockerfile'), + cacheFrom: { + images: [pulumi.interpolate`${repo.repositoryUrl}:latest`], + }, + args: { + SENTRY_DSN: sentryDsn, + SENTRY_ORG: sentryOrg, + SENTRY_PROJECT: sentryProject, + NEXT_PUBLIC_POSTHOG_KEY: postHogApiKey, + NEXT_PUBLIC_POSTHOG_HOST: 'https://eu.i.posthog.com', + }, + }, + imageName: pulumi.interpolate`${repo.repositoryUrl}:latest`, + registry: { + server: repo.repositoryUrl, + username: token.userName, + password: pulumi.secret(token.password), + }, + }), + ) +const coreImage = new docker.Image('LatitudeLLMCoreImage', { + build: { + platform: 'linux/amd64', + context: resolve('../../../'), + dockerfile: resolve('../../../packages/core/docker/Dockerfile'), + cacheFrom: { + images: [pulumi.interpolate`${coreRepo.repositoryUrl}:latest`], + }, + }, + imageName: pulumi.interpolate`${coreRepo.repositoryUrl}:latest`, + registry: { + server: coreRepo.repositoryUrl, + username: token.userName, + password: pulumi.secret(token.password), + }, +}) + +// Create a Fargate task definition +const containerName = 'LatitudeLLMAppContainer' +// Create the log group +const logGroup = new aws.cloudwatch.LogGroup('LatitudeLLMAppLogGroup', { + name: '/ecs/LatitudeLLMApp', + retentionInDays: 7, +}) + +const taskDefinition = pulumi + .all([logGroup.name, image.imageName, coreImage.imageName, environment]) + .apply( + ([logGroupName, imageName, coreImageName, environment]) => + new aws.ecs.TaskDefinition('LatitudeLLMAppTaskDefinition', { + family: 'LatitudeLLMTaskFamily', + cpu: '256', + memory: '512', + networkMode: 'awsvpc', + requiresCompatibilities: ['FARGATE'], + executionRoleArn: ecsTaskExecutionRole, + taskRoleArn: ecsTaskExecutionRole, + containerDefinitions: JSON.stringify([ + { + name: containerName, + image: imageName, + essential: true, + portMappings: [ + { containerPort: 8080, hostPort: 8080, protocol: 'tcp' }, + ], + environment, + healthCheck: { + command: [ + 'CMD-SHELL', + 'curl -f http://localhost:8080/api/health || exit 1', + ], + interval: 30, + timeout: 5, + retries: 3, + startPeriod: 60, + }, + logConfiguration: { + logDriver: 'awslogs', + options: { + 'awslogs-group': logGroupName, + 'awslogs-region': 'eu-central-1', + 'awslogs-stream-prefix': 'ecs', + }, + }, + }, + { + name: 'db-migrate', + image: coreImageName, + command: ['pnpm', '--prefix', 'packages/core', 'db:migrate'], + essential: false, + environment, + logConfiguration: { + logDriver: 'awslogs', + options: { + 'awslogs-group': logGroupName, + 'awslogs-region': 'eu-central-1', + 'awslogs-stream-prefix': 'ecs', + }, + }, + }, + ]), + }), + ) + +const greenTargetGroup = new aws.lb.TargetGroup('LatitudeLLMAppTg', { + port: 8080, + vpcId, + protocol: 'HTTP', + targetType: 'ip', + healthCheck: { + path: '/api/health', + interval: 5, + timeout: 2, + healthyThreshold: 2, + unhealthyThreshold: 2, + }, + deregistrationDelay: 5, +}) + +const defaultListenerArn = coreStack.requireOutput('defaultListenerArn') + +const cluster = coreStack.requireOutput('cluster') as pulumi.Output + +const ecsService = new aws.ecs.Service('LatitudeLLMApp', { + cluster: cluster.arn, + taskDefinition: taskDefinition.arn, + desiredCount: 2, + launchType: 'FARGATE', + forceNewDeployment: true, + enableExecuteCommand: true, + deploymentController: { + type: 'CODE_DEPLOY', + }, + networkConfiguration: { + subnets: privateSubnets.ids, + assignPublicIp: false, + securityGroups: [ecsSecurityGroup], + }, + loadBalancers: [ + { + targetGroupArn: greenTargetGroup.arn, + containerName, + containerPort: 8080, + }, + ], +}) + +const blueTargetGroup = new aws.lb.TargetGroup('LatitudeLLMAppBlueTg', { + port: 8080, + vpcId, + protocol: 'HTTP', + targetType: 'ip', + healthCheck: { + path: '/api/health', + interval: 5, + timeout: 2, + healthyThreshold: 2, + unhealthyThreshold: 2, + }, + deregistrationDelay: 5, +}) + +const codeDeployApp = new aws.codedeploy.Application( + 'LatitudeLLMCodeDeployApp', + { + computePlatform: 'ECS', + }, +) + +new aws.codedeploy.DeploymentGroup('LatitudeLLMDeploymentGroup', { + appName: codeDeployApp.name, + serviceRoleArn: ecsTaskExecutionRole, + deploymentConfigName: 'CodeDeployDefault.ECSAllAtOnce', + deploymentGroupName: 'LatitudeLLMDeploymentGroup', + ecsService: { + clusterName: cluster.name, + serviceName: ecsService.name, + }, + autoRollbackConfiguration: { + enabled: true, + events: ['DEPLOYMENT_FAILURE'], + }, + blueGreenDeploymentConfig: { + deploymentReadyOption: { + actionOnTimeout: 'CONTINUE_DEPLOYMENT', + waitTimeInMinutes: 0, + }, + terminateBlueInstancesOnDeploymentSuccess: { + action: 'TERMINATE', + terminationWaitTimeInMinutes: 5, + }, + }, + deploymentStyle: { + deploymentOption: 'WITH_TRAFFIC_CONTROL', + deploymentType: 'BLUE_GREEN', + }, + loadBalancerInfo: { + targetGroupPairInfo: { + prodTrafficRoute: { + listenerArns: [defaultListenerArn], + }, + testTrafficRoute: { + listenerArns: [defaultListenerArn], + }, + targetGroups: [ + { name: blueTargetGroup.name }, + { name: greenTargetGroup.name }, + ], + }, + }, +}) + +export const serviceUrl = pulumi.interpolate`https://${DNS_ADDRESS}` diff --git a/apps/infra/src/deployments/production/websockets.ts b/apps/infra/src/deployments/production/websockets.ts new file mode 100644 index 000000000..e01d00ab5 --- /dev/null +++ b/apps/infra/src/deployments/production/websockets.ts @@ -0,0 +1,150 @@ +import * as aws from '@pulumi/aws' +import { Cluster } from '@pulumi/aws/ecs' +import * as docker from '@pulumi/docker' +import * as pulumi from '@pulumi/pulumi' + +import { + ecsSecurityGroup, + ecsTaskExecutionRole, + privateSubnets, + resolve, + vpcId, +} from '../../shared' +import { coreStack, environment } from '../shared' + +const DNS_ADDRESS = 'ws.latitude.so' + +// Create an ECR repository +const repo = new aws.ecr.Repository('latitude-llm-websockets-repo') + +// Build and push the Docker image +const token = await aws.ecr.getAuthorizationToken() +const image = new docker.Image('LatitudeLLMWebsocketsImage', { + build: { + platform: 'linux/amd64', + context: resolve('../../../'), + dockerfile: resolve('../../../apps/websockets/docker/Dockerfile'), + cacheFrom: { + images: [pulumi.interpolate`${repo.repositoryUrl}:latest`], + }, + }, + imageName: pulumi.interpolate`${repo.repositoryUrl}:latest`, + registry: { + server: repo.repositoryUrl, + username: token.userName, + password: pulumi.secret(token.password), + }, +}) + +// Create a Fargate task definition +const containerName = 'LatitudeLLMWebsocketsContainer' +// Create the log group +const logGroup = new aws.cloudwatch.LogGroup('LatitudeLLMWebsocketsLogGroup', { + name: '/ecs/LatitudeLLMWebsockets', + retentionInDays: 7, +}) + +const taskDefinition = pulumi + .all([logGroup.name, image.imageName, environment]) + .apply( + ([logGroupName, imageName, environment]) => + new aws.ecs.TaskDefinition('LatitudeLLMWebsocketsTaskDefinition', { + family: 'LatitudeLLMTaskFamily', + cpu: '256', + memory: '512', + networkMode: 'awsvpc', + requiresCompatibilities: ['FARGATE'], + executionRoleArn: ecsTaskExecutionRole, + taskRoleArn: ecsTaskExecutionRole, + containerDefinitions: JSON.stringify([ + { + name: containerName, + image: imageName, + essential: true, + portMappings: [ + { containerPort: 8080, hostPort: 8080, protocol: 'tcp' }, + ], + environment, + healthCheck: { + command: [ + 'CMD-SHELL', + 'curl -f http://localhost:8080/health || exit 1', + ], + interval: 30, + timeout: 5, + retries: 3, + startPeriod: 60, + }, + logConfiguration: { + logDriver: 'awslogs', + options: { + 'awslogs-group': logGroupName, + 'awslogs-region': 'eu-central-1', + 'awslogs-stream-prefix': 'ecs', + }, + }, + }, + ]), + }), + ) + +const targetGroup = new aws.lb.TargetGroup('LatitudeLLMWebsocketsTg', { + port: 8080, + vpcId, + protocol: 'HTTP', + targetType: 'ip', + healthCheck: { + path: '/health', + interval: 5, + timeout: 2, + healthyThreshold: 2, + unhealthyThreshold: 2, + }, + deregistrationDelay: 5, +}) + +const defaultListenerArn = coreStack.requireOutput('defaultListenerArn') + +new aws.lb.ListenerRule('LatitudeLLMWebsocketsListenerRule', { + listenerArn: defaultListenerArn, + actions: [ + { + type: 'forward', + targetGroupArn: targetGroup.arn, + }, + ], + conditions: [ + { + hostHeader: { + values: [DNS_ADDRESS], + }, + }, + ], +}) + +const cluster = coreStack.requireOutput('cluster') as pulumi.Output +new aws.ecs.Service('LatitudeLLMWebsockets', { + cluster: cluster.arn, + taskDefinition: taskDefinition.arn, + desiredCount: 1, + launchType: 'FARGATE', + forceNewDeployment: true, + enableExecuteCommand: true, + networkConfiguration: { + subnets: privateSubnets.ids, + assignPublicIp: false, + securityGroups: [ecsSecurityGroup], + }, + loadBalancers: [ + { + targetGroupArn: targetGroup.arn, + containerName, + containerPort: 8080, + }, + ], + triggers: { + digest: image.repoDigest, + }, +}) + +export const serviceUrl = pulumi.interpolate`https://${DNS_ADDRESS}` diff --git a/apps/infra/src/deployments/production/workers.ts b/apps/infra/src/deployments/production/workers.ts new file mode 100644 index 000000000..274a1ef6f --- /dev/null +++ b/apps/infra/src/deployments/production/workers.ts @@ -0,0 +1,100 @@ +import * as aws from '@pulumi/aws' +import { Cluster } from '@pulumi/aws/ecs' +import * as docker from '@pulumi/docker' +import * as pulumi from '@pulumi/pulumi' + +import { + ecsSecurityGroup, + ecsTaskExecutionRole, + privateSubnets, + resolve, +} from '../../shared' +import { coreStack, environment } from '../shared' + +const repo = new aws.ecr.Repository('latitude-llm-workers-repo') + +const token = await aws.ecr.getAuthorizationToken() +const image = new docker.Image('LatitudeLLMWorkersImage', { + build: { + platform: 'linux/amd64', + context: resolve('../../../'), + dockerfile: resolve('../../../apps/workers/docker/Dockerfile'), + cacheFrom: { + images: [pulumi.interpolate`${repo.repositoryUrl}:latest`], + }, + }, + imageName: pulumi.interpolate`${repo.repositoryUrl}:latest`, + registry: { + server: repo.repositoryUrl, + username: token.userName, + password: pulumi.secret(token.password), + }, +}) + +const containerName = 'LatitudeLLMWorkersContainer' +const logGroup = new aws.cloudwatch.LogGroup('LatitudeLLMWorkersLogGroup', { + name: '/ecs/LatitudeLLMWorkers', + retentionInDays: 7, +}) + +const taskDefinition = pulumi + .all([logGroup.name, image.imageName, environment]) + .apply( + ([logGroupName, imageName, environment]) => + new aws.ecs.TaskDefinition('LatitudeLLMWorkersTaskDefinition', { + family: 'LatitudeLLMTaskFamily', + cpu: '256', + memory: '512', + networkMode: 'awsvpc', + requiresCompatibilities: ['FARGATE'], + executionRoleArn: ecsTaskExecutionRole, + taskRoleArn: ecsTaskExecutionRole, + containerDefinitions: JSON.stringify([ + { + name: containerName, + image: imageName, + essential: true, + environment, + healthCheck: { + command: [ + 'CMD-SHELL', + 'curl -f http://localhost:3002/health || exit 1', + ], + interval: 30, + timeout: 5, + retries: 3, + startPeriod: 60, + }, + logConfiguration: { + logDriver: 'awslogs', + options: { + 'awslogs-group': logGroupName, + 'awslogs-region': 'eu-central-1', + 'awslogs-stream-prefix': 'ecs', + }, + }, + }, + ]), + }), + ) + +const cluster = coreStack.requireOutput('cluster') as pulumi.Output +export const service = new aws.ecs.Service('LatitudeLLMWorkers', { + cluster: cluster.arn, + taskDefinition: taskDefinition.arn, + desiredCount: 2, + launchType: 'FARGATE', + forceNewDeployment: true, + enableExecuteCommand: true, + networkConfiguration: { + subnets: privateSubnets.ids, + assignPublicIp: false, + securityGroups: [ecsSecurityGroup], + }, + tags: { + diggest: image.repoDigest, + }, + triggers: { + diggest: image.repoDigest, + }, +}) diff --git a/apps/infra/src/deployments/web.ts b/apps/infra/src/deployments/web.ts index 32d5e6609..c0dfd37c0 100644 --- a/apps/infra/src/deployments/web.ts +++ b/apps/infra/src/deployments/web.ts @@ -140,7 +140,7 @@ const taskDefinition = pulumi }), ) -const targetGroup = new aws.lb.TargetGroup('LatitudeLLMAppTg', { +const greenTargetGroup = new aws.lb.TargetGroup('LatitudeLLMAppTg', { port: 8080, vpcId, protocol: 'HTTP', @@ -157,31 +157,18 @@ const targetGroup = new aws.lb.TargetGroup('LatitudeLLMAppTg', { const defaultListenerArn = coreStack.requireOutput('defaultListenerArn') -new aws.lb.ListenerRule('LatitudeLLMAppListenerRule', { - listenerArn: defaultListenerArn, - actions: [ - { - type: 'forward', - targetGroupArn: targetGroup.arn, - }, - ], - conditions: [ - { - hostHeader: { - values: [DNS_ADDRESS], - }, - }, - ], -}) - const cluster = coreStack.requireOutput('cluster') as pulumi.Output -new aws.ecs.Service('LatitudeLLMApp', { + +const ecsService = new aws.ecs.Service('LatitudeLLMApp', { cluster: cluster.arn, taskDefinition: taskDefinition.arn, desiredCount: 2, launchType: 'FARGATE', forceNewDeployment: true, enableExecuteCommand: true, + deploymentController: { + type: 'CODE_DEPLOY', + }, networkConfiguration: { subnets: privateSubnets.ids, assignPublicIp: false, @@ -189,14 +176,75 @@ new aws.ecs.Service('LatitudeLLMApp', { }, loadBalancers: [ { - targetGroupArn: targetGroup.arn, + targetGroupArn: greenTargetGroup.arn, containerName, containerPort: 8080, }, ], - triggers: { - digest: image.repoDigest, - coreDigest: coreImage.repoDigest, +}) + +const blueTargetGroup = new aws.lb.TargetGroup('LatitudeLLMAppBlueTg', { + port: 8080, + vpcId, + protocol: 'HTTP', + targetType: 'ip', + healthCheck: { + path: '/api/health', + interval: 5, + timeout: 2, + healthyThreshold: 2, + unhealthyThreshold: 2, + }, + deregistrationDelay: 5, +}) + +const codeDeployApp = new aws.codedeploy.Application( + 'LatitudeLLMCodeDeployApp', + { + computePlatform: 'ECS', + }, +) + +new aws.codedeploy.DeploymentGroup('LatitudeLLMDeploymentGroup', { + appName: codeDeployApp.name, + serviceRoleArn: ecsTaskExecutionRole, + deploymentConfigName: 'CodeDeployDefault.ECSAllAtOnce', + deploymentGroupName: 'LatitudeLLMDeploymentGroup', + ecsService: { + clusterName: cluster.name, + serviceName: ecsService.name, + }, + autoRollbackConfiguration: { + enabled: true, + events: ['DEPLOYMENT_FAILURE'], + }, + blueGreenDeploymentConfig: { + deploymentReadyOption: { + actionOnTimeout: 'CONTINUE_DEPLOYMENT', + waitTimeInMinutes: 0, + }, + terminateBlueInstancesOnDeploymentSuccess: { + action: 'TERMINATE', + terminationWaitTimeInMinutes: 5, + }, + }, + deploymentStyle: { + deploymentOption: 'WITH_TRAFFIC_CONTROL', + deploymentType: 'BLUE_GREEN', + }, + loadBalancerInfo: { + targetGroupPairInfo: { + prodTrafficRoute: { + listenerArns: [defaultListenerArn], + }, + testTrafficRoute: { + listenerArns: [defaultListenerArn], + }, + targetGroups: [ + { name: blueTargetGroup.name }, + { name: greenTargetGroup.name }, + ], + }, }, })