Skip to main content
Glama
init.ts20.4 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { CertificateSummary, ValidationMethod } from '@aws-sdk/client-acm'; import { ACMClient, ListCertificatesCommand, RequestCertificateCommand } from '@aws-sdk/client-acm'; import { CloudFrontClient, CreatePublicKeyCommand } from '@aws-sdk/client-cloudfront'; import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; import type { MedplumInfraConfig } from '@medplum/core'; import { normalizeErrorString } from '@medplum/core'; import { generateKeyPairSync, randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { getConfigFileName, writeConfig } from '../utils'; import { ask, checkOk, choose, chooseInt, closeTerminal, header, initTerminal, print, yesOrNo } from './terminal'; import { getServerVersions, writeParameters } from './utils'; type MedplumDomainType = 'api' | 'app' | 'storage'; type MedplumDomainSetting = `${MedplumDomainType}DomainName`; type MedplumDomainCertSetting = `${MedplumDomainType}SslCertArn`; const getDomainSetting = (domain: MedplumDomainType): MedplumDomainSetting => `${domain}DomainName`; const getDomainCertSetting = (domain: MedplumDomainType): MedplumDomainCertSetting => `${domain}SslCertArn`; export async function initStackCommand(): Promise<void> { const config = { apiPort: 8103, region: 'us-east-1' } as MedplumInfraConfig; initTerminal(); header('MEDPLUM'); print('This tool prepares the necessary prerequisites for deploying Medplum in your AWS account.'); print(''); print('Most Medplum infrastructure is deployed using the AWS CDK.'); print('However, some AWS resources must be created manually, such as email addresses and SSL certificates.'); print('This tool will help you create those resources.'); print(''); print('Upon completion, this tool will:'); print(' 1. Generate a Medplum CDK config file (i.e., medplum.demo.config.json)'); print(' 2. Optionally generate an AWS CloudFront signing key'); print(' 3. Optionally request SSL certificates from AWS Certificate Manager'); print(' 4. Optionally write server config settings to AWS Parameter Store'); print(''); print('The Medplum infra config file is an input to the Medplum CDK.'); print('The Medplum CDK will create and manage the necessary AWS resources.'); print(''); print('We will ask a series of questions to generate your infra config file.'); print('Some questions have predefined options in [square brackets].'); print('Some questions have default values in (parentheses), which you can accept by pressing Enter.'); print('Press Ctrl+C at any time to exit.'); const currentAccountId = await getAccountId(config.region); if (!currentAccountId) { print('It appears that you do not have AWS credentials configured.'); print('AWS credentials are not strictly required, but will enable some additional features.'); print('If you intend to use AWS credentials, please configure them now.'); await checkOk('Do you want to continue without AWS credentials?'); } header('ENVIRONMENT NAME'); print('Medplum deployments have a short environment name such as "prod", "staging", "alice", or "demo".'); print('The environment name is used in multiple places:'); print(' 1. As part of config file names (i.e., medplum.demo.config.json)'); print(' 2. As the base of CloudFormation stack names (i.e., MedplumDemo)'); print(' 3. AWS Parameter Store keys (i.e., /medplum/demo/...)'); config.name = await ask('What is your environment name?', 'demo'); print('Using environment name "' + config.name + '"...'); header('CONFIG FILE'); print('Medplum Infrastructure will create a config file in the current directory.'); const configFileName = await ask('What is the config file name?', `medplum.${config.name}.config.json`); if (existsSync(configFileName)) { print('Config file already exists.'); await checkOk('Do you want to overwrite the config file?'); } print('Using config file "' + configFileName + '"...'); writeConfig(configFileName, config); header('AWS REGION'); print('Most Medplum resources will be created in a single AWS region.'); config.region = await ask('Enter your AWS region:', 'us-east-1'); writeConfig(configFileName, config); header('AWS ACCOUNT NUMBER'); print('Medplum Infrastructure will use your AWS account number to create AWS resources.'); if (currentAccountId) { print('Using the AWS CLI, your current account ID is: ' + currentAccountId); } config.accountNumber = await ask('What is your AWS account number?', currentAccountId); writeConfig(configFileName, config); header('STACK NAME'); print('Medplum will create a CloudFormation stack to manage AWS resources.'); print('AWS CloudFormation stack names '); const defaultStackName = 'Medplum' + config.name.charAt(0).toUpperCase() + config.name.slice(1); config.stackName = await ask('Enter your CloudFormation stack name?', defaultStackName); writeConfig(configFileName, config); header('BASE DOMAIN NAME'); print('Please enter the base domain name for your Medplum deployment.'); print(''); print('Medplum deploys multiple subdomains for various services.'); print(''); print('For example, "api." for the REST API and "app." for the web application.'); print('The base domain name is the common suffix for all subdomains.'); print(''); print('For example, if your base domain name is "example.com",'); print('then the REST API will be "api.example.com".'); print(''); print('The base domain should include the TLD (i.e., ".com", ".org", ".net").'); print(''); print('Note that you must own the base domain, and it must use Route53 DNS.'); while (!config.domainName) { config.domainName = await ask('Enter your base domain name:'); } writeConfig(configFileName, config); header('SUPPORT EMAIL'); print('Medplum sends transactional emails to users.'); print('For example, emails to new users or for password reset.'); print('Medplum will use the support email address to send these emails.'); print('Note that you must verify the support email address in SES.'); const supportEmail = await ask('Enter your support email address:'); header('API DOMAIN NAME'); print('Medplum deploys a REST API for the backend services.'); config.apiDomainName = await ask('Enter your REST API domain name:', 'api.' + config.domainName); config.baseUrl = `https://${config.apiDomainName}/`; writeConfig(configFileName, config); header('APP DOMAIN NAME'); print('Medplum deploys a web application for the user interface.'); config.appDomainName = await ask('Enter your web application domain name:', 'app.' + config.domainName); writeConfig(configFileName, config); header('STORAGE DOMAIN NAME'); print('Medplum deploys a storage service for file uploads.'); config.storageDomainName = await ask('Enter your storage domain name:', 'storage.' + config.domainName); writeConfig(configFileName, config); header('STORAGE BUCKET'); print('Medplum uses an S3 bucket to store binary content such as file uploads.'); print('Medplum will create a the S3 bucket as part of the CloudFormation stack.'); config.storageBucketName = await ask('Enter your storage bucket name:', config.storageDomainName); writeConfig(configFileName, config); header('MAX AVAILABILITY ZONES'); print('Medplum API servers can be deployed in multiple availability zones.'); print('This provides redundancy and high availability.'); print('However, it also increases the cost of the deployment.'); print('If you want to use all availability zones, choose a large number such as 99.'); print('If you want to restrict the number, for example to manage EIP limits,'); print('then choose a small number such as 2 or 3.'); config.maxAzs = await chooseInt('Enter the maximum number of availability zones:', [2, 3, 99], 2); header('DATABASE INSTANCES'); print('Medplum uses a relational database to store data.'); print('Medplum can create a new RDS database as part of the CloudFormation stack,'); print('or can set up your own database and enter the database name, username, and password.'); if (await yesOrNo('Do you want to create a new RDS database as part of the CloudFormation stack?')) { print('Medplum will create a new RDS database as part of the CloudFormation stack.'); print(''); print('If you need high availability, you can choose multiple instances.'); print('Use 1 for a single instance, or 2 for a primary and a standby.'); config.rdsInstances = await chooseInt('Enter the number of database instances:', [1, 2], 1); } else { print('Medplum will not create a new RDS database.'); print('Please create a new RDS database and enter the database name, username, and password.'); print('Set the AWS Secrets Manager secret ARN in the config file in the "rdsSecretsArn" setting.'); config.rdsSecretsArn = 'TODO'; } writeConfig(configFileName, config); header('SERVER INSTANCES'); print('Medplum uses AWS Fargate to run the API servers.'); print('Medplum will create a new Fargate cluster as part of the CloudFormation stack.'); print('Fargate will automatically scale the number of servers up and down.'); print('If you need high availability, you can choose multiple instances.'); config.desiredServerCount = await chooseInt('Enter the number of server instances:', [1, 2, 3, 4, 6, 8], 1); writeConfig(configFileName, config); header('SERVER MEMORY'); print('You can choose the amount of memory for each server instance.'); print('The default is 512 MB, which is sufficient for getting started.'); print('Note that only certain CPU units are compatible with memory units.'); print('Consult AWS Fargate "Task Definition Parameters" for more information.'); config.serverMemory = await chooseInt('Enter the server memory (MB):', [512, 1024, 2048, 4096, 8192, 16384], 512); writeConfig(configFileName, config); header('SERVER CPU'); print('You can choose the amount of CPU for each server instance.'); print('CPU is expressed as an integer using AWS CPU units'); print('The default is 256, which is sufficient for getting started.'); print('Note that only certain CPU units are compatible with memory units.'); print('Consult AWS Fargate "Task Definition Parameters" for more information.'); config.serverCpu = await chooseInt('Enter the server CPU:', [256, 512, 1024, 2048, 4096, 8192, 16384], 256); writeConfig(configFileName, config); header('SERVER IMAGE'); print('Medplum uses Docker images for the API servers.'); print('You can choose the image to use for the servers.'); print('Docker images can be loaded from either Docker Hub or AWS ECR.'); print('The default is the latest Medplum release.'); const latestVersion = (await getServerVersions())[0] ?? 'latest'; config.serverImage = await ask('Enter the server image:', `medplum/medplum-server:${latestVersion}`); writeConfig(configFileName, config); header('SIGNING KEY'); print('Medplum uses AWS CloudFront Presigned URLs for binary content such as file uploads.'); const signingKey = await generateSigningKey(config.region, config.stackName + 'SigningKey'); if (signingKey) { config.signingKeyId = signingKey.keyId; config.storagePublicKey = signingKey.publicKey; writeConfig(configFileName, config); } else { print('Unable to generate signing key.'); print('Please manually create a signing key and enter the key ID and public key in the config file.'); print('You must set the "signingKeyId", "signingKey", and "signingKeyPassphrase" settings.'); } header('SSL CERTIFICATES'); print(`Medplum will now check for existing SSL certificates for the subdomains.`); const allCerts = await listAllCertificates(config.region); print('Found ' + allCerts.length + ' certificate(s).'); // Process certificates for each subdomain // Note: The "api" certificate must be created in the same region as the API // Note: The "app" and "storage" certificates must be created in us-east-1 for (const { region, certName } of [ { region: config.region, certName: 'api' }, { region: 'us-east-1', certName: 'app' }, { region: 'us-east-1', certName: 'storage' }, ] as const) { print(''); const arn = await processCert(config, allCerts, region, certName); config[getDomainCertSetting(certName)] = arn; writeConfig(configFileName, config); } header('AWS PARAMETER STORE'); print('Medplum uses AWS Parameter Store to store sensitive configuration values.'); print('These values will be encrypted at rest.'); print(`The values will be stored in the "/medplum/${config.name}" path.`); const serverParams: Record<string, string | number> = { port: config.apiPort, baseUrl: config.baseUrl, appBaseUrl: `https://${config.appDomainName}/`, storageBaseUrl: `https://${config.storageDomainName}/binary/`, binaryStorage: `s3:${config.storageBucketName}`, supportEmail: supportEmail, }; if (signingKey) { serverParams.signingKeyId = signingKey.keyId; serverParams.signingKey = signingKey.privateKey; serverParams.signingKeyPassphrase = signingKey.passphrase; } print( JSON.stringify( { ...serverParams, signingKey: '****', signingKeyPassphrase: '****', }, null, 2 ) ); if (await yesOrNo('Do you want to store these values in AWS Parameter Store?')) { await writeParameters(config.region, `/medplum/${config.name}/`, serverParams); } else { const serverConfigFileName = getConfigFileName(config.name, { server: true }); writeConfig(serverConfigFileName, serverParams); print('Skipping AWS Parameter Store.'); print(`Writing values to local config file: ${serverConfigFileName}`); print('Please add these values to AWS Parameter Store manually.'); } header('DONE!'); print('Medplum configuration complete.'); print('You can now proceed to deploying the Medplum infrastructure with CDK.'); print('Run:'); print(''); print(` npx cdk bootstrap -c config=${configFileName}`); print(` npx cdk synth -c config=${configFileName}`); if (config.region === 'us-east-1') { print(` npx cdk deploy -c config=${configFileName}`); } else { print(` npx cdk deploy -c config=${configFileName} --all`); } print(''); print('See Medplum documentation for more information:'); print(''); print(' https://www.medplum.com/docs/self-hosting/install-on-aws'); print(''); closeTerminal(); } /** * Returns the current AWS account ID. * This is used as the default value for the "accountNumber" config setting. * @param region - The AWS region. * @returns The AWS account ID. */ async function getAccountId(region: string): Promise<string | undefined> { try { const client = new STSClient({ region }); const command = new GetCallerIdentityCommand({}); const response = await client.send(command); return response.Account as string; } catch (err) { console.log('Warning: Unable to get AWS account ID', (err as Error).message); return undefined; } } /** * Returns a list of all AWS certificates. * This is used to find existing certificates for the subdomains. * If the primary region is not us-east-1, then certificates in us-east-1 will also be returned. * @param region - The AWS region. * @returns The list of AWS Certificates. */ async function listAllCertificates(region: string): Promise<CertificateSummary[]> { const result = await listCertificates(region); if (region !== 'us-east-1') { const usEast1Result = await listCertificates('us-east-1'); result.push(...usEast1Result); } return result; } /** * Returns a list of AWS Certificates. * This is used to find existing certificates for the subdomains. * @param region - The AWS region. * @returns The list of AWS Certificates. */ async function listCertificates(region: string): Promise<CertificateSummary[]> { try { const client = new ACMClient({ region }); const command = new ListCertificatesCommand({ MaxItems: 1000 }); const response = await client.send(command); return response.CertificateSummaryList as CertificateSummary[]; } catch (err) { console.log('Warning: Unable to list certificates', (err as Error).message); return []; } } /** * Processes a required certificate. * * 1. If the certificate already exists, return the ARN. * 2. If the certificate does not exist, and the user wants to create a new certificate, create it and return the ARN. * 3. If the certificate does not exist, and the user does not want to create a new certificate, return a placeholder. * @param config - In-progress config settings. * @param allCerts - List of all existing certificates. * @param region - The AWS region where the certificate is needed. * @param certName - The name of the certificate (api, app, or storage). * @returns The ARN of the certificate or placeholder if a new certificate is needed. */ async function processCert( config: MedplumInfraConfig, allCerts: CertificateSummary[], region: string, certName: 'api' | 'app' | 'storage' ): Promise<string> { const domainName = config[getDomainSetting(certName)]; const existingCert = allCerts.find((cert) => cert.CertificateArn?.includes(region) && cert.DomainName === domainName); if (existingCert) { print(`Found existing certificate for "${domainName}" in "${region}.`); return existingCert.CertificateArn as string; } print(`No existing certificate found for "${domainName}" in "${region}.`); if (!(await yesOrNo('Do you want to request a new certificate?'))) { print(`Please add your certificate ARN to the config file in the "${getDomainCertSetting(certName)}" setting.`); return 'TODO'; } const arn = await requestCert(region, domainName); print('Certificate ARN: ' + arn); return arn; } /** * Requests an AWS Certificate. * @param region - The AWS region. * @param domain - The domain name. * @returns The AWS Certificate ARN on success, or undefined on failure. */ async function requestCert(region: string, domain: string): Promise<string> { try { const validationMethod = await choose( 'Validate certificate using DNS or email validation?', ['dns', 'email'], 'dns' ); const client = new ACMClient({ region }); const command = new RequestCertificateCommand({ DomainName: domain, ValidationMethod: validationMethod.toUpperCase() as ValidationMethod, }); const response = await client.send(command); return response.CertificateArn as string; } catch (err) { console.log('Error: Unable to request certificate', (err as Error).message); return 'TODO'; } } /** * Generates an AWS CloudFront signing key. * * Requirements: * * 1. It must be an SSH-2 RSA key pair. * 2. It must be in base64-encoded PEM format. * 3. It must be a 2048-bit key pair. * * See: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-trusted-signers.html#private-content-creating-cloudfront-key-pairs * * @param region - The AWS region. * @param keyName - The key name. * @returns A new signing key. */ async function generateSigningKey( region: string, keyName: string ): Promise< | { keyId: string; publicKey: string; privateKey: string; passphrase: string; } | undefined > { const passphrase = randomUUID(); const signingKey = generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem', }, privateKeyEncoding: { type: 'pkcs1', format: 'pem', cipher: 'aes-256-cbc', passphrase, }, }); try { const response = await new CloudFrontClient({ region }).send( new CreatePublicKeyCommand({ PublicKeyConfig: { Name: keyName, CallerReference: randomUUID(), EncodedKey: signingKey.publicKey, }, }) ); return { keyId: response.PublicKey?.Id as string, publicKey: signingKey.publicKey, privateKey: signingKey.privateKey, passphrase, }; } catch (err) { console.log('Error: Unable to create signing key: ', normalizeErrorString(err)); return undefined; } }

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