diff --git a/apigw-vpclink-alb-ecs/.dockerignore b/apigw-vpclink-alb-ecs/.dockerignore new file mode 100644 index 000000000..c267d3dc0 --- /dev/null +++ b/apigw-vpclink-alb-ecs/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.nyc_output +coverage +.kiro \ No newline at end of file diff --git a/apigw-vpclink-alb-ecs/Dockerfile b/apigw-vpclink-alb-ecs/Dockerfile new file mode 100644 index 000000000..e52e8b06e --- /dev/null +++ b/apigw-vpclink-alb-ecs/Dockerfile @@ -0,0 +1,25 @@ +# Use official Node.js runtime as base image +FROM node:24-alpine + +# Set working directory in container +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy application source code +COPY src/ ./src/ + +# Expose port 3000 +EXPOSE 3000 + +# Create non-root user for security +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nodejs -u 1001 +USER nodejs + +# Start the application +CMD ["npm", "start"] \ No newline at end of file diff --git a/apigw-vpclink-alb-ecs/README.md b/apigw-vpclink-alb-ecs/README.md new file mode 100644 index 000000000..d4a4ed2d1 --- /dev/null +++ b/apigw-vpclink-alb-ecs/README.md @@ -0,0 +1,200 @@ +# REST APIs using Amazon API Gateway private integration with Application Load Balancer + +This sample project demonstrates how API Gateway connects to Application Load Balancer using VPV Link V2. + +## Requirements + +- [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +- [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +- [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed +- [Node 24 or above](https://nodejs.org/en/download) installed +- [Docker] installed + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + + ```bash + git clone https://github.com/aws-samples/serverless-patterns + ``` + +2. Change directory to the pattern directory: + + ```bash + cd serverless-patterns/apigw-vpclink-alb-ecs + ``` + +3. Create an ECR repository: + + ```bash + aws ecr create-repository --repository-name products-api --region + ``` + +4. Get the login token and authenticate Docker: + + ```bash + aws ecr get-login-password --region | docker login --username AWS --password-stdin .dkr.ecr..amazonaws.com + ``` + +5. Install dependencies: + + ```bash + npm install + ``` + +6. Build the Docker image and push it to ECR: + + ```bash + # Build the Docker image + docker build --platform linux/amd64 -t products-api . + + # Tag the image for ECR + docker tag products-api:latest .dkr.ecr..amazonaws.com/products-api:latest + + # Push the image to ECR + docker push .dkr.ecr..amazonaws.com/products-api:latest + ``` + +7. From the command line, run the following commands: + + ```bash + sam build + sam deploy --guided + ``` + +8. During the prompts: + + - Enter a stack name + - Enter the desired AWS Region e.g. `us-east-1`. + - Enter VpcCidr - keep the default value + - Enter ECRImageURI - Replace with your ECR URI e.g. .dkr.ecr..amazonaws.com/products-api:latest + - Allow SAM CLI to create IAM roles with the required permissions. + - Keep default values to the rest of the parameters. + + Once you have run `sam deploy --guided` mode once and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. + +9. Note the outputs from the SAM deployment process. These contain the resource names and/or ARNs which are used for next step as well as testing. + +## How it works + +The SAM template deploys the following resources: + +![End to End Architecture](diagram/architecture.png) + +Here's a breakdown of the steps: + +1. **Amazon API Gateway**: The API Gateway exposes a REST API endpoint. The API Gateway connects to Application Load Balancer using VPC link V2. + +## Testing + +### Using EC2 Instance test internal ALB + +1. Open a terminal in your laptop and use [curl](https://curl.se/) to send a HTTP GET request to the `InternalALBEndpoint`. Replace the value of `InternalALBEndpoint` from `sam deploy` output. + +```bash +curl -X GET +``` + +Expected Response: +This request will timeout and you will not get any response. This is an internal ALB endpoint. Hence, this is not accessible over public internet. + +2. Launch an EC2 instance in one of the private subnets within the same VPC + +3. SSH into the instance + +4. Install curl if not available: + +```bash +# Amazon Linux/RHEL/CentOS +sudo yum install -y curl + +# Ubuntu/Debian +sudo apt-get update && sudo apt-get install -y curl +``` + +5. Test the products endpoint functionality + +```bash +curl -X GET +``` + +Expected Response: + +```json +{ + "products": [ + { + "id": "1", + "name": "Sample Product", + "description": "A demo product for testing", + "price": 29.99, + "category": "Electronics" + }, + { + "id": "2", + "name": "Demo Widget", + "description": "Another test product", + "price": 15.50, + "category": "Gadgets" + }, + { + "id": "3", + "name": "Test Item", + "description": "Third demo product", + "price": 99.99, + "category": "Tools" + } + ] +} +``` + +6. Now, test the API Gateway API endpoint. Replace `APIEndpoint` with the value from `sam deploy` output. + +```bash +curl -X GET +``` + +Expected Response: + +```json +{ + "products": [ + { + "id": "1", + "name": "Sample Product", + "description": "A demo product for testing", + "price": 29.99, + "category": "Electronics" + }, + { + "id": "2", + "name": "Demo Widget", + "description": "Another test product", + "price": 15.50, + "category": "Gadgets" + }, + { + "id": "3", + "name": "Test Item", + "description": "Third demo product", + "price": 99.99, + "category": "Tools" + } + ] +} +``` + +## Cleanup + +1. To delete the resources deployed to your AWS account via AWS SAM, run the following command: + +```bash +sam delete +``` + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 \ No newline at end of file diff --git a/apigw-vpclink-alb-ecs/diagram/architecture.png b/apigw-vpclink-alb-ecs/diagram/architecture.png new file mode 100644 index 000000000..039435480 Binary files /dev/null and b/apigw-vpclink-alb-ecs/diagram/architecture.png differ diff --git a/apigw-vpclink-alb-ecs/example-pattern.json b/apigw-vpclink-alb-ecs/example-pattern.json new file mode 100644 index 000000000..3afce66b6 --- /dev/null +++ b/apigw-vpclink-alb-ecs/example-pattern.json @@ -0,0 +1,59 @@ +{ + "title": "Amazon API Gateway private integration with Application Load Balancer", + "description": "This sample project demonstrates how API Gateway connects to Application Load Balancer using VPV Link V2.", + "language": "Node.js", + "level": "200", + "framework": "AWS SAM", + "introBox": { + "headline": "How it works", + "text": [ + "Amazon API Gateway receives the HTTP GET request.", + "The API Gateway routes the request to Application Load Balancer using VPC link V2.", + "The Application Load Balancer routes the request to one of the tasks under Amazon ECS cluster." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-vpclink-alb-ecs", + "templateURL": "serverless-patterns/apigw-vpclink-alb-ecs", + "projectFolder": "apigw-vpclink-alb-ecs", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Lambda tenant isolation", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation.html" + }, + { + "text": "AWS Blog - Build scalable REST APIs using Amazon API Gateway private integration with Application Load Balancer", + "link": "https://aws.amazon.com/blogs/compute/build-scalable-rest-apis-using-amazon-api-gateway-private-integration-with-application-load-balancer/" + } + ] + }, + "deploy": { + "text": [ + "sam build", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete." + ] + }, + "authors": [ + { + "name": "Biswanath Mukherjee", + "image": "https://serverlessland.com/assets/images/resources/contributors/biswanath-mukherjee.jpg", + "bio": "I am a Sr. Solutions Architect working at AWS India. I help strategic global enterprise customer to architect their workload to run on AWS.", + "linkedin": "biswanathmukherjee" + } + ] +} diff --git a/apigw-vpclink-alb-ecs/package.json b/apigw-vpclink-alb-ecs/package.json new file mode 100644 index 000000000..8c7d29258 --- /dev/null +++ b/apigw-vpclink-alb-ecs/package.json @@ -0,0 +1,33 @@ +{ + "name": "products-api", + "version": "1.0.0", + "description": "Simple GET products REST API for AWS service connectivity demonstration", + "main": "src/app.js", + "scripts": { + "start": "node src/app.js", + "dev": "node src/app.js", + "test": "jest", + "test:watch": "jest --watch" + }, + "keywords": [ + "api", + "products", + "express", + "aws", + "demo" + ], + "author": "", + "license": "MIT", + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^6.3.3", + "fast-check": "^3.15.0" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/apigw-vpclink-alb-ecs/src/app.js b/apigw-vpclink-alb-ecs/src/app.js new file mode 100644 index 000000000..b20217c57 --- /dev/null +++ b/apigw-vpclink-alb-ecs/src/app.js @@ -0,0 +1,89 @@ +// Main application entry point +const express = require('express'); +const cors = require('cors'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Hardcoded product data for demonstration +const products = [ + { + id: "1", + name: "Sample Product", + description: "A demo product for testing", + price: 29.99, + category: "Electronics" + }, + { + id: "2", + name: "Demo Widget", + description: "Another test product", + price: 15.50, + category: "Gadgets" + }, + { + id: "3", + name: "Test Item", + description: "Third demo product", + price: 99.99, + category: "Tools" + } +]; + +// Routes +app.get('/products', (req, res) => { + try { + res.status(200).json({ + products: products + }); + } catch (error) { + res.status(500).json({ + error: { + code: "INTERNAL_ERROR", + message: "An unexpected error occurred" + } + }); + } +}); + +app.get('/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString() + }); +}); + +// Handle method not allowed for products endpoint +app.all('/products', (req, res) => { + if (req.method !== 'GET') { + return res.status(405).json({ + error: { + code: "METHOD_NOT_ALLOWED", + message: "Only GET method is allowed" + } + }); + } +}); + +// Handle 404 for unknown routes +app.use('*', (req, res) => { + res.status(404).json({ + error: { + code: "NOT_FOUND", + message: "Endpoint not found" + } + }); +}); + +// Start server only if not being required as a module +if (require.main === module) { + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); +} + +module.exports = app; \ No newline at end of file diff --git a/apigw-vpclink-alb-ecs/src/app.test.js b/apigw-vpclink-alb-ecs/src/app.test.js new file mode 100644 index 000000000..6056bc0db --- /dev/null +++ b/apigw-vpclink-alb-ecs/src/app.test.js @@ -0,0 +1,132 @@ +const request = require('supertest'); +const fc = require('fast-check'); +const app = require('./app'); + +describe('Products API Property Tests', () => { + + // **Feature: products-api, Property 1: Valid JSON Response Format** + // **Validates: Requirements 1.1, 6.2** + test('Property 1: Valid JSON Response Format - For any valid GET request to the products endpoint, the response should be valid JSON containing a "products" array', () => { + return fc.assert( + fc.asyncProperty(fc.constant('/products'), async (endpoint) => { + const response = await request(app).get(endpoint); + + // Response should be valid JSON + expect(response.type).toBe('application/json'); + + // Response body should contain products array + expect(response.body).toHaveProperty('products'); + expect(Array.isArray(response.body.products)).toBe(true); + + // Each product should have required fields + response.body.products.forEach(product => { + expect(product).toHaveProperty('id'); + expect(product).toHaveProperty('name'); + expect(product).toHaveProperty('description'); + expect(product).toHaveProperty('price'); + expect(product).toHaveProperty('category'); + }); + }), + { numRuns: 100 } + ); + }); + + // **Feature: products-api, Property 2: Successful Request Status Code** + // **Validates: Requirements 1.2** + test('Property 2: Successful Request Status Code - For any valid GET request to the products endpoint, the response should have HTTP status code 200', () => { + return fc.assert( + fc.asyncProperty(fc.constant('/products'), async (endpoint) => { + const response = await request(app).get(endpoint); + + // Response should have status 200 + expect(response.status).toBe(200); + }), + { numRuns: 100 } + ); + }); + + // **Feature: products-api, Property 3: Required Response Headers** + // **Validates: Requirements 1.3** + test('Property 3: Required Response Headers - For any valid GET request, the response should include appropriate HTTP headers including Content-Type: application/json', () => { + return fc.assert( + fc.asyncProperty(fc.constant('/products'), async (endpoint) => { + const response = await request(app).get(endpoint); + + // Response should have Content-Type header set to application/json + expect(response.headers['content-type']).toMatch(/application\/json/); + + // Response should have other standard headers + expect(response.headers).toHaveProperty('content-length'); + }), + { numRuns: 100 } + ); + }); + + // **Feature: products-api, Property 4: Consistent Response Structure** + // **Validates: Requirements 1.5** + test('Property 4: Consistent Response Structure - For any valid request to the products endpoint, the response should maintain the same JSON structure regardless of the number of products returned', () => { + return fc.assert( + fc.asyncProperty(fc.constant('/products'), async (endpoint) => { + const response = await request(app).get(endpoint); + + // Response should always have the same top-level structure + expect(response.body).toHaveProperty('products'); + expect(Array.isArray(response.body.products)).toBe(true); + + // Response should not have any other top-level properties + const expectedKeys = ['products']; + const actualKeys = Object.keys(response.body); + expect(actualKeys).toEqual(expectedKeys); + + // Each product in the array should have consistent structure + response.body.products.forEach(product => { + const productKeys = Object.keys(product).sort(); + const expectedProductKeys = ['id', 'name', 'description', 'price', 'category'].sort(); + expect(productKeys).toEqual(expectedProductKeys); + }); + }), + { numRuns: 100 } + ); + }); + + // **Feature: products-api, Property 5: Appropriate Status Codes** + // **Validates: Requirements 6.4** + test('Property 5: Appropriate Status Codes - For any request scenario (valid, invalid, error), the API should return the appropriate HTTP status code corresponding to the request outcome', () => { + return fc.assert( + fc.asyncProperty( + fc.oneof( + fc.constant({ method: 'GET', path: '/products', expectedStatus: 200 }), + fc.constant({ method: 'GET', path: '/health', expectedStatus: 200 }), + fc.constant({ method: 'POST', path: '/products', expectedStatus: 405 }), + fc.constant({ method: 'PUT', path: '/products', expectedStatus: 405 }), + fc.constant({ method: 'DELETE', path: '/products', expectedStatus: 405 }), + fc.constant({ method: 'GET', path: '/nonexistent', expectedStatus: 404 }), + fc.constant({ method: 'POST', path: '/nonexistent', expectedStatus: 404 }) + ), + async (scenario) => { + let response; + + switch (scenario.method) { + case 'GET': + response = await request(app).get(scenario.path); + break; + case 'POST': + response = await request(app).post(scenario.path); + break; + case 'PUT': + response = await request(app).put(scenario.path); + break; + case 'DELETE': + response = await request(app).delete(scenario.path); + break; + } + + // Response should have the expected status code + expect(response.status).toBe(scenario.expectedStatus); + } + ), + { numRuns: 100 } + ); + }); + +}); \ No newline at end of file diff --git a/apigw-vpclink-alb-ecs/template.yaml b/apigw-vpclink-alb-ecs/template.yaml new file mode 100644 index 000000000..407635632 --- /dev/null +++ b/apigw-vpclink-alb-ecs/template.yaml @@ -0,0 +1,662 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: 'Products API - Simple REST API with VPC, ALB, and ECS' + +Parameters: + Environment: + Type: String + Default: 'dev' + Description: 'Environment name for resource naming' + + VpcCidr: + Type: String + Default: '10.0.0.0/16' + Description: 'CIDR block for the VPC' + + ECRImageURI: + Type: String + Description: 'ECR image URI for the container (e.g., 123456789012.dkr.ecr.us-east-1.amazonaws.com/products-api:latest)' + +Globals: + Function: + Timeout: 30 + MemorySize: 128 + +Resources: + # VPC and Networking Components + ProductsVPC: + Type: AWS::EC2::VPC + Properties: + CidrBlock: !Ref VpcCidr + EnableDnsHostnames: true + EnableDnsSupport: true + Tags: + - Key: Name + Value: !Sub '${Environment}-products-vpc' + + # Internet Gateway + InternetGateway: + Type: AWS::EC2::InternetGateway + Properties: + Tags: + - Key: Name + Value: !Sub '${Environment}-products-igw' + + InternetGatewayAttachment: + Type: AWS::EC2::VPCGatewayAttachment + Properties: + InternetGatewayId: !Ref InternetGateway + VpcId: !Ref ProductsVPC + + # Private Subnets across 2 AZs + PrivateSubnet1: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref ProductsVPC + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: '10.0.1.0/24' + MapPublicIpOnLaunch: false + Tags: + - Key: Name + Value: !Sub '${Environment}-private-subnet-1' + + PrivateSubnet2: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref ProductsVPC + AvailabilityZone: !Select [1, !GetAZs ''] + CidrBlock: '10.0.2.0/24' + MapPublicIpOnLaunch: false + Tags: + - Key: Name + Value: !Sub '${Environment}-private-subnet-2' + + # Public Subnets for NAT Gateways (needed for ECS tasks to pull images) + PublicSubnet1: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref ProductsVPC + AvailabilityZone: !Select [0, !GetAZs ''] + CidrBlock: '10.0.101.0/24' + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: !Sub '${Environment}-public-subnet-1' + + PublicSubnet2: + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref ProductsVPC + AvailabilityZone: !Select [1, !GetAZs ''] + CidrBlock: '10.0.102.0/24' + MapPublicIpOnLaunch: true + Tags: + - Key: Name + Value: !Sub '${Environment}-public-subnet-2' + + # NAT Gateways for private subnet internet access + NatGateway1EIP: + Type: AWS::EC2::EIP + DependsOn: InternetGatewayAttachment + Properties: + Domain: vpc + + NatGateway2EIP: + Type: AWS::EC2::EIP + DependsOn: InternetGatewayAttachment + Properties: + Domain: vpc + + NatGateway1: + Type: AWS::EC2::NatGateway + Properties: + AllocationId: !GetAtt NatGateway1EIP.AllocationId + SubnetId: !Ref PublicSubnet1 + + NatGateway2: + Type: AWS::EC2::NatGateway + Properties: + AllocationId: !GetAtt NatGateway2EIP.AllocationId + SubnetId: !Ref PublicSubnet2 + + # Route Tables + PublicRouteTable: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref ProductsVPC + Tags: + - Key: Name + Value: !Sub '${Environment}-public-routes' + + DefaultPublicRoute: + Type: AWS::EC2::Route + DependsOn: InternetGatewayAttachment + Properties: + RouteTableId: !Ref PublicRouteTable + DestinationCidrBlock: '0.0.0.0/0' + GatewayId: !Ref InternetGateway + + PublicSubnet1RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: !Ref PublicRouteTable + SubnetId: !Ref PublicSubnet1 + + PublicSubnet2RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: !Ref PublicRouteTable + SubnetId: !Ref PublicSubnet2 + + PrivateRouteTable1: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref ProductsVPC + Tags: + - Key: Name + Value: !Sub '${Environment}-private-routes-1' + + DefaultPrivateRoute1: + Type: AWS::EC2::Route + Properties: + RouteTableId: !Ref PrivateRouteTable1 + DestinationCidrBlock: '0.0.0.0/0' + NatGatewayId: !Ref NatGateway1 + + PrivateSubnet1RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: !Ref PrivateRouteTable1 + SubnetId: !Ref PrivateSubnet1 + + PrivateRouteTable2: + Type: AWS::EC2::RouteTable + Properties: + VpcId: !Ref ProductsVPC + Tags: + - Key: Name + Value: !Sub '${Environment}-private-routes-2' + + DefaultPrivateRoute2: + Type: AWS::EC2::Route + Properties: + RouteTableId: !Ref PrivateRouteTable2 + DestinationCidrBlock: '0.0.0.0/0' + NatGatewayId: !Ref NatGateway2 + + PrivateSubnet2RouteTableAssociation: + Type: AWS::EC2::SubnetRouteTableAssociation + Properties: + RouteTableId: !Ref PrivateRouteTable2 + SubnetId: !Ref PrivateSubnet2 + + # Security Groups + ALBSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'Security group for Application Load Balancer' + VpcId: !Ref ProductsVPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: '10.0.0.0/16' + Description: 'HTTP access from within VPC' + Tags: + - Key: Name + Value: !Sub '${Environment}-alb-sg' + + ECSSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'Security group for ECS tasks' + VpcId: !Ref ProductsVPC + SecurityGroupEgress: + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: '0.0.0.0/0' + Description: 'HTTPS outbound for ECR and AWS services' + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: '0.0.0.0/0' + Description: 'HTTP outbound' + - IpProtocol: tcp + FromPort: 53 + ToPort: 53 + CidrIp: '0.0.0.0/0' + Description: 'DNS TCP' + - IpProtocol: udp + FromPort: 53 + ToPort: 53 + CidrIp: '0.0.0.0/0' + Description: 'DNS UDP' + Tags: + - Key: Name + Value: !Sub '${Environment}-ecs-sg' + + # VPC Endpoints for ECS tasks to access AWS services + ECRApiEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !Ref ProductsVPC + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.ecr.api' + VpcEndpointType: Interface + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + SecurityGroupIds: + - !Ref VPCEndpointSecurityGroup + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: '*' + Action: + - ecr:GetAuthorizationToken + - ecr:BatchCheckLayerAvailability + - ecr:GetDownloadUrlForLayer + - ecr:BatchGetImage + Resource: '*' + + ECRDkrEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !Ref ProductsVPC + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.ecr.dkr' + VpcEndpointType: Interface + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + SecurityGroupIds: + - !Ref VPCEndpointSecurityGroup + + CloudWatchLogsEndpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !Ref ProductsVPC + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.logs' + VpcEndpointType: Interface + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + SecurityGroupIds: + - !Ref VPCEndpointSecurityGroup + + S3Endpoint: + Type: AWS::EC2::VPCEndpoint + Properties: + VpcId: !Ref ProductsVPC + ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3' + VpcEndpointType: Gateway + RouteTableIds: + - !Ref PrivateRouteTable1 + - !Ref PrivateRouteTable2 + + # Security Group for VPC Endpoints + VPCEndpointSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'Security group for VPC endpoints' + VpcId: !Ref ProductsVPC + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + SourceSecurityGroupId: !Ref ECSSecurityGroup + Description: 'HTTPS from ECS tasks' + Tags: + - Key: Name + Value: !Sub '${Environment}-vpc-endpoint-sg' + + # Security Group Rules (to avoid circular dependencies) + ALBToECSRule: + Type: AWS::EC2::SecurityGroupEgress + Properties: + GroupId: !Ref ALBSecurityGroup + IpProtocol: tcp + FromPort: 3000 + ToPort: 3000 + DestinationSecurityGroupId: !Ref ECSSecurityGroup + Description: 'Access to ECS tasks' + + ECSFromALBRule: + Type: AWS::EC2::SecurityGroupIngress + Properties: + GroupId: !Ref ECSSecurityGroup + IpProtocol: tcp + FromPort: 3000 + ToPort: 3000 + SourceSecurityGroupId: !Ref ALBSecurityGroup + Description: 'Access from ALB' + # Application Load Balancer + ApplicationLoadBalancer: + Type: AWS::ElasticLoadBalancingV2::LoadBalancer + Properties: + Name: !Sub '${Environment}-products-alb' + Scheme: internal + Type: application + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + SecurityGroups: + - !Ref ALBSecurityGroup + Tags: + - Key: Name + Value: !Sub '${Environment}-products-alb' + + # Target Group for ECS Tasks + ALBTargetGroup: + Type: AWS::ElasticLoadBalancingV2::TargetGroup + Properties: + Name: !Sub '${Environment}-products-tg' + Port: 3000 + Protocol: HTTP + VpcId: !Ref ProductsVPC + TargetType: ip + HealthCheckEnabled: true + HealthCheckIntervalSeconds: 30 + HealthCheckPath: '/health' + HealthCheckProtocol: HTTP + HealthCheckTimeoutSeconds: 5 + HealthyThresholdCount: 2 + UnhealthyThresholdCount: 3 + Matcher: + HttpCode: '200' + Tags: + - Key: Name + Value: !Sub '${Environment}-products-tg' + + # ALB Listener + ALBListener: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + DefaultActions: + - Type: forward + TargetGroupArn: !Ref ALBTargetGroup + LoadBalancerArn: !Ref ApplicationLoadBalancer + Port: 80 + Protocol: HTTP + # IAM Roles and Policies + 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: ECRAccess + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - ecr:GetAuthorizationToken + - ecr:BatchCheckLayerAvailability + - ecr:GetDownloadUrlForLayer + - ecr:BatchGetImage + Resource: '*' + - Effect: Allow + Action: + - logs:CreateLogStream + - logs:PutLogEvents + Resource: + - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/${Environment}-products-api' + - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/${Environment}-products-api:*' + Tags: + - Key: Name + Value: !Sub '${Environment}-products-task-execution-role' + + ECSTaskRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: ecs-tasks.amazonaws.com + Action: sts:AssumeRole + Tags: + - Key: Name + Value: !Sub '${Environment}-products-task-role' + # ECS Cluster + ECSCluster: + Type: AWS::ECS::Cluster + Properties: + ClusterName: !Sub '${Environment}-products-cluster' + CapacityProviders: + - FARGATE + - FARGATE_SPOT + DefaultCapacityProviderStrategy: + - CapacityProvider: FARGATE + Weight: 1 + ClusterSettings: + - Name: containerInsights + Value: enabled + Tags: + - Key: Name + Value: !Sub '${Environment}-products-cluster' + + # CloudWatch Log Group + ECSLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub '/ecs/${Environment}-products-api' + RetentionInDays: 7 + + # ECS Task Definition + ECSTaskDefinition: + Type: AWS::ECS::TaskDefinition + Properties: + Family: !Sub '${Environment}-products-api' + NetworkMode: awsvpc + RequiresCompatibilities: + - FARGATE + Cpu: 256 + Memory: 512 + ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn + TaskRoleArn: !GetAtt ECSTaskRole.Arn + ContainerDefinitions: + - Name: products-api + Image: !Ref ECRImageURI + PortMappings: + - ContainerPort: 3000 + Protocol: tcp + Essential: true + LogConfiguration: + LogDriver: awslogs + Options: + awslogs-group: !Ref ECSLogGroup + awslogs-region: !Ref AWS::Region + awslogs-stream-prefix: ecs + Environment: + - Name: PORT + Value: '3000' + - Name: NODE_ENV + Value: production + Tags: + - Key: Name + Value: !Sub '${Environment}-products-task-def' + + # ECS Service + ECSService: + Type: AWS::ECS::Service + DependsOn: ALBListener + Properties: + ServiceName: !Sub '${Environment}-products-service' + Cluster: !Ref ECSCluster + TaskDefinition: !Ref ECSTaskDefinition + DesiredCount: 2 + LaunchType: FARGATE + NetworkConfiguration: + AwsvpcConfiguration: + SecurityGroups: + - !Ref ECSSecurityGroup + Subnets: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + AssignPublicIp: DISABLED + LoadBalancers: + - ContainerName: products-api + ContainerPort: 3000 + TargetGroupArn: !Ref ALBTargetGroup + HealthCheckGracePeriodSeconds: 60 + DeploymentConfiguration: + MaximumPercent: 200 + MinimumHealthyPercent: 50 + Tags: + - Key: Name + Value: !Sub '${Environment}-products-service' + # API Gateway VPC Link V2 + VPCLinkV2: + Type: AWS::ApiGatewayV2::VpcLink + Properties: + Name: !Sub '${Environment}-products-vpclink' + SubnetIds: + - !Ref PrivateSubnet1 + - !Ref PrivateSubnet2 + SecurityGroupIds: + - !Ref VPCLinkSecurityGroup + Tags: + Name: !Sub '${Environment}-products-vpclink' + + # Security Group for VPC Link + VPCLinkSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: 'Security group for VPC Link V2' + VpcId: !Ref ProductsVPC + SecurityGroupEgress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + DestinationSecurityGroupId: !Ref ALBSecurityGroup + Description: 'Access to ALB' + Tags: + - Key: Name + Value: !Sub '${Environment}-vpclink-sg' + + # Update ALB Security Group to allow VPC Link access + VPCLinkToALBRule: + Type: AWS::EC2::SecurityGroupIngress + Properties: + GroupId: !Ref ALBSecurityGroup + IpProtocol: tcp + FromPort: 80 + ToPort: 80 + SourceSecurityGroupId: !Ref VPCLinkSecurityGroup + Description: 'Access from VPC Link' + + # API Gateway REST API + ProductsRestAPI: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Sub '${Environment}-products-api' + Description: 'Products REST API with VPC Link V2 integration and TLS 1.3 security policy' + EndpointConfiguration: + Types: + - REGIONAL + SecurityPolicy: SecurityPolicy_TLS13_1_3_2025_09 + EndpointAccessMode: BASIC + Tags: + - Key: Name + Value: !Sub '${Environment}-products-api' + + # API Gateway Resource for /products + ProductsResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref ProductsRestAPI + ParentId: !GetAtt ProductsRestAPI.RootResourceId + PathPart: 'products' + + # API Gateway Resource for /health + HealthResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref ProductsRestAPI + ParentId: !GetAtt ProductsRestAPI.RootResourceId + PathPart: 'health' + + # API Gateway Method for GET /products + ProductsMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref ProductsRestAPI + ResourceId: !Ref ProductsResource + HttpMethod: GET + AuthorizationType: NONE + Integration: + Type: HTTP_PROXY + IntegrationHttpMethod: GET + Uri: !Sub 'http://${ApplicationLoadBalancer.DNSName}/products' + ConnectionType: VPC_LINK + ConnectionId: !Ref VPCLinkV2 + IntegrationTarget: !Ref ApplicationLoadBalancer + + # API Gateway Method for GET /health + HealthMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref ProductsRestAPI + ResourceId: !Ref HealthResource + HttpMethod: GET + AuthorizationType: NONE + Integration: + Type: HTTP_PROXY + IntegrationHttpMethod: GET + Uri: !Sub 'http://${ApplicationLoadBalancer.DNSName}/health' + ConnectionType: VPC_LINK + ConnectionId: !Ref VPCLinkV2 + IntegrationTarget: !Ref ApplicationLoadBalancer + + # API Gateway Deployment + APIDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: + - ProductsMethod + - HealthMethod + Properties: + RestApiId: !Ref ProductsRestAPI + StageName: !Ref Environment + +Outputs: + ECRRepositoryURI: + Description: 'ECR Repository URI for pushing Docker images' + Value: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/products-api' + + ECSClusterName: + Description: 'Name of the ECS Cluster' + Value: !Ref ECSCluster + Export: + Name: !Sub '${Environment}-products-cluster-name' + + VPCLinkId: + Description: 'VPC Link V2 ID' + Value: !Ref VPCLinkV2 + Export: + Name: !Sub '${Environment}-products-vpclink-id' + + VPCId: + Description: 'VPC ID for the Products VPC' + Value: !Ref ProductsVPC + + APIEndpoint: + Description: 'API endpoint URL for testing (via API Gateway default endpoint)' + Value: !Sub 'https://${ProductsRestAPI}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/products' + + HealthEndpoint: + Description: 'Health endpoint URL for testing (via API Gateway default endpoint)' + Value: !Sub 'https://${ProductsRestAPI}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/health' + + InternalALBEndpoint: + Description: 'Internal ALB endpoint URL for testing (from within VPC)' + Value: !Sub 'http://${ApplicationLoadBalancer.DNSName}/products' \ No newline at end of file