Skip to content

Latest commit

 

History

History
827 lines (693 loc) · 23.3 KB

README.md

File metadata and controls

827 lines (693 loc) · 23.3 KB

@studion/infra-code-blocks

Studion Platform common infra components.

Table of Contents

  1. Prerequisites
  2. Installation
  3. Usage
  4. API

Prerequisites

  • Working Pulumi project
  • AWS account with necessary permissions for each Studion component

Installation

  • Run the command:
$ npm i @studion/infra-code-blocks

Usage

  • Import Studion infra components in your project
import * as studion from '@studion/infra-code-blocks';
  • Use Studion components
import * as studion from '@studion/infra-code-blocks';

const project = new studion.Project('demo-project', {
  services: [
    {
      type: 'REDIS',
      serviceName: 'redis',
      dbName: 'test-db',
    },
  ],
});

export const projectName = project.name;
  • Deploy Pulumi stack
$ pulumi up

API

  1. Project
  2. Database
  3. Database Replica
  4. Redis
  5. StaticSite
  6. WebServer
  7. Nuxt SSR
  8. Mongo
  9. EcsService

Project

Project component makes it easy to spin up project infrastructure, hiding infrastructure complexity.
The component creates its own VPC used for resources within the project.

Services are created only if specified in the services list.
If services is an empty list, VPC is the only service created by default.

new Project(name: string, args: ProjectArgs, opts?: pulumi.CustomResourceOptions);
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
type ProjectArgs = {
  services: (
    | DatabaseServiceOptions
    | RedisServiceOptions
    | StaticSiteServiceOptions
    | WebServerServiceOptions
    | NuxtSSRServiceOptions
    | MongoServiceOptions
    | EcsServiceOptions
  )[];
  enableSSMConnect?: pulumi.Input<boolean>;
  numberOfAvailabilityZones?: number;
};
Argument Description
services * Service list.
enableSSMConnect Set up ec2 instance and SSM in order to connect to the database in the private subnet. Please refer to the SSM Connect section for more info.
numberOfAvailabilityZones Default is 2 which is recommended. If building a dev server, we can reduce to 1 availability zone to reduce hosting cost.
type DatabaseServiceOptions = {
  type: 'DATABASE';
  serviceName: string;
  dbName: pulumi.Input<string>;
  username: pulumi.Input<string>;
  password?: pulumi.Input<string>;
  multiAz?: pulumi.Input<boolean>;
  applyImmediately?: pulumi.Input<boolean>;
  skipFinalSnapshot?: pulumi.Input<boolean>;
  allocatedStorage?: pulumi.Input<number>;
  maxAllocatedStorage?: pulumi.Input<number>;
  instanceClass?: pulumi.Input<string>;
  enableMonitoring?: pulumi.Input<boolean>;
  parameterGroupName?: pulumi.Input<string>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};
export type RedisServiceOptions = {
  type: 'REDIS';
  serviceName: string;
  dbName: pulumi.Input<string>;
  region?: pulumi.Input<string>;
};
export type StaticSiteServiceOptions = {
  type: 'STATIC_SITE';
  serviceName: string;
  domain?: pulumi.Input<string>;
  hostedZoneId?: pulumi.Input<string>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};
export type WebServerServiceOptions = {
  type: 'WEB_SERVER';
  serviceName: string;
  image: pulumi.Input<string>;
  port: pulumi.Input<number>;
  domain?: pulumi.Input<string>;
  hostedZoneId?: pulumi.Input<string>;
  environment?:
    | aws.ecs.KeyValuePair[]
    | ((services: Services) => aws.ecs.KeyValuePair[]);
  secrets?: aws.ecs.Secret[] | ((services: Services) => aws.ecs.Secret[]);
  desiredCount?: pulumi.Input<number>;
  autoscaling?: pulumi.Input<{
    enabled: pulumi.Input<boolean>;
    minCount?: pulumi.Input<number>;
    maxCount?: pulumi.Input<number>;
  }>;
  size?: pulumi.Input<Size>;
  healthCheckPath?: pulumi.Input<string>;
  taskExecutionRoleInlinePolicies?: pulumi.Input<
    pulumi.Input<RoleInlinePolicy>[]
  >;
  taskRoleInlinePolicies?: pulumi.Input<pulumi.Input<RoleInlinePolicy>[]>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};
