UNPKG

@aws-amplify/amplify-category-api

Version:
485 lines (432 loc) 19.1 kB
import path from 'path'; import { constructSqlDirectiveDataSourceStrategies, DDB_AMPLIFY_MANAGED_DATASOURCE_STRATEGY, DDB_DEFAULT_DATASOURCE_STRATEGY, getDefaultStrategyNameForDbType, getImportedRDSTypeFromStrategyDbType, isDynamoDbType, isSqlDbType, UserDefinedSlot, } from '@aws-amplify/graphql-transformer-core'; import { AppSyncAuthConfiguration, DataSourceStrategiesProvider, ModelDataSourceStrategy, ModelDataSourceStrategyDbType, ModelDataSourceStrategySqlDbType, RDSLayerMapping, RDSSNSTopicMapping, SQLLambdaModelDataSourceStrategy, SqlDirectiveDataSourceStrategy, SqlModelDataSourceDbConnectionConfig, TransformerLog, TransformerLogLevel, VpcConfig, } from '@aws-amplify/graphql-transformer-interfaces'; import * as fs from 'fs-extra'; import { ResourceConstants } from 'graphql-transformer-common'; import { sanityCheckProject } from 'graphql-transformer-core'; import _ from 'lodash'; import { executeTransform } from '@aws-amplify/graphql-transformer'; import { $TSContext, AmplifyCategories, AmplifySupportedService, JSONUtilities, pathManager } from '@aws-amplify/amplify-cli-core'; import { printer } from '@aws-amplify/amplify-prompts'; import { getHostVpc } from '@aws-amplify/graphql-schema-generator'; import fetch from 'node-fetch'; import { getConnectionSecrets, getExistingConnectionDbConnectionConfig, getSecretsKey, } from '../provider-utils/awscloudformation/utils/rds-resources/database-resources'; import { getAppSyncAPIName } from '../provider-utils/awscloudformation/utils/amplify-meta-utils'; import { checkForUnsupportedDirectives, containsSqlModelOrDirective } from '../provider-utils/awscloudformation/utils/rds-resources/utils'; import { isAuthModeUpdated } from './auth-mode-compare'; import { getAdminRoles, getIdentityPoolId, mergeUserConfigWithTransformOutput, writeDeploymentToDisk } from './utils'; import { generateTransformerOptions } from './transformer-options-v2'; import { TransformerProjectOptions } from './transformer-options-types'; import { DeploymentResources } from './cdk-compat/deployment-resources'; import { TransformManager } from './cdk-compat/transform-manager'; const PARAMETERS_FILENAME = 'parameters.json'; const SCHEMA_FILENAME = 'schema.graphql'; const SCHEMA_DIR_NAME = 'schema'; const PROVIDER_NAME = 'awscloudformation'; const USE_BETA_SQL_LAYER = 'use-beta-sql-layer'; /** * Transform GraphQL Schema */ export const transformGraphQLSchemaV2 = async (context: $TSContext, options): Promise<DeploymentResources | undefined> => { let resourceName: string; const backEndDir = pathManager.getBackendDirPath(); const flags = context.parameters.options; if (flags['no-gql-override']) { return undefined; } let { resourceDir, parameters } = options; const { forceCompile } = options; // Compilation during the push step const { resourcesToBeCreated, resourcesToBeUpdated, allResources } = await context.amplify.getResourceStatus(AmplifyCategories.API); let resources = resourcesToBeCreated.concat(resourcesToBeUpdated); // When build folder is missing include the API // to be compiled without the backend/api/<api-name>/build // cloud formation push will fail even if there is no changes in the GraphQL API // https://github.com/aws-amplify/amplify-console/issues/10 const resourceNeedCompile = allResources .filter((r) => !resources.includes(r)) .filter((r) => { const buildDir = path.normalize(path.join(backEndDir, AmplifyCategories.API, r.resourceName, 'build')); return !fs.pathExistsSync(buildDir); }); resources = resources.concat(resourceNeedCompile); if (forceCompile) { resources = resources.concat(allResources); } resources = resources.filter((resource) => resource.service === 'AppSync'); if (!resourceDir) { // There can only be one appsync resource if (resources.length > 0) { const resource = resources[0]; if (resource.providerPlugin !== PROVIDER_NAME) { return undefined; } const { category } = resource; ({ resourceName } = resource); resourceDir = path.normalize(path.join(backEndDir, category, resourceName)); } else { // No appsync resource to update/add return undefined; } } const previouslyDeployedBackendDir = options.cloudBackendDirectory; if (!previouslyDeployedBackendDir) { if (resources.length > 0) { const resource = resources[0]; if (resource.providerPlugin !== PROVIDER_NAME) { return undefined; } } } const parametersFilePath = path.join(resourceDir, PARAMETERS_FILENAME); if (!parameters && fs.pathExistsSync(parametersFilePath)) { try { parameters = JSONUtilities.readJson(parametersFilePath); // OpenSearch Instance type support for x.y.search types if (parameters[ResourceConstants.PARAMETERS.OpenSearchInstanceType]) { parameters[ResourceConstants.PARAMETERS.OpenSearchInstanceType] = parameters[ ResourceConstants.PARAMETERS.OpenSearchInstanceType ].replace('.search', '.elasticsearch'); } } catch (e) { parameters = {}; } } let { authConfig }: { authConfig: AppSyncAuthConfiguration } = options; if (_.isEmpty(authConfig) && !_.isEmpty(resources)) { authConfig = await context.amplify.invokePluginMethod( context, AmplifyCategories.API, AmplifySupportedService.APPSYNC, 'getAuthConfig', [context, resources[0].resourceName], ); // handle case where auth project is not migrated , if Auth not migrated above function will return empty Object if (_.isEmpty(authConfig)) { // // If we don't have an authConfig from the caller, use it from the // already read resources[0], which is an AppSync API. // if (resources[0].output.securityType) { // Convert to multi-auth format if needed. authConfig = { defaultAuthentication: { authenticationType: resources[0].output.securityType, }, additionalAuthenticationProviders: [], }; } else { ({ authConfig } = resources[0].output); } } } const buildDir = path.normalize(path.join(resourceDir, 'build')); const schemaFilePath = path.normalize(path.join(resourceDir, SCHEMA_FILENAME)); const schemaDirPath = path.normalize(path.join(resourceDir, SCHEMA_DIR_NAME)); // If it is a dry run, don't create the build folder as it could make a follow-up command // to not to trigger a build, hence a corrupt deployment. if (!options.dryRun) { fs.ensureDirSync(buildDir); } // The buildConfig.projectConfig returned by `generateTransformerOptions` is not actually compatible with DataSourceStrategiesProvider. We // will correct that in buildAPIProject. const buildConfig: TransformerProjectOptions = await generateTransformerOptions(context, options); if (!buildConfig) { return undefined; } const transformerOutput = await buildAPIProject(context, buildConfig); printer.success(`GraphQL schema compiled successfully.\n\nEdit your schema at ${schemaFilePath} or \ place .graphql files in a directory at ${schemaDirPath}`); if (isAuthModeUpdated(options)) { parameters.AuthModeLastUpdated = new Date(); } if (!options.dryRun) { JSONUtilities.writeJson(parametersFilePath, parameters); } return transformerOutput; }; const getAuthenticationTypesForAuthConfig = (authConfig?: AppSyncAuthConfiguration): (string | undefined)[] => [authConfig?.defaultAuthentication, ...(authConfig?.additionalAuthenticationProviders ?? [])].map( (authConfigEntry) => authConfigEntry?.authenticationType, ); const hasIamAuth = (authConfig?: AppSyncAuthConfiguration): boolean => getAuthenticationTypesForAuthConfig(authConfig).some((authType) => authType === 'AWS_IAM'); const hasUserPoolAuth = (authConfig?: AppSyncAuthConfiguration): boolean => getAuthenticationTypesForAuthConfig(authConfig).some((authType) => authType === 'AMAZON_COGNITO_USER_POOLS'); /** * Given an array of DataSourceType shapes from the Gen1 CLI import flow, finds the single SQL database type. Throws an error if more than * one SQL database type is detected. */ const getSqlDbTypeFromDataSourceTypes = ( dataSourceTypes: Array<{ dbType: ModelDataSourceStrategyDbType; provisionDB: boolean; provisionStrategy: 'DEFAULT' | 'AMPLIFY_TABLE'; }>, ): ModelDataSourceStrategySqlDbType | undefined => { const dbTypes = Object.values(dataSourceTypes) .map((dsType) => dsType.dbType) .filter(isSqlDbType); if (dbTypes.length === 0) { return undefined; } if (new Set(dbTypes).size > 1) { throw new Error(`Multiple imported SQL datasource types ${Array.from(dbTypes)} are detected. Only one type is supported.`); } return dbTypes[0]; }; /** * The `projectConfig` argument to `fixUpDataSourceStrategiesProvider` is a `ProjectConfiguration` from the Gen1 CLI import flow. That type * is not exported, and don't want to pollute the Gen2 / CDK compat interfaces with those legacy attributes, so instead we'll strongly type * the fields we're interested in operating on. */ interface Gen1ProjectConfiguration { schema: string; modelToDatasourceMap: Map< string, { dbType: ModelDataSourceStrategyDbType; provisionDB: boolean; provisionStrategy: 'DEFAULT' | 'AMPLIFY_TABLE'; } >; customQueries: Map<string, string>; } /** * Utility to fix up the project config generated by the Gen1 CLI flow into the ModelDataSourceStrategy types expected by the transformer * internals. Internally, this function makes network calls to retrieve the database connection parameter info from SSM, and uses those * values to discover VPC configurations for the database. */ const fixUpDataSourceStrategiesProvider = async ( context: $TSContext, projectConfig: Gen1ProjectConfiguration, ): Promise<DataSourceStrategiesProvider> => { const modelToDatasourceMap = projectConfig.modelToDatasourceMap ?? new Map(); const datasourceMapValues: Array<{ dbType: ModelDataSourceStrategyDbType; provisionDB: boolean; provisionStrategy: 'DEFAULT' | 'AMPLIFY_TABLE'; }> = modelToDatasourceMap ? Array.from(modelToDatasourceMap.values()) : []; // We allow a DynamoDB and SQL data source to live alongside each other, although we don't (yet) support relationships between them. We'll // process the dbTypes separately so we can validate that there is only one SQL source. const dataSourceStrategies: Record<string, ModelDataSourceStrategy> = {}; modelToDatasourceMap.forEach((value, key) => { if (!isDynamoDbType(value.dbType)) { return; } switch (value.provisionStrategy) { case 'DEFAULT': dataSourceStrategies[key] = DDB_DEFAULT_DATASOURCE_STRATEGY; break; case 'AMPLIFY_TABLE': dataSourceStrategies[key] = DDB_AMPLIFY_MANAGED_DATASOURCE_STRATEGY; break; default: throw new Error(`Unsupported provisionStrategy ${value.provisionStrategy}`); } }); const sqlDbType = getSqlDbTypeFromDataSourceTypes(datasourceMapValues); let sqlDirectiveDataSourceStrategies: SqlDirectiveDataSourceStrategy[] | undefined; if (sqlDbType) { const dbConnectionConfig = getDbConnectionConfig(); const vpcConfiguration = await isSqlLambdaVpcConfigRequired(context, sqlDbType); const strategy: SQLLambdaModelDataSourceStrategy = { name: getDefaultStrategyNameForDbType(sqlDbType), dbType: sqlDbType, dbConnectionConfig, vpcConfiguration, }; modelToDatasourceMap.forEach((value, key) => { if (!isSqlDbType(value.dbType)) { return; } dataSourceStrategies[key] = strategy; }); let customSqlStatements: Record<string, string> | undefined; if (typeof projectConfig.customQueries === 'object') { customSqlStatements = {}; (projectConfig.customQueries as Map<string, string>).forEach((value, key) => { customSqlStatements[key] = value; }); } sqlDirectiveDataSourceStrategies = constructSqlDirectiveDataSourceStrategies(projectConfig.schema, strategy, customSqlStatements); } return { dataSourceStrategies, sqlDirectiveDataSourceStrategies, }; }; /** * buildAPIProject * * Note that SQL-backed API support is quite limited in this function. Notably: * - It requires there is only one SQL data source in the API * - It does not support declaring a SQL schema without a `@model` tied to a SQL data source * * TODO: Remove SQL handling from Gen1 CLI. */ const buildAPIProject = async (context: $TSContext, opts: TransformerProjectOptions): Promise<DeploymentResources | undefined> => { // The incoming opts.projectConfig is not actually compatible with DataSourceStrategiesProvider. We will correct that in this function. const schema = opts.projectConfig.schema.toString(); // Skip building the project if the schema is blank if (!schema) { return undefined; } const { dataSourceStrategies, sqlDirectiveDataSourceStrategies } = await fixUpDataSourceStrategiesProvider( context, opts.projectConfig as unknown as Gen1ProjectConfiguration, ); checkForUnsupportedDirectives(schema, { dataSourceStrategies }); const useBetaSqlLayer = context?.input?.options?.[USE_BETA_SQL_LAYER] ?? false; // Read the RDS Mapping S3 Manifest only if the schema contains SQL models or @sql directives. let rdsLayerMapping: RDSLayerMapping | undefined = undefined; let rdsSnsTopicMapping: RDSSNSTopicMapping | undefined = undefined; if (containsSqlModelOrDirective(dataSourceStrategies, sqlDirectiveDataSourceStrategies)) { rdsLayerMapping = await getRDSLayerMapping(context, useBetaSqlLayer); rdsSnsTopicMapping = await getRDSSNSTopicMapping(context, useBetaSqlLayer); } const transformManager = new TransformManager( opts.overrideConfig, hasIamAuth(opts.authConfig), hasUserPoolAuth(opts.authConfig), await getAdminRoles(context, opts.resourceName), await getIdentityPoolId(context), ); executeTransform({ ...opts, scope: transformManager.rootStack, nestedStackProvider: transformManager.getNestedStackProvider(), assetProvider: transformManager.getAssetProvider(), parameterProvider: transformManager.getParameterProvider(), synthParameters: transformManager.getSynthParameters(), schema, dataSourceStrategies, sqlDirectiveDataSourceStrategies, printTransformerLog, rdsLayerMapping, rdsSnsTopicMapping, }); const transformOutput: DeploymentResources = { ...transformManager.generateDeploymentResources(), userOverriddenSlots: opts.userDefinedSlots ? getUserOverridenSlots(opts.userDefinedSlots) : [], }; const builtProject = mergeUserConfigWithTransformOutput(opts.projectConfig, transformOutput, opts); const buildLocation = path.join(opts.projectDirectory, 'build'); const currentCloudLocation = opts.currentCloudBackendDirectory ? path.join(opts.currentCloudBackendDirectory, 'build') : undefined; if (opts.projectDirectory && !opts.dryRun) { await writeDeploymentToDisk(context, builtProject, buildLocation, opts.rootStackFileName, opts.buildParameters); await sanityCheckProject( currentCloudLocation, buildLocation, opts.rootStackFileName, opts.sanityCheckRules.diffRules, opts.sanityCheckRules.projectRules, ); } return builtProject; }; export const getUserOverridenSlots = (userDefinedSlots: Record<string, UserDefinedSlot[]>): string[] => Object.values(userDefinedSlots) .flat() .flatMap((slot) => [slot.requestResolver?.fileName, slot.responseResolver?.fileName]) .flat() .filter((slotName) => slotName !== undefined); const getRDSLayerMapping = async (context: $TSContext, useBetaSqlLayer = false): Promise<RDSLayerMapping> => { const bucket = `${ResourceConstants.RESOURCES.SQLLayerManifestBucket}${useBetaSqlLayer ? '-beta' : ''}`; const region = context.amplify.getProjectMeta().providers.awscloudformation.Region; const url = `https://${bucket}.s3.amazonaws.com/${ResourceConstants.RESOURCES.SQLLayerVersionManifestKeyPrefix}${region}`; const response = await fetch(url); if (response.status === 200) { const result = await response.text(); const mapping = { [region]: { layerRegion: result, }, }; return mapping as RDSLayerMapping; } else { throw new Error(`Unable to retrieve layer mapping from ${url} with status code ${response.status}.`); } }; const getRDSSNSTopicMapping = async (context: $TSContext, useBetaSqlLayer = false): Promise<RDSSNSTopicMapping> => { const bucket = `${ResourceConstants.RESOURCES.SQLLayerManifestBucket}${useBetaSqlLayer ? '-beta' : ''}`; const region = context.amplify.getProjectMeta().providers.awscloudformation.Region; const url = `https://${bucket}.s3.amazonaws.com/${ResourceConstants.RESOURCES.SQLSNSTopicARNManifestKeyPrefix}${region}`; const response = await fetch(url); if (response.status === 200) { const result = await response.text(); const mapping = { [region]: { topicArn: result, }, }; return mapping as RDSSNSTopicMapping; } else { throw new Error(`Unable to retrieve sns topic ARN mapping from ${url} with status code ${response.status}.`); } }; const isSqlLambdaVpcConfigRequired = async (context: $TSContext, dbType: ModelDataSourceStrategyDbType): Promise<VpcConfig | undefined> => { // If the database is in VPC, we will use the same VPC configuration for the SQL lambda. // Customers are required to add inbound rule for port 443 from the private subnet in the Security Group. // https://docs.aws.amazon.com/systems-manager/latest/userguide/setup-create-vpc.html#vpc-requirements-and-limitations const vpcSubnetConfig = await getSQLLambdaVpcConfig(context, dbType); return vpcSubnetConfig; }; const getDbConnectionConfig = (): SqlModelDataSourceDbConnectionConfig => { const apiName = getAppSyncAPIName(); const secretsKey = getSecretsKey(); const paths = getExistingConnectionDbConnectionConfig(apiName, secretsKey); return paths; }; const getSQLLambdaVpcConfig = async (context: $TSContext, dbType: ModelDataSourceStrategyDbType): Promise<VpcConfig> => { const [secretsKey, engine] = [getSecretsKey(), getImportedRDSTypeFromStrategyDbType(dbType)]; const { secrets } = await getConnectionSecrets(context, secretsKey, engine); const region = context.amplify.getProjectMeta().providers.awscloudformation.Region; const vpcConfig = await getHostVpc(secrets.host, region); return vpcConfig; }; const printTransformerLog = (log: TransformerLog): void => { switch (log.level) { case TransformerLogLevel.ERROR: printer.error(log.message); break; case TransformerLogLevel.WARN: printer.warn(log.message); break; case TransformerLogLevel.INFO: printer.info(log.message); break; case TransformerLogLevel.DEBUG: printer.debug(log.message); break; default: printer.error(log.message); } };