From 05c7d6082a71633c2e1f6970d725cf0585c79840 Mon Sep 17 00:00:00 2001 From: Yohta Kimura <38206553+rajyan@users.noreply.github.com> Date: Mon, 12 Sep 2022 09:26:50 +0900 Subject: [PATCH] feat: update props and add CfnOutputs (#37) Fixes # --- .projenrc.ts | 1 + API.md | 55 +++++++++++++++++ README.md | 13 ++-- bin/low-cost-ecs.ts | 15 ----- cdk.json | 2 +- examples/minimum.ts | 13 ++++ examples/scheduled-autoscaling.ts | 32 ++++++++++ src/low-cost-ecs.ts | 64 ++++++++++++-------- test/__snapshots__/low-cost-ecs.test.ts.snap | 28 +++++++++ todo.md | 2 +- tsconfig.dev.json | 3 +- 11 files changed, 178 insertions(+), 50 deletions(-) delete mode 100644 bin/low-cost-ecs.ts create mode 100644 examples/minimum.ts create mode 100644 examples/scheduled-autoscaling.ts diff --git a/.projenrc.ts b/.projenrc.ts index 6a3b0ab..e191c69 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -43,6 +43,7 @@ const project = new awscdk.AwsCdkConstructLibrary({ projenrcTs: true, }); +project.tsconfigDev.addInclude('examples/**/*.ts'); // workaround until fixed https://youtrack.jetbrains.com/issue/WEB-57089/ESLint823-TypeError-thislibOptionsparse-is-not-a-function project.addDevDeps('eslint@8.22.0'); diff --git a/API.md b/API.md index ecd2a22..34c5b0d 100644 --- a/API.md +++ b/API.md @@ -464,6 +464,11 @@ The construct to start the search from. | nestedStackParent | aws-cdk-lib.Stack | If this is a nested stack, returns it's parent stack. | | nestedStackResource | aws-cdk-lib.CfnResource | If this is a nested stack, this represents its `AWS::CloudFormation::Stack` resource. | | terminationProtection | boolean | Whether termination protection is enabled for this stack. | +| certFileSystem | aws-cdk-lib.aws_efs.FileSystem | *No description.* | +| cluster | aws-cdk-lib.aws_ecs.Cluster | *No description.* | +| hostAutoScalingGroup | aws-cdk-lib.aws_autoscaling.AutoScalingGroup | *No description.* | +| service | aws-cdk-lib.aws_ecs.Ec2Service | *No description.* | +| vpc | aws-cdk-lib.aws_ec2.IVpc | *No description.* | --- @@ -797,6 +802,56 @@ Whether termination protection is enabled for this stack. --- +##### `certFileSystem`Required + +```typescript +public readonly certFileSystem: FileSystem; +``` + +- *Type:* aws-cdk-lib.aws_efs.FileSystem + +--- + +##### `cluster`Required + +```typescript +public readonly cluster: Cluster; +``` + +- *Type:* aws-cdk-lib.aws_ecs.Cluster + +--- + +##### `hostAutoScalingGroup`Required + +```typescript +public readonly hostAutoScalingGroup: AutoScalingGroup; +``` + +- *Type:* aws-cdk-lib.aws_autoscaling.AutoScalingGroup + +--- + +##### `service`Required + +```typescript +public readonly service: Ec2Service; +``` + +- *Type:* aws-cdk-lib.aws_ecs.Ec2Service + +--- + +##### `vpc`Required + +```typescript +public readonly vpc: IVpc; +``` + +- *Type:* aws-cdk-lib.aws_ec2.IVpc + +--- + ## Structs diff --git a/README.md b/README.md index 4b68bda..7263c82 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ Edit settings in `bin/low-cost-ecs.ts` and deploy the cdk construct. [Public hos ``` git clone https://github.com/rajyan/low-cost-ecs.git +yarn install # edit settings in bin/low-cost-ecs.ts -npx cdk deploy +./node_modules/.bin/cdk deploy ``` Access to configured `recordDomainNames` and see that the nginx sample server has been deployed. @@ -48,8 +49,8 @@ class SampleStack extends Stack { super(scope, id, props); const vpc = { /** Your VPC */ }; - const securityGroup = {/** Your security group */ }; - const serverTaskDefinition = {/** Your task definition */ }; + const securityGroup = { /** Your security group */ }; + const serverTaskDefinition = { /** Your task definition */ }; new LowCostECS(this, 'LowCostECS', { hostedZoneDomain: "rajyan.net", @@ -79,8 +80,8 @@ Resources generated in this stack * ECS-optimized Amazon Linux 2 AMI instance auto-scaling group * Automatically associated with Elastic IP on instance initialization * ECS Service - * TLS/SSL certificate installation on default container startup - * Certificate EFS mounted on `/etc/letsencrypt` + * TLS/SSL certificate installation before default container startup + * Certificate EFS mounted on default container as `/etc/letsencrypt` * Others * VPC with only public subnets (no NAT Gateways to decrease cost) * Security groups with minimum inbounds @@ -129,7 +130,7 @@ aws ecs execute-command \ # Limitations -The ECS service occupies the host port, only one service can be run at a time. +The ECS service occupies the host port, so only one service can be run at a time. The old task must be terminated before the new task launches, and this causes downtime on release. Also, if you make changes that require recreating service, you may need to manually terminate the task of old the service. diff --git a/bin/low-cost-ecs.ts b/bin/low-cost-ecs.ts deleted file mode 100644 index 6774bb2..0000000 --- a/bin/low-cost-ecs.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { App } from "aws-cdk-lib"; -import { LowCostECS } from '../src'; - -const app = new App(); - -new LowCostECS(app, "LowCostECSStack", { - env: { - account: process.env.CDK_DEFAULT_ACCOUNT, - region: process.env.CDK_DEFAULT_REGION, - }, - hostedZoneDomain: "rajyan.net", - recordDomainNames: ["test1.rajyan.net", "test2.rajyan.net"], - email: "kitakita7617@gmail.com", - hostInstanceSpotPrice: "0.0050", -}); diff --git a/cdk.json b/cdk.json index 55acca3..6c16e69 100644 --- a/cdk.json +++ b/cdk.json @@ -1,3 +1,3 @@ { - "app": "npx ts-node --prefer-ts-exts bin/low-cost-ecs.ts" + "app": "npx ts-node --prefer-ts-exts examples/minimum.ts" } diff --git a/examples/minimum.ts b/examples/minimum.ts new file mode 100644 index 0000000..5409159 --- /dev/null +++ b/examples/minimum.ts @@ -0,0 +1,13 @@ +import { App } from 'aws-cdk-lib'; +import { LowCostECS } from '../src'; + +const app = new App(); + +new LowCostECS(app, 'LowCostECSStack', { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, + hostedZoneDomain: 'rajyan.net', + email: 'kitakita7617@gmail.com', +}); diff --git a/examples/scheduled-autoscaling.ts b/examples/scheduled-autoscaling.ts new file mode 100644 index 0000000..3aaca3d --- /dev/null +++ b/examples/scheduled-autoscaling.ts @@ -0,0 +1,32 @@ +import { App } from 'aws-cdk-lib'; +import { Schedule } from 'aws-cdk-lib/aws-autoscaling'; +import { LowCostECS } from '../src'; + +const app = new App(); + +const stack = new LowCostECS(app, 'LowCostECSStack', { + env: { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION, + }, + hostedZoneDomain: 'rajyan.net', + recordDomainNames: ['test1.rajyan.net', 'test2.rajyan.net'], + email: 'kitakita7617@gmail.com', + hostInstanceSpotPrice: '0.0050', +}); +stack.hostAutoScalingGroup.scaleOnSchedule('IncreaseAtMorning', { + timeZone: 'Asia/Tokyo', + schedule: Schedule.cron({ + minute: '0', + hour: '8', + }), + desiredCapacity: 1, +}); +stack.hostAutoScalingGroup.scaleOnSchedule('DecreaseAtNight', { + timeZone: 'Asia/Tokyo', + schedule: Schedule.cron({ + minute: '0', + hour: '23', + }), + desiredCapacity: 0, +}); diff --git a/src/low-cost-ecs.ts b/src/low-cost-ecs.ts index b13fdba..60c8984 100644 --- a/src/low-cost-ecs.ts +++ b/src/low-cost-ecs.ts @@ -1,5 +1,6 @@ import * as path from 'path'; import * as lib from 'aws-cdk-lib'; +import { AutoScalingGroup } from 'aws-cdk-lib/aws-autoscaling'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import { FileSystem } from 'aws-cdk-lib/aws-efs'; @@ -121,10 +122,16 @@ export interface LowCostECSProps extends lib.StackProps { }; export class LowCostECS extends lib.Stack { + readonly vpc: ec2.IVpc; + readonly hostAutoScalingGroup: AutoScalingGroup; + readonly certFileSystem: FileSystem; + readonly cluster: ecs.Cluster; + readonly service: ecs.Ec2Service; + constructor(scope: Construct, id: string, props: LowCostECSProps) { super(scope, id, props); - const vpc = + this.vpc = props.vpc ?? new ec2.Vpc(this, 'Vpc', { natGateways: 0, @@ -136,12 +143,12 @@ export class LowCostECS extends lib.Stack { ], }); - const cluster = new ecs.Cluster(this, 'Cluster', { - vpc, + this.cluster = new ecs.Cluster(this, 'Cluster', { + vpc: this.vpc, containerInsights: props.containerInsights, }); - const hostAutoScalingGroup = cluster.addCapacity('HostInstanceCapacity', { + this.hostAutoScalingGroup = this.cluster.addCapacity('HostInstanceCapacity', { machineImage: ecs.EcsOptimizedImage.amazonLinux2( ecs.AmiHardwareType.STANDARD, { @@ -157,15 +164,15 @@ export class LowCostECS extends lib.Stack { }); if (props.securityGroup) { - hostAutoScalingGroup.addSecurityGroup(props.securityGroup); + this.hostAutoScalingGroup.addSecurityGroup(props.securityGroup); } else { - hostAutoScalingGroup.connections.allowFromAnyIpv4(ec2.Port.tcp(80)); - hostAutoScalingGroup.connections.allowFromAnyIpv4(ec2.Port.tcp(443)); - hostAutoScalingGroup.connections.allowFrom( + this.hostAutoScalingGroup.connections.allowFromAnyIpv4(ec2.Port.tcp(80)); + this.hostAutoScalingGroup.connections.allowFromAnyIpv4(ec2.Port.tcp(443)); + this.hostAutoScalingGroup.connections.allowFrom( ec2.Peer.anyIpv6(), ec2.Port.tcp(80), ); - hostAutoScalingGroup.connections.allowFrom( + this.hostAutoScalingGroup.connections.allowFrom( ec2.Peer.anyIpv6(), ec2.Port.tcp(443), ); @@ -174,13 +181,13 @@ export class LowCostECS extends lib.Stack { /** * Add managed policy to allow ssh through ssm manager */ - hostAutoScalingGroup.role.addManagedPolicy( + this.hostAutoScalingGroup.role.addManagedPolicy( ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'), ); /** * Add policy to associate elastic ip on startup */ - hostAutoScalingGroup.role.addToPrincipalPolicy( + this.hostAutoScalingGroup.role.addToPrincipalPolicy( new PolicyStatement({ effect: Effect.ALLOW, actions: ['ec2:DescribeAddresses', 'ec2:AssociateAddress'], @@ -193,23 +200,23 @@ export class LowCostECS extends lib.Stack { hostInstanceIp.tags.setTag('Name', tagUniqueId); const awsCliTag = props.awsCliDockerTag ?? 'latest'; - hostAutoScalingGroup.addUserData( + this.hostAutoScalingGroup.addUserData( 'INSTANCE_ID=$(curl --silent http://169.254.169.254/latest/meta-data/instance-id)', - `ALLOCATION_ID=$(docker run --net=host amazon/aws-cli:${awsCliTag} ec2 describe-addresses --region ${hostAutoScalingGroup.env.region} --filter Name=tag:Name,Values=${tagUniqueId} --query 'Addresses[].AllocationId' --output text | head)`, - `docker run --net=host amazon/aws-cli:${awsCliTag} ec2 associate-address --region ${hostAutoScalingGroup.env.region} --instance-id "$INSTANCE_ID" --allocation-id "$ALLOCATION_ID" --allow-reassociation`, + `ALLOCATION_ID=$(docker run --net=host amazon/aws-cli:${awsCliTag} ec2 describe-addresses --region ${this.hostAutoScalingGroup.env.region} --filter Name=tag:Name,Values=${tagUniqueId} --query 'Addresses[].AllocationId' --output text | head)`, + `docker run --net=host amazon/aws-cli:${awsCliTag} ec2 associate-address --region ${this.hostAutoScalingGroup.env.region} --instance-id "$INSTANCE_ID" --allocation-id "$ALLOCATION_ID" --allow-reassociation`, ); - const certFileSystem = new FileSystem(this, 'FileSystem', { - vpc, + this.certFileSystem = new FileSystem(this, 'FileSystem', { + vpc: this.vpc, encrypted: true, securityGroup: new ec2.SecurityGroup(this, 'FileSystemSecurityGroup', { - vpc, + vpc: this.vpc, allowAllOutbound: false, }), removalPolicy: props.removalPolicy ?? lib.RemovalPolicy.DESTROY, }); - certFileSystem.connections.allowDefaultPortTo(hostAutoScalingGroup); - certFileSystem.connections.allowDefaultPortFrom(hostAutoScalingGroup); + this.certFileSystem.connections.allowDefaultPortTo(this.hostAutoScalingGroup); + this.certFileSystem.connections.allowDefaultPortFrom(this.hostAutoScalingGroup); /** * ARecord to Elastic ip @@ -288,14 +295,14 @@ export class LowCostECS extends lib.Stack { }, ); - certFileSystem.grant( + this.certFileSystem.grant( certbotTaskDefinition.taskRole, 'elasticfilesystem:ClientWrite', ); certbotTaskDefinition.addVolume({ name: 'certVolume', efsVolumeConfiguration: { - fileSystemId: certFileSystem.fileSystemId, + fileSystemId: this.certFileSystem.fileSystemId, }, }); certbotContainer.addMountPoints({ @@ -316,7 +323,7 @@ export class LowCostECS extends lib.Stack { }); const certbotRunTask = new sfn_tasks.EcsRunTask(this, 'CreateCertificate', { - cluster: cluster, + cluster: this.cluster, taskDefinition: certbotTaskDefinition, launchTarget: new sfn_tasks.EcsEc2LaunchTarget(), integrationPattern: sfn.IntegrationPattern.RUN_JOB, @@ -346,14 +353,14 @@ export class LowCostECS extends lib.Stack { */ const serverTaskDefinition = props.serverTaskDefinition ?? this.sampleSeverTask(records, logGroup); - certFileSystem.grant( + this.certFileSystem.grant( serverTaskDefinition.taskRole, 'elasticfilesystem:ClientMount', ); serverTaskDefinition.addVolume({ name: 'certVolume', efsVolumeConfiguration: { - fileSystemId: certFileSystem.fileSystemId, + fileSystemId: this.certFileSystem.fileSystemId, }, }); serverTaskDefinition.defaultContainer?.addMountPoints({ @@ -396,8 +403,8 @@ export class LowCostECS extends lib.Stack { ); certbotStateMachine.grantStartExecution(serverTaskDefinition.taskRole); - new ecs.Ec2Service(this, 'Service', { - cluster: cluster, + this.service = new ecs.Ec2Service(this, 'Service', { + cluster: this.cluster, taskDefinition: serverTaskDefinition, desiredCount: 1, minHealthyPercent: 0, @@ -407,6 +414,11 @@ export class LowCostECS extends lib.Stack { }, enableExecuteCommand: true, }); + + new lib.CfnOutput(this, 'PublicIpAddress', { value: hostInstanceIp.ref }); + new lib.CfnOutput(this, 'certbotStateMachineName', { value: certbotStateMachine.stateMachineName }); + new lib.CfnOutput(this, 'ClusterName', { value: this.cluster.clusterName }); + new lib.CfnOutput(this, 'ServiceName', { value: this.service.serviceName }); } private sampleSeverTask( diff --git a/test/__snapshots__/low-cost-ecs.test.ts.snap b/test/__snapshots__/low-cost-ecs.test.ts.snap index 9fa9dbe..1622b8c 100644 --- a/test/__snapshots__/low-cost-ecs.test.ts.snap +++ b/test/__snapshots__/low-cost-ecs.test.ts.snap @@ -2,6 +2,34 @@ exports[`Template matches snapshot 1`] = ` Object { + "Outputs": Object { + "ClusterName": Object { + "Value": Object { + "Ref": "ClusterEB0386A7", + }, + }, + "PublicIpAddress": Object { + "Value": Object { + "Ref": "HostInstanceIp", + }, + }, + "ServiceName": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "ServiceD69D759B", + "Name", + ], + }, + }, + "certbotStateMachineName": Object { + "Value": Object { + "Fn::GetAtt": Array [ + "StateMachine2E01A3A5", + "Name", + ], + }, + }, + }, "Parameters": Object { "BootstrapVersion": Object { "Default": "/cdk-bootstrap/hnb659fds/version", diff --git a/todo.md b/todo.md index cfa4c7d..75b42b3 100644 --- a/todo.md +++ b/todo.md @@ -1,3 +1,3 @@ # todo -* add properties to expose \ No newline at end of file +* add tests \ No newline at end of file diff --git a/tsconfig.dev.json b/tsconfig.dev.json index 24ccaa6..caf97a0 100644 --- a/tsconfig.dev.json +++ b/tsconfig.dev.json @@ -29,7 +29,8 @@ "src/**/*.ts", "test/**/*.ts", ".projenrc.ts", - "projenrc/**/*.ts" + "projenrc/**/*.ts", + "examples/**/*.ts" ], "exclude": [ "node_modules"