export type NuxtSSRServiceOptions = {
  type: 'NUXT_SSR';
  serviceName: string;
  image: pulumi.Input<string>;
  port: pulumi.Input<number>;
  domain?: pulumi.Input<string>;
  hostedZoneId?: pulumi.Input<string>;
  environment?:
    | aws.ecs.KeyValuePair[]
    | ((services: Services) => aws.ecs.KeyValuePair[]);
  secrets?: aws.ecs.Secret[] | ((services: Services) => aws.ecs.Secret[]);
  desiredCount?: pulumi.Input<number>;
  autoscaling?: pulumi.Input<{
    enabled: pulumi.Input<boolean>;
    minCount?: pulumi.Input<number>;
    maxCount?: pulumi.Input<number>;
  }>;
  size?: pulumi.Input<Size>;
  healthCheckPath?: pulumi.Input<string>;
  taskExecutionRoleInlinePolicies?: pulumi.Input<
    pulumi.Input<RoleInlinePolicy>[]
  >;
  taskRoleInlinePolicies?: pulumi.Input<pulumi.Input<RoleInlinePolicy>[]>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};
type MongoServiceOptions = {
  type: 'MONGO';
  serviceName: string;
  username: pulumi.Input<string>;
  password?: pulumi.Input<string>;
  port?: pulumi.Input<number>;
  size?: pulumi.Input<Size>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};
type EcsServiceOptions = {
  type: 'ECS_SERVICE';
  serviceName: string;
  image: pulumi.Input<string>;
  port: pulumi.Input<number>;
  enableServiceAutoDiscovery: pulumi.Input<boolean>;
  lbTargetGroupArn?: aws.lb.TargetGroup['arn'];
  persistentStorageVolumePath?: pulumi.Input<string>;
  securityGroup?: aws.ec2.SecurityGroup;
  assignPublicIp?: pulumi.Input<boolean>;
  dockerCommand?: pulumi.Input<string[]>;
  environment?:
    | aws.ecs.KeyValuePair[]
    | ((services: Services) => aws.ecs.KeyValuePair[]);
  secrets?: aws.ecs.Secret[] | ((services: Services) => aws.ecs.Secret[]);
  desiredCount?: pulumi.Input<number>;
  autoscaling?: pulumi.Input<{
    enabled: pulumi.Input<boolean>;
    minCount?: pulumi.Input<number>;
    maxCount?: pulumi.Input<number>;
  }>;
  size?: pulumi.Input<Size>;
  healthCheckPath?: pulumi.Input<string>;
  taskExecutionRoleInlinePolicies?: pulumi.Input<
    pulumi.Input<RoleInlinePolicy>[]
  >;
  taskRoleInlinePolicies?: pulumi.Input<pulumi.Input<RoleInlinePolicy>[]>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};

Often, web server depends on other services such as database, Redis, etc. For that purpose, environment factory can be used. The factory function receives services bag as argument.

const project = new studion.Project('demo-project', {
  services: [
    {
      type: 'REDIS',
      serviceName: 'redis',
      dbName: 'test-db',
    },
    {
      type: 'WEB_SERVER',
      serviceName: 'api',
      image: imageUri,
      port: 3000,
      domain: 'api.my-domain.com',
      hostedZoneId: 'my-domain.com-hostedZoneId',
      environment: (services: Services) => {
        const redisServiceName = 'redis';
        const redis = services[redisServiceName];
        return [
          { name: 'REDIS_HOST', value: redis.endpoint },
          { name: 'REDIS_PORT', value: redis.port.apply(port => String(port)) },
        ];
      },
    },
  ],
});

In order to pass sensitive information to the container, use secrets instead of environment. AWS will fetch values from Secret Manager based on arn that is provided for the valueFrom field.

