Skip to content

Commit

Permalink
fix: eliminate circular dependency when generating CMS assets
Browse files Browse the repository at this point in the history
  • Loading branch information
edwardfoyle committed Nov 1, 2022
1 parent fda7872 commit 5313a86
Showing 1 changed file with 79 additions and 69 deletions.
148 changes: 79 additions & 69 deletions packages/amplify-provider-awscloudformation/src/admin-modelgen.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import {
$TSAny, $TSContext, AmplifyError, AmplifyFault, pathManager, stateManager,
$TSAny, $TSContext, pathManager, stateManager,
} from 'amplify-cli-core';
import { printer } from 'amplify-prompts';
import AWS from 'aws-sdk';
import { PromiseResult } from 'aws-sdk/lib/request';
import * as fs from 'fs-extra';
import { isDataStoreEnabled } from 'graphql-transformer-core';
import ora from 'ora';
import _ from 'lodash';
import * as path from 'path';
import { AmplifyBackend } from './aws-utils/aws-amplify-backend';
import { S3 } from './aws-utils/aws-s3';
import { ProviderName as providerName } from './constants';
import { isAmplifyAdminApp } from './utils/admin-helpers';

Expand All @@ -25,88 +23,100 @@ export const adminModelgen = async (context: $TSContext, resources: $TSAny[]): P
const { resourceName } = appSyncResource;

const amplifyMeta = stateManager.getMeta();
const localEnvInfo = stateManager.getLocalEnvInfo();

const appId = amplifyMeta?.providers?.[providerName]?.AmplifyAppId;

if (!appId) {
return;
}

const { envName } = localEnvInfo;
const { isAdminApp } = await isAmplifyAdminApp(appId);
const isDSEnabled = await isDataStoreEnabled(path.join(pathManager.getBackendDirPath(), 'api', resourceName));

if (!isAdminApp || !isDSEnabled) {
return;
}

const spinner = ora('Generating models in the cloud...\n').start();
const amplifyBackendInstance = await AmplifyBackend.getInstance(context);
// the following is a hack to enable us to upload assets needed by studio CMS to the deployment bucket without
// calling AmplifyBackend.generateBackendAPIModels.
// Calling this API introduces a circular dependency because this API in turn executes the CLI to generate codegen assets

