Skip to main content
Glama
utils.ts13.7 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { Stack, StackResource, StackSummary } from '@aws-sdk/client-cloudformation'; import { CloudFormationClient, DescribeStackResourcesCommand, DescribeStacksCommand, paginateListStacks, } from '@aws-sdk/client-cloudformation'; import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'; import { ECSClient } from '@aws-sdk/client-ecs'; import { S3Client } from '@aws-sdk/client-s3'; import { GetParameterCommand, PutParameterCommand, SSMClient } from '@aws-sdk/client-ssm'; import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; import { normalizeErrorString } from '@medplum/core'; import fetch from 'node-fetch'; import { readdirSync } from 'node:fs'; import * as semver from 'semver'; import { getConfigFileName } from '../utils'; import { checkOk, print } from './terminal'; export interface MedplumStackDetails { stack: Stack; tag: string; ecsCluster?: StackResource; ecsService?: StackResource; appBucket?: StackResource; appDistribution?: StackResource; appOriginAccessIdentity?: StackResource; storageBucket?: StackResource; storageDistribution?: StackResource; storageOriginAccessIdentity?: StackResource; } export const cloudFormationClient = new CloudFormationClient({}); export const cloudFrontClient = new CloudFrontClient({ region: 'us-east-1' }); export const ecsClient = new ECSClient({}); export const s3Client = new S3Client({}); export const tagKey = 'medplum:environment'; /** * Returns a list of all AWS CloudFormation stacks (both Medplum and non-Medplum). * @returns List of AWS CloudFormation stacks. */ export async function getAllStacks(): Promise<(StackSummary & { StackName: string })[]> { const listResult = [] as StackSummary[]; const paginator = paginateListStacks( { client: cloudFormationClient }, { StackStatusFilter: [ 'CREATE_COMPLETE', 'CREATE_FAILED', 'CREATE_IN_PROGRESS', 'DELETE_FAILED', 'DELETE_IN_PROGRESS', 'IMPORT_COMPLETE', 'IMPORT_IN_PROGRESS', 'IMPORT_ROLLBACK_COMPLETE', 'IMPORT_ROLLBACK_FAILED', 'IMPORT_ROLLBACK_IN_PROGRESS', 'REVIEW_IN_PROGRESS', 'ROLLBACK_COMPLETE', 'ROLLBACK_FAILED', 'ROLLBACK_IN_PROGRESS', 'UPDATE_COMPLETE', 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS', 'UPDATE_FAILED', 'UPDATE_IN_PROGRESS', 'UPDATE_ROLLBACK_COMPLETE', 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS', 'UPDATE_ROLLBACK_FAILED', 'UPDATE_ROLLBACK_IN_PROGRESS', ], } ); for await (const page of paginator) { if (page.StackSummaries) { for (const stack of page.StackSummaries) { listResult.push(stack); } } } return listResult as (StackSummary & { StackName: string })[]; } /** * Returns Medplum stack details for the given tag. * @param tag - The Medplum stack tag. * @returns The Medplum stack details. */ export async function getStackByTag(tag: string): Promise<MedplumStackDetails | undefined> { const stackSummaries = await getAllStacks(); for (const stackSummary of stackSummaries) { const stackName = stackSummary.StackName; const details = await getStackDetails(stackName); if (details?.tag === tag) { return details; } } return undefined; } /** * Returns Medplum stack details for the given stack name. * @param stackName - The CloudFormation stack name. * @returns The Medplum stack details. */ export async function getStackDetails(stackName: string): Promise<MedplumStackDetails | undefined> { const result = {} as Partial<MedplumStackDetails>; await buildStackDetails(cloudFormationClient, stackName, result); if ((await cloudFormationClient.config.region()) !== 'us-east-1') { try { await buildStackDetails(new CloudFormationClient({ region: 'us-east-1' }), stackName + '-us-east-1', result); } catch { // Fail gracefully } } return result as MedplumStackDetails; } /** * Builds the Medplum stack details for the given stack name and region. * @param client - The CloudFormation client. * @param stackName - The CloudFormation stack name. * @param result - The Medplum stack details builder. */ async function buildStackDetails( client: CloudFormationClient, stackName: string, result: Partial<MedplumStackDetails> ): Promise<void> { const describeStacksCommand = new DescribeStacksCommand({ StackName: stackName }); const stackDetails = await client.send(describeStacksCommand); const stack = stackDetails?.Stacks?.[0]; const medplumTag = stack?.Tags?.find((tag) => tag.Key === tagKey); if (!medplumTag) { return; } const stackResources = await client.send(new DescribeStackResourcesCommand({ StackName: stackName })); if (!stackResources.StackResources) { return; } if (client === cloudFormationClient) { result.stack = stack; result.tag = medplumTag.Value as string; } for (const resource of stackResources.StackResources) { assignStackDetails(resource, result); } } function assignStackDetails(resource: StackResource, result: Partial<MedplumStackDetails>): void { if (resource.ResourceType === 'AWS::ECS::Cluster') { result.ecsCluster = resource; } else if (resource.ResourceType === 'AWS::ECS::Service') { result.ecsService = resource; } else if ( resource.ResourceType === 'AWS::S3::Bucket' && resource.LogicalResourceId?.startsWith('FrontEndAppBucket') ) { result.appBucket = resource; } else if ( resource.ResourceType === 'AWS::CloudFront::Distribution' && resource.LogicalResourceId?.startsWith('FrontEndAppDistribution') ) { result.appDistribution = resource; } else if ( resource.ResourceType === 'AWS::CloudFront::CloudFrontOriginAccessIdentity' && resource.LogicalResourceId?.startsWith('FrontEndOriginAccessIdentity') ) { result.appOriginAccessIdentity = resource; } else if ( resource.ResourceType === 'AWS::S3::Bucket' && resource.LogicalResourceId?.startsWith('StorageStorageBucket') ) { result.storageBucket = resource; } else if ( resource.ResourceType === 'AWS::CloudFront::Distribution' && resource.LogicalResourceId?.startsWith('StorageStorageDistribution') ) { result.storageDistribution = resource; } else if ( resource.ResourceType === 'AWS::CloudFront::CloudFrontOriginAccessIdentity' && resource.LogicalResourceId?.startsWith('StorageOriginAccessIdentity') ) { result.storageOriginAccessIdentity = resource; } } /** * Prints the given Medplum stack details to stdout. * @param details - The Medplum stack details. */ export function printStackDetails(details: MedplumStackDetails): void { console.log(`Medplum Tag: ${details.tag}`); console.log(`Stack Name: ${details.stack?.StackName}`); console.log(`Stack ID: ${details.stack?.StackId}`); console.log(`Status: ${details.stack?.StackStatus}`); console.log(`ECS Cluster: ${details.ecsCluster?.PhysicalResourceId}`); console.log(`ECS Service: ${getEcsServiceName(details.ecsService)}`); console.log(`App Bucket: ${details.appBucket?.PhysicalResourceId}`); console.log(`App Distribution: ${details.appDistribution?.PhysicalResourceId}`); console.log(`App OAI: ${details.appOriginAccessIdentity?.PhysicalResourceId}`); console.log(`Storage Bucket: ${details.storageBucket?.PhysicalResourceId}`); console.log(`Storage Distribution: ${details.storageDistribution?.PhysicalResourceId}`); console.log(`Storage OAI: ${details.storageOriginAccessIdentity?.PhysicalResourceId}`); } /** * Parses the ECS service name from the given AWS ECS service resource. * @param resource - The AWS ECS service resource. * @returns The ECS service name. */ export function getEcsServiceName(resource: StackResource | undefined): string | undefined { return resource?.PhysicalResourceId?.split('/')?.pop() || ''; } /** * Creates a CloudFront invalidation to clear the cache for all files. * This is not strictly necessary, but it helps to ensure that the latest version of the app is served. * In a perfect world, every deploy is clean, and hashed resources should be cached forever. * However, we do not recalculate hashes after variable replacements. * So if variables change, we need to invalidate the cache. * @param distributionId - The CloudFront distribution ID. */ export async function createInvalidation(distributionId: string): Promise<void> { const response = await cloudFrontClient.send( new CreateInvalidationCommand({ DistributionId: distributionId, InvalidationBatch: { CallerReference: `invalidate-all-${Date.now()}`, Paths: { Quantity: 1, Items: ['/*'], }, }, }) ); console.log(`Created invalidation with ID: ${response.Invalidation?.Id}`); } export async function getServerVersions(from?: string): Promise<string[]> { const response = await fetch('https://api.github.com/repos/medplum/medplum/releases?per_page=100', { headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', }, }); const json = (await response.json()) as { tag_name: string }[]; const versions = json.map((release) => release.tag_name.startsWith('v') ? release.tag_name.slice(1) : release.tag_name ); // Sort in descending order versions.sort((a, b) => semver.compare(b, a)); return from ? versions.slice(0, versions.indexOf(from)) : versions; } /** * Writes a collection of parameters to AWS Parameter Store. * @param region - The AWS region. * @param prefix - The AWS Parameter Store prefix. * @param params - The parameters to write. */ export async function writeParameters( region: string, prefix: string, params: Record<string, string | number> ): Promise<void> { const client = new SSMClient({ region }); for (const [key, value] of Object.entries(params)) { const name = prefix + key; const valueStr = value.toString(); const existingValue = await readParameter(client, name); if (existingValue !== undefined && existingValue !== valueStr) { print(`Parameter "${name}" exists with different value.`); await checkOk(`Do you want to overwrite "${name}"?`); } await writeParameter(client, name, valueStr); } } /** * Reads a parameter from AWS Parameter Store. * @param client - The AWS SSM client. * @param name - The parameter name. * @returns The parameter value, or undefined if not found. */ async function readParameter(client: SSMClient, name: string): Promise<string | undefined> { const command = new GetParameterCommand({ Name: name, WithDecryption: true, }); try { const result = await client.send(command); return result.Parameter?.Value; } catch (err: any) { if (err.name === 'ParameterNotFound') { return undefined; } throw err; } } /** * Writes a parameter to AWS Parameter Store. * @param client - The AWS SSM client. * @param name - The parameter name. * @param value - The parameter value. */ async function writeParameter(client: SSMClient, name: string, value: string): Promise<void> { const command = new PutParameterCommand({ Name: name, Value: value, Type: 'SecureString', Overwrite: true, }); await client.send(command); } /** * Prints a "config not found" message to stdout. * Includes helpful debugging information such as available configs. * @param tagName - Medplum stack tag name. * @param options - Additional command line options. */ export function printConfigNotFound(tagName: string, options?: Record<string, any>): void { console.log(`Config not found: ${tagName} (${getConfigFileName(tagName, options)})`); if (options) { const entries = Object.entries(options); if (entries.length > 0) { console.log('Additional options:'); for (const [key, value] of entries) { console.log(` ${key}: ${value}`); } } } console.log(); let files: any[] = readdirSync('.', { withFileTypes: true }); files = files .filter((f) => f.isFile() && f.name.startsWith('medplum.') && f.name.endsWith('.json')) .map((f) => f.name); if (files.length === 0) { console.log('No configs found'); } else { console.log('Available configs:'); for (const file of files) { console.log( ` ${file .replaceAll('medplum.', '') .replaceAll('.config', '') .replaceAll('.server', '') .replaceAll('.json', '') .padEnd(40, ' ')} (${file})` ); } } } /** * Prints a "stack not found" message to stdout. * Includes helpful debugging information such as AWS account ID and region. * @param tagName - Medplum stack tag name. */ export async function printStackNotFound(tagName: string): Promise<void> { console.log(`Stack not found: ${tagName}`); console.log(); try { const client = new STSClient(); const command = new GetCallerIdentityCommand({}); const response = await client.send(command); const region = await client.config.region(); console.log('AWS Region: ', region); console.log('AWS Account ID: ', response.Account); console.log('AWS Account ARN: ', response.Arn); console.log('AWS User ID: ', response.UserId); } catch (err) { console.log('Warning: Unable to get AWS account ID', normalizeErrorString(err)); } }

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