const project = new studion.Project('demo-project', {
  services: [
    {
      type: 'WEB_SERVER',
      serviceName: 'api',
      image: imageUri,
      port: 3000,
      domain: 'api.my-domain.com',
      hostedZoneId: 'my-domain.com-hostedZoneId',
      secrets: [
        { name: 'DB_PASSWORD', valueFrom: 'arn-of-the-secret-manager-secret' },
      ],
    },
  ],
});
const project = new studion.Project('demo-project', {
  services: [
    {
      type: 'REDIS',
      serviceName: 'redis',
      dbName: 'test-db',
    },
    {
      type: 'WEB_SERVER',
      serviceName: 'api',
      image: imageUri,
      port: 3000,
      domain: 'api.my-domain.com',
      hostedZoneId: 'my-domain.com-hostedZoneId',
      secrets: (services: Services) => {
        const redisServiceName = 'redis';
        const redis = services[redisServiceName];
        return [
          { name: 'REDIS_PASSWORD', valueFrom: redis.passwordSecret.arn },
        ];
      },
    },
  ],
});

Database

AWS RDS Postgres instance.

Features:

  • enabled encryption with a symmetric encryption key
  • deployed inside an isolated subnet
  • backup enabled with retention period set to 14 days

new Database(name: string, args: DatabaseArgs, opts?: pulumi.CustomResourceOptions);
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
type DatabaseArgs = {
  dbName: pulumi.Input<string>;
  username: pulumi.Input<string>;
  vpcId: pulumi.Input<string>;
  isolatedSubnetIds: pulumi.Input<pulumi.Input<string>[]>;
  vpcCidrBlock: pulumi.Input<string>;
  password?: pulumi.Input<string>;
  multiAz?: pulumi.Input<boolean>;
  applyImmediately?: pulumi.Input<boolean>;
  skipFinalSnapshot?: pulumi.Input<boolean>;
  allocatedStorage?: pulumi.Input<number>;
  maxAllocatedStorage?: pulumi.Input<number>;
  instanceClass?: pulumi.Input<string>;
  enableMonitoring?: pulumi.Input<boolean>;
  parameterGroupName?: pulumi.Input<string>;
  engineVersion?: pulumi.Input<string>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};

If the password is not specified, it will be autogenerated. The database password is stored as a secret inside AWS Secret Manager. The secret will be available on the Database resource as password.secret.

Database Replica

AWS RDS Postgres instance.

Features:

  • enabled encryption with a symmetric encryption key
  • deployed inside an isolated subnet

new DatabaseReplica(name: string, args: DatabaseReplicaArgs, opts?: pulumi.CustomResourceOptions);
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
type DatabaseReplicaArgs = {
  replicateSourceDb: pulumi.Input<string>;
  dbSecurityGroupId: pulumi.Input<string>;
  dbSubnetGroupName?: pulumi.Input<string>;
  monitoringRole?: aws.iam.Role;
  multiAz?: pulumi.Input<boolean>;
  applyImmediately?: pulumi.Input<boolean>;
  allocatedStorage?: pulumi.Input<number>;
  maxAllocatedStorage?: pulumi.Input<number>;
  instanceClass?: pulumi.Input<string>;
  parameterGroupName?: pulumi.Input<string>;
  engineVersion?: pulumi.Input<string>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};

Database replica requires primary DB instance to exist. If the replica is in the same region as primary instance, we should not set dbSubnetGroupNameParam. The replicateSourceDb param is referenced like this:

  const primaryDb = new studion.Database(...);
  const replica = new studion.DatabaseReplica('replica', {
    replicateSourceDb: primaryDb.instance.identifier
  });

Redis

Upstash Redis instance.

Prerequisites

  1. Stack Config
Name Description Secret
upstash:email * Upstash user email. true
upstash:apiKey * Upstash API key. true
$ pulumi config set --secret upstash:email [email protected]
$ pulumi config set --secret upstash:apiKey my-api-key

new Redis(name: string, args: RedisArgs, opts: RedisOptions);
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
type RedisArgs = {
  dbName: pulumi.Input<string>;
  region?: pulumi.Input<string>;
};

interface RedisOptions extends pulumi.ComponentResourceOptions {
  provider: upstash.Provider;
}

After creating the Redis resource, the passwordSecret AWS Secret Manager Secret will exist on the resource.

Static Site

AWS S3 + Cloudfront.

Features:

  • creates TLS certificate for the specified domain
  • redirects HTTP traffic to HTTPS
  • enables http2 and http3 protocols
  • uses North America and Europe edge locations

new StaticSite(name: string, args: StaticSiteArgs, opts?: pulumi.ComponentResourceOptions );
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
type StaticSiteArgs = {
  domain?: pulumi.Input<string>;
  hostedZoneId?: pulumi.Input<string>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};

Web Server