const originalProjectConfig = stateManager.getProjectConfig();
const relativeTempOutputDir = 'amplify-codegen-temp';
const absoluteTempOutputDir = path.join(pathManager.findProjectRoot(), relativeTempOutputDir);
const forceJSCodegenProjectConfig = {
projectName: 'tempCodegen',
version: '3.1',
frontend: 'javascript',
javascript: {
framework: 'none',
config: {
SourceDir: relativeTempOutputDir,
DistributionDir: 'dist',
BuildCommand: 'npm run-script build',
StartCommand: 'npm run-script start',
},
},
providers: [
'awscloudformation',
],
};
const originalStdoutWrite = process.stdout.write;
try {
const jobStartDetails = await amplifyBackendInstance.amplifyBackend
.generateBackendAPIModels({
AppId: appId,
BackendEnvironmentName: envName,
ResourceName: resourceName,
})
.promise();

const jobCompletionDetails = await pollUntilDone(
jobStartDetails.JobId,
appId,
envName,
2 * 1000,
2000 * 1000,
amplifyBackendInstance.amplifyBackend,
);
if (jobCompletionDetails.Status === 'COMPLETED') {
spinner.succeed('Successfully generated models in the cloud.');
} else {
throw new AmplifyError('ModelgenError', { message: `Failed to generate models in the cloud.` });
// overwrite project config with config that forces codegen to output js to a temp location
stateManager.setProjectConfig(undefined, forceJSCodegenProjectConfig);

// generateModels and generateModelIntrospection print confusing and duplicate output when executing these codegen paths
// so pipe stdout to a file and then reset it at the end to suppress this output
const tempStdoutWrite = fs.createWriteStream(path.join(absoluteTempOutputDir, 'temp-console-log.txt'));
process.stdout.write = tempStdoutWrite.write.bind(tempStdoutWrite);

// invokes https://github.com/aws-amplify/amplify-codegen/blob/main/packages/amplify-codegen/src/commands/models.js#L60
await context.amplify.invokePluginMethod(context, 'codegen', undefined, 'generateModels', [context]);

// generateModelIntrospection expects --output-dir option to be set
if (!context.parameters?.options?.['output-dir']) {
_.set(context, ['parameters', 'options', 'output-dir'], relativeTempOutputDir);
}
} catch (e) {
spinner.stop();
printer.error(`Failed to create models in the cloud: ${e.message}`);

// invokes https://github.com/aws-amplify/amplify-codegen/blob/main/packages/amplify-codegen/src/commands/model-intropection.js#L8
await context.amplify.invokePluginMethod(context, 'codegen', undefined, 'generateModelIntrospection', [context]);

const localSchemaPath = path.join(pathManager.getResourceDirectoryPath(undefined, 'api', resourceName), 'schema.graphql');
const localSchemaJsPath = path.join(absoluteTempOutputDir, 'models', 'schema.js');
const localModelIntrospectionPath = path.join(absoluteTempOutputDir, 'model-introspection.json');

// ==================== DO NOT MODIFY THIS MAP UNLESS YOU ARE 100% SURE OF THE IMPLICATIONS ====================
// this map represents an implicit interface between the CLI and the Studio CMS frontend
const s3ApiModelsPrefix = `models/${resourceName}/`;
const cmsArtifactLocalToS3KeyMap: Record<LocalPath, S3Key> = {
[localSchemaPath]: `${s3ApiModelsPrefix}schema.graphql`,
[localSchemaJsPath]: `${s3ApiModelsPrefix}schema.js`,
[localModelIntrospectionPath]: `${s3ApiModelsPrefix}modelIntrospection.json`,
};
// ==================== DO NOT MODIFY THIS MAP UNLESS YOU ARE 100% SURE OF THE IMPLICATIONS ====================

await uploadCMSArtifacts(await S3.getInstance(context), cmsArtifactLocalToS3KeyMap);
} finally {
process.stdout.write = originalStdoutWrite;
await fs.remove(absoluteTempOutputDir);
stateManager.setProjectConfig(undefined, originalProjectConfig);
}
};

// interval is how often to poll
// timeout is how long to poll waiting for a result (0 means try forever)

const pollUntilDone = async (
jobId: string,
appId: string,
backendEnvironmentName: string,
interval: number,
timeout: number,
amplifyBackendClient: AWS.AmplifyBackend,
): Promise<PromiseResult<AWS.AmplifyBackend.GetBackendJobResponse, AWS.AWSError>> => {
const start = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
const jobDetails = await amplifyBackendClient
.getBackendJob({
JobId: jobId,
AppId: appId,
BackendEnvironmentName: backendEnvironmentName,
})
.promise();

if (jobDetails.Status === 'FAILED' || jobDetails.Status === 'COMPLETED') {
// we know we're done here, return from here whatever you
// want the final resolved value of the promise to be
return jobDetails;
}
if (timeout !== 0 && Date.now() - start > timeout) {
throw new AmplifyFault('TimeoutFault', { message: `Job Timed out for ${jobId}` });
} else {
// run again with a short delay
await delay(interval);
}
}
/**
* Uploads the files specified in uploadMap to the corresponding S3 key
*/
const uploadCMSArtifacts = async (s3Client: S3, uploadMap: Record<LocalPath, S3Key>): Promise<void> => {
const doNotShowSpinner = false;
const uploadPromises = Object.entries(uploadMap)
.map(([localPath, s3Key]) => ({
Body: fs.createReadStream(localPath),
Key: s3Key,
}))
.map(uploadParams => s3Client.uploadFile(uploadParams, doNotShowSpinner));
await Promise.all(uploadPromises);
};

// create a promise that resolves after a short delay
const delay = (t: number): Promise<void> => new Promise(resolve => setTimeout(resolve, t));
type LocalPath = string;
type S3Key = string;

0 comments on commit 5313a86

Please sign in to comment.