diff --git a/.dockerignore b/.dockerignore index f6e1422..3a359cf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,3 @@ -secrets \ No newline at end of file +secrets +secrets.json +.vscode \ No newline at end of file diff --git a/.github/workflows/github_cd.yaml b/.github/workflows/github_cd.yaml index 0de73da..fc504fd 100644 --- a/.github/workflows/github_cd.yaml +++ b/.github/workflows/github_cd.yaml @@ -1,6 +1,7 @@ -name: Upload Python Package +name: Continuous Deployment Workflow on: + workflow_dispatch: release: types: [published] @@ -28,6 +29,7 @@ jobs: with: name: release-dists path: dist/ + pypi-publish: runs-on: ubuntu-latest needs: @@ -45,3 +47,57 @@ jobs: path: dist/ - name: Publish release distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + continue-on-error: true + + upload-docker-images: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build Docker images + run: | + # Define public ECR repository URL + PUBLIC_ECR_URL=public.ecr.aws/e7b5q5z5/nam685 + # LangGraph API server + docker build -t chatbot -f src/chatbot/Dockerfile . + docker tag chatbot:latest $PUBLIC_ECR_URL/chatbot:${{ github.sha }} + # API + docker build -t chatbot-api -f api/Dockerfile . + docker tag chatbot-api:latest $PUBLIC_ECR_URL/chatbot-api:${{ github.sha }} + # UI + docker build -t chatbot-ui -f ui/Dockerfile ./ui + docker tag chatbot-ui:latest $PUBLIC_ECR_URL/chatbot-ui:${{ github.sha }} + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + - name: Log in to Amazon ECR + run: | + aws ecr-public get-login-password --region us-east-1 | \ + docker login --username AWS --password-stdin public.ecr.aws + - name: Push Docker images to ECR + run: | + PUBLIC_ECR_URL=public.ecr.aws/e7b5q5z5/nam685 + docker push $PUBLIC_ECR_URL/chatbot:${{ github.sha }} + docker push $PUBLIC_ECR_URL/chatbot-api:${{ github.sha }} + docker push $PUBLIC_ECR_URL/chatbot-ui:${{ github.sha }} + + deploy-ecs-by-cloudformation: + runs-on: ubuntu-latest + needs: + - upload-docker-images + steps: + - uses: actions/checkout@v4 + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4.1.0 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + - name: Deploy CloudFormation stack + uses: aws-actions/aws-cloudformation-github-deploy@v1 + with: + name: chatbot-ecs + template: ecs-infrastructure.yaml + parameter-overrides: "GitCommitHash=${{ github.sha }},KeyPairName=chatbot" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 66df350..c3db12c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ __pycache__/ .ruff_cache .langgraph_api dist -ui/node_modules \ No newline at end of file +ui/node_modules +secrets.json \ No newline at end of file diff --git a/README.md b/README.md index b15dceb..a2dbb90 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,12 @@ Agent architecture: use 2 chat models for separation of concern - Helper model to classify if conversation is sensitive or not - Main model equiped with tools -Added minimal unit tests +I also made FastAPI backend and NextJS frontend to interact with chatbot +Deploy chatbot as standalone container via LangGraph Platform -Prepared for deployment as standalone container via LangGraph Platform +Deploy to AWS ECS as separate services +Current deployment's URL: http://chatbot-alb-468353364.eu-central-1.elb.amazonaws.com/ +(I don't keep this URL if I take it down, because I don't pay money for a fixed one)) [![PyPI - Version](https://img.shields.io/pypi/v/chatbot.svg)](https://pypi.org/project/chatbot) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/chatbot.svg)](https://pypi.org/project/chatbot) diff --git a/api/Dockerfile b/api/Dockerfile index 0ea30a6..8f86fd1 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12 +FROM python:3.12-alpine WORKDIR /code COPY ./pyproject.toml /code/pyproject.toml diff --git a/api/healthcheck.py b/api/healthcheck.py new file mode 100644 index 0000000..9a04f19 --- /dev/null +++ b/api/healthcheck.py @@ -0,0 +1,20 @@ +import os +import urllib.request + + +def health_check(): + port = os.getenv("CONTAINER_PORT", "8080") + url = f"http://localhost:{port}/health" + try: + with urllib.request.urlopen(url) as response: + status_code = response.getcode() + body = response.read().decode() + print(f"Status code: {status_code}") + print(f"Response body: {body}") + except Exception as e: + print(f"Health check failed: {e}") + exit(1) + + +if __name__ == "__main__": + health_check() diff --git a/api/main.py b/api/main.py index b90d8ac..2eda590 100644 --- a/api/main.py +++ b/api/main.py @@ -1,22 +1,6 @@ from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware from .routers import router app = FastAPI() - -origins = [ - "http://127.0.0.1:3000", - "http://localhost:3000", - "http://ui:3000", -] - -app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - app.include_router(router) diff --git a/api/routers.py b/api/routers.py index 2e0d9a0..3af249f 100644 --- a/api/routers.py +++ b/api/routers.py @@ -1,3 +1,4 @@ +import os from typing import Any from fastapi import APIRouter @@ -5,8 +6,13 @@ from .models import ChatMessage, HumanReview, Thread +langgraph_api_uri = os.getenv("LANGGRAPH_API_URI") +if not langgraph_api_uri: + raise ValueError("LANGGRAPH_API_URI environment variable is not set") +print(f"LANGGRAPH_API_URI: {langgraph_api_uri}") + router = APIRouter() -langgraph_client = get_client(url="http://langgraph-api:8000") +langgraph_client = get_client(url=langgraph_api_uri) def parse_ai_response( @@ -34,6 +40,11 @@ async def read_main() -> dict: return {"msg": "Hello! Welcome to the LangGraph Chat API"} +@router.get("/health") +async def health_check() -> dict: + return {"status": "ok"} + + @router.get("/chat") async def list_chat_threads() -> list[Thread]: threads_data = await langgraph_client.threads.search( diff --git a/api/test_main.py b/api/test_main.py index b9b5e31..56bd646 100644 --- a/api/test_main.py +++ b/api/test_main.py @@ -1,5 +1,9 @@ +import os + from fastapi.testclient import TestClient +os.environ["LANGGRAPH_API_URI"] = "http://localhost:8000" + from .main import app client = TestClient(app) diff --git a/app.py b/app.py index e5625f0..762b54c 100644 --- a/app.py +++ b/app.py @@ -1,3 +1,5 @@ +# For usage in terminal + from pprint import pprint from langchain_core.runnables.config import RunnableConfig diff --git a/compose.yaml b/compose.yaml index a17be38..d3a0a58 100644 --- a/compose.yaml +++ b/compose.yaml @@ -37,15 +37,45 @@ services: env_file: - .env environment: + CONTAINER_PORT: 8000 REDIS_URI: redis://langgraph-redis:6379 - POSTGRES_URI: postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable + DATABASE_URI: postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable + healthcheck: + test: [ "CMD", "python", "src/chatbot/healthcheck.py" ] + interval: 10s + timeout: 1s + retries: 5 + start_period: 10s api: image: chatbot-api:latest ports: - "8080:8080" + environment: + CONTAINER_PORT: 8080 + LANGGRAPH_API_URI: http://langgraph-api:8000 + depends_on: + langgraph-api: + condition: service_healthy + healthcheck: + test: [ "CMD", "python", "api/healthcheck.py" ] + start_period: 3s + timeout: 1s + retries: 5 + interval: 5s ui: image: chatbot-ui:latest ports: - "3000:3000" environment: - - HOSTNAME=0.0.0.0 + HOSTNAME: 0.0.0.0 + CONTAINER_PORT: 3000 + API_URL: http://api:8080 + depends_on: + api: + condition: service_healthy + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:3000/health" ] + start_period: 3s + timeout: 1s + retries: 5 + interval: 5s diff --git a/ecs-infrastructure.yaml b/ecs-infrastructure.yaml new file mode 100644 index 0000000..74fd6b4 --- /dev/null +++ b/ecs-infrastructure.yaml @@ -0,0 +1,788 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'ECS Infrastructure for Chatbot Application' + +Parameters: + VpcCidr: + Type: String + Default: '10.0.0.0/16' + + ECRRepositoryURI: + Type: String + Description: 'Base ECR repository URI (without image name)' + Default: 'public.ecr.aws/e7b5q5z5/nam685' + + SecretARN: + Type: String + Description: Secret ARN for chatbot application + Default: 'arn:aws:secretsmanager:eu-central-1:417744795771:secret:chatbot-secrets-TXOVcr' + + GitCommitHash: + Type: String + Description: 'Git commit hash for Docker images' + + KeyPairName: + Type: AWS::EC2::KeyPair::KeyName + Description: 'EC2 Key Pair for SSH access to instances' + +Resources: + # VPC and Networking + VPC: + Type: AWS::EC2::VPC + Properties: + CidrBlock: !Ref VpcCidr + EnableDnsHostnames: true + EnableDnsSupport: true + Tags: + - Key: Name + Value: chatbot-vpc + + InternetGateway: + Type: AWS::EC2::InternetGateway + Properties: + Tags: + - Key: Name + Value: chatbot-igw + + AttachGateway: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + VpcId: !Ref VPC + InternetGatewayId: !Ref InternetGateway + + PublicSubnet: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref VPC + CidrBlock: '10.0.1.0/24' + AvailabilityZone: !Select [0, !GetAZs ''] + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: chatbot-public-subnet + + PrivateSubnet1: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref VPC + CidrBlock: '10.0.2.0/24' + AvailabilityZone: !Select [0, !GetAZs ''] + Tags: + - Key: Name + Value: chatbot-private-subnet-1 + + PrivateSubnet2: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref VPC + CidrBlock: '10.0.3.0/24' + AvailabilityZone: !Select [1, !GetAZs ''] + Tags: + - Key: Name + Value: chatbot-private-subnet-2 + + NATGateway: + Type: AWS::EC2::NatGateway + Properties: + AllocationId: !GetAtt EIPForNAT.AllocationId + SubnetId: !Ref PublicSubnet + Tags: + - Key: Name + Value: chatbot-nat + + EIPForNAT: + Type: AWS::EC2::EIP + DependsOn: AttachGateway + Properties: + Domain: vpc + + PublicRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: chatbot-public-rt + + PrivateRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref VPC + Tags: + - Key: Name + Value: chatbot-private-rt + + PublicRoute: + Type: AWS::EC2::Route + DependsOn: AttachGateway + Properties: + RouteTableId: !Ref PublicRouteTable + DestinationCidrBlock: '0.0.0.0/0' + GatewayId: !Ref InternetGateway + + PrivateRoute: + Type: AWS::EC2::Route + Properties: + RouteTableId: !Ref PrivateRouteTable + DestinationCidrBlock: '0.0.0.0/0' + NatGatewayId: !Ref NATGateway + + PublicSubnetRouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PublicSubnet + RouteTableId: !Ref PublicRouteTable + + PrivateSubnet1RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PrivateSubnet1 + RouteTableId: !Ref PrivateRouteTable + + PrivateSubnet2RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + SubnetId: !Ref PrivateSubnet2 + RouteTableId: !Ref PrivateRouteTable + + # Security Groups + ALBSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security group for Application Load Balancer + VpcId: !Ref VPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: '0.0.0.0/0' + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: '0.0.0.0/0' + + ECSSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security group for ECS tasks + VpcId: !Ref VPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 3000 + ToPort: 3000 + CidrIp: !Ref VpcCidr + - IpProtocol: tcp + FromPort: 8080 + ToPort: 8080 + CidrIp: !Ref VpcCidr + - IpProtocol: tcp + FromPort: 8000 + ToPort: 8000 + CidrIp: !Ref VpcCidr + - IpProtocol: tcp + FromPort: 5432 + ToPort: 5432 + CidrIp: !Ref VpcCidr + - IpProtocol: tcp + FromPort: 6379 + ToPort: 6379 + CidrIp: !Ref VpcCidr + - IpProtocol: tcp + FromPort: 22 + ToPort: 22 + SourceSecurityGroupId: !Ref BastionSecurityGroup + SecurityGroupEgress: + - IpProtocol: -1 + CidrIp: '0.0.0.0/0' + + # EFS for PostgreSQL data persistence + EFSFileSystem: + Type: AWS::EFS::FileSystem + Properties: + FileSystemTags: + - Key: Name + Value: chatbot-postgres-data + + EFSMountTarget1: + Type: AWS::EFS::MountTarget + Properties: + FileSystemId: !Ref EFSFileSystem + SubnetId: !Ref PrivateSubnet1 + SecurityGroups: + - !Ref EFSSecurityGroup + + EFSMountTarget2: + Type: AWS::EFS::MountTarget + Properties: + FileSystemId: !Ref EFSFileSystem + SubnetId: !Ref PrivateSubnet2 + SecurityGroups: + - !Ref EFSSecurityGroup + + EFSSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security group for EFS + VpcId: !Ref VPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 2049 + ToPort: 2049 + CidrIp: !Ref VpcCidr + + BastionSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Security group for bastion host + VpcId: !Ref VPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 22 + ToPort: 22 + CidrIp: '0.0.0.0/0' + + BastionHost: + Type: AWS::EC2::Instance + Properties: + ImageId: ami-0e6b9772c95871b64 + InstanceType: t2.micro + KeyName: !Ref KeyPairName + SubnetId: !Ref PublicSubnet + SecurityGroupIds: + - !Ref BastionSecurityGroup + Tags: + - Key: Name + Value: chatbot-bastion + + ECSTaskExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: ecs-tasks.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy + Policies: + - PolicyName: SecretsManagerAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - secretsmanager:GetSecretValue + Resource: !Ref SecretARN + + ECSCluster: + Type: AWS::ECS::Cluster + Properties: + ClusterName: chatbot-cluster + + LaunchTemplate: + Type: AWS::EC2::LaunchTemplate + Properties: + LaunchTemplateName: chatbot-ecs-lt + LaunchTemplateData: + ImageId: ami-0e6b9772c95871b64 + InstanceType: t2.micro + KeyName: !Ref KeyPairName + IamInstanceProfile: + Arn: !GetAtt EC2InstanceProfile.Arn + SecurityGroupIds: + - !Ref ECSSecurityGroup + UserData: + Fn::Base64: !Sub | + #!/bin/bash + echo ECS_CLUSTER=${ECSCluster} >> /etc/ecs/ecs.config + + AutoScalingGroup: + Type: AWS::AutoScaling::AutoScalingGroup + Properties: + VPCZoneIdentifier: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + LaunchTemplate: + LaunchTemplateId: !Ref LaunchTemplate + Version: !GetAtt LaunchTemplate.LatestVersionNumber + MinSize: 6 + MaxSize: 11 + DesiredCapacity: 6 + Tags: + - Key: Name + Value: chatbot-ecs-instance + PropagateAtLaunch: true + + ECSCapacityProvider: + Type: AWS::ECS::CapacityProvider + Properties: + Name: chatbot-ec2-capacity-provider + AutoScalingGroupProvider: + AutoScalingGroupArn: !Ref AutoScalingGroup + ManagedScaling: + Status: ENABLED + TargetCapacity: 100 + ManagedTerminationProtection: DISABLED + + ECSClusterCapacityProviderAssociations: + Type: AWS::ECS::ClusterCapacityProviderAssociations + Properties: + Cluster: !Ref ECSCluster + CapacityProviders: + - !Ref ECSCapacityProvider + DefaultCapacityProviderStrategy: + - CapacityProvider: !Ref ECSCapacityProvider + Weight: 1 + + EC2Role: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role + + EC2InstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Roles: + - !Ref EC2Role + + # Service Discovery + ServiceDiscoveryNamespace: + Type: AWS::ServiceDiscovery::PrivateDnsNamespace + Properties: + Name: chatbot.local + Vpc: !Ref VPC + + # Task Definitions + PostgresTaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: chatbot-postgres + NetworkMode: awsvpc + RequiresCompatibilities: + - EC2 + ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn + ContainerDefinitions: + - Name: postgres + Image: public.ecr.aws/docker/library/postgres:16 + Memory: 512 + Essential: true + Environment: + - Name: POSTGRES_DB + Value: postgres + - Name: POSTGRES_USER + Value: postgres + - Name: POSTGRES_PASSWORD + Value: postgres + MountPoints: + - SourceVolume: postgres-data + ContainerPath: /var/lib/postgresql/data + HealthCheck: + Command: + - CMD-SHELL + - pg_isready -U postgres + Interval: 30 + Timeout: 5 + Retries: 3 + StartPeriod: 60 + PortMappings: + - ContainerPort: 5432 + Protocol: tcp + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-group: !Ref LogGroup + awslogs-region: !Ref AWS::Region + awslogs-stream-prefix: postgres + Volumes: + - Name: postgres-data + EFSVolumeConfiguration: + FilesystemId: !Ref EFSFileSystem + + RedisTaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: chatbot-redis + NetworkMode: awsvpc + RequiresCompatibilities: + - EC2 + ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn + ContainerDefinitions: + - Name: redis + Image: public.ecr.aws/docker/library/redis:8 + Memory: 256 + Essential: true + HealthCheck: + Command: + - CMD-SHELL + - redis-cli ping + Interval: 30 + Timeout: 5 + Retries: 3 + PortMappings: + - ContainerPort: 6379 + Protocol: tcp + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-group: !Ref LogGroup + awslogs-region: !Ref AWS::Region + awslogs-stream-prefix: redis + + LangGraphTaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: chatbot-langgraph + NetworkMode: awsvpc + RequiresCompatibilities: + - EC2 + ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn + ContainerDefinitions: + - Name: langgraph-api + Image: !Sub "${ECRRepositoryURI}/chatbot:${GitCommitHash}" + Memory: 512 + Essential: true + Environment: + - Name: CONTAINER_PORT + Value: "8000" + - Name: REDIS_URI + Value: redis://redis.chatbot.local:6379 + - Name: DATABASE_URI + Value: postgres://postgres:postgres@postgres.chatbot.local:5432/postgres?sslmode=disable + - Name: LANGSMITH_TRACING + Value: "true" + - Name: LANGSMITH_PROJECT + Value: chatbot + - Name: LANGSMITH_ENDPOINT + Value: https://eu.api.smith.langchain.com + Secrets: + - Name: LANGSMITH_API_KEY + ValueFrom: !Sub "${SecretARN}:LANGSMITH_API_KEY::" + - Name: OPENAI_API_KEY + ValueFrom: !Sub "${SecretARN}:OPENAI_API_KEY::" + - Name: TAVILY_API_KEY + ValueFrom: !Sub "${SecretARN}:TAVILY_API_KEY::" + HealthCheck: + Command: + - CMD-SHELL + - python src/chatbot/healthcheck.py + Interval: 30 + Timeout: 5 + Retries: 3 + StartPeriod: 60 + PortMappings: + - ContainerPort: 8000 + Protocol: tcp + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-group: !Ref LogGroup + awslogs-region: !Ref AWS::Region + awslogs-stream-prefix: langgraph + + FastAPITaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: chatbot-fastapi + NetworkMode: awsvpc + RequiresCompatibilities: + - EC2 + ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn + ContainerDefinitions: + - Name: fastapi + Image: !Sub "${ECRRepositoryURI}/chatbot-api:${GitCommitHash}" + Memory: 256 + Essential: true + Environment: + - Name: CONTAINER_PORT + Value: "8080" + - Name: LANGGRAPH_API_URI + Value: http://langgraph-api.chatbot.local:8000 + HealthCheck: + Command: + - CMD-SHELL + - python api/healthcheck.py + Interval: 30 + Timeout: 5 + Retries: 3 + StartPeriod: 30 + PortMappings: + - ContainerPort: 8080 + Protocol: tcp + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-group: !Ref LogGroup + awslogs-region: !Ref AWS::Region + awslogs-stream-prefix: fastapi + + NextJSTaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: chatbot-nextjs + NetworkMode: awsvpc + RequiresCompatibilities: + - EC2 + ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn + ContainerDefinitions: + - Name: nextjs + Image: !Sub "${ECRRepositoryURI}/chatbot-ui:${GitCommitHash}" + Memory: 256 + Essential: true + Environment: + - Name: HOSTNAME + Value: 0.0.0.0 + - Name: CONTAINER_PORT + Value: "3000" + - Name: API_URL + Value: http://fastapi.chatbot.local:8080 + HealthCheck: + Command: + - CMD-SHELL + - curl -v http://localhost:3000/health || (echo failed && exit 1) + Interval: 30 + Timeout: 5 + Retries: 5 + StartPeriod: 30 + PortMappings: + - ContainerPort: 3000 + Protocol: tcp + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-group: !Ref LogGroup + awslogs-region: !Ref AWS::Region + awslogs-stream-prefix: nextjs + + # CloudWatch Log Group + LogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: /ecs/chatbot + RetentionInDays: 1 + + # ECS Services + PostgresService: + Type: AWS::ECS::Service + Properties: + Cluster: !Ref ECSCluster + TaskDefinition: !Ref PostgresTaskDefinition + DesiredCount: 1 + LaunchType: EC2 + NetworkConfiguration: + AwsvpcConfiguration: + SecurityGroups: + - !Ref ECSSecurityGroup + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + ServiceRegistries: + - RegistryArn: !GetAtt PostgresServiceDiscovery.Arn + + PostgresServiceDiscovery: + Type: AWS::ServiceDiscovery::Service + Properties: + Name: postgres + DnsConfig: + NamespaceId: !Ref ServiceDiscoveryNamespace + DnsRecords: + - Type: A + TTL: 60 + HealthCheckCustomConfig: + FailureThreshold: 1 + + RedisService: + Type: AWS::ECS::Service + Properties: + Cluster: !Ref ECSCluster + TaskDefinition: !Ref RedisTaskDefinition + DesiredCount: 1 + LaunchType: EC2 + NetworkConfiguration: + AwsvpcConfiguration: + SecurityGroups: + - !Ref ECSSecurityGroup + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + ServiceRegistries: + - RegistryArn: !GetAtt RedisServiceDiscovery.Arn + + RedisServiceDiscovery: + Type: AWS::ServiceDiscovery::Service + Properties: + Name: redis + DnsConfig: + NamespaceId: !Ref ServiceDiscoveryNamespace + DnsRecords: + - Type: A + TTL: 60 + HealthCheckCustomConfig: + FailureThreshold: 1 + + LangGraphService: + Type: AWS::ECS::Service + DependsOn: + - PostgresService + - RedisService + Properties: + Cluster: !Ref ECSCluster + TaskDefinition: !Ref LangGraphTaskDefinition + DesiredCount: 1 + LaunchType: EC2 + NetworkConfiguration: + AwsvpcConfiguration: + SecurityGroups: + - !Ref ECSSecurityGroup + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + ServiceRegistries: + - RegistryArn: !GetAtt LangGraphServiceDiscovery.Arn + + LangGraphServiceDiscovery: + Type: AWS::ServiceDiscovery::Service + Properties: + Name: langgraph-api + DnsConfig: + NamespaceId: !Ref ServiceDiscoveryNamespace + DnsRecords: + - Type: A + TTL: 60 + HealthCheckCustomConfig: + FailureThreshold: 1 + + FastAPIService: + Type: AWS::ECS::Service + DependsOn: LangGraphService + Properties: + Cluster: !Ref ECSCluster + TaskDefinition: !Ref FastAPITaskDefinition + DesiredCount: 1 + LaunchType: EC2 + NetworkConfiguration: + AwsvpcConfiguration: + SecurityGroups: + - !Ref ECSSecurityGroup + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + ServiceRegistries: + - RegistryArn: !GetAtt FastAPIServiceDiscovery.Arn + + FastAPIServiceDiscovery: + Type: AWS::ServiceDiscovery::Service + Properties: + Name: fastapi + DnsConfig: + NamespaceId: !Ref ServiceDiscoveryNamespace + DnsRecords: + - Type: A + TTL: 60 + HealthCheckCustomConfig: + FailureThreshold: 1 + + NextJSService: + Type: AWS::ECS::Service + DependsOn: FastAPIService + Properties: + Cluster: !Ref ECSCluster + TaskDefinition: !Ref NextJSTaskDefinition + DesiredCount: 1 + LaunchType: EC2 + NetworkConfiguration: + AwsvpcConfiguration: + SecurityGroups: + - !Ref ECSSecurityGroup + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + LoadBalancers: + - ContainerName: nextjs + ContainerPort: 3000 + TargetGroupArn: !Ref ALBTargetGroup + ServiceRegistries: + - RegistryArn: !GetAtt NextJSServiceDiscovery.Arn + + NextJSServiceDiscovery: + Type: AWS::ServiceDiscovery::Service + Properties: + Name: ui + DnsConfig: + NamespaceId: !Ref ServiceDiscoveryNamespace + DnsRecords: + - Type: A + TTL: 60 + HealthCheckCustomConfig: + FailureThreshold: 1 + + # Application Load Balancer + ApplicationLoadBalancer: + Type: AWS::ElasticLoadBalancingV2::LoadBalancer + Properties: + Name: chatbot-alb + Scheme: internet-facing + Type: application + Subnets: + - !Ref PublicSubnet + - !Ref PrivateSubnet2 # Need second public subnet for ALB + SecurityGroups: + - !Ref ALBSecurityGroup + + ALBTargetGroup: + Type: AWS::ElasticLoadBalancingV2::TargetGroup + Properties: + Name: chatbot-nextjs-tg + Port: 3000 + Protocol: HTTP + VpcId: !Ref VPC + TargetType: ip + HealthCheckPath: /health + HealthCheckProtocol: HTTP + HealthCheckIntervalSeconds: 30 + HealthCheckTimeoutSeconds: 5 + HealthyThresholdCount: 2 + UnhealthyThresholdCount: 3 + + ALBListener: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + DefaultActions: + - Type: forward + TargetGroupArn: !Ref ALBTargetGroup + LoadBalancerArn: !Ref ApplicationLoadBalancer + Port: 80 + Protocol: HTTP + +Outputs: + LoadBalancerDNS: + Description: DNS name of the load balancer + Value: !GetAtt ApplicationLoadBalancer.DNSName + Export: + Name: !Sub "${AWS::StackName}-LoadBalancerDNS" + + VPCId: + Description: VPC ID + Value: !Ref VPC + Export: + Name: !Sub "${AWS::StackName}-VPC" + + ECSClusterName: + Description: ECS Cluster Name + Value: !Ref ECSCluster + Export: + Name: !Sub "${AWS::StackName}-ECSCluster" + + BastionHostIP: + Description: Bastion Host Public IP + Value: !GetAtt BastionHost.PublicIp + Export: + Name: !Sub "${AWS::StackName}-BastionIP" \ No newline at end of file diff --git a/src/chatbot/Dockerfile b/src/chatbot/Dockerfile index 2cd79eb..86d7253 100644 --- a/src/chatbot/Dockerfile +++ b/src/chatbot/Dockerfile @@ -7,7 +7,7 @@ ADD . /deps/chatbot # -- End of local package . -- # -- Installing all local dependencies -- -RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/* +RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e /deps/* # -- End of local dependencies install -- ENV LANGSERVE_GRAPHS='{"chat": "/deps/chatbot/src/chatbot/graph.py:graph"}' @@ -15,12 +15,13 @@ ENV LANGSERVE_GRAPHS='{"chat": "/deps/chatbot/src/chatbot/graph.py:graph"}' # -- Ensure user deps didn't inadvertently overwrite langgraph-api RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py -RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir --no-deps -e /api +RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir --no-deps -e /api # -- End of ensuring user deps didn't inadvertently overwrite langgraph-api -- # -- Removing pip from the final image ~<:===~~~ -- RUN pip uninstall -y pip setuptools wheel && rm -rf /usr/local/lib/python*/site-packages/pip* /usr/local/lib/python*/site-packages/setuptools* /usr/local/lib/python*/site-packages/wheel* && find /usr/local/bin -name "pip*" -delete || true # pip removal for wolfi RUN rm -rf /usr/lib/python*/site-packages/pip* /usr/lib/python*/site-packages/setuptools* /usr/lib/python*/site-packages/wheel* && find /usr/bin -name "pip*" -delete || true +RUN uv pip uninstall --system pip setuptools wheel && rm /usr/bin/uv /usr/bin/uvx # -- End of pip removal -- WORKDIR /deps/chatbot \ No newline at end of file diff --git a/src/chatbot/__about__.py b/src/chatbot/__about__.py index e581cd2..b2cc0f9 100644 --- a/src/chatbot/__about__.py +++ b/src/chatbot/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2025-present Nam Le # # SPDX-License-Identifier: MIT -__version__ = "0.0.8" +__version__ = "0.0.9" diff --git a/src/chatbot/healthcheck.py b/src/chatbot/healthcheck.py new file mode 100644 index 0000000..8f8cf6a --- /dev/null +++ b/src/chatbot/healthcheck.py @@ -0,0 +1,20 @@ +import os +import urllib.request + + +def health_check(): + port = os.getenv("CONTAINER_PORT", "8000") + url = f"http://localhost:{port}/ok" + try: + with urllib.request.urlopen(url) as response: + status_code = response.getcode() + body = response.read().decode() + print(f"Status code: {status_code}") + print(f"Response body: {body}") + except Exception as e: + print(f"Health check failed: {e}") + exit(1) + + +if __name__ == "__main__": + health_check() diff --git a/ui/Dockerfile b/ui/Dockerfile index 10bef73..bf40bd3 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -5,7 +5,7 @@ FROM node:18-alpine AS base # Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat +RUN apk add --no-cache libc6-compat WORKDIR /app # Install dependencies based on the preferred package manager @@ -47,20 +47,20 @@ ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs -COPY --from=builder /app/public ./public - # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +USER root +RUN apk add --no-cache curl USER nextjs EXPOSE 3000 - ENV PORT=3000 - # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/config/next-config-js/output ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] \ No newline at end of file + +CMD ["node", "server.js"] diff --git a/ui/app/api/chat/[id]/human_review/route.ts b/ui/app/api/chat/[id]/human_review/route.ts new file mode 100644 index 0000000..2460865 --- /dev/null +++ b/ui/app/api/chat/[id]/human_review/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; + +const API_URL = process.env.API_URL; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const body = await request.json(); + const res = await fetch(`${API_URL}/chat/${id}/human_review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return NextResponse.json(await res.json(), { status: res.status }); +} diff --git a/ui/app/api/chat/[id]/route.ts b/ui/app/api/chat/[id]/route.ts new file mode 100644 index 0000000..25fcfe9 --- /dev/null +++ b/ui/app/api/chat/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server'; + +const API_URL = process.env.API_URL; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const res = await fetch(`${API_URL}/chat/${id}`, { + method: 'DELETE', + }); + return NextResponse.json(await res.json(), { status: res.status }); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const body = await request.json(); + const res = await fetch(`${API_URL}/chat/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return NextResponse.json(await res.json(), { status: res.status }); +} diff --git a/ui/app/api/chat/route.ts b/ui/app/api/chat/route.ts new file mode 100644 index 0000000..916526c --- /dev/null +++ b/ui/app/api/chat/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; + +const API_URL = process.env.API_URL; + +export async function GET() { + console.log('[API/chat] Trying:', `${API_URL}/chat`); + + try { + const res = await fetch(`${API_URL}/chat`); + const text = await res.text(); + + console.log('[API/chat] Response text:', text); + console.log('[API/chat] Status:', res.status); + + return NextResponse.json(JSON.parse(text)); + } catch (e) { + console.error('[API/chat] Fetch failed:', e); + return NextResponse.json({ error: 'Connection failed' }, { status: 502 }); + } +} diff --git a/ui/app/health/route.ts b/ui/app/health/route.ts new file mode 100644 index 0000000..0f32005 --- /dev/null +++ b/ui/app/health/route.ts @@ -0,0 +1,5 @@ +export async function GET() { + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/ui/app/utils/chat.tsx b/ui/app/utils/chat.ts similarity index 53% rename from ui/app/utils/chat.tsx rename to ui/app/utils/chat.ts index e3a3c0d..1f63f29 100644 --- a/ui/app/utils/chat.tsx +++ b/ui/app/utils/chat.ts @@ -1,34 +1,23 @@ -export const apiUrl = 'http://localhost:8080'; - export async function fetchThreads() { - const res = await fetch(`${apiUrl}/chat`); - if (!res.ok) { - throw new Error('Failed to fetch thread IDs'); - } + const res = await fetch(`/api/chat`); return res.json(); } export async function deleteThread(threadId: string) { - const res = await fetch(`${apiUrl}/chat/${threadId}`, { + const res = await fetch(`/api/chat/${threadId}`, { method: 'DELETE', }); - if (!res.ok) { - throw new Error('Failed to delete thread'); - } return res.json(); } export async function sendMessage(threadId: string, message: string) { - const res = await fetch(`${apiUrl}/chat/${threadId}`, { + const res = await fetch(`/api/chat/${threadId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: message }), }); - if (!res.ok) { - throw new Error('Failed to send message'); - } return res.json(); } @@ -36,15 +25,12 @@ export async function sendHumanReview( threadId: string, review: { action: string; data: string } ) { - const res = await fetch(`${apiUrl}/chat/${threadId}/human_review`, { + const res = await fetch(`/api/chat/${threadId}/human_review`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(review), }); - if (!res.ok) { - throw new Error('Failed to send human review'); - } return res.json(); } diff --git a/ui/cypress.config.ts b/ui/cypress.config.ts index e6b8b76..bebdaa5 100644 --- a/ui/cypress.config.ts +++ b/ui/cypress.config.ts @@ -1,10 +1,10 @@ -import { defineConfig } from "cypress"; +import { defineConfig } from 'cypress'; export default defineConfig({ component: { devServer: { - framework: "next", - bundler: "webpack", + framework: 'next', + bundler: 'webpack', }, }, diff --git a/ui/cypress/components/ThreadList.cy.tsx b/ui/cypress/components/ThreadList.cy.tsx index d1729b1..3a7a14f 100644 --- a/ui/cypress/components/ThreadList.cy.tsx +++ b/ui/cypress/components/ThreadList.cy.tsx @@ -4,22 +4,22 @@ import ThreadList from '../../app/components/ThreadList'; import { Thread } from '../../app/models'; describe('', () => { - it('should display thread IDs', () => { - const mockThreads = [ - new Thread('thread-1', 'idle'), - new Thread('thread-2', 'interrupted'), - ]; + it('should display thread IDs', () => { + const mockThreads = [ + new Thread('thread-1', 'idle'), + new Thread('thread-2', 'interrupted'), + ]; - mount( - { }} - deleteThread={() => { }} - /> - ); + mount( + {}} + deleteThread={() => {}} + /> + ); - cy.contains('thread-1').should('be.visible'); - cy.contains('thread-2').should('be.visible'); - }); -}); \ No newline at end of file + cy.contains('thread-1').should('be.visible'); + cy.contains('thread-2').should('be.visible'); + }); +}); diff --git a/ui/cypress/e2e/page.cy.tsx b/ui/cypress/e2e/page.cy.tsx index 006af1d..27767c3 100644 --- a/ui/cypress/e2e/page.cy.tsx +++ b/ui/cypress/e2e/page.cy.tsx @@ -1,9 +1,9 @@ describe('Page Title', () => { - it('should display the correct page title', () => { - // Visit the home page - cy.visit('http://localhost:3000/'); + it('should display the correct page title', () => { + // Visit the home page + cy.visit('http://localhost:3000/'); - // Check if the page contains an h1 with the text "Chatbot" - cy.get('h1').should('contain', 'Chatbot'); - }); -}); \ No newline at end of file + // Check if the page contains an h1 with the text "Chatbot" + cy.get('h1').should('contain', 'Chatbot'); + }); +}); diff --git a/ui/cypress/support/commands.ts b/ui/cypress/support/commands.ts index 698b01a..95857ae 100644 --- a/ui/cypress/support/commands.ts +++ b/ui/cypress/support/commands.ts @@ -34,4 +34,4 @@ // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable // } // } -// } \ No newline at end of file +// } diff --git a/ui/cypress/support/component-index.html b/ui/cypress/support/component-index.html index 3e16e9b..2cbfac6 100644 --- a/ui/cypress/support/component-index.html +++ b/ui/cypress/support/component-index.html @@ -1,9 +1,9 @@ - + - - - + + + Components App
@@ -11,4 +11,4 @@
- \ No newline at end of file + diff --git a/ui/cypress/support/component.ts b/ui/cypress/support/component.ts index fd3bffc..23fbccc 100644 --- a/ui/cypress/support/component.ts +++ b/ui/cypress/support/component.ts @@ -14,9 +14,9 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import './commands' +import './commands'; -import { mount } from 'cypress/react' +import { mount } from 'cypress/react'; // Augment the Cypress namespace to include type definitions for // your custom command. @@ -25,12 +25,12 @@ import { mount } from 'cypress/react' declare global { namespace Cypress { interface Chainable { - mount: typeof mount + mount: typeof mount; } } } -Cypress.Commands.add('mount', mount) +Cypress.Commands.add('mount', mount); // Example use: -// cy.mount() \ No newline at end of file +// cy.mount() diff --git a/ui/cypress/support/e2e.ts b/ui/cypress/support/e2e.ts index e4e246e..e66558e 100644 --- a/ui/cypress/support/e2e.ts +++ b/ui/cypress/support/e2e.ts @@ -14,4 +14,4 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import './commands' \ No newline at end of file +import './commands'; diff --git a/ui/package.json b/ui/package.json index edf9e94..5d55471 100644 --- a/ui/package.json +++ b/ui/package.json @@ -31,4 +31,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/ui/public/.gitkeep b/ui/public/.gitkeep new file mode 100644 index 0000000..e69de29