AWS ECS Fargate.

Features:

  • memory and CPU autoscaling enabled
  • creates TLS certificate for the specified domain
  • redirects HTTP traffic to HTTPS
  • creates CloudWatch log group
  • comes with predefined CPU and memory options: small, medium, large, xlarge

new WebServer(name: string, args: WebServerArgs, opts?: pulumi.ComponentResourceOptions );
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
export type WebServerArgs = {
  image: pulumi.Input<string>;
  port: pulumi.Input<number>;
  clusterId: pulumi.Input<string>;
  clusterName: pulumi.Input<string>;
  vpcId: pulumi.Input<string>;
  vpcCidrBlock: pulumi.Input<string>;
  publicSubnetIds: pulumi.Input<pulumi.Input<string>[]>;
  domain?: pulumi.Input<string>;
  hostedZoneId?: pulumi.Input<string>;
  desiredCount?: pulumi.Input<number>;
  autoscaling?: pulumi.Input<{
    enabled: pulumi.Input<boolean>;
    minCount?: pulumi.Input<number>;
    maxCount?: pulumi.Input<number>;
  }>;
  size?: pulumi.Input<Size>;
  environment?: aws.ecs.KeyValuePair[];
  secrets?: aws.ecs.Secret[];
  healthCheckPath?: pulumi.Input<string>;
  taskExecutionRoleInlinePolicies?: pulumi.Input<
    pulumi.Input<RoleInlinePolicy>[]
  >;
  taskRoleInlinePolicies?: pulumi.Input<pulumi.Input<RoleInlinePolicy>[]>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};

Nuxt SSR preset

AWS ECS Fargate + Cloudfront.

Features:

  • memory and CPU autoscaling enabled
  • creates TLS certificate for the specified domain
  • redirects HTTP traffic to HTTPS
  • creates CloudWatch log group
  • comes with predefined CPU and memory options: small, medium, large, xlarge
  • CDN in front of the application load balancer for static resource caching

new NuxtSSR(name: string, args: NuxtSSRArgs, opts?: pulumi.ComponentResourceOptions );
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
export type NuxtSSRArgs = {
  image: pulumi.Input<string>;
  port: pulumi.Input<number>;
  clusterId: pulumi.Input<string>;
  clusterName: pulumi.Input<string>;
  vpcId: pulumi.Input<string>;
  vpcCidrBlock: pulumi.Input<string>;
  publicSubnetIds: pulumi.Input<pulumi.Input<string>[]>;
  domain?: pulumi.Input<string>;
  hostedZoneId?: pulumi.Input<string>;
  desiredCount?: pulumi.Input<number>;
  autoscaling?: pulumi.Input<{
    enabled: pulumi.Input<boolean>;
    minCount?: pulumi.Input<number>;
    maxCount?: pulumi.Input<number>;
  }>;
  size?: pulumi.Input<Size>;
  environment?: aws.ecs.KeyValuePair[];
  secrets?: aws.ecs.Secret[];
  healthCheckPath?: pulumi.Input<string>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};

Mongo

AWS ECS Fargate.

Features:

  • persistent storage
  • service auto-discovery
  • creates CloudWatch log group
  • comes with predefined CPU and memory options: small, medium, large, xlarge

new Mongo(name: string, args: MongoArgs, opts?: pulumi.ComponentResourceOptions );
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
export type MongoArgs = {
  clusterId: pulumi.Input<string>;
  clusterName: pulumi.Input<string>;
  vpcId: pulumi.Input<string>;
  vpcCidrBlock: pulumi.Input<string>;
  privateSubnetIds: pulumi.Input<pulumi.Input<string>[]>;
  username: pulumi.Input<string>;
  password?: pulumi.Input<string>;
  port?: pulumi.Input<number>;
  size?: pulumi.Input<Size>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};

If the password is not specified it will be autogenerated. The Mongo password is stored as a secret inside AWS Secret Manager. The secret will be available on the Mongo resource as password.secret.

Ecs Service

AWS ECS Fargate.

Features:

  • memory and CPU autoscaling
  • service auto-discovery
  • persistent storage
  • CloudWatch logs
  • comes with predefined cpu and memory options: small, medium, large, xlarge

