diff --git a/Makefile b/Makefile index c57ad97..6f93ad9 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ ad-hoc-base-diff: cdk diff --app='./lib/examples/ad-hoc/index.js' -e ExampleAdHocBaseStack ad-hoc-base-deploy: - cdk deploy --app='./lib/examples/ad-hoc/index.js' -e ExampleAdHocBaseStack + cdk deploy --verbose --app='./lib/examples/ad-hoc/index.js' -e ExampleAdHocBaseStack ad-hoc-base-deploy-approve: cdk deploy --app='./lib/examples/ad-hoc/index.js' --require-approval never -e ExampleAdHocBaseStack diff --git a/packages/ecs-app-update/action.yml b/packages/ecs-app-update/action.yml new file mode 100644 index 0000000..fde285c --- /dev/null +++ b/packages/ecs-app-update/action.yml @@ -0,0 +1,60 @@ +name: 'Action for updating ECS service' +description: 'Action for updating ECS service' +author: 'Brian Caffey' +inputs: + BASE_ENV: + required: true + description: 'Base env name (e.g. dev)' + APP_ENV: + required: true + description: 'App env name (e.g. alpha)' + VERSION: + required: true + description: 'Application version git tag (e.g. v1.2.3)' + ECR_REPO: + required: true + description: 'ECR repo to use' + CONTAINER_NAME: + required: true + description: 'Name of the container to update' + AWS_REGION: + required: false + description: 'AWS Region' + default: 'us-east-1' + +# Trigger / Inputs +runs: + using: "composite" + steps: + # Note: this assumes that your ECR repo lives in the same AWS account as your ECS cluster + - name: Get current AWS Account + id: get-aws-account + shell: bash + run: | + AWS_ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account) + echo "AWS_ACCOUNT_ID=$AWS_ACCOUNT_ID" >> $GITHUB_ENV + + - name: Download existing task definition + id: download-task-definition + shell: bash + run: | + aws ecs describe-task-definition \ + --task-definition ${{ env.FULL_TASK_NAME }} \ + | jq '.taskDefinition' > task-definition.json + + - name: Render new task definition + id: render-new-task-definition + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: ${{ inputs.CONTAINER_NAME }} + image: ${{ env.AWS_ACCOUNT_ID }}.dkr.ecr.${{ inputs.AWS_REGION}}.amazonaws.com/${{ inputs.ECR_REPO }}:${{ inputs.VERSION }} + + - name: Deploy new task definition + id: deploy-new-task-definition + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + cluster: ${{ inputs.APP_ENV }}-cluster + service: ${{ inputs.APP_ENV }}-${{ inputs.CONTAINER_NAME }} + task-definition: ${{ steps.render-new-task-definition.outputs.task-definition }} + diff --git a/src/constructs/ad-hoc/app/index.ts b/src/constructs/ad-hoc/app/index.ts index c501d37..656801f 100644 --- a/src/constructs/ad-hoc/app/index.ts +++ b/src/constructs/ad-hoc/app/index.ts @@ -1,17 +1,15 @@ import { CfnOutput, Stack } from 'aws-cdk-lib'; import { IVpc, ISecurityGroup } from 'aws-cdk-lib/aws-ec2'; import { Repository } from 'aws-cdk-lib/aws-ecr'; -import { Cluster, ContainerImage, EcrImage } from 'aws-cdk-lib/aws-ecs'; +import { Cluster, EcrImage } from 'aws-cdk-lib/aws-ecs'; import { IApplicationLoadBalancer, ApplicationListener } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; import { DatabaseInstance } from 'aws-cdk-lib/aws-rds'; import { CnameRecord, HostedZone } from 'aws-cdk-lib/aws-route53'; import { Bucket } from 'aws-cdk-lib/aws-s3'; -import { PrivateDnsNamespace } from 'aws-cdk-lib/aws-servicediscovery'; import { Construct } from 'constructs'; // import { HighestPriorityRule } from '../../internal/customResources/highestPriorityRule'; import { EcsRoles } from '../../internal/ecs/iam'; import { ManagementCommandTask } from '../../internal/ecs/management-command'; -import { RedisService } from '../../internal/ecs/redis'; import { WebService } from '../../internal/ecs/web'; import { WorkerService } from '../../internal/ecs/worker'; @@ -20,11 +18,11 @@ export interface AdHocAppProps { readonly vpc: IVpc; readonly alb: IApplicationLoadBalancer; readonly appSecurityGroup: ISecurityGroup; - readonly serviceDiscoveryNamespace: PrivateDnsNamespace; readonly rdsInstance: DatabaseInstance; readonly assetsBucket: Bucket; readonly domainName: string; readonly listener: ApplicationListener; + readonly elastiCacheHost: string; } export class AdHocApp extends Construct { @@ -54,20 +52,22 @@ export class AdHocApp extends Construct { enableFargateCapacityProviders: true, }); - const serviceDiscoveryNamespace = props.serviceDiscoveryNamespace.namespaceName; - const settingsModule = this.node.tryGetContext('config').settingsModule ?? 'backend.settings.production'; // shared environment variables let environmentVariables: { [key: string]: string } = { S3_BUCKET_NAME: props.assetsBucket.bucketName, - REDIS_SERVICE_HOST: `${stackName}-redis.${serviceDiscoveryNamespace}`, + REDIS_SERVICE_HOST: props.elastiCacheHost, POSTGRES_SERVICE_HOST: props.rdsInstance.dbInstanceEndpointAddress, - POSTGRES_NAME: `${stackName}-db`, + POSTGRES_NAME: stackName, DJANGO_SETTINGS_MODULE: settingsModule, FRONTEND_URL: `https://${stackName}.${props.domainName}`, DOMAIN_NAME: props.domainName, // TODO: read this from ad hoc base stack DB_SECRET_NAME: 'DB_SECRET_NAME', + APP_ENV_NAME: stackName, // e.g. alpha + BASE_ENV_NAME: props.baseStackName, // e.g. dev + AD_HOC_ENV: 'True', // used in application code + FOO: 'foo', }; const extraEnvVars = this.node.tryGetContext('config').extraEnvVars; @@ -87,18 +87,6 @@ export class AdHocApp extends Construct { zone: hostedZone, }); - new RedisService(this, 'RedisService', { - cluster, - environmentVariables: {}, - vpc: props.vpc, - appSecurityGroup: props.appSecurityGroup, - taskRole: ecsRoles.ecsTaskRole, - executionRole: ecsRoles.taskExecutionRole, - image: ContainerImage.fromRegistry('redis:5.0.3-alpine'), - name: 'redis', - serviceDiscoveryNamespace: props.serviceDiscoveryNamespace, - }); - // api service // const backendService = new WebService(this, 'ApiService', { @@ -110,7 +98,16 @@ export class AdHocApp extends Construct { executionRole: ecsRoles.taskExecutionRole, image: backendImage, listener: props.listener, - command: ['gunicorn', '-t', '1000', '-b', '0.0.0.0:8000', '--log-level', 'info', 'backend.wsgi'], + command: [ + 'gunicorn', + '-t', + '1000', + '-b', + '0.0.0.0:8000', + '--log-level', + 'info', + 'backend.wsgi', + ], name: 'gunicorn', port: 8000, domainName: props.domainName, @@ -148,7 +145,14 @@ export class AdHocApp extends Construct { taskRole: ecsRoles.ecsTaskRole, executionRole: ecsRoles.taskExecutionRole, image: backendImage, - command: ['celery', '--app=backend.celery_app:app', 'worker', '--loglevel=INFO', '-Q', 'default'], + command: [ + 'celery', + '--app=backend.celery_app:app', + 'worker', + '--loglevel=INFO', + '-Q', + 'default', + ], name: 'default-worker', }); @@ -167,6 +171,19 @@ export class AdHocApp extends Construct { name: 'backendUpdate', }); + // worker service + new WorkerService(this, 'EcsExecBastion', { + cluster, + environmentVariables, + vpc: props.vpc, + appSecurityGroup: props.appSecurityGroup, + taskRole: ecsRoles.ecsTaskRole, + executionRole: ecsRoles.taskExecutionRole, + image: backendImage, + command: ['sh', '-c', 'tail -f /dev/null'], + name: 'ecs-exec', + }); + // define stack output use for running the management command new CfnOutput(this, 'backendUpdateCommand', { value: backendUpdateTask.executionScript }); new CfnOutput(this, 'domainName', { value: cnameRecord.domainName }); diff --git a/src/constructs/ad-hoc/base/index.ts b/src/constructs/ad-hoc/base/index.ts index 3441bf8..ee341da 100644 --- a/src/constructs/ad-hoc/base/index.ts +++ b/src/constructs/ad-hoc/base/index.ts @@ -3,10 +3,10 @@ import { IVpc, SecurityGroup } from 'aws-cdk-lib/aws-ec2'; import { ApplicationListener, ApplicationLoadBalancer } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; import { DatabaseInstance } from 'aws-cdk-lib/aws-rds'; import { Bucket } from 'aws-cdk-lib/aws-s3'; -import { PrivateDnsNamespace } from 'aws-cdk-lib/aws-servicediscovery'; import { Construct } from 'constructs'; import { AlbResources } from '../../internal/alb'; import { BastionHostResources } from '../../internal/bastion'; +import { ElastiCacheCluster } from '../../internal/ec'; import { RdsInstance } from '../../internal/rds'; import { SecurityGroupResources } from '../../internal/sg'; import { ApplicationVpc } from '../../internal/vpc'; @@ -22,11 +22,11 @@ export class AdHocBase extends Construct { public alb: ApplicationLoadBalancer; public appSecurityGroup: SecurityGroup; public albSecurityGroup: SecurityGroup; - public serviceDiscoveryNamespace: PrivateDnsNamespace; public databaseInstance: DatabaseInstance; public assetsBucket: Bucket; public domainName: string; public listener: ApplicationListener; + public elastiCacheHostname: string; constructor(scope: Construct, id: string, props: AdHocBaseProps) { super(scope, id); @@ -58,12 +58,6 @@ export class AdHocBase extends Construct { this.alb = alb; this.listener = listener; - const serviceDiscoveryPrivateDnsNamespace = new PrivateDnsNamespace(this, 'ServiceDiscoveryNamespace', { - vpc: this.vpc, - name: `${stackName}-sd-ns`, - }); - this.serviceDiscoveryNamespace = serviceDiscoveryPrivateDnsNamespace; - const rdsInstance = new RdsInstance(this, 'RdsInstance', { vpc: this.vpc, appSecurityGroup: appSecurityGroup, @@ -72,6 +66,16 @@ export class AdHocBase extends Construct { this.databaseInstance = rdsInstance.rdsInstance; const { dbInstanceEndpointAddress } = rdsInstance.rdsInstance; + // elasticache cluster + const elastiCacheCluster = new ElastiCacheCluster(this, 'ElastiCacheCluster', { + vpc: this.vpc, + appSecurityGroup: appSecurityGroup, + }); + + // get the elasticache cluster hostname + this.elastiCacheHostname = elastiCacheCluster.elastiCacheHost; + + // TODO: is this needed? new BastionHostResources(this, 'BastionHostResources', { appSecurityGroup, vpc: this.vpc, diff --git a/src/constructs/internal/ec/index.ts b/src/constructs/internal/ec/index.ts new file mode 100644 index 0000000..f36db25 --- /dev/null +++ b/src/constructs/internal/ec/index.ts @@ -0,0 +1,68 @@ +import { IVpc, Port, SecurityGroup, SubnetType } from 'aws-cdk-lib/aws-ec2'; +import { CfnSubnetGroup, CfnCacheCluster, CfnParameterGroup } from 'aws-cdk-lib/aws-elasticache'; +import { Construct } from 'constructs'; + + +interface ElastiCacheClusterProps { + readonly vpc: IVpc; + readonly appSecurityGroup: SecurityGroup; + readonly instanceClass?: string; + readonly instanceSize?: string; +} + +export class ElastiCacheCluster extends Construct { + // public rdsInstance: DatabaseInstance; + private instanceClass: string; + private instanceSize: string; + public elastiCacheHost: string; + + + constructor(scope: Construct, id: string, props: ElastiCacheClusterProps) { + super(scope, id); + + // const stackName = Stack.of(this).stackName; + + // instance type from props + this.instanceClass = props.instanceClass ?? 't4g'; + this.instanceSize = props.instanceSize ?? 'micro'; + + const cacheNodeType = `cache.${this.instanceClass}.${this.instanceSize}`; + + // security group + const elastiCacheSecurityGroup = new SecurityGroup(this, 'SecurityGroup', { + vpc: props.vpc, + description: 'Allow all outbound traffic', + allowAllOutbound: true, + }); + + // elastiCacheSecurityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(6379), 'ElastiCacheRedis'); + elastiCacheSecurityGroup.addIngressRule(props.appSecurityGroup, Port.tcp(6379), 'AppSecurityGroup'); + + // ElastiCache subnet group + const subnetGroup = new CfnSubnetGroup(this, 'SubnetGroup', { + description: 'Subnet group for ElastiCache', + subnetIds: props.vpc.selectSubnets({ subnetType: SubnetType.PRIVATE_WITH_EGRESS }).subnetIds, + }); + + // ElastiCache parameter group + const elastiCacheParameterGroup = new CfnParameterGroup(this, 'ElastiCacheParameterGroup', { + description: 'parameter group for elasticache cluster', + cacheParameterGroupFamily: 'redis7', + properties: {}, + }); + + // ElastiCache cluster + const cacheCluster = new CfnCacheCluster(this, 'CacheCluster', { + cacheNodeType: cacheNodeType, // Node type for a single-node cluster + engine: 'redis', + engineVersion: '7.0', + numCacheNodes: 1, // Single node + cacheSubnetGroupName: subnetGroup.ref, + cacheParameterGroupName: elastiCacheParameterGroup.ref, + vpcSecurityGroupIds: [elastiCacheSecurityGroup.securityGroupId], + }); + + this.elastiCacheHost = cacheCluster.attrRedisEndpointAddress; + + } +} diff --git a/src/constructs/internal/ecs/iam/index.ts b/src/constructs/internal/ecs/iam/index.ts index 7a9d486..6e19bf8 100644 --- a/src/constructs/internal/ecs/iam/index.ts +++ b/src/constructs/internal/ecs/iam/index.ts @@ -47,6 +47,7 @@ export class EcsRoles extends Construct { }); // S3 + // TODO: tighten persmissions https://stackoverflow.com/a/23667874/6084948 taskExecutionRole.addToPolicy(new PolicyStatement({ effect: Effect.ALLOW, actions: ['s3:*'], diff --git a/src/constructs/internal/rds/index.ts b/src/constructs/internal/rds/index.ts index c0ec14c..6f6e409 100644 --- a/src/constructs/internal/rds/index.ts +++ b/src/constructs/internal/rds/index.ts @@ -1,4 +1,3 @@ -// import { Stack } from 'aws-cdk-lib'; import { Stack } from 'aws-cdk-lib'; import { InstanceType, IVpc, Peer, Port, SecurityGroup, SubnetType } from 'aws-cdk-lib/aws-ec2'; import { Credentials, DatabaseInstance, DatabaseInstanceEngine, PostgresEngineVersion } from 'aws-cdk-lib/aws-rds'; diff --git a/src/examples/ad-hoc/index.ts b/src/examples/ad-hoc/index.ts index 0cb3a1a..b953c73 100644 --- a/src/examples/ad-hoc/index.ts +++ b/src/examples/ad-hoc/index.ts @@ -34,15 +34,15 @@ const addHocApp = new AdHocApp(appStack, 'AdHocApp', { vpc: adHocBase.vpc, alb: adHocBase.alb, appSecurityGroup: adHocBase.appSecurityGroup, - serviceDiscoveryNamespace: adHocBase.serviceDiscoveryNamespace, rdsInstance: adHocBase.databaseInstance, assetsBucket: adHocBase.assetsBucket, domainName: adHocBase.domainName, listener: adHocBase.listener, + elastiCacheHost: adHocBase.elastiCacheHostname, }); /** * Add tagging for this construct and all child constructs */ -Tags.of(adHocBase).add('env', adHocBaseEnvName); -Tags.of(addHocApp).add('env', adHocAppEnvName); \ No newline at end of file +Tags.of(adHocBase).add('base-env', adHocBaseEnvName); +Tags.of(addHocApp).add('app-env', adHocAppEnvName); \ No newline at end of file