Skip to content

Commit

Permalink
Add CloudFormation template to deploy signed API (#122)
Browse files Browse the repository at this point in the history
* Fix docker scripts

* Implement signed API CF template

* Generalize the template

* Remove port configuration from signed API

* Fix link to a private repository

* Rename cloudformation template
  • Loading branch information
Siegrift authored Nov 22, 2023
1 parent 71ec7f7 commit a3e59ca
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 24 deletions.
21 changes: 12 additions & 9 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ the data in memory and provides endpoints to push and retrieve beacon data.
1. `cp config/signed-api.example.json config/signed-api.json` - To create a config file from the example one. Optionally
change the defaults.
2. `cp .env.example .env` - To copy the example environment variables. Optionally change the defaults.
3. `pnpm run dev` - To start the API server. The port number can be configured in the configuration file.
3. `pnpm run dev` - To start the API server. The port number can be configured by `DEV_SERVER_PORT` environment
variable.

### Testing

Expand Down Expand Up @@ -134,10 +135,6 @@ The delay in seconds for the endpoint. The endpoint will only serve data that is
The maximum number of signed data entries that can be inserted in one batch. This is a safety measure to prevent
spamming theAPI with large payloads. The batch is rejected if it contains more entries than this value.

#### `port`

The port on which the API is served.

#### `cache.maxAgeSeconds`

The maximum age of the cache header in seconds.
Expand Down Expand Up @@ -177,15 +174,21 @@ The API provides the following endpoints:

## Deployment

TODO: Write example how to deploy on AWS.
To deploy signed API on AWS you can use a CloudFormation template in the `deployment` folder. You need to specify the
docker image of the signed API and the URL of the signed API configuration which will be download when the service is
started.

The template will create all necessary AWS resources and assign a domain name to access the the API. You can get the URL
from the output parameters of the CloudFormation stack or by checking the DNS record of the load balancer.

To deploy on premise you can use the Docker instructions below.

## Docker

The API is also dockerized. To run the dockerized APi, you need to:

1. Publish the port of the API to the host machine using the `--publish` flag.
1. Publish the port of the API to the host machine. The port number of signed API in the container is set to `80`. So
the command should look like `--publish <HOST_PORT>:80`.
2. Mount config folder to `/app/config`. The folder should contain the `signed-api.json` file.
3. Pass the `-it --init` flags to the docker run command. This is needed to ensure the docker is stopped gracefully. See
[this](https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#handling-kernel-signals) for details.
Expand All @@ -195,8 +198,8 @@ The API is also dockerized. To run the dockerized APi, you need to:
For example:

```sh
# Assuming the current folder contains the "config" folder and ".env" file and the API port is 8090.
docker run --publish 8090:8090 -it --init --volume $(pwd)/config:/app/config --env-file .env --rm api3/signed-api:latest
# Assuming the current folder contains the "config" folder and ".env" file and the intended host port is 8090.
docker run --publish 8090:80 -it --init --volume $(pwd)/config:/app/config --env-file .env --rm api3/signed-api:latest
```

As of now, the docker image is not published anywhere. You need to build it locally. To build the image run:
Expand Down
1 change: 0 additions & 1 deletion packages/api/config/signed-api.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
],
"allowedAirnodes": ["0x8A791620dd6260079BF849Dc5567aDC3F2FdC318"],
"maxBatchSize": 10,
"port": 8090,
"cache": {
"maxAgeSeconds": 300
}
Expand Down
272 changes: 272 additions & 0 deletions packages/api/deployment/cloudformation-template.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
{
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "CloudFormation template for deploying Signed API with public access",
"Outputs": {
"LoadBalancerDNS": {
"Description": "The DNS name of the load balancer",
"Value": { "Fn::GetAtt": ["ELB", "DNSName"] },
"Export": {
"Name": { "Fn::Sub": "${AWS::StackName}-LoadBalancerDNS" }
}
}
},
"Resources": {
"SignedApiLogsGroup": {
"Type": "AWS::Logs::LogGroup",
"Properties": {
"LogGroupName": "/ecs/signedApi",
"RetentionInDays": 7
}
},
"SignedApiTaskDefinition": {
"Type": "AWS::ECS::TaskDefinition",
"Properties": {
"Family": "signed-api-task",
"Cpu": "256",
"Memory": "512",
"NetworkMode": "awsvpc",
"RequiresCompatibilities": ["FARGATE"],
"ExecutionRoleArn": { "Ref": "ECSTaskRole" },
"ContainerDefinitions": [
{
"Name": "signed-api-container",
"Image": "<DOCKER_IMAGE>",
"Environment": [
{
"Name": "CONFIG_SOURCE",
"Value": "local"
},
{
"Name": "LOG_LEVEL",
"Value": "debug"
}
],
"EntryPoint": [
"/bin/sh",
"-c",
"wget -O - <SIGNED_API_CONFIGURATION_URL> >> ./config/signed-api.json && node dist/index.js"
],
"PortMappings": [
{
"ContainerPort": 80,
"HostPort": 80
}
],
"LogConfiguration": {
"LogDriver": "awslogs",
"Options": {
"awslogs-group": { "Ref": "SignedApiLogsGroup" },
"awslogs-region": { "Ref": "AWS::Region" },
"awslogs-stream-prefix": "ecs"
}
}
}
]
}
},
"SignedApiService": {
"Type": "AWS::ECS::Service",
"DependsOn": "SignedApiListener",
"Properties": {
"Cluster": { "Ref": "ECSCluster" },
"LaunchType": "FARGATE",
"TaskDefinition": { "Ref": "SignedApiTaskDefinition" },
"DesiredCount": 1,
"NetworkConfiguration": {
"AwsvpcConfiguration": {
"Subnets": [{ "Ref": "PublicSubnet1" }, { "Ref": "PublicSubnet2" }],
"SecurityGroups": [{ "Ref": "ECSSecurityGroup" }],
"AssignPublicIp": "ENABLED"
}
},
"LoadBalancers": [
{
"ContainerName": "signed-api-container",
"ContainerPort": 80,
"TargetGroupArn": { "Ref": "SignedApiTargetGroup" }
}
]
}
},
"ECSCluster": {
"Type": "AWS::ECS::Cluster",
"Properties": {
"ClusterName": "signed-api-cluster"
}
},
"ELB": {
"Type": "AWS::ElasticLoadBalancingV2::LoadBalancer",
"Properties": {
"Name": "signed-api-elb",
"Subnets": [{ "Ref": "PublicSubnet1" }, { "Ref": "PublicSubnet2" }],
"SecurityGroups": [{ "Ref": "ELBSecurityGroup" }],
"Scheme": "internet-facing"
}
},
"SignedApiListener": {
"Type": "AWS::ElasticLoadBalancingV2::Listener",
"Properties": {
"DefaultActions": [
{
"Type": "forward",
"TargetGroupArn": { "Ref": "SignedApiTargetGroup" }
}
],
"LoadBalancerArn": { "Ref": "ELB" },
"Port": 80,
"Protocol": "HTTP"
}
},
"SignedApiTargetGroup": {
"Type": "AWS::ElasticLoadBalancingV2::TargetGroup",
"Properties": {
"Port": 80,
"Protocol": "HTTP",
"VpcId": { "Ref": "VPC" },
"TargetType": "ip"
}
},
"VPC": {
"Type": "AWS::EC2::VPC",
"Properties": {
"CidrBlock": "10.0.0.0/16",
"EnableDnsSupport": "true",
"EnableDnsHostnames": "true"
}
},
"PublicSubnet1": {
"Type": "AWS::EC2::Subnet",
"Properties": {
"VpcId": { "Ref": "VPC" },
"CidrBlock": "10.0.1.0/24",
"AvailabilityZone": { "Fn::Select": [0, { "Fn::GetAZs": "" }] },
"MapPublicIpOnLaunch": "true"
}
},
"PublicSubnet2": {
"Type": "AWS::EC2::Subnet",
"Properties": {
"VpcId": { "Ref": "VPC" },
"CidrBlock": "10.0.2.0/24",
"AvailabilityZone": { "Fn::Select": [1, { "Fn::GetAZs": "" }] },
"MapPublicIpOnLaunch": "true"
}
},
"InternetGateway": {
"Type": "AWS::EC2::InternetGateway"
},
"GatewayAttachment": {
"Type": "AWS::EC2::VPCGatewayAttachment",
"Properties": {
"VpcId": { "Ref": "VPC" },
"InternetGatewayId": { "Ref": "InternetGateway" }
}
},
"RouteTable": {
"Type": "AWS::EC2::RouteTable",
"Properties": {
"VpcId": { "Ref": "VPC" }
}
},
"Route": {
"Type": "AWS::EC2::Route",
"DependsOn": "GatewayAttachment",
"Properties": {
"RouteTableId": { "Ref": "RouteTable" },
"DestinationCidrBlock": "0.0.0.0/0",
"GatewayId": { "Ref": "InternetGateway" }
}
},
"Subnet1RouteTableAssociation": {
"Type": "AWS::EC2::SubnetRouteTableAssociation",
"Properties": {
"SubnetId": { "Ref": "PublicSubnet1" },
"RouteTableId": { "Ref": "RouteTable" }
}
},
"Subnet2RouteTableAssociation": {
"Type": "AWS::EC2::SubnetRouteTableAssociation",
"Properties": {
"SubnetId": { "Ref": "PublicSubnet2" },
"RouteTableId": { "Ref": "RouteTable" }
}
},
"ECSSecurityGroup": {
"Type": "AWS::EC2::SecurityGroup",
"Properties": {
"GroupDescription": "Security Group for ECS Tasks",
"VpcId": { "Ref": "VPC" },
"SecurityGroupIngress": [
{
"IpProtocol": "tcp",
"FromPort": 80,
"ToPort": 80,
"SourceSecurityGroupId": { "Ref": "ELBSecurityGroup" }
}
]
}
},
"ELBSecurityGroup": {
"Type": "AWS::EC2::SecurityGroup",
"Properties": {
"GroupDescription": "Security Group for ELB",
"VpcId": { "Ref": "VPC" },
"SecurityGroupIngress": [
{
"IpProtocol": "tcp",
"FromPort": 80,
"ToPort": 80,
"CidrIp": "0.0.0.0/0"
}
]
}
},
"ECSTaskRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": ["ecs-tasks.amazonaws.com"]
},
"Action": ["sts:AssumeRole"]
}
]
},
"Path": "/",
"Policies": [
{
"PolicyName": "ecs-service",
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:AuthorizeSecurityGroupIngress",
"ec2:Describe*",
"elasticloadbalancing:DeregisterInstancesFromLoadBalancer",
"elasticloadbalancing:DeregisterTargets",
"elasticloadbalancing:Describe*",
"elasticloadbalancing:RegisterInstancesWithLoadBalancer",
"elasticloadbalancing:RegisterTargets",
"ec2:CreateSecurityGroup",
"ec2:DeleteSecurityGroup",
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:DescribeLogStreams",
"logs:PutLogEvents"
],
"Resource": "*"
}
]
}
}
]
}
}
}
}
6 changes: 3 additions & 3 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
"scripts": {
"build": "tsc --project tsconfig.build.json",
"clean": "rm -rf coverage dist",
"dev": "nodemon --ext ts,js,json,env --exec \"pnpm ts-node src/index.ts\"",
"dev": "nodemon --ext ts,js,json,env --exec \"pnpm ts-node src/dev-server.ts\"",
"docker:build": "docker build --target api --tag api3/signed-api:latest ../../",
"docker:start": "docker run --publish 8090:8090 -it --init --volume $(pwd)/config:/app/config --env-file .env --rm api3/signed-api:latest",
"docker:run": "docker run --publish 8090:80 -it --init --volume $(pwd)/config:/app/config --env-file .env --rm api3/signed-api:latest",
"eslint:check": "eslint . --ext .js,.ts --max-warnings 0",
"eslint:fix": "eslint . --ext .js,.ts --fix",
"prettier:check": "prettier --check \"./**/*.{js,ts,md,json}\"",
"prettier:fix": "prettier --write \"./**/*.{js,ts,md,json}\"",
"start-prod": "node dist/index.js",
"start-prod": "node dist/dev-server.js",
"test": "jest",
"tsc": "tsc --project ."
},
Expand Down
30 changes: 30 additions & 0 deletions packages/api/src/dev-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import z from 'zod';

import { fetchAndCacheConfig } from './config';
import { logger } from './logger';
import { DEFAULT_PORT, startServer } from './server';

const portSchema = z.number().int().positive();

const startDevServer = async () => {
const config = await fetchAndCacheConfig();
logger.info('Using configuration', config);

const parsedPort = portSchema.safeParse(process.env.DEV_SERVER_PORT);
let port: number;
if (parsedPort.success) {
port = parsedPort.data;
logger.debug('Using DEV_SERVER_PORT environment variable as port number.', {
port,
});
} else {
port = DEFAULT_PORT;
logger.debug('DEV_SERVER_PORT environment variable not set or invalid. Using default port.', {
port,
});
}

startServer(config, port);
};

void startDevServer();
Loading

0 comments on commit a3e59ca

Please sign in to comment.