From 2bccacb52bf440a1c424853316e7769c44b90228 Mon Sep 17 00:00:00 2001 From: andrewpatto Date: Mon, 2 Oct 2023 14:04:41 +1100 Subject: [PATCH] Biting the bullet on app runner --- ...a-data-application-app-runner-construct.ts | 233 +++++------------- .../app/elsa-data-application-construct.ts | 229 ----------------- ...elsa-data-application-fargate-construct.ts | 128 ++++++++++ .../app/elsa-data-application-shared.ts | 119 +++++++++ .../construct/container-construct.ts | 41 ++- packages/aws-application/elsa-data-stack.ts | 46 ++-- 6 files changed, 370 insertions(+), 426 deletions(-) delete mode 100644 packages/aws-application/app/elsa-data-application-construct.ts create mode 100644 packages/aws-application/app/elsa-data-application-fargate-construct.ts create mode 100644 packages/aws-application/app/elsa-data-application-shared.ts diff --git a/packages/aws-application/app/elsa-data-application-app-runner-construct.ts b/packages/aws-application/app/elsa-data-application-app-runner-construct.ts index f302300..7fe8624 100644 --- a/packages/aws-application/app/elsa-data-application-app-runner-construct.ts +++ b/packages/aws-application/app/elsa-data-application-app-runner-construct.ts @@ -1,24 +1,24 @@ -import { - aws_ec2 as ec2, - aws_ecs as ecs, - CfnOutput, - Stack, - StackProps, -} from "aws-cdk-lib"; +import { aws_ec2 as ec2, CfnOutput, Stack } from "aws-cdk-lib"; import { Construct } from "constructs"; -import { DockerImageAsset, Platform } from "aws-cdk-lib/aws-ecr-assets"; import { Policy, PolicyStatement, Role, ServicePrincipal, } from "aws-cdk-lib/aws-iam"; -import { ISecret } from "aws-cdk-lib/aws-secretsmanager"; -import { Service } from "aws-cdk-lib/aws-servicediscovery"; -import { IHostedZone } from "aws-cdk-lib/aws-route53"; -import { ICertificate } from "aws-cdk-lib/aws-certificatemanager"; +import { INamespace } from "aws-cdk-lib/aws-servicediscovery"; import { ElsaDataApplicationSettings } from "../elsa-data-application-settings"; import * as apprunner from "@aws-cdk/aws-apprunner-alpha"; +import { + addAccessPointStatementsToPolicy, + addBaseStatementsToPolicy, +} from "./elsa-data-application-shared"; +import { getPolicyStatementsFromDataBucketPaths } from "../helper/bucket-names-to-policy"; +import { ISecurityGroup } from "aws-cdk-lib/aws-ec2"; +import { IBucket } from "aws-cdk-lib/aws-s3"; +import { ContainerConstruct } from "../construct/container-construct"; +import { IHostedZone } from "aws-cdk-lib/aws-route53"; +import { ICertificate } from "aws-cdk-lib/aws-certificatemanager"; // // WIP warning @@ -28,91 +28,80 @@ import * as apprunner from "@aws-cdk/aws-apprunner-alpha"; // AND IMPLEMENT HERE // -interface Props extends StackProps { - vpc: ec2.IVpc; +interface Props extends ElsaDataApplicationSettings { + readonly vpc: ec2.IVpc; + + readonly container: ContainerConstruct; - hostedZone: IHostedZone; - hostedZoneCertificate: ICertificate; + readonly cloudMapNamespace: INamespace; - cloudMapService: Service; + // in anticipation of app runner being able to make CNAME mappings automatically - currently not used + readonly hostedZone: IHostedZone; + readonly hostedZoneCertificate: ICertificate; + readonly deployedUrl: string; - /** - * The (passwordless) DSN of our EdgeDb instance as passed to us - * from the EdgeDb stack. - */ - edgeDbDsnNoPassword: string; + // the security group of our edgedb - that we will put ourselves in to enable access + readonly edgeDbSecurityGroup: ISecurityGroup; - /** - * The secret holding the password of our EdgeDb instance. - */ - edgeDbPasswordSecret: ISecret; + // a policy statement that we need to add to our app service in order to give us access to the secrets + readonly accessSecretsPolicyStatement: PolicyStatement; - settings: ElsaDataApplicationSettings; + // a policy statement that we need to add to our app service in order to discover other services via cloud map + readonly discoverServicesPolicyStatement: PolicyStatement; + + // an already created temp bucket we can use + readonly tempBucket: IBucket; } /** * The stack for deploying the actual Elsa Data web application via AppRunner. */ export class ElsaDataApplicationAppRunnerConstruct extends Construct { - public deployedUrl: string; - constructor(scope: Construct, id: string, props: Props) { super(scope, id); - this.deployedUrl = `https://${props.settings.urlPrefix}.${props.hostedZone.zoneName}`; - - if (!props.settings.buildLocal) return; - - const asset = new DockerImageAsset(this, "DockerImageAsset", { - directory: props.settings.buildLocal.folder, - platform: Platform.LINUX_AMD64, - buildArgs: { - ELSA_DATA_BASE_IMAGE: props.settings.imageBaseName, - }, - }); - const vpcConnector = new apprunner.VpcConnector(this, "VpcConnector", { vpc: props.vpc, vpcSubnets: props.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, }), + securityGroups: [props.edgeDbSecurityGroup], }); - const policy = this.createPolicy( - props.settings.awsPermissions.dataBucketPaths, - props.settings.awsPermissions.enableAccessPoints + const policy = new Policy(this, "AppRunnerServiceTaskPolicy"); + + addBaseStatementsToPolicy( + policy, + Stack.of(this).partition, + Stack.of(this).region, + Stack.of(this).account, + props.accessSecretsPolicyStatement, + props.discoverServicesPolicyStatement, + ...getPolicyStatementsFromDataBucketPaths( + Stack.of(this).partition, + props.awsPermissions.dataBucketPaths + ) ); + if (props.awsPermissions.enableAccessPoints) + addAccessPointStatementsToPolicy( + policy, + Stack.of(this).partition, + Stack.of(this).region, + Stack.of(this).account + ); + const role = new Role(this, "ServiceRole", { assumedBy: new ServicePrincipal("tasks.apprunner.amazonaws.com"), }); role.attachInlinePolicy(policy); + // 👇 grant access to bucket + props.tempBucket.grantReadWrite(role); + const appService = new apprunner.Service(this, "Service", { - source: apprunner.Source.fromAsset({ - imageConfiguration: { - port: 80, - environmentSecrets: { - EDGEDB_PASSWORD: ecs.Secret.fromSecretsManager( - props.edgeDbPasswordSecret - ), - }, - environmentVariables: { - EDGEDB_DSN: props.edgeDbDsnNoPassword, - EDGEDB_CLIENT_TLS_SECURITY: "insecure", - ELSA_DATA_META_CONFIG_FOLDERS: - props.settings.metaConfigFolders || "./config", - ELSA_DATA_META_CONFIG_SOURCES: props.settings.metaConfigSources, - // override any config settings that we know definitively here because of the - // way we have done the deployment - ELSA_DATA_CONFIG_DEPLOYED_URL: this.deployedUrl, - ELSA_DATA_CONFIG_PORT: "80", - ELSA_DATA_CONFIG_AWS_TEMP_BUCKET: "tempbucket", - }, - }, - asset: asset, - }), + source: props.container.appRunnerSource, instanceRole: role, autoDeploymentsEnabled: false, vpcConnector: vpcConnector, @@ -122,114 +111,4 @@ export class ElsaDataApplicationAppRunnerConstruct extends Construct { value: appService.serviceUrl, }); } - - /** - * A policy statement that we can use that gives access only to - * known Elsa Data secrets (by naming convention). - * - * @private - */ - private getSecretPolicyStatement(): PolicyStatement { - return new PolicyStatement({ - actions: ["secretsmanager:GetSecretValue"], - resources: [ - `arn:aws:secretsmanager:${Stack.of(this).region}:${ - Stack.of(this).account - }:secret:ElsaData*`, - ], - }); - } - - private createPolicy( - dataBucketPaths: { [p: string]: string[] }, - enableAccessPoints: boolean - ): Policy { - const policy = new Policy(this, "FargateServiceTaskPolicy"); - - // need to be able to fetch secrets - we wildcard to every Secret that has our designated prefix of elsa* - policy.addStatements(this.getSecretPolicyStatement()); - - // for some of our scaling out work (Beacon etc) - we are going to make Lambdas that we want to be able to invoke - // again we wildcard to a designated prefix of elsa-data* - policy.addStatements( - new PolicyStatement({ - actions: ["lambda:InvokeFunction"], - resources: [ - `arn:aws:lambda:${Stack.of(this).region}:${ - Stack.of(this).account - }:function:elsa-data-*`, - ], - }) - ); - - // restrict our Get operations to a very specific set of keys in the named buckets - // NOTE: our 'signing' is always done by a different user so this is not the only - // permission that has to be set correctly - for (const [bucketName, keyWildcards] of Object.entries(dataBucketPaths)) { - policy.addStatements( - new PolicyStatement({ - actions: ["s3:GetObject"], - // NOTE: we could consider restricting to region or account here in constructing the ARNS - // but given the bucket names are already globally specific we leave them open - resources: keyWildcards.map((k) => `arn:aws:s3:::${bucketName}/${k}`), - }) - ); - } - - policy.addStatements( - new PolicyStatement({ - actions: ["s3:ListBucket"], - resources: Object.keys(dataBucketPaths).map((b) => `arn:aws:s3:::${b}`), - }) - ); - - if (enableAccessPoints) { - policy.addStatements( - // temporarily give all S3 accesspoint perms - can we tighten? - new PolicyStatement({ - actions: [ - "s3:CreateAccessPoint", - "s3:DeleteAccessPoint", - "s3:DeleteAccessPointPolicy", - "s3:GetAccessPoint", - "s3:GetAccessPointPolicy", - "s3:GetAccessPointPolicyStatus", - "s3:ListAccessPoints", - "s3:PutAccessPointPolicy", - "s3:PutAccessPointPublicAccessBlock", - ], - resources: [`*`], - }) - ); - - policy.addStatements( - // access points need the ability to do CloudFormation - // TODO: tighten the policy on the CreateStack as that is a powerful function - // possibly restrict the source of the template url - // possibly restrict the user enacting the CreateStack to only them to create access points - new PolicyStatement({ - actions: [ - "cloudformation:CreateStack", - "cloudformation:DescribeStacks", - "cloudformation:DeleteStack", - ], - resources: [ - `arn:aws:cloudformation:${Stack.of(this).region}:${ - Stack.of(this).account - }:stack/elsa-data-*`, - ], - }) - ); - } - - // for AwsDiscoveryService - policy.addStatements( - new PolicyStatement({ - actions: ["servicediscovery:DiscoverInstances"], - resources: ["*"], - }) - ); - - return policy; - } } diff --git a/packages/aws-application/app/elsa-data-application-construct.ts b/packages/aws-application/app/elsa-data-application-construct.ts deleted file mode 100644 index 9774b4b..0000000 --- a/packages/aws-application/app/elsa-data-application-construct.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { Stack } from "aws-cdk-lib"; -import { Construct } from "constructs"; -import { DockerServiceWithHttpsLoadBalancerConstruct } from "../construct/docker-service-with-https-load-balancer-construct"; -import { Policy, PolicyStatement } from "aws-cdk-lib/aws-iam"; -import { ISecurityGroup } from "aws-cdk-lib/aws-ec2"; -import { INamespace, Service } from "aws-cdk-lib/aws-servicediscovery"; -import { IHostedZone } from "aws-cdk-lib/aws-route53"; -import { ICertificate } from "aws-cdk-lib/aws-certificatemanager"; -import { ElsaDataApplicationSettings } from "../elsa-data-application-settings"; -import { IBucket } from "aws-cdk-lib/aws-s3"; -import { ClusterConstruct } from "../construct/cluster-construct"; -import { ContainerConstruct } from "../construct/container-construct"; -import { TaskDefinitionConstruct } from "../construct/task-definition-construct"; -import { FargateService } from "aws-cdk-lib/aws-ecs"; -import { getPolicyStatementsFromDataBucketPaths } from "../helper/bucket-names-to-policy"; - -interface Props extends ElsaDataApplicationSettings { - readonly cluster: ClusterConstruct; - - readonly container: ContainerConstruct; - - readonly taskDefinition: TaskDefinitionConstruct; - - readonly cloudMapNamespace: INamespace; - - readonly hostedZone: IHostedZone; - readonly hostedZoneCertificate: ICertificate; - - readonly deployedUrl: string; - - // the security group of our edgedb - that we will put ourselves in to enable access - readonly edgeDbSecurityGroup: ISecurityGroup; - - // a policy statement that we need to add to our app service in order to give us access to the secrets - readonly accessSecretsPolicyStatement: PolicyStatement; - - // a policy statement that we need to add to our app service in order to discover other services via cloud map - readonly discoverServicesPolicyStatement: PolicyStatement; - - // an already created temp bucket we can use - readonly tempBucket: IBucket; -} - -/** - * A construct that deploys Elsa Data as a Fargate service. - */ -export class ElsaDataApplicationConstruct extends Construct { - private readonly privateServiceWithLoadBalancer: DockerServiceWithHttpsLoadBalancerConstruct; - - constructor(scope: Construct, id: string, props: Props) { - super(scope, id); - - this.privateServiceWithLoadBalancer = - new DockerServiceWithHttpsLoadBalancerConstruct( - this, - "PrivateServiceWithLb", - { - cluster: props.cluster, - taskDefinition: props.taskDefinition, - // we need to at least be placed in the EdgeDb security group so that we can access EdgeDb - securityGroups: [props.edgeDbSecurityGroup], - hostedPrefix: props.urlPrefix, - hostedZone: props.hostedZone, - hostedZoneCertificate: props.hostedZoneCertificate, - desiredCount: props.desiredCount ?? 1, - healthCheckPath: "/api/health/check", - } - ); - - const policy = new Policy(this, "FargateServiceTaskPolicy"); - - // need to be able to fetch secrets but the infrastructure can give us a wildcard - // policy statement that does that - policy.addStatements(props.accessSecretsPolicyStatement); - - // need to be able to discover instances in the cloud map namespace - and our - // infrastructure can give us a policy statement to enable that - policy.addStatements(props.discoverServicesPolicyStatement); - - // we (currently) give the application access to all the data bucket objects - // TODO consider subsetting even this permissions (only manifests??) - policy.addStatements( - ...getPolicyStatementsFromDataBucketPaths( - Stack.of(this).partition, - props.awsPermissions.dataBucketPaths - ) - ); - - // allow cloudtrail queries to get data egress records - policy.addStatements( - new PolicyStatement({ - actions: ["cloudtrail:StartQuery", "cloudtrail:GetQueryResults"], - resources: ["*"], - }) - ); - - // allow sending emails through SES via Node Mailer (https://nodemailer.com/transports/ses/) - policy.addStatements( - new PolicyStatement({ - actions: ["ses:SendRawEmail"], - resources: ["*"], - }) - ); - - // TODO consider moving all the "write" permissions here to be a CMD level (i.e. cluster admins only) - // and only have "read" permissions here - if (props.awsPermissions.enableAccessPoints) { - policy.addStatements( - // temporarily give all S3 accesspoint perms - can we tighten? - new PolicyStatement({ - actions: [ - "s3:CreateAccessPoint", - "s3:DeleteAccessPoint", - "s3:DeleteAccessPointPolicy", - "s3:GetAccessPoint", - "s3:GetAccessPointPolicy", - "s3:GetAccessPointPolicyStatus", - "s3:ListAccessPoints", - "s3:PutAccessPointPolicy", - "s3:PutAccessPointPublicAccessBlock", - ], - resources: [`*`], - }) - ); - - policy.addStatements( - // access points need the ability to do CloudFormation - // TODO: tighten the policy on the CreateStack as that is a powerful function - // possibly restrict the source of the template url - // possibly restrict the user enacting the CreateStack to only them to create access points - new PolicyStatement({ - actions: [ - "cloudformation:CreateStack", - "cloudformation:DescribeStacks", - "cloudformation:DeleteStack", - ], - resources: [ - `arn:${Stack.of(this).partition}:cloudformation:${ - Stack.of(this).region - }:${Stack.of(this).account}:stack/elsa-data-*`, - ], - }) - ); - } - - // allow starting our steps copy out and any lookup operations we need to perform - policy.addStatements( - new PolicyStatement({ - actions: ["states:StartExecution"], - resources: [ - `arn:${Stack.of(this).partition}:states:${Stack.of(this).region}:${ - Stack.of(this).account - }:stateMachine:CopyOut*`, - ], - }), - new PolicyStatement({ - actions: [ - "states:StopExecution", - "states:DescribeExecution", - "states:ListMapRuns", - ], - resources: [ - `arn:${Stack.of(this).partition}:states:${Stack.of(this).region}:${ - Stack.of(this).account - }:execution:CopyOut*:*`, - ], - }), - new PolicyStatement({ - actions: ["states:DescribeMapRun"], - resources: [ - `arn:${Stack.of(this).partition}:states:${Stack.of(this).region}:${ - Stack.of(this).account - }:mapRun:CopyOut*/*:*`, - ], - }) - ); - - // for some of our scaling out work (Beacon etc) - we are going to make Lambdas that we want to be able to invoke - // again we wildcard to a designated prefix of elsa-data* - // TODO parameterise this to not have a magic string - policy.addStatements( - new PolicyStatement({ - actions: ["lambda:InvokeFunction"], - resources: [ - `arn:${Stack.of(this).partition}:lambda:${Stack.of(this).region}:${ - Stack.of(this).account - }:function:elsa-data-*`, - ], - }) - ); - - // allow discovery - policy.addStatements( - new PolicyStatement({ - actions: ["servicediscovery:DiscoverInstances"], - resources: ["*"], - }) - ); - - // the permissions of the running container (i.e all AWS functionality used by Elsa Data code) - this.privateServiceWithLoadBalancer.service.taskDefinition.taskRole.attachInlinePolicy( - policy - ); - - // 👇 grant access to bucket - props.tempBucket.grantReadWrite( - this.privateServiceWithLoadBalancer.service.taskDefinition.taskRole - ); - - // register a cloudMapService for the Application in our namespace - // chose a sensible default - but allow an alteration in case I guess someone might - // want to run two Elsa *in the same infrastructure* - const service = new Service(this, "CloudMapService", { - namespace: props.cloudMapNamespace, - name: "Application", - description: "Web application", - }); - - service.registerNonIpInstance("CloudMapCustomAttributes", { - customAttributes: { - deployedUrl: props.deployedUrl, - }, - }); - } - - public fargateService(): FargateService { - return this.privateServiceWithLoadBalancer.service.service; - } -} diff --git a/packages/aws-application/app/elsa-data-application-fargate-construct.ts b/packages/aws-application/app/elsa-data-application-fargate-construct.ts new file mode 100644 index 0000000..8fc6909 --- /dev/null +++ b/packages/aws-application/app/elsa-data-application-fargate-construct.ts @@ -0,0 +1,128 @@ +import { Stack } from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { DockerServiceWithHttpsLoadBalancerConstruct } from "../construct/docker-service-with-https-load-balancer-construct"; +import { Policy, PolicyStatement } from "aws-cdk-lib/aws-iam"; +import { ISecurityGroup } from "aws-cdk-lib/aws-ec2"; +import { INamespace, Service } from "aws-cdk-lib/aws-servicediscovery"; +import { IHostedZone } from "aws-cdk-lib/aws-route53"; +import { ICertificate } from "aws-cdk-lib/aws-certificatemanager"; +import { ElsaDataApplicationSettings } from "../elsa-data-application-settings"; +import { IBucket } from "aws-cdk-lib/aws-s3"; +import { ClusterConstruct } from "../construct/cluster-construct"; +import { ContainerConstruct } from "../construct/container-construct"; +import { TaskDefinitionConstruct } from "../construct/task-definition-construct"; +import { FargateService } from "aws-cdk-lib/aws-ecs"; +import { getPolicyStatementsFromDataBucketPaths } from "../helper/bucket-names-to-policy"; +import { + addAccessPointStatementsToPolicy, + addBaseStatementsToPolicy, +} from "./elsa-data-application-shared"; + +interface Props extends ElsaDataApplicationSettings { + // the cluster to run the fargate tasks in + readonly cluster: ClusterConstruct; + + // the container to run in fargate + readonly container: ContainerConstruct; + + readonly taskDefinition: TaskDefinitionConstruct; + + readonly cloudMapNamespace: INamespace; + + readonly hostedZone: IHostedZone; + readonly hostedZoneCertificate: ICertificate; + + readonly deployedUrl: string; + + // the security group of our edgedb - that we will put ourselves in to enable access + readonly edgeDbSecurityGroup: ISecurityGroup; + + // a policy statement that we need to add to our app service in order to give us access to the secrets + readonly accessSecretsPolicyStatement: PolicyStatement; + + // a policy statement that we need to add to our app service in order to discover other services via cloud map + readonly discoverServicesPolicyStatement: PolicyStatement; + + // an already created temp bucket we can use + readonly tempBucket: IBucket; +} + +/** + * A construct that deploys Elsa Data as a Fargate service. + */ +export class ElsaDataApplicationFargateConstruct extends Construct { + private readonly privateServiceWithLoadBalancer: DockerServiceWithHttpsLoadBalancerConstruct; + + constructor(scope: Construct, id: string, props: Props) { + super(scope, id); + + this.privateServiceWithLoadBalancer = + new DockerServiceWithHttpsLoadBalancerConstruct( + this, + "PrivateServiceWithLb", + { + cluster: props.cluster, + taskDefinition: props.taskDefinition, + // we need to at least be placed in the EdgeDb security group so that we can access EdgeDb + securityGroups: [props.edgeDbSecurityGroup], + hostedPrefix: props.urlPrefix, + hostedZone: props.hostedZone, + hostedZoneCertificate: props.hostedZoneCertificate, + desiredCount: props.desiredCount ?? 1, + healthCheckPath: "/api/health/check", + } + ); + + const policy = new Policy(this, "FargateServiceTaskPolicy"); + + addBaseStatementsToPolicy( + policy, + Stack.of(this).partition, + Stack.of(this).region, + Stack.of(this).account, + props.accessSecretsPolicyStatement, + props.discoverServicesPolicyStatement, + ...getPolicyStatementsFromDataBucketPaths( + Stack.of(this).partition, + props.awsPermissions.dataBucketPaths + ) + ); + + if (props.awsPermissions.enableAccessPoints) + addAccessPointStatementsToPolicy( + policy, + Stack.of(this).partition, + Stack.of(this).region, + Stack.of(this).account + ); + + // the permissions of the running container (i.e. all AWS functionality used by Elsa Data code) + this.privateServiceWithLoadBalancer.service.taskDefinition.taskRole.attachInlinePolicy( + policy + ); + + // 👇 grant access to bucket + props.tempBucket.grantReadWrite( + this.privateServiceWithLoadBalancer.service.taskDefinition.taskRole + ); + + // register a cloudMapService for the Application in our namespace + // chose a sensible default - but allow an alteration in case I guess someone might + // want to run two Elsa *in the same infrastructure* + const service = new Service(this, "CloudMapService", { + namespace: props.cloudMapNamespace, + name: "Application", + description: "Web application", + }); + + service.registerNonIpInstance("CloudMapCustomAttributes", { + customAttributes: { + deployedUrl: props.deployedUrl, + }, + }); + } + + public fargateService(): FargateService { + return this.privateServiceWithLoadBalancer.service.service; + } +} diff --git a/packages/aws-application/app/elsa-data-application-shared.ts b/packages/aws-application/app/elsa-data-application-shared.ts new file mode 100644 index 0000000..5442659 --- /dev/null +++ b/packages/aws-application/app/elsa-data-application-shared.ts @@ -0,0 +1,119 @@ +import { Policy, PolicyStatement } from "aws-cdk-lib/aws-iam"; + +export function addBaseStatementsToPolicy( + policy: Policy, + partition: string, + region: string, + account: string, + ...statement: PolicyStatement[] +) { + for (const s of statement ?? []) { + policy.addStatements(s); + } + + // allow cloudtrail queries to get data egress records + policy.addStatements( + new PolicyStatement({ + actions: ["cloudtrail:StartQuery", "cloudtrail:GetQueryResults"], + resources: ["*"], + }) + ); + + // allow sending emails through SES via Node Mailer (https://nodemailer.com/transports/ses/) + policy.addStatements( + new PolicyStatement({ + actions: ["ses:SendRawEmail"], + resources: ["*"], + }) + ); + + // allow starting our steps copy out and any lookup operations we need to perform + policy.addStatements( + new PolicyStatement({ + actions: ["states:StartExecution"], + resources: [ + `arn:${partition}:states:${region}:${account}:stateMachine:CopyOut*`, + ], + }), + new PolicyStatement({ + actions: [ + "states:StopExecution", + "states:DescribeExecution", + "states:ListMapRuns", + ], + resources: [ + `arn:${partition}:states:${region}:${account}:execution:CopyOut*:*`, + ], + }), + new PolicyStatement({ + actions: ["states:DescribeMapRun"], + resources: [ + `arn:${partition}:states:${region}:${account}:mapRun:CopyOut*/*:*`, + ], + }) + ); + + // for some of our scaling out work (Beacon etc) - we are going to make Lambdas that we want to be able to invoke + // again we wildcard to a designated prefix of elsa-data* + // TODO parameterise this to not have a magic string + policy.addStatements( + new PolicyStatement({ + actions: ["lambda:InvokeFunction"], + resources: [ + `arn:${partition}:lambda:${region}:${account}:function:elsa-data-*`, + ], + }) + ); + + // allow discovery + policy.addStatements( + new PolicyStatement({ + actions: ["servicediscovery:DiscoverInstances"], + resources: ["*"], + }) + ); +} + +export function addAccessPointStatementsToPolicy( + policy: Policy, + partition: string, + region: string, + account: string +) { + // TODO consider moving all the "write" permissions here to be a CMD level (i.e. cluster admins only) + // and only have "read" permissions here + policy.addStatements( + // temporarily give all S3 accesspoint perms - can we tighten? + new PolicyStatement({ + actions: [ + "s3:CreateAccessPoint", + "s3:DeleteAccessPoint", + "s3:DeleteAccessPointPolicy", + "s3:GetAccessPoint", + "s3:GetAccessPointPolicy", + "s3:GetAccessPointPolicyStatus", + "s3:ListAccessPoints", + "s3:PutAccessPointPolicy", + "s3:PutAccessPointPublicAccessBlock", + ], + resources: [`*`], + }) + ); + + policy.addStatements( + // access points need the ability to do CloudFormation + // TODO: tighten the policy on the CreateStack as that is a powerful function + // possibly restrict the source of the template url + // possibly restrict the user enacting the CreateStack to only them to create access points + new PolicyStatement({ + actions: [ + "cloudformation:CreateStack", + "cloudformation:DescribeStacks", + "cloudformation:DeleteStack", + ], + resources: [ + `arn:${partition}:cloudformation:${region}:${account}:stack/elsa-data-*`, + ], + }) + ); +} diff --git a/packages/aws-application/construct/container-construct.ts b/packages/aws-application/construct/container-construct.ts index aaa4d54..6b5331f 100644 --- a/packages/aws-application/construct/container-construct.ts +++ b/packages/aws-application/construct/container-construct.ts @@ -1,7 +1,9 @@ import { Construct } from "constructs"; import { DockerImageAsset, Platform } from "aws-cdk-lib/aws-ecr-assets"; -import { ContainerImage } from "aws-cdk-lib/aws-ecs"; +import { ContainerImage, Secret } from "aws-cdk-lib/aws-ecs"; import { ElsaDataApplicationBuildLocal } from "../elsa-data-application-settings"; +import { Source } from "@aws-cdk/aws-apprunner-alpha"; +import * as apprunner from "@aws-cdk/aws-apprunner-alpha"; interface Props { /** @@ -19,6 +21,17 @@ interface Props { * setup. See also `buildLocal.folder`. */ readonly imageBaseName: string; + + /** + * Environment variables to appear in the running container. + */ + readonly environment: { [p: string]: string }; + + /** + * Secrets that can be expanded out in the environment on spin + * up (hidden from AWS console) NOTE: ecs Secrets, not Secret Manager secrets + */ + readonly secrets: { [p: string]: Secret }; } // we need a consistent name within the ECS infrastructure for our container @@ -26,7 +39,11 @@ interface Props { const FIXED_CONTAINER_NAME = "ElsaData"; /** - * The stack for deploying the actual Elsa Data web application. + * A construct that represents the runnable Elsa Data container. + * + * This construct can both be used as a container for Fargate OR a source + * for AppRunner. Once the AppRunner constructs come out of Alpha this may + * see this able to be consolidated. */ export class ContainerConstruct extends Construct { // we allow our Elsa image to either be the straight Elsa image from the public repo @@ -34,6 +51,7 @@ export class ContainerConstruct extends Construct { // added etc) public readonly containerImage: ContainerImage; public readonly containerName = FIXED_CONTAINER_NAME; + public readonly appRunnerSource: Source; constructor(scope: Construct, id: string, props: Props) { super(scope, id); @@ -60,12 +78,31 @@ export class ContainerConstruct extends Construct { }), }, }); + this.containerImage = ContainerImage.fromDockerImageAsset(asset); + + this.appRunnerSource = apprunner.Source.fromAsset({ + imageConfiguration: { + port: 80, + environmentSecrets: props.secrets, + environmentVariables: props.environment, + }, + asset: asset, + }); } else { this.containerImage = ContainerImage.fromRegistry( props.imageBaseName, {} ); + + this.appRunnerSource = apprunner.Source.fromEcrPublic({ + imageConfiguration: { + port: 80, + environmentSecrets: props.secrets, + environmentVariables: props.environment, + }, + imageIdentifier: props.imageBaseName, + }); } } } diff --git a/packages/aws-application/elsa-data-stack.ts b/packages/aws-application/elsa-data-stack.ts index bbf890f..c0444e6 100644 --- a/packages/aws-application/elsa-data-stack.ts +++ b/packages/aws-application/elsa-data-stack.ts @@ -1,6 +1,6 @@ import { aws_ecs as ecs, CfnOutput, Stack, StackProps } from "aws-cdk-lib"; import { Construct } from "constructs"; -import { ElsaDataApplicationConstruct } from "./app/elsa-data-application-construct"; +import { ElsaDataApplicationFargateConstruct } from "./app/elsa-data-application-fargate-construct"; import { ElsaDataStackSettings } from "./elsa-data-stack-settings"; import { InfrastructureClient } from "@elsa-data/aws-infrastructure-client"; import { ElsaDataCommandConstruct } from "./command/elsa-data-command-construct"; @@ -9,6 +9,7 @@ import { RetentionDays } from "aws-cdk-lib/aws-logs"; import { ContainerConstruct } from "./construct/container-construct"; import { TaskDefinitionConstruct } from "./construct/task-definition-construct"; import { CpuArchitecture } from "aws-cdk-lib/aws-ecs"; +import { ElsaDataApplicationAppRunnerConstruct } from "./app/elsa-data-application-app-runner-construct"; export { ElsaDataStackSettings, @@ -60,12 +61,6 @@ export class ElsaDataStack extends Stack { const tempBucket = infraClient.getTempBucketFromLookup(this); - // the Elsa Data container is a shared bundling up of the Elsa Data image - const container = new ContainerConstruct(this, "Container", { - buildLocal: applicationProps.buildLocal, - imageBaseName: applicationProps.imageBaseName, - }); - // the cluster is a shared location to run the Elsa Data containers on const cluster = new ClusterConstruct(this, "Cluster", { vpc: vpc, @@ -105,17 +100,25 @@ export class ElsaDataStack extends Stack { ECS_IMAGE_PULL_BEHAVIOR: "default", }); - const makeSecrets = (): { [p: string]: ecs.Secret } => ({ + const makeEcsSecrets = (): { [p: string]: ecs.Secret } => ({ EDGEDB_PASSWORD: ecs.Secret.fromSecretsManager(edgeDbAdminPasswordSecret), }); + // the Elsa Data container is a shared bundling up of the Elsa Data image + const container = new ContainerConstruct(this, "Container", { + buildLocal: applicationProps.buildLocal, + imageBaseName: applicationProps.imageBaseName, + environment: makeEnvironment(), + secrets: makeEcsSecrets(), + }); + const appDef = new TaskDefinitionConstruct(this, "AppDef", { cluster: cluster, container: container, memoryLimitMiB: applicationProps.memoryLimitMiB ?? 2048, cpu: applicationProps.cpu ?? 1024, environment: makeEnvironment(), - secrets: makeSecrets(), + secrets: makeEcsSecrets(), cpuArchitecture: CpuArchitecture.X86_64, logStreamPrefix: "elsa-data-app", }); @@ -124,18 +127,25 @@ export class ElsaDataStack extends Stack { DISABLED - WAITING ON A CDK CONSTRUCT FOR SETTING CNAME OF APPRUNNER THEN WE REALLY SHOULD CONSIDER THIS WOULD REPLACE THE TASK/CLUSTER FOR ACTUALLY RUNNING THE ELSA WEBSITE - new ElsaDataApplicationAppRunnerConstruct(this, "ElsaDataAppRunner", { - env: props.env, + */ + + new ElsaDataApplicationAppRunnerConstruct(this, "AppRunner", { vpc: vpc, - settings: props.serviceElsaData, + container: container, hostedZoneCertificate: certificate!, hostedZone: hostedZone, - cloudMapService: cloudMapService, - edgeDbDsnNoPassword: edgeDb.dsnForEnvironmentVariable, - edgeDbPasswordSecret: edgeDb.edgeDbPasswordSecret, - });*/ + deployedUrl: deployedUrl, + edgeDbSecurityGroup: edgeDbSecurityGroup, + accessSecretsPolicyStatement: + infraClient.getSecretPolicyStatementFromLookup(this), + discoverServicesPolicyStatement: + infraClient.getCloudMapDiscoveryPolicyStatementFromLookup(this), + cloudMapNamespace: namespace, + tempBucket: tempBucket, + ...applicationProps, + }); - const app = new ElsaDataApplicationConstruct(this, "App", { + const app = new ElsaDataApplicationFargateConstruct(this, "App", { cluster: cluster, container: container, taskDefinition: appDef, @@ -160,7 +170,7 @@ export class ElsaDataStack extends Stack { memoryLimitMiB: 1024, cpu: 512, environment: makeEnvironment(), - secrets: makeSecrets(), + secrets: makeEcsSecrets(), cpuArchitecture: CpuArchitecture.X86_64, logStreamPrefix: "elsa-data-command", });