diff --git a/README.md b/README.md index 99e817d..f25be40 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ $ pulumi up 3. [Redis](#redis) 4. [StaticSite](#static-site) 5. [WebServer](#web-server) +6. [Mongo](#mongo) +7. [EcsService](#ecs-service) ### Project @@ -86,6 +88,8 @@ type ProjectArgs = { | RedisService | StaticSiteService | WebServerService + | MongoService + | EcsService )[]; hostedZoneId?: pulumi.Input; enableSSMConnect?: pulumi.Input; @@ -140,18 +144,69 @@ export type StaticSiteService = { export type WebServerService = { type: 'WEB_SERVER'; serviceName: string; + image: pulumi.Input; + port: pulumi.Input; + domain: pulumi.Input; environment?: | aws.ecs.KeyValuePair[] | ((services: Services) => aws.ecs.KeyValuePair[]); secrets?: aws.ecs.Secret[] | ((services: Services) => aws.ecs.Secret[]); + desiredCount?: pulumi.Input; + autoscaling?: pulumi.Input<{ + enabled: pulumi.Input; + minCount?: pulumi.Input; + maxCount?: pulumi.Input; + }>; + size?: pulumi.Input; + healthCheckPath?: pulumi.Input; + taskExecutionRoleInlinePolicies?: pulumi.Input< + pulumi.Input[] + >; + taskRoleInlinePolicies?: pulumi.Input[]>; + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; +}; +``` + +```ts +type MongoService = { + type: 'MONGO'; + serviceName: string; + username: pulumi.Input; + password: pulumi.Input; + port?: pulumi.Input; + size?: pulumi.Input; + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; +}; +``` + +```ts +type EcsService = { + type: 'ECS'; + serviceName: string; image: pulumi.Input; port: pulumi.Input; - domain: pulumi.Input; + enableServiceAutoDiscovery: pulumi.Input; + lbTargetGroupArn?: aws.lb.TargetGroup['arn']; + persistentStorageVolumePath?: pulumi.Input; + securityGroup?: aws.ec2.SecurityGroup; + assignPublicIp?: pulumi.Input; + dockerCommand?: pulumi.Input; + environment?: + | aws.ecs.KeyValuePair[] + | ((services: Services) => aws.ecs.KeyValuePair[]); + secrets?: aws.ecs.Secret[] | ((services: Services) => aws.ecs.Secret[]); desiredCount?: pulumi.Input; - minCount?: pulumi.Input; - maxCount?: pulumi.Input; + autoscaling?: pulumi.Input<{ + enabled: pulumi.Input; + minCount?: pulumi.Input; + maxCount?: pulumi.Input; + }>; size?: pulumi.Input; - healtCheckPath?: pulumi.Input; + healthCheckPath?: pulumi.Input; taskExecutionRoleInlinePolicies?: pulumi.Input< pulumi.Input[] >; @@ -367,7 +422,7 @@ AWS ECS Fargate web server. Features: -- Memory and CPU autoscaling enabled +- memory and CPU autoscaling enabled - creates TLS certificate for the specified domain - redirects HTTP traffic to HTTPS - creates CloudWatch log group @@ -394,12 +449,107 @@ export type WebServerArgs = { hostedZoneId: pulumi.Input; vpc: awsx.ec2.Vpc; desiredCount?: pulumi.Input; - minCount?: pulumi.Input; - maxCount?: pulumi.Input; + autoscaling?: pulumi.Input<{ + enabled: pulumi.Input; + minCount?: pulumi.Input; + maxCount?: pulumi.Input; + }>; + size?: pulumi.Input; + environment?: aws.ecs.KeyValuePair[]; + secrets?: aws.ecs.Secret[]; + healthCheckPath?: pulumi.Input; + taskExecutionRoleInlinePolicies?: pulumi.Input< + pulumi.Input[] + >; + taskRoleInlinePolicies?: pulumi.Input[]>; + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; +}; +``` + +### Mongo + +AWS ECS Fargate mongo service. + +Features: + +- persistent storage +- service auto-discovery +- creates CloudWatch log group +- comes with predefined cpu and memory options: `small`, `medium`, `large`, `xlarge` + +
+ +```ts +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. | + +```ts +export type MongoArgs = { + cluster: aws.ecs.Cluster; + vpc: awsx.ec2.Vpc; + username: pulumi.Input; + password: pulumi.Input; + port?: pulumi.Input; + size?: pulumi.Input; + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; +}; +``` + +### Ecs Service + +AWS ECS Fargate service. + +Features: + +- memory and CPU autoscaling +- service auto discovery +- persistent storage +- CloudWatch logs +- comes with predefined cpu and memory options: `small`, `medium`, `large`, `xlarge` + +
+ +```ts +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. | + +```ts +export type EcsServiceArgs = { + image: pulumi.Input; + port: pulumi.Input; + cluster: aws.ecs.Cluster; + vpc: awsx.ec2.Vpc; + desiredCount?: pulumi.Input; + autoscaling?: pulumi.Input<{ + enabled: pulumi.Input; + minCount?: pulumi.Input; + maxCount?: pulumi.Input; + }>; size?: pulumi.Input; environment?: aws.ecs.KeyValuePair[]; secrets?: aws.ecs.Secret[]; - healtCheckPath?: pulumi.Input; + enableServiceAutoDiscovery: pulumi.Input; + persistentStorageVolumePath?: pulumi.Input; + dockerCommand?: pulumi.Input; + lbTargetGroupArn?: aws.lb.TargetGroup['arn']; + securityGroup?: aws.ec2.SecurityGroup; + assignPublicIp?: pulumi.Input; taskExecutionRoleInlinePolicies?: pulumi.Input< pulumi.Input[] >; @@ -511,5 +661,4 @@ const project = new studion.Project('demo-project', { ## 🚧 TODO - [ ] Add worker service for executing tasks -- [ ] Add MongoDB service - [ ] Enable RDS password rotation diff --git a/src/components/ecs-service.ts b/src/components/ecs-service.ts new file mode 100644 index 0000000..9154d0c --- /dev/null +++ b/src/components/ecs-service.ts @@ -0,0 +1,582 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as aws from '@pulumi/aws'; +import * as awsx from '@pulumi/awsx'; +import { CustomSize, Size } from '../types/size'; +import { PredefinedSize, commonTags } from '../constants'; +import { ContainerDefinition } from '@pulumi/aws/ecs'; + +const config = new pulumi.Config('aws'); +export const awsRegion = config.require('region'); + +export const assumeRolePolicy: aws.iam.PolicyDocument = { + Version: '2012-10-17', + Statement: [ + { + Action: 'sts:AssumeRole', + Principal: { + Service: 'ecs-tasks.amazonaws.com', + }, + Effect: 'Allow', + Sid: '', + }, + ], +}; + +export type RoleInlinePolicy = { + /** + * Name of the role policy. + */ + name?: pulumi.Input; + /** + * Policy document as a JSON formatted string. + */ + policy?: pulumi.Input; +}; + +export type EcsServiceArgs = { + /** + * The ECR image used to start a container. + */ + image: pulumi.Input; + /** + * Exposed service port. + */ + port: pulumi.Input; + /** + * The aws.ecs.Cluster resource. + */ + cluster: aws.ecs.Cluster; + /** + * The awsx.ec2.Vpc resource. + */ + vpc: awsx.ec2.Vpc; + /** + * Number of instances of the task definition to place and keep running. Defaults to 1. + */ + desiredCount?: pulumi.Input; + /** + * CPU and memory size used for running the container. Defaults to "small". + * Available predefined options are: + * - small (0.25 vCPU, 0.5 GB memory) + * - medium (0.5 vCPU, 1 GB memory) + * - large (1 vCPU memory, 2 GB memory) + * - xlarge (2 vCPU, 4 GB memory) + */ + size?: pulumi.Input; + + /** + * The environment variables to pass to a container. Don't use this field for + * sensitive information such as passwords, API keys, etc. For that purpose, + * please use the `secrets` property. + * Defaults to []. + */ + environment?: aws.ecs.KeyValuePair[]; + /** + * The secrets to pass to the container. Defaults to []. + */ + secrets?: aws.ecs.Secret[]; + /** + * Enable service auto discovery and assign DNS record to service. + * Defaults to false. + */ + enableServiceAutoDiscovery: pulumi.Input; + /** + * Location of persistent storage volume. + */ + persistentStorageVolumePath?: pulumi.Input; + /** + * Alternate docker CMD instruction. + */ + dockerCommand?: pulumi.Input; + /** + * Autoscaling options for ecs service. + */ + autoscaling?: pulumi.Input<{ + /** + * Is autoscaling enabled or disabled. Defaults to false. + */ + enabled: pulumi.Input; + /** + * Min capacity of the scalable target. Defaults to 1. + */ + minCount?: pulumi.Input; + /** + * Max capacity of the scalable target. Defaults to 1. + */ + maxCount?: pulumi.Input; + }>; + lbTargetGroupArn?: aws.lb.TargetGroup['arn']; + /** + * Custom service security group + * In case no security group is provided, default security group will be used. + */ + securityGroup?: aws.ec2.SecurityGroup; + /** + * Assign public IP address to service. + */ + assignPublicIp?: pulumi.Input; + taskExecutionRoleInlinePolicies?: pulumi.Input< + pulumi.Input[] + >; + taskRoleInlinePolicies?: pulumi.Input[]>; + /** + * A map of tags to assign to the resource. + */ + tags?: pulumi.Input<{ + [key: string]: pulumi.Input; + }>; +}; + +const defaults = { + desiredCount: 1, + size: 'small', + environment: [], + secrets: [], + enableServiceAutoDiscovery: false, + assignPublicIp: false, + taskExecutionRoleInlinePolicies: [], + taskRoleInlinePolicies: [], + autoscaling: { + enabled: false, + minCount: 1, + maxCount: 1, + }, +}; + +export class EcsService extends pulumi.ComponentResource { + name: string; + logGroup: aws.cloudwatch.LogGroup; + taskDefinition: aws.ecs.TaskDefinition; + serviceDiscoveryService?: aws.servicediscovery.Service; + service: aws.ecs.Service; + + constructor( + name: string, + args: EcsServiceArgs, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:ecs:Service', name, {}, opts); + const argsWithDefaults = Object.assign({}, defaults, args); + + this.name = name; + this.logGroup = this.createLogGroup(); + this.taskDefinition = this.createTaskDefinition(args); + if (argsWithDefaults.enableServiceAutoDiscovery) { + this.serviceDiscoveryService = this.createServiceDiscovery( + argsWithDefaults.vpc, + ); + } + this.service = this.createEcsService(args, opts); + if (argsWithDefaults.autoscaling.enabled) { + this.enableAutoscaling(args); + } + + this.registerOutputs(); + } + + private createLogGroup() { + const logGroup = new aws.cloudwatch.LogGroup( + `${this.name}-log-group`, + { + retentionInDays: 14, + namePrefix: `/ecs/${this.name}-`, + tags: commonTags, + }, + { parent: this }, + ); + return logGroup; + } + + private createPersistentStorage(vpc: awsx.ec2.Vpc, assignPublicIp: boolean) { + const efs = new aws.efs.FileSystem( + `${this.name}-efs`, + { + encrypted: true, + lifecyclePolicies: [ + { + transitionToPrimaryStorageClass: 'AFTER_1_ACCESS', + }, + { + transitionToIa: 'AFTER_7_DAYS', + }, + ], + performanceMode: 'generalPurpose', + throughputMode: 'bursting', + tags: { + ...commonTags, + Name: `${this.name}-data`, + }, + }, + { parent: this }, + ); + + const securityGroup = new aws.ec2.SecurityGroup( + `${this.name}-persistent-storage-security-group`, + { + vpcId: vpc.vpcId, + ingress: [ + { + fromPort: 2049, + toPort: 2049, + protocol: 'tcp', + cidrBlocks: [vpc.vpc.cidrBlock], + }, + ], + tags: commonTags, + }, + { parent: this }, + ); + + const subnetIds = assignPublicIp + ? vpc.publicSubnetIds + : vpc.privateSubnetIds; + + subnetIds.apply(privateSubnets => { + privateSubnets.forEach(it => { + const mountTarget = new aws.efs.MountTarget( + `${this.name}-mount-target-${it}`, + { + fileSystemId: efs.id, + subnetId: it, + securityGroups: [securityGroup.id], + }, + { parent: this }, + ); + }); + }); + + return efs; + } + + private createTaskDefinition(args: EcsServiceArgs) { + const argsWithDefaults = Object.assign({}, defaults, args); + const stack = pulumi.getStack(); + + const secretManagerSecretsInlinePolicy = { + name: `${this.name}-secret-manager-access`, + policy: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'AllowContainerToGetSecretManagerSecrets', + Effect: 'Allow', + Action: ['secretsmanager:GetSecretValue'], + Resource: '*', + }, + ], + }), + }; + + const taskExecutionRole = new aws.iam.Role( + `${this.name}-ecs-task-exec-role`, + { + namePrefix: `${this.name}-ecs-task-exec-role-`, + assumeRolePolicy, + managedPolicyArns: [ + 'arn:aws:iam::aws:policy/CloudWatchFullAccess', + 'arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess', + ], + inlinePolicies: [ + secretManagerSecretsInlinePolicy, + ...argsWithDefaults.taskExecutionRoleInlinePolicies, + ], + tags: commonTags, + }, + { parent: this }, + ); + + const execCmdInlinePolicy = { + name: `${this.name}-ecs-exec`, + policy: JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Sid: 'AllowContainerToCreateECSExecSSMChannel', + Effect: 'Allow', + Action: [ + 'ssmmessages:CreateControlChannel', + 'ssmmessages:CreateDataChannel', + 'ssmmessages:OpenControlChannel', + 'ssmmessages:OpenDataChannel', + ], + Resource: '*', + }, + ], + }), + }; + + const taskRole = new aws.iam.Role( + `${this.name}-ecs-task-role`, + { + namePrefix: `${this.name}-ecs-task-role-`, + assumeRolePolicy, + inlinePolicies: [ + execCmdInlinePolicy, + ...argsWithDefaults.taskRoleInlinePolicies, + ], + tags: commonTags, + }, + { parent: this }, + ); + + const parsedSize = pulumi.all([argsWithDefaults.size]).apply(([size]) => { + const mapCapabilities = ({ cpu, memory }: CustomSize) => ({ + cpu: String(cpu), + memory: String(memory), + }); + if (typeof size === 'string') { + return mapCapabilities(PredefinedSize[size]); + } + if (typeof size === 'object') { + return mapCapabilities(size); + } + throw Error('Incorrect EcsService size argument'); + }); + + const taskDefinition = new aws.ecs.TaskDefinition( + `${this.name}-task-definition`, + { + family: `${this.name}-task-definition-${stack}`, + networkMode: 'awsvpc', + executionRoleArn: taskExecutionRole.arn, + taskRoleArn: taskRole.arn, + cpu: parsedSize.cpu, + memory: parsedSize.memory, + requiresCompatibilities: ['FARGATE'], + containerDefinitions: pulumi + .all([ + this.name, + argsWithDefaults.image, + argsWithDefaults.port, + argsWithDefaults.environment, + argsWithDefaults.secrets, + argsWithDefaults.persistentStorageVolumePath, + argsWithDefaults.dockerCommand, + this.logGroup.name, + awsRegion, + ]) + .apply( + ([ + containerName, + image, + port, + environment, + secrets, + persistentStorageVolumePath, + command, + logGroup, + region, + ]) => { + return JSON.stringify([ + { + readonlyRootFilesystem: false, + name: containerName, + image, + essential: true, + portMappings: [ + { + containerPort: port, + protocol: 'tcp', + }, + ], + ...(persistentStorageVolumePath && { + mountPoints: [ + { + containerPath: persistentStorageVolumePath, + sourceVolume: `${this.name}-volume`, + }, + ], + }), + logConfiguration: { + logDriver: 'awslogs', + options: { + 'awslogs-group': logGroup, + 'awslogs-region': region, + 'awslogs-stream-prefix': 'ecs', + }, + }, + command, + environment, + secrets, + }, + ] as ContainerDefinition[]); + }, + ), + ...(argsWithDefaults.persistentStorageVolumePath && { + volumes: [ + { + name: `${this.name}-volume`, + efsVolumeConfiguration: { + fileSystemId: this.createPersistentStorage( + argsWithDefaults.vpc, + argsWithDefaults.assignPublicIp, + ).id, + transitEncryption: 'ENABLED', + }, + }, + ], + }), + tags: { ...commonTags, ...argsWithDefaults.tags }, + }, + { parent: this }, + ); + + return taskDefinition; + } + + private createServiceDiscovery(vpc: awsx.ec2.Vpc) { + const privateDnsNamespace = new aws.servicediscovery.PrivateDnsNamespace( + `${this.name}-private-dns-namespace`, + { + vpc: vpc.vpcId, + name: this.name, + tags: commonTags, + }, + { parent: this }, + ); + + return new aws.servicediscovery.Service( + `mongo-service`, + { + name: this.name, + dnsConfig: { + namespaceId: privateDnsNamespace.id, + dnsRecords: [ + { + ttl: 10, + type: 'A', + }, + ], + routingPolicy: 'MULTIVALUE', + }, + tags: commonTags, + }, + { parent: this }, + ); + } + + private createEcsService( + args: EcsServiceArgs, + opts: pulumi.ComponentResourceOptions, + ) { + const argsWithDefaults = Object.assign({}, defaults, args); + + const securityGroup = + argsWithDefaults.securityGroup || + new aws.ec2.SecurityGroup( + `${this.name}-service-security-group`, + { + vpcId: argsWithDefaults.vpc.vpcId, + ingress: [ + { + fromPort: 0, + toPort: 0, + protocol: '-1', + cidrBlocks: [argsWithDefaults.vpc.vpc.cidrBlock], + }, + ], + egress: [ + { + fromPort: 0, + toPort: 0, + protocol: '-1', + cidrBlocks: ['0.0.0.0/0'], + }, + ], + tags: commonTags, + }, + { parent: this }, + ); + + const service = new aws.ecs.Service( + `${this.name}-service`, + { + name: this.name, + cluster: argsWithDefaults.cluster.id, + launchType: 'FARGATE', + desiredCount: argsWithDefaults.desiredCount, + taskDefinition: this.taskDefinition.arn, + enableExecuteCommand: true, + ...(argsWithDefaults.lbTargetGroupArn && { + loadBalancers: [ + { + containerName: this.name, + containerPort: argsWithDefaults.port, + targetGroupArn: argsWithDefaults.lbTargetGroupArn, + }, + ], + }), + networkConfiguration: { + assignPublicIp: argsWithDefaults.assignPublicIp, + subnets: argsWithDefaults.assignPublicIp + ? argsWithDefaults.vpc.publicSubnetIds + : argsWithDefaults.vpc.privateSubnetIds, + securityGroups: [securityGroup.id], + }, + ...(argsWithDefaults.enableServiceAutoDiscovery && + this.serviceDiscoveryService && { + serviceRegistries: { + registryArn: this.serviceDiscoveryService.arn, + }, + }), + tags: { ...commonTags, ...argsWithDefaults.tags }, + }, + { + parent: this, + dependsOn: opts.dependsOn, + }, + ); + return service; + } + + private enableAutoscaling(args: EcsServiceArgs) { + const argsWithDefaults = Object.assign({}, defaults, args); + + const autoscalingTarget = new aws.appautoscaling.Target( + `${this.name}-autoscale-target`, + { + minCapacity: argsWithDefaults.autoscaling.minCount, + maxCapacity: argsWithDefaults.autoscaling.maxCount, + resourceId: pulumi.interpolate`service/${argsWithDefaults.cluster.name}/${this.service.name}`, + serviceNamespace: 'ecs', + scalableDimension: 'ecs:service:DesiredCount', + tags: commonTags, + }, + { parent: this }, + ); + + const memoryAutoscalingPolicy = new aws.appautoscaling.Policy( + `${this.name}-memory-autoscale-policy`, + { + policyType: 'TargetTrackingScaling', + resourceId: autoscalingTarget.resourceId, + scalableDimension: autoscalingTarget.scalableDimension, + serviceNamespace: autoscalingTarget.serviceNamespace, + targetTrackingScalingPolicyConfiguration: { + predefinedMetricSpecification: { + predefinedMetricType: 'ECSServiceAverageMemoryUtilization', + }, + targetValue: 70, + }, + }, + { parent: this }, + ); + + const cpuAutoscalingPolicy = new aws.appautoscaling.Policy( + `${this.name}-cpu-autoscale-policy`, + { + policyType: 'TargetTrackingScaling', + resourceId: autoscalingTarget.resourceId, + scalableDimension: autoscalingTarget.scalableDimension, + serviceNamespace: autoscalingTarget.serviceNamespace, + targetTrackingScalingPolicyConfiguration: { + predefinedMetricSpecification: { + predefinedMetricType: 'ECSServiceAverageCPUUtilization', + }, + targetValue: 70, + }, + }, + { parent: this }, + ); + } +} diff --git a/src/components/mongo.ts b/src/components/mongo.ts new file mode 100644 index 0000000..1967d42 --- /dev/null +++ b/src/components/mongo.ts @@ -0,0 +1,100 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as aws from '@pulumi/aws'; +import { commonTags } from '../constants'; +import { EcsService, EcsServiceArgs } from './ecs-service'; + +export type MongoArgs = Pick< + EcsServiceArgs, + 'size' | 'cluster' | 'vpc' | 'tags' +> & { + /** + * Username for the master DB user. + */ + username: pulumi.Input; + /** + * Password for the master DB user. + * The value will be stored as a secret in AWS Secret Manager. + */ + password: pulumi.Input; + /** + * Exposed service port. Defaults to 27017. + */ + port?: pulumi.Input; +}; + +export class Mongo extends pulumi.ComponentResource { + name: string; + service: EcsService; + passwordSecret: aws.secretsmanager.Secret; + + constructor( + name: string, + args: MongoArgs, + opts: pulumi.ComponentResourceOptions = {}, + ) { + super('studion:Mongo', name, args, opts); + + const port = args.port || 27017; + + const { username, password, ...ecsArgs } = args; + + this.name = name; + this.passwordSecret = this.createPasswordSecret(password); + + this.service = new EcsService( + name, + { + ...ecsArgs, + port, + image: + 'mongo:7.0.3@sha256:238b1636bdd7820c752b91bec8a669f92568eb313ad89a1fc4a92903c1b40489', + desiredCount: 1, + autoscaling: { enabled: false }, + enableServiceAutoDiscovery: true, + persistentStorageVolumePath: '/data/db', + dockerCommand: ['mongod', '--port', port.toString()], + assignPublicIp: false, + environment: [ + { + name: 'MONGO_INITDB_ROOT_USERNAME', + value: username, + }, + ], + secrets: [ + { + name: 'MONGO_INITDB_ROOT_PASSWORD', + valueFrom: this.passwordSecret.arn, + }, + ], + }, + { ...opts, parent: this }, + ); + + this.registerOutputs(); + } + + private createPasswordSecret(password: MongoArgs['password']) { + const project = pulumi.getProject(); + const stack = pulumi.getStack(); + + const passwordSecret = new aws.secretsmanager.Secret( + `${this.name}-password-secret`, + { + namePrefix: `${stack}/${project}/MongoPassword-`, + tags: commonTags, + }, + { parent: this }, + ); + + const passwordSecretValue = new aws.secretsmanager.SecretVersion( + `${this.name}-password-secret-value`, + { + secretId: passwordSecret.id, + secretString: password, + }, + { parent: this, dependsOn: [passwordSecret] }, + ); + + return passwordSecret; + } +} diff --git a/src/components/project.ts b/src/components/project.ts index fbfe736..5a5bd84 100644 --- a/src/components/project.ts +++ b/src/components/project.ts @@ -4,12 +4,20 @@ import * as awsx from '@pulumi/awsx'; import * as upstash from '@upstash/pulumi'; import { Database, DatabaseArgs } from './database'; import { WebServer, WebServerArgs } from './web-server'; +import { Mongo, MongoArgs } from './mongo'; import { Redis, RedisArgs } from './redis'; import { StaticSite, StaticSiteArgs } from './static-site'; import { Ec2SSMConnect } from './ec2-ssm-connect'; import { commonTags } from '../constants'; +import { EcsService, EcsServiceArgs } from './ecs-service'; -export type Service = Database | Redis | StaticSite | WebServer; +export type Service = + | Database + | Redis + | StaticSite + | WebServer + | Mongo + | EcsService; export type Services = Record; type ServiceArgs = { @@ -19,16 +27,16 @@ type ServiceArgs = { serviceName: string; }; -export type DatabaseService = { type: 'DATABASE' } & ServiceArgs & +export type DatabaseServiceOptions = { type: 'DATABASE' } & ServiceArgs & Omit; -export type RedisService = { type: 'REDIS' } & ServiceArgs & +export type RedisServiceOptions = { type: 'REDIS' } & ServiceArgs & Pick; -export type StaticSiteService = { type: 'STATIC_SITE' } & ServiceArgs & +export type StaticSiteServiceOptions = { type: 'STATIC_SITE' } & ServiceArgs & Omit; -export type WebServerService = { +export type WebServerServiceOptions = { type: 'WEB_SERVER'; environment?: | aws.ecs.KeyValuePair[] @@ -40,12 +48,31 @@ export type WebServerService = { 'cluster' | 'vpc' | 'hostedZoneId' | 'environment' | 'secrets' >; +export type MongoServiceOptions = { + type: 'MONGO'; +} & ServiceArgs & + Omit; + +export type EcsServiceOptions = { + type: 'ECS'; + environment?: + | aws.ecs.KeyValuePair[] + | ((services: Services) => aws.ecs.KeyValuePair[]); + secrets?: aws.ecs.Secret[] | ((services: Services) => aws.ecs.Secret[]); +} & ServiceArgs & + Omit< + EcsServiceArgs, + 'cluster' | 'vpc' | 'hostedZoneId' | 'environment' | 'secrets' + >; + export type ProjectArgs = { services: ( - | DatabaseService - | RedisService - | StaticSiteService - | WebServerService + | DatabaseServiceOptions + | RedisServiceOptions + | StaticSiteServiceOptions + | WebServerServiceOptions + | MongoServiceOptions + | EcsServiceOptions )[]; hostedZoneId?: pulumi.Input; enableSSMConnect?: pulumi.Input; @@ -61,6 +88,13 @@ export class MissingHostedZoneId extends Error { } } +export class MissingEcsCluster extends Error { + constructor() { + super('Ecs Cluster does not exist'); + this.name = this.constructor.name; + } +} + export class Project extends pulumi.ComponentResource { name: string; vpc: awsx.ec2.Vpc; @@ -113,14 +147,20 @@ export class Project extends pulumi.ComponentResource { private createServices(services: ProjectArgs['services']) { const hasRedisService = services.some(it => it.type === 'REDIS'); - const hasWebServerService = services.some(it => it.type === 'WEB_SERVER'); + const shouldCreateEcsCluster = + services.some( + it => + it.type === 'WEB_SERVER' || it.type === 'MONGO' || it.type === 'ECS', + ) && !this.cluster; if (hasRedisService) this.createRedisPrerequisites(); - if (hasWebServerService) this.createWebServerPrerequisites(); + if (shouldCreateEcsCluster) this.createEcsCluster(); services.forEach(it => { if (it.type === 'DATABASE') this.createDatabaseService(it); if (it.type === 'REDIS') this.createRedisService(it); if (it.type === 'STATIC_SITE') this.createStaticSiteService(it); if (it.type === 'WEB_SERVER') this.createWebServerService(it); + if (it.type === 'MONGO') this.createMongoService(it); + if (it.type === 'ECS') this.createEcsService(it); }); } @@ -133,7 +173,7 @@ export class Project extends pulumi.ComponentResource { }); } - private createWebServerPrerequisites() { + private createEcsCluster() { const stack = pulumi.getStack(); this.cluster = new aws.ecs.Cluster( `${this.name}-cluster`, @@ -145,7 +185,7 @@ export class Project extends pulumi.ComponentResource { ); } - private createDatabaseService(options: DatabaseService) { + private createDatabaseService(options: DatabaseServiceOptions) { const { serviceName, type, ...databaseOptions } = options; const service = new Database( serviceName, @@ -158,7 +198,7 @@ export class Project extends pulumi.ComponentResource { this.services[serviceName] = service; } - private createRedisService(options: RedisService) { + private createRedisService(options: RedisServiceOptions) { if (!this.upstashProvider) return; const { serviceName, ...redisOptions } = options; const service = new Redis(serviceName, redisOptions, { @@ -168,7 +208,7 @@ export class Project extends pulumi.ComponentResource { this.services[options.serviceName] = service; } - private createStaticSiteService(options: StaticSiteService) { + private createStaticSiteService(options: StaticSiteServiceOptions) { const { serviceName, ...staticSiteOptions } = options; if (!this.hostedZoneId) throw new MissingHostedZoneId(options.type); const service = new StaticSite( @@ -182,8 +222,8 @@ export class Project extends pulumi.ComponentResource { this.services[serviceName] = service; } - private createWebServerService(options: WebServerService) { - if (!this.cluster) return; + private createWebServerService(options: WebServerServiceOptions) { + if (!this.cluster) throw new MissingEcsCluster(); if (!this.hostedZoneId) throw new MissingHostedZoneId(options.type); const { serviceName, environment, secrets, ...ecsOptions } = options; @@ -209,4 +249,47 @@ export class Project extends pulumi.ComponentResource { ); this.services[options.serviceName] = service; } + + private createMongoService(options: MongoServiceOptions) { + if (!this.cluster) throw new MissingEcsCluster(); + + const { serviceName, ...mongoOptions } = options; + + const service = new Mongo( + serviceName, + { + ...mongoOptions, + cluster: this.cluster, + vpc: this.vpc, + }, + { parent: this }, + ); + this.services[options.serviceName] = service; + } + + private createEcsService(options: EcsServiceOptions) { + if (!this.cluster) throw new MissingEcsCluster(); + + const { serviceName, environment, secrets, ...ecsOptions } = options; + const parsedEnv = + typeof environment === 'function' + ? environment(this.services) + : environment; + + const parsedSecrets = + typeof secrets === 'function' ? secrets(this.services) : secrets; + + const service = new EcsService( + serviceName, + { + ...ecsOptions, + cluster: this.cluster, + vpc: this.vpc, + environment: parsedEnv, + secrets: parsedSecrets, + }, + { parent: this }, + ); + this.services[options.serviceName] = service; + } } diff --git a/src/components/web-server.ts b/src/components/web-server.ts index 9610148..728e3b5 100644 --- a/src/components/web-server.ts +++ b/src/components/web-server.ts @@ -1,164 +1,81 @@ import * as pulumi from '@pulumi/pulumi'; import * as aws from '@pulumi/aws'; -import * as awsx from '@pulumi/awsx'; -import { CustomSize, Size } from '../types/size'; -import { PredefinedSize, commonTags } from '../constants'; -import { ContainerDefinition } from '@pulumi/aws/ecs'; +import { commonTags } from '../constants'; import { AcmCertificate } from './acm-certificate'; - -const config = new pulumi.Config('aws'); -const awsRegion = config.require('region'); - -const assumeRolePolicy: aws.iam.PolicyDocument = { - Version: '2012-10-17', - Statement: [ - { - Action: 'sts:AssumeRole', - Principal: { - Service: 'ecs-tasks.amazonaws.com', - }, - Effect: 'Allow', - Sid: '', - }, - ], -}; - -export type RoleInlinePolicy = { - /** - * Name of the role policy. - */ - name?: pulumi.Input; - /** - * Policy document as a JSON formatted string. - */ - policy?: pulumi.Input; -}; - -export type WebServerArgs = { - /** - * The ECR image used to start a container. - */ - image: pulumi.Input; - /** - * Exposed service port. - */ - port: pulumi.Input; +import { EcsService, EcsServiceArgs } from './ecs-service'; + +export type WebServerArgs = Pick< + EcsServiceArgs, + | 'image' + | 'port' + | 'cluster' + | 'vpc' + | 'desiredCount' + | 'autoscaling' + | 'size' + | 'environment' + | 'secrets' + | 'taskExecutionRoleInlinePolicies' + | 'taskRoleInlinePolicies' + | 'tags' +> & { /** * The domain which will be used to access the service. * The domain or subdomain must belong to the provided hostedZone. */ domain: pulumi.Input; - /** - * The aws.ecs.Cluster resource. - */ - cluster: aws.ecs.Cluster; /** * The ID of the hosted zone. */ hostedZoneId: pulumi.Input; /** - * The awsx.ec2.Vpc resource. - */ - vpc: awsx.ec2.Vpc; - /** - * Number of instances of the task definition to place and keep running. Defaults to 1. - */ - desiredCount?: pulumi.Input; - /** - * Min capacity of the scalable target. Defaults to 1. - */ - minCount?: pulumi.Input; - /** - * Max capacity of the scalable target. Defaults to 10. - */ - maxCount?: pulumi.Input; - /** - * CPU and memory size used for running the container. Defaults to "small". - * Available predefined options are: - * - small (0.25 vCPU, 0.5 GB memory) - * - medium (0.5 vCPU, 1 GB memory) - * - large (1 vCPU memory, 2 GB memory) - * - xlarge (2 vCPU, 4 GB memory) - */ - size?: pulumi.Input; - /** - * The environment variables to pass to a container. Don't use this field for - * sensitive information such as passwords, API keys, etc. For that purpose, - * please use the `secrets` property. - * Defaults to []. + * Path for the health check request. Defaults to "/healthcheck". */ - environment?: aws.ecs.KeyValuePair[]; - /** - * The secrets to pass to the container. Defaults to []. - */ - secrets?: aws.ecs.Secret[]; - /** - * Path for the health check request. Defaults to "/healtcheck". - */ - healtCheckPath?: pulumi.Input; - taskExecutionRoleInlinePolicies?: pulumi.Input< - pulumi.Input[] - >; - taskRoleInlinePolicies?: pulumi.Input[]>; - /** - * A map of tags to assign to the resource. - */ - tags?: pulumi.Input<{ - [key: string]: pulumi.Input; - }>; + healthCheckPath?: pulumi.Input; }; const defaults = { - desiredCount: 1, - minCount: 1, - maxCount: 10, - size: 'small', - environment: [], - secrets: [], - healtCheckPath: '/healtcheck', - taskExecutionRoleInlinePolicies: [], - taskRoleInlinePolicies: [], + healthCheckPath: '/healthcheck', }; export class WebServer extends pulumi.ComponentResource { name: string; + service: EcsService; certificate: AcmCertificate; - logGroup: aws.cloudwatch.LogGroup; lbSecurityGroup: aws.ec2.SecurityGroup; + serviceSecurityGroup: aws.ec2.SecurityGroup; lb: aws.lb.LoadBalancer; lbTargetGroup: aws.lb.TargetGroup; lbHttpListener: aws.lb.Listener; lbTlsListener: aws.lb.Listener; - taskDefinition: aws.ecs.TaskDefinition; - service: aws.ecs.Service; constructor( name: string, args: WebServerArgs, opts: pulumi.ComponentResourceOptions = {}, ) { - super('studion:WebServer', name, {}, opts); + super('studion:WebServer', name, args, opts); + + const { vpc, port, healthCheckPath, domain, hostedZoneId } = args; this.name = name; - const { domain, hostedZoneId, vpc, port, healtCheckPath } = args; this.certificate = this.createTlsCertificate({ domain, hostedZoneId }); - this.logGroup = this.createLogGroup(); const { lb, lbTargetGroup, lbHttpListener, lbTlsListener, lbSecurityGroup, - } = this.createLoadBalancer({ vpc, port, healtCheckPath }); + } = this.createLoadBalancer({ vpc, port, healthCheckPath }); this.lb = lb; this.lbTargetGroup = lbTargetGroup; this.lbHttpListener = lbHttpListener; this.lbTlsListener = lbTlsListener; this.lbSecurityGroup = lbSecurityGroup; - this.taskDefinition = this.createTaskDefinition(args); + this.serviceSecurityGroup = this.createSecurityGroup({ vpc }); this.service = this.createEcsService(args); + this.createDnsRecord({ domain, hostedZoneId }); - this.enableAutoscaling(args); this.registerOutputs(); } @@ -178,24 +95,11 @@ export class WebServer extends pulumi.ComponentResource { return certificate; } - private createLogGroup() { - const logGroup = new aws.cloudwatch.LogGroup( - `${this.name}-log-group`, - { - retentionInDays: 14, - namePrefix: `/ecs/${this.name}-`, - tags: commonTags, - }, - { parent: this }, - ); - return logGroup; - } - private createLoadBalancer({ vpc, port, - healtCheckPath, - }: Pick) { + healthCheckPath, + }: Pick) { const lbSecurityGroup = new aws.ec2.SecurityGroup( `${this.name}-lb-security-group`, { @@ -254,7 +158,7 @@ export class WebServer extends pulumi.ComponentResource { unhealthyThreshold: 2, interval: 60, timeout: 5, - path: healtCheckPath || defaults.healtCheckPath, + path: healthCheckPath || defaults.healthCheckPath, }, tags: { ...commonTags, Name: `${this.name}-lb-target-group` }, }, @@ -309,162 +213,11 @@ export class WebServer extends pulumi.ComponentResource { }; } - private createTaskDefinition(args: WebServerArgs) { - const argsWithDefaults = Object.assign({}, defaults, args); - const stack = pulumi.getStack(); - - const secretManagerSecretsInlinePolicy = { - name: `${this.name}-secret-manager-access`, - policy: JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Sid: 'AllowContainerToGetSecretManagerSecrets', - Effect: 'Allow', - Action: ['secretsmanager:GetSecretValue'], - Resource: '*', - }, - ], - }), - }; - - const taskExecutionRole = new aws.iam.Role( - `${this.name}-ecs-task-exec-role`, - { - namePrefix: `${this.name}-ecs-task-exec-role-`, - assumeRolePolicy, - managedPolicyArns: [ - 'arn:aws:iam::aws:policy/CloudWatchFullAccess', - 'arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess', - ], - inlinePolicies: [ - secretManagerSecretsInlinePolicy, - ...argsWithDefaults.taskExecutionRoleInlinePolicies, - ], - tags: commonTags, - }, - { parent: this }, - ); - - const execCmdInlinePolicy = { - name: `${this.name}-ecs-exec`, - policy: JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Sid: 'AllowContainerToCreateECSExecSSMChannel', - Effect: 'Allow', - Action: [ - 'ssmmessages:CreateControlChannel', - 'ssmmessages:CreateDataChannel', - 'ssmmessages:OpenControlChannel', - 'ssmmessages:OpenDataChannel', - ], - Resource: '*', - }, - ], - }), - }; - - const taskRole = new aws.iam.Role( - `${this.name}-ecs-task-role`, - { - namePrefix: `${this.name}-ecs-task-role-`, - assumeRolePolicy, - inlinePolicies: [ - execCmdInlinePolicy, - ...argsWithDefaults.taskRoleInlinePolicies, - ], - tags: commonTags, - }, - { parent: this }, - ); - - const parsedSize = pulumi.all([argsWithDefaults.size]).apply(([size]) => { - const mapCapabilities = ({ cpu, memory }: CustomSize) => ({ - cpu: String(cpu), - memory: String(memory), - }); - if (typeof size === 'string') { - return mapCapabilities(PredefinedSize[size]); - } - if (typeof size === 'object') { - return mapCapabilities(size); - } - throw Error('Incorrect EcsService size argument'); - }); - - const taskDefinition = new aws.ecs.TaskDefinition( - `${this.name}-task-definition`, - { - family: `${this.name}-task-definition-${stack}`, - networkMode: 'awsvpc', - executionRoleArn: taskExecutionRole.arn, - taskRoleArn: taskRole.arn, - cpu: parsedSize.cpu, - memory: parsedSize.memory, - requiresCompatibilities: ['FARGATE'], - containerDefinitions: pulumi - .all([ - this.name, - argsWithDefaults.image, - argsWithDefaults.port, - argsWithDefaults.environment, - argsWithDefaults.secrets, - this.logGroup.name, - awsRegion, - ]) - .apply( - ([ - containerName, - image, - port, - environment, - secrets, - logGroup, - region, - ]) => { - return JSON.stringify([ - { - readonlyRootFilesystem: false, - name: containerName, - image, - essential: true, - portMappings: [ - { - containerPort: port, - protocol: 'tcp', - }, - ], - logConfiguration: { - logDriver: 'awslogs', - options: { - 'awslogs-group': logGroup, - 'awslogs-region': region, - 'awslogs-stream-prefix': 'ecs', - }, - }, - environment, - secrets, - }, - ] as ContainerDefinition[]); - }, - ), - tags: { ...commonTags, ...argsWithDefaults.tags }, - }, - { parent: this }, - ); - - return taskDefinition; - } - - private createEcsService(args: WebServerArgs) { - const argsWithDefaults = Object.assign({}, defaults, args); - - const serviceSecurityGroup = new aws.ec2.SecurityGroup( + private createSecurityGroup({ vpc }: Pick) { + const securityGroup = new aws.ec2.SecurityGroup( `${this.name}-security-group`, { - vpcId: argsWithDefaults.vpc.vpcId, + vpcId: vpc.vpcId, ingress: [ { fromPort: 0, @@ -485,29 +238,18 @@ export class WebServer extends pulumi.ComponentResource { }, { parent: this }, ); + return securityGroup; + } - const service = new aws.ecs.Service( - `${this.name}-service`, + private createEcsService(args: WebServerArgs) { + const service = new EcsService( + this.name, { - name: this.name, - cluster: argsWithDefaults.cluster.id, - launchType: 'FARGATE', - desiredCount: argsWithDefaults.desiredCount, - taskDefinition: this.taskDefinition.arn, - enableExecuteCommand: true, - loadBalancers: [ - { - containerName: this.name, - containerPort: argsWithDefaults.port, - targetGroupArn: this.lbTargetGroup.arn, - }, - ], - networkConfiguration: { - assignPublicIp: true, - subnets: argsWithDefaults.vpc.publicSubnetIds, - securityGroups: [serviceSecurityGroup.id], - }, - tags: { ...commonTags, ...argsWithDefaults.tags }, + ...args, + enableServiceAutoDiscovery: false, + lbTargetGroupArn: this.lbTargetGroup.arn, + assignPublicIp: true, + securityGroup: this.serviceSecurityGroup, }, { parent: this, @@ -543,55 +285,4 @@ export class WebServer extends pulumi.ComponentResource { { parent: this }, ); } - - private enableAutoscaling(args: WebServerArgs) { - const argsWithDefaults = Object.assign({}, defaults, args); - - const autoscalingTarget = new aws.appautoscaling.Target( - `${this.name}-autoscale-target`, - { - minCapacity: argsWithDefaults.minCount, - maxCapacity: argsWithDefaults.maxCount, - resourceId: pulumi.interpolate`service/${argsWithDefaults.cluster.name}/${this.service.name}`, - serviceNamespace: 'ecs', - scalableDimension: 'ecs:service:DesiredCount', - tags: commonTags, - }, - { parent: this }, - ); - - const memoryAutoscalingPolicy = new aws.appautoscaling.Policy( - `${this.name}-memory-autoscale-policy`, - { - policyType: 'TargetTrackingScaling', - resourceId: autoscalingTarget.resourceId, - scalableDimension: autoscalingTarget.scalableDimension, - serviceNamespace: autoscalingTarget.serviceNamespace, - targetTrackingScalingPolicyConfiguration: { - predefinedMetricSpecification: { - predefinedMetricType: 'ECSServiceAverageMemoryUtilization', - }, - targetValue: 80, - }, - }, - { parent: this }, - ); - - const cpuAutoscalingPolicy = new aws.appautoscaling.Policy( - `${this.name}-cpu-autoscale-policy`, - { - policyType: 'TargetTrackingScaling', - resourceId: autoscalingTarget.resourceId, - scalableDimension: autoscalingTarget.scalableDimension, - serviceNamespace: autoscalingTarget.serviceNamespace, - targetTrackingScalingPolicyConfiguration: { - predefinedMetricSpecification: { - predefinedMetricType: 'ECSServiceAverageCPUUtilization', - }, - targetValue: 60, - }, - }, - { parent: this }, - ); - } } diff --git a/src/index.ts b/src/index.ts index 7764d9a..59e1a48 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export * from './components/web-server'; +export * from './components/mongo'; export * from './components/static-site'; export * from './components/database'; export * from './components/redis'; export * from './components/project'; export * from './components/ec2-ssm-connect'; +export * from './components/ecs-service';