Skip to main content
Glama
backend.ts29.7 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { MedplumInfraConfig } from '@medplum/core'; import { Duration, RemovalPolicy, aws_ec2 as ec2, aws_ecs as ecs, aws_elasticache as elasticache, aws_elasticloadbalancingv2 as elbv2, aws_iam as iam, aws_logs as logs, aws_rds as rds, aws_route53 as route53, aws_s3 as s3, aws_secretsmanager as secretsmanager, aws_ssm as ssm, aws_route53_targets as targets, aws_wafv2 as wafv2, } from 'aws-cdk-lib'; import { Repository } from 'aws-cdk-lib/aws-ecr'; import { ClusterInstance, DBClusterStorageType, ParameterGroup } from 'aws-cdk-lib/aws-rds'; import { Secret, SecretTargetAttachment } from 'aws-cdk-lib/aws-secretsmanager'; import { Construct } from 'constructs'; import assert from 'node:assert'; import { buildWaf } from './waf'; /** * Based on: https://github.com/aws-samples/http-api-aws-fargate-cdk/blob/master/cdk/singleAccount/lib/fargate-vpclink-stack.ts * * RDS config: https://docs.aws.amazon.com/cdk/api/latest/docs/aws-rds-readme.html */ export class BackEnd extends Construct { vpc: ec2.IVpc; botLambdaRole: iam.IRole; rdsSecretsArn?: string; rdsCluster?: rds.DatabaseCluster; rdsProxy?: rds.DatabaseProxy; redisSubnetGroup: elasticache.CfnSubnetGroup; redisSecurityGroup: ec2.ISecurityGroup; redisPassword: secretsmanager.ISecret; redisCluster: elasticache.CfnReplicationGroup; redisSecrets: secretsmanager.ISecret; ecsCluster: ecs.Cluster; taskRolePolicies: iam.PolicyDocument; taskRole: iam.Role; taskDefinition: ecs.FargateTaskDefinition; logGroup?: logs.ILogGroup; logDriver: ecs.LogDriver; serviceContainer: ecs.ContainerDefinition; fargateSecurityGroup: ec2.SecurityGroup; fargateService: ecs.FargateService; targetGroup: elbv2.ApplicationTargetGroup; loadBalancer: elbv2.ApplicationLoadBalancer; waf: wafv2.CfnWebACL; wafAssociation: wafv2.CfnWebACLAssociation; dnsRecord?: route53.ARecord; regionParameter: ssm.StringParameter; databaseSecretsParameter: ssm.StringParameter; databaseProxyEndpointParameter?: ssm.StringParameter; redisSecretsParameter: ssm.StringParameter; botLambdaRoleParameter: ssm.StringParameter; rdsClusterParameterGroup?: rds.ParameterGroup; rdsWriterParameterGroup?: rds.ParameterGroup; rdsReaderParameterGroup?: rds.ParameterGroup; constructor(scope: Construct, config: MedplumInfraConfig) { super(scope, 'BackEnd'); const name = config.name; const accountNumber = config.accountNumber; const region = config.region; // VPC if (config.vpcId) { // Lookup VPC by ARN this.vpc = ec2.Vpc.fromLookup(this, 'VPC', { vpcId: config.vpcId }); } else { // VPC Flow Logs const vpcFlowLogs = new logs.LogGroup(this, 'VpcFlowLogs', { logGroupName: '/medplum/flowlogs/' + name, removalPolicy: RemovalPolicy.DESTROY, }); // Create VPC this.vpc = new ec2.Vpc(this, 'VPC', { maxAzs: config.maxAzs, flowLogs: { cloudwatch: { destination: ec2.FlowLogDestination.toCloudWatchLogs(vpcFlowLogs), trafficType: ec2.FlowLogTrafficType.ALL, }, }, }); } // Bot Lambda Role this.botLambdaRole = new iam.Role(this, 'BotLambdaRole', { assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), }); // RDS this.rdsSecretsArn = config.rdsSecretsArn; if (!this.rdsSecretsArn) { const { engine, majorVersion } = getPostgresEngine( config.rdsInstanceVersion, rds.AuroraPostgresEngineVersion.VER_16_9 ); const clusterParameters: NonNullable<rds.ParameterGroupProps['parameters']> = { statement_timeout: '60000', default_transaction_isolation: 'REPEATABLE READ', ...config.rdsClusterParameters, }; if (config.rdsPersistentParameterGroups) { // bindToCluster and bindToInstance to force parameter group existence even when not actively used by a cluster const idPrefix = `MedplumPG${majorVersion}`; this.rdsClusterParameterGroup = new ParameterGroup(this, `${idPrefix}ClusterParameterGroup`, { engine, parameters: clusterParameters, removalPolicy: RemovalPolicy.RETAIN, }); this.rdsClusterParameterGroup.bindToCluster({}); this.rdsWriterParameterGroup = new ParameterGroup(this, `${idPrefix}WriterInstanceParameterGroup`, { engine, removalPolicy: RemovalPolicy.RETAIN, }); this.rdsWriterParameterGroup.bindToInstance({}); this.rdsReaderParameterGroup = new ParameterGroup(this, `${idPrefix}ReaderInstanceParameterGroup`, { engine, removalPolicy: RemovalPolicy.RETAIN, }); this.rdsReaderParameterGroup.bindToInstance({}); } if (config.rdsIdsMajorVersionSuffix) { if ( !config.rdsPersistentParameterGroups || !this.rdsClusterParameterGroup || !this.rdsWriterParameterGroup || !this.rdsReaderParameterGroup ) { throw new Error('rdsPersistentParameterGroups must be true when rdsIdsMajorVersionSuffix is true'); } this.rdsClusterParameterGroup satisfies rds.ParameterGroup; this.rdsWriterParameterGroup satisfies rds.ParameterGroup; this.rdsReaderParameterGroup satisfies rds.ParameterGroup; } // See: https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_rds-readme.html#migrating-from-instanceprops const defaultInstanceProps: rds.ProvisionedClusterInstanceProps = { enablePerformanceInsights: true, isFromLegacyInstanceProps: true, caCertificate: rds.CaCertificate.RDS_CA_RSA2048_G1, }; const legacyClusterParams = new ParameterGroup(this, 'MedplumDatabaseClusterParams', { engine, parameters: clusterParameters, }); const readerInstanceType = config.rdsReaderInstanceType ?? config.rdsInstanceType; const readerInstanceProps: rds.ProvisionedClusterInstanceProps = { ...defaultInstanceProps, instanceType: readerInstanceType ? new ec2.InstanceType(readerInstanceType) : undefined, parameterGroup: config.rdsIdsMajorVersionSuffix ? this.rdsReaderParameterGroup : undefined, }; const writerInstanceType = config.rdsInstanceType; const writerInstanceProps: rds.ProvisionedClusterInstanceProps = { ...defaultInstanceProps, instanceType: writerInstanceType ? new ec2.InstanceType(writerInstanceType) : undefined, parameterGroup: config.rdsIdsMajorVersionSuffix ? this.rdsWriterParameterGroup : undefined, }; let readers: rds.IClusterInstance[] | undefined; if (config.rdsInstances > 1) { readers = []; for (let i = 1; i < config.rdsInstances; i++) { readers.push(ClusterInstance.provisioned('Instance' + (i + 1), readerInstanceProps)); } } const credentials = rds.Credentials.fromGeneratedSecret('clusteradmin'); const defaultClusterProps: Partial<rds.DatabaseClusterProps> = { credentials, defaultDatabaseName: 'medplum', storageEncrypted: true, // Instances with attached NVMe SSD (db.*d* instance classes) should utilize I/O optimized storage // to unlock additional performance improvements (e.g. tiered caching between memory and SSD) storageType: writerInstanceType?.match(/^\w+d\w*\./i) ? DBClusterStorageType.AURORA_IOPT1 : DBClusterStorageType.AURORA, vpc: this.vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, }, backup: { retention: Duration.days(7), }, cloudwatchLogsExports: ['postgresql'], instanceUpdateBehaviour: rds.InstanceUpdateBehaviour.ROLLING, removalPolicy: RemovalPolicy.RETAIN, autoMinorVersionUpgrade: config.rdsAutoMinorVersionUpgrade, }; const rdsClusterId = getDatabaseClusterId(config.rdsIdsMajorVersionSuffix ? majorVersion : undefined); this.rdsCluster = new rds.DatabaseCluster(this, rdsClusterId, { ...defaultClusterProps, engine, writer: ClusterInstance.provisioned('Instance1', writerInstanceProps), readers, parameterGroup: this.rdsClusterParameterGroup ?? legacyClusterParams, }); const secretAttachment = this.rdsCluster.secret; assert(secretAttachment !== undefined, 'rdsCluster.secret is undefined'); secretAttachment.applyRemovalPolicy(RemovalPolicy.RETAIN); // rdsCluster.secret is actually a SecretAttachment; not the secret itself assert(secretAttachment instanceof SecretTargetAttachment, 'rdsCluster.secret is not a SecretTargetAttachment'); // there is no direct way to get from SecretTargetAttachment to Secret, so break glass by going through node.scope const secret = secretAttachment.node.scope; assert(secret instanceof Secret, 'rdsCluster.secretAttachment.node.scope is not a Secret'); secret.applyRemovalPolicy(RemovalPolicy.RETAIN); this.rdsSecretsArn = secretAttachment.secretArn; if (config.rdsProxyEnabled) { this.rdsProxy = new rds.DatabaseProxy(this, 'DatabaseProxy', { proxyTarget: rds.ProxyTarget.fromCluster(this.rdsCluster), secrets: [secretAttachment], vpc: this.vpc, }); } } // Redis // Important: For HIPAA compliance, you must specify TransitEncryptionEnabled as true, an AuthToken, and a CacheSubnetGroup. this.redisSubnetGroup = new elasticache.CfnSubnetGroup(this, 'RedisSubnetGroup', { description: 'Redis Subnet Group', subnetIds: this.vpc.privateSubnets.map((subnet) => subnet.subnetId), }); if (config.cacheSecurityGroupId) { this.redisSecurityGroup = ec2.SecurityGroup.fromSecurityGroupId( this, 'RedisSecurityGroup', config.cacheSecurityGroupId ); } else { this.redisSecurityGroup = new ec2.SecurityGroup(this, 'RedisSecurityGroup', { vpc: this.vpc, description: 'Redis Security Group', allowAllOutbound: false, }); } this.redisPassword = new secretsmanager.Secret(this, 'RedisPassword', { generateSecretString: { secretStringTemplate: '{}', generateStringKey: 'password', excludeCharacters: '@%*()_+=`~{}|[]\\:";\'?,./', }, }); this.redisCluster = new elasticache.CfnReplicationGroup(this, 'RedisCluster', { engine: 'Redis', engineVersion: '6.x', cacheNodeType: config.cacheNodeType ?? 'cache.t2.medium', replicationGroupDescription: 'RedisReplicationGroup', authToken: this.redisPassword.secretValueFromJson('password').toString(), transitEncryptionEnabled: true, atRestEncryptionEnabled: true, multiAzEnabled: true, cacheSubnetGroupName: this.redisSubnetGroup.ref, numNodeGroups: 1, replicasPerNodeGroup: 1, securityGroupIds: [this.redisSecurityGroup.securityGroupId], }); this.redisCluster.node.addDependency(this.redisPassword); this.redisSecrets = new secretsmanager.Secret(this, 'RedisSecrets', { generateSecretString: { secretStringTemplate: JSON.stringify({ host: this.redisCluster.attrPrimaryEndPointAddress, port: this.redisCluster.attrPrimaryEndPointPort, password: this.redisPassword.secretValueFromJson('password').toString(), tls: {}, }), generateStringKey: 'unused', }, }); this.redisSecrets.node.addDependency(this.redisPassword); this.redisSecrets.node.addDependency(this.redisCluster); // ECS Cluster let clusterProps: ecs.ClusterProps = { vpc: this.vpc }; if (config.containerInsightsV2) { clusterProps = { ...clusterProps, containerInsightsV2: config.containerInsightsV2 as ecs.ContainerInsights }; } this.ecsCluster = new ecs.Cluster(this, 'Cluster', clusterProps); // Task Policies this.taskRolePolicies = new iam.PolicyDocument({ statements: [ // CloudWatch Logs: Create streams and put events new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'logs:PutLogEvents', 'logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:DescribeLogStreams', 'logs:DescribeLogGroups', 'logs:PutRetentionPolicy', ], resources: [`arn:aws:logs:${region}:${accountNumber}:log-group:*`], }), // Secrets Manager: Read only access to secrets // https://docs.aws.amazon.com/mediaconnect/latest/ug/iam-policy-examples-asm-secrets.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'secretsmanager:GetResourcePolicy', 'secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret', 'secretsmanager:ListSecrets', 'secretsmanager:ListSecretVersionIds', ], resources: [`arn:aws:secretsmanager:${region}:${accountNumber}:secret:*`], }), // Parameter Store: Read only access // https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-access.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['ssm:GetParametersByPath', 'ssm:GetParameters', 'ssm:GetParameter', 'ssm:DescribeParameters'], resources: [`arn:aws:ssm:${region}:${accountNumber}:parameter/medplum/${name}/*`], }), // SES: Send emails // https://docs.aws.amazon.com/ses/latest/dg/sending-authorization-policy-examples.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['ses:SendEmail', 'ses:SendRawEmail'], resources: [`arn:aws:ses:${region}:${accountNumber}:identity/*`], }), // S3: List storage bucket // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazons3.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['s3:ListBucket'], resources: [`arn:aws:s3:::${config.storageBucketName}`], }), // S3: Read, write, and delete access to storage bucket // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazons3.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'], resources: [`arn:aws:s3:::${config.storageBucketName}/*`], }), // IAM: Pass role to innvoke lambda functions // https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_passrole.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['iam:ListRoles', 'iam:GetRole', 'iam:PassRole'], resources: [this.botLambdaRole.roleArn], }), // Lambda: Create, read, update, delete, and invoke functions // https://docs.aws.amazon.com/lambda/latest/dg/access-control-identity-based.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'lambda:CreateFunction', 'lambda:GetFunction', 'lambda:GetFunctionConfiguration', 'lambda:UpdateFunctionCode', 'lambda:UpdateFunctionConfiguration', 'lambda:InvokeFunction', ], resources: [`arn:aws:lambda:${region}:${accountNumber}:function:medplum-bot-lambda-*`], }), // Lambda layers: List layer versions new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['lambda:ListLayerVersions'], resources: [`arn:aws:lambda:${region}:${accountNumber}:layer:medplum-bot-layer`], }), // Lambda layers: Get layer version new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: ['lambda:GetLayerVersion'], resources: [`arn:aws:lambda:${region}:${accountNumber}:layer:medplum-bot-layer:*`], }), // XRay: Write to segment store // https://docs.aws.amazon.com/xray/latest/devguide/xray-api-permissions-ref.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'xray:PutTraceSegments', 'xray:PutTelemetryRecords', 'xray:GetSamplingRules', 'xray:GetSamplingTargets', 'xray:GetSamplingStatisticSummaries', ], resources: ['*'], }), // Textract and Comprehend Medical: Analyze medical text // https://docs.aws.amazon.com/comprehend/latest/dg/security_iam_id-based-policy-examples.html new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ 'comprehend:DetectEntities', 'comprehend:DetectKeyPhrases', 'comprehend:DetectDominantLanguage', 'comprehend:DetectSentiment', 'comprehend:DetectTargetedSentiment', 'comprehend:DetectSyntax', 'comprehendmedical:DetectEntitiesV2', 'textract:DetectDocumentText', 'textract:AnalyzeDocument', 'textract:StartDocumentTextDetection', 'textract:GetDocumentTextDetection', ], resources: ['*'], }), ], }); // Task Role this.taskRole = new iam.Role(this, 'TaskExecutionRole', { assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), description: 'Medplum Server Task Execution Role', inlinePolicies: { TaskExecutionPolicies: this.taskRolePolicies, }, }); // Task Definitions this.taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition', { memoryLimitMiB: config.serverMemory, cpu: config.serverCpu, taskRole: this.taskRole, }); // Log Drivers if (config.fireLens?.enabled) { this.logDriver = new ecs.FireLensLogDriver({ options: { ...config.fireLens.logDriverConfig?.options, }, }); } else { this.logGroup = new logs.LogGroup(this, 'LogGroup', { logGroupName: '/ecs/medplum/' + name, removalPolicy: RemovalPolicy.DESTROY, }); this.logDriver = new ecs.AwsLogDriver({ logGroup: this.logGroup, streamPrefix: 'Medplum', }); } let containerRegistryCredentials: secretsmanager.ISecret | undefined = undefined; if (config.containerRegistryCredentialsSecretArn) { containerRegistryCredentials = secretsmanager.Secret.fromSecretCompleteArn( this, 'RegistryCredentialsSecret', config.containerRegistryCredentialsSecretArn ); } // Task Containers this.serviceContainer = this.taskDefinition.addContainer('MedplumTaskDefinition', { image: this.getContainerImage(config, config.serverImage, containerRegistryCredentials), command: [region === 'us-east-1' ? `aws:/medplum/${name}/` : `aws:${region}:/medplum/${name}/`], logging: this.logDriver, environment: config.environment, }); this.serviceContainer.addPortMappings({ containerPort: config.apiPort, hostPort: config.apiPort, }); if (config.additionalContainers) { for (const container of config.additionalContainers) { this.taskDefinition.addContainer('AdditionalContainer-' + container.name, { containerName: container.name, image: this.getContainerImage(config, container.image, containerRegistryCredentials), command: container.command, environment: container.environment, logging: this.logDriver, essential: container.essential ?? false, // Default to false }); } } if (config.fireLens?.enabled) { this.taskDefinition.addFirelensLogRouter('FireLensRouter', { image: ecs.ContainerImage.fromRegistry('public.ecr.aws/aws-observability/aws-for-fluent-bit:stable'), essential: true, firelensConfig: config.fireLens.logRouterConfig as ecs.FirelensConfig, environment: config.fireLens.environment, }); } // Security Groups this.fargateSecurityGroup = new ec2.SecurityGroup(this, 'ServiceSecurityGroup', { allowAllOutbound: true, securityGroupName: 'MedplumSecurityGroup', vpc: this.vpc, }); // Fargate Services this.fargateService = new ecs.FargateService(this, 'FargateService', { cluster: this.ecsCluster, taskDefinition: this.taskDefinition, assignPublicIp: false, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, }, desiredCount: config.desiredServerCount, securityGroups: [this.fargateSecurityGroup], healthCheckGracePeriod: Duration.minutes(5), minHealthyPercent: 50, // 50% is the default; make it explicit }); // Add autoscaling if (config.fargateAutoScaling) { const scaling = this.fargateService.autoScaleTaskCount({ minCapacity: config.fargateAutoScaling.minCapacity, maxCapacity: config.fargateAutoScaling.maxCapacity, }); scaling.scaleOnCpuUtilization('CpuScaling', { targetUtilizationPercent: config.fargateAutoScaling.targetUtilizationPercent, scaleInCooldown: Duration.seconds(config.fargateAutoScaling.scaleInCooldown), scaleOutCooldown: Duration.seconds(config.fargateAutoScaling.scaleOutCooldown), }); } // Add dependencies - make sure Fargate service is created after RDS and Redis if (this.rdsCluster) { this.fargateService.node.addDependency(this.rdsCluster); } if (this.rdsProxy) { this.fargateService.node.addDependency(this.rdsProxy); } this.fargateService.node.addDependency(this.redisCluster); // Load Balancer Target Group this.targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', { vpc: this.vpc, port: config.apiPort, protocol: elbv2.ApplicationProtocol.HTTP, healthCheck: { path: '/healthcheck', interval: Duration.seconds(30), timeout: Duration.seconds(3), healthyThresholdCount: 2, unhealthyThresholdCount: 5, }, targets: [this.fargateService], }); let loadBalancerSecurityGroup: ec2.ISecurityGroup | undefined = undefined; if (config.loadBalancerSecurityGroupId) { loadBalancerSecurityGroup = ec2.SecurityGroup.fromSecurityGroupId( this, 'LoadBalancerSecurityGroup', config.loadBalancerSecurityGroupId ); } // Load Balancer this.loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', { vpc: this.vpc, internetFacing: config.apiInternetFacing !== false, // default true http2Enabled: true, securityGroup: loadBalancerSecurityGroup, }); if (config.loadBalancerLoggingBucket) { // Load Balancer logging this.loadBalancer.logAccessLogs( s3.Bucket.fromBucketName(this, 'LoggingBucket', config.loadBalancerLoggingBucket), config.loadBalancerLoggingPrefix ); } // HTTPS Listener // Forward to the target group this.loadBalancer.addListener('HttpsListener', { port: 443, certificates: [ { certificateArn: config.apiSslCertArn, }, ], sslPolicy: elbv2.SslPolicy.RECOMMENDED_TLS, defaultAction: elbv2.ListenerAction.forward([this.targetGroup]), }); // WAF this.waf = buildWaf( this, 'BackEndWAF', `${config.stackName}-BackEndWAF`, 'REGIONAL', config.apiWafIpSetArn, config.wafLogGroupName, config.wafLogGroupCreate ); // Create an association between the load balancer and the WAF this.wafAssociation = new wafv2.CfnWebACLAssociation(this, 'LoadBalancerAssociation', { resourceArn: this.loadBalancer.loadBalancerArn, webAclArn: this.waf.attrArn, }); if (this.rdsCluster) { // Grant RDS access to the fargate group this.rdsCluster.connections.allowDefaultPortFrom(this.fargateSecurityGroup); // Retain RDS cluster security groups and their rules this.rdsCluster.connections.securityGroups.forEach((sg) => { sg.applyRemovalPolicy(RemovalPolicy.RETAIN); sg.node.children.forEach((child) => { if (child instanceof ec2.CfnSecurityGroupIngress || child instanceof ec2.CfnSecurityGroupEgress) { child.applyRemovalPolicy(RemovalPolicy.RETAIN); } }); }); } // Grant RDS Proxy access to the fargate group if (this.rdsProxy) { // Cannot call allowDefaultPortFrom(): this resource has no default port // See: https://repost.aws/knowledge-center/rds-proxy-connection-issues this.rdsProxy.connections.allowFrom(this.fargateSecurityGroup, ec2.Port.tcp(5432)); } // Grant Redis access to the fargate group this.redisSecurityGroup.addIngressRule(this.fargateSecurityGroup, ec2.Port.tcp(6379)); // DNS if (!config.skipDns) { // Route 53 const hostedZoneName = config.hostedZoneName ?? config.domainName.split('.').slice(-2).join('.'); const zone = route53.HostedZone.fromLookup(this, 'Zone', { domainName: hostedZoneName }); // Route53 alias record for the load balancer this.dnsRecord = new route53.ARecord(this, 'LoadBalancerAliasRecord', { recordName: config.apiDomainName, target: route53.RecordTarget.fromAlias(new targets.LoadBalancerTarget(this.loadBalancer)), zone: zone, }); } // SSM Parameters this.regionParameter = new ssm.StringParameter(this, 'RegionParameter', { tier: ssm.ParameterTier.STANDARD, parameterName: `/medplum/${name}/awsRegion`, description: 'AWS region', stringValue: config.region, }); this.databaseSecretsParameter = new ssm.StringParameter(this, 'DatabaseSecretsParameter', { tier: ssm.ParameterTier.STANDARD, parameterName: `/medplum/${name}/DatabaseSecrets`, description: 'Database secrets ARN', stringValue: this.rdsSecretsArn, }); if (this.rdsProxy) { this.databaseProxyEndpointParameter = new ssm.StringParameter(this, 'DatabaseProxyEndpointParameter', { tier: ssm.ParameterTier.STANDARD, parameterName: `/medplum/${name}/databaseProxyEndpoint`, description: 'Database proxy endpoint', stringValue: this.rdsProxy?.endpoint as string, }); } this.redisSecretsParameter = new ssm.StringParameter(this, 'RedisSecretsParameter', { tier: ssm.ParameterTier.STANDARD, parameterName: `/medplum/${name}/RedisSecrets`, description: 'Redis secrets ARN', stringValue: this.redisSecrets.secretArn, }); this.botLambdaRoleParameter = new ssm.StringParameter(this, 'BotLambdaRoleParameter', { tier: ssm.ParameterTier.STANDARD, parameterName: `/medplum/${name}/botLambdaRoleArn`, description: 'Bot lambda execution role ARN', stringValue: this.botLambdaRole.roleArn, }); } /** * Returns a container image for the given image name. * If the image name is an ECR image, then the image will be pulled from ECR. * Otherwise, the image name is assumed to be a Docker Hub image. * @param config - The config settings (account number and region). * @param imageName - The image name. * @param credentials - The credentials for the image repository. * @returns The container image. */ private getContainerImage( config: MedplumInfraConfig, imageName: string, credentials: secretsmanager.ISecret | undefined ): ecs.ContainerImage { // Pull out the image name and tag from the image URI if it's an ECR image const ecrImageUriRegex = new RegExp( `^${config.accountNumber}\\.dkr\\.ecr\\.${config.region}\\.amazonaws\\.com/(.*)[:@](.*)$` ); const nameTagMatches = ecrImageUriRegex.exec(imageName); const serverImageName = nameTagMatches?.[1]; const serverImageTag = nameTagMatches?.[2]; if (serverImageName && serverImageTag) { // Creating an ecr repository image will automatically grant fine-grained permissions to ecs to access the image const ecrRepo = Repository.fromRepositoryArn( this, 'ServerImageRepo', `arn:aws:ecr:${config.region}:${config.accountNumber}:repository/${serverImageName}` ); return ecs.ContainerImage.fromEcrRepository(ecrRepo, serverImageTag); } // Otherwise, use the standard container image return ecs.ContainerImage.fromRegistry(imageName, { credentials }); } } function getPostgresEngine( version?: string, defaultVersion?: rds.AuroraPostgresEngineVersion ): { engine: rds.IClusterEngine; majorVersion: string } { if (!version) { if (defaultVersion) { return { engine: rds.DatabaseClusterEngine.auroraPostgres({ version: defaultVersion }), majorVersion: defaultVersion.auroraPostgresMajorVersion, }; } throw new Error('Missing or empty RDS version: ' + version); } const majorVersion = version.slice(0, version.indexOf('.')); return { engine: rds.DatabaseClusterEngine.auroraPostgres({ version: rds.AuroraPostgresEngineVersion.of(version, majorVersion, { s3Import: true, s3Export: true, }), }), majorVersion, }; } function getDatabaseClusterId(majorVersion?: string): string { return majorVersion ? `DatabaseClusterPG${majorVersion}` : 'DatabaseCluster'; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server