new EcsService(name: string, args: EcsServiceArgs, opts?: pulumi.ComponentResourceOptions );
Argument Description
name * The unique name of the resource.
args * The arguments to resource properties.
opts Bag of options to control resource's behavior.
export type EcsServiceArgs = {
  image: pulumi.Input<string>;
  port: pulumi.Input<number>;
  clusterId: pulumi.Input<string>;
  clusterName: pulumi.Input<string>;
  vpcId: pulumi.Input<string>;
  vpcCidrBlock: pulumi.Input<string>;
  subnetIds: pulumi.Input<pulumi.Input<string>[]>;
  desiredCount?: pulumi.Input<number>;
  autoscaling?: pulumi.Input<{
    enabled: pulumi.Input<boolean>;
    minCount?: pulumi.Input<number>;
    maxCount?: pulumi.Input<number>;
  }>;
  size?: pulumi.Input<Size>;
  environment?: aws.ecs.KeyValuePair[];
  secrets?: aws.ecs.Secret[];
  enableServiceAutoDiscovery: pulumi.Input<boolean>;
  persistentStorageVolumePath?: pulumi.Input<string>;
  dockerCommand?: pulumi.Input<string[]>;
  lbTargetGroupArn?: aws.lb.TargetGroup['arn'];
  securityGroup?: aws.ec2.SecurityGroup;
  assignPublicIp?: pulumi.Input<boolean>;
  taskExecutionRoleInlinePolicies?: pulumi.Input<
    pulumi.Input<RoleInlinePolicy>[]
  >;
  taskRoleInlinePolicies?: pulumi.Input<pulumi.Input<RoleInlinePolicy>[]>;
  tags?: pulumi.Input<{
    [key: string]: pulumi.Input<string>;
  }>;
};

Exec into running ECS task

Prerequisites

  1. Install the Session Manager plugin
$ brew install --cask session-manager-plugin
  1. Install jq
$ brew install jq

In order to exec into running ECS container run the following command:

aws ecs execute-command  \
  --cluster CLUSTER_NAME \
  --task $(aws ecs list-tasks --cluster CLUSTER_NAME --family TASK_FAMILY_NAME | jq -r '.taskArns[0] | split("/")[2]') \
  --command "/bin/sh" \
  --interactive

Where CLUSTER_NAME is the name of the ECS cluster and TASK_FAMILY_NAME is the name of the task family that task belongs to.

SSM Connect

The Database component deploys a database instance inside an isolated subnet, and it's not publicly accessible from outside of VPC.
In order to connect to the database we need to deploy the ec2 instance which will be used to forward traffic to the database instance.
Because of security reasons, the ec2 instance is deployed inside a private subnet which means we can't directly connect to it. For that purpose, we use AWS System Manager which enables us to connect to the ec2 instance even though it's inside a private subnet. Another benefit of using AWS SSM is that we don't need a ssh key pair.

AWS RDS connection schema

Prerequisites

  1. Install the Session Manager plugin
$ brew install --cask session-manager-plugin

SSM Connect can be enabled by setting enableSSMConnect property to true.

const project = new studion.Project('demo-project', {
  enableSSMConnect: true,
  ...
});

export const ec2InstanceId = project.ec2SSMConnect?.ec2.id;

Open up your terminal and run the following command:

$ aws ssm start-session --target EC2_INSTANCE_ID --document-name AWS-StartPortForwardingSessionToRemoteHost --parameters '{"host": ["DATABASE_ADDRESS"], "portNumber":["DATABASE_PORT"], "localPortNumber":["5555"]}'

Where EC2_INSTANCE_ID is an ID of the EC2 instance that is created for you (ID can be obtained by exporting it from the stack), and DATABASE_ADDRESS and DATABASE_PORT are the address and port of the database instance.

And that is it! 🥳 Now you can use your favorite database client to connect to the database.

RDS connection

It is important that for the host you set localhost and for the port you set 5555 because we are port-forwarding traffic from localhost:5555 to DATABASE_ADDRESS:DATABASE_PORT. For the user, password, and database field, set values which are set in the Project.

const project = new studion.Project('demo-project', {
  enableSSMConnect: true,
  services: [
    {
      type: 'DATABASE',
      dbName: 'database_name',
      username: 'username',
      password: 'password',
      ...
    }
  ]
});

🚧 TODO

  • Add worker service for executing tasks
  • Enable RDS password rotation