From bd69230907e31d9d97b184cee8895c4a26ce5160 Mon Sep 17 00:00:00 2001 From: Biswanath Mukherjee Date: Sat, 20 Dec 2025 08:06:00 +0530 Subject: [PATCH] initial commit --- apigw-vpclink-alb-ecs/.dockerignore | 9 + apigw-vpclink-alb-ecs/Dockerfile | 25 + apigw-vpclink-alb-ecs/README.md | 200 ++++++ .../diagram/architecture.png | Bin 0 -> 46831 bytes apigw-vpclink-alb-ecs/example-pattern.json | 59 ++ apigw-vpclink-alb-ecs/package.json | 33 + apigw-vpclink-alb-ecs/src/app.js | 89 +++ apigw-vpclink-alb-ecs/src/app.test.js | 132 ++++ apigw-vpclink-alb-ecs/template.yaml | 662 ++++++++++++++++++ 9 files changed, 1209 insertions(+) create mode 100644 apigw-vpclink-alb-ecs/.dockerignore create mode 100644 apigw-vpclink-alb-ecs/Dockerfile create mode 100644 apigw-vpclink-alb-ecs/README.md create mode 100644 apigw-vpclink-alb-ecs/diagram/architecture.png create mode 100644 apigw-vpclink-alb-ecs/example-pattern.json create mode 100644 apigw-vpclink-alb-ecs/package.json create mode 100644 apigw-vpclink-alb-ecs/src/app.js create mode 100644 apigw-vpclink-alb-ecs/src/app.test.js create mode 100644 apigw-vpclink-alb-ecs/template.yaml 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 0000000000000000000000000000000000000000..03943548035a57edb8746ea83030ddbfff486900 GIT binary patch literal 46831 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 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