diff --git a/packages/amplify-category-storage/amplify-plugin.json b/packages/amplify-category-storage/amplify-plugin.json index 6eabe87cf19..b46d4d9565b 100644 --- a/packages/amplify-category-storage/amplify-plugin.json +++ b/packages/amplify-category-storage/amplify-plugin.json @@ -1,7 +1,7 @@ { "name": "storage", "type": "category", - "commands": ["add", "import", "console", "push", "remove", "update", "help"], + "commands": ["add", "import", "console", "push", "remove", "update", "help", "override"], "commandAliases": { "configure": "update" }, diff --git a/packages/amplify-category-storage/package.json b/packages/amplify-category-storage/package.json index bd53c0c6e62..865941812ec 100644 --- a/packages/amplify-category-storage/package.json +++ b/packages/amplify-category-storage/package.json @@ -1,6 +1,6 @@ { - "name": "amplify-category-storage", - "version": "2.12.9", + "name": "@aws-amplify/amplify-category-storage", + "version": "1.0.0", "description": "amplify-cli storage plugin", "repository": { "type": "git", @@ -15,7 +15,8 @@ "build": "tsc", "test": "jest", "clean": "rimraf lib tsconfig.tsbuildinfo", - "watch": "tsc -w" + "watch": "tsc -w", + "generateSchemas": "ts-node ./resources/genInputSchema.ts" }, "keywords": [ "amplify", @@ -27,6 +28,9 @@ "amplify-util-import": "1.5.12", "chalk": "^4.1.1", "cloudform-types": "^4.2.0", + "@aws-cdk/aws-s3": "~1.124.0", + "@aws-cdk/core": "~1.124.0", + "@aws-cdk/aws-dynamodb": "~1.124.0", "enquirer": "^2.3.6", "fs-extra": "^8.1.0", "inquirer": "^7.3.3", @@ -52,6 +56,10 @@ "json", "node" ], - "collectCoverage": true + "collectCoverage": true, + "coverageReporters": [ + "json", + "html" + ] } } diff --git a/packages/amplify-category-storage/resources/genInputSchema.ts b/packages/amplify-category-storage/resources/genInputSchema.ts new file mode 100644 index 00000000000..20e8eb40016 --- /dev/null +++ b/packages/amplify-category-storage/resources/genInputSchema.ts @@ -0,0 +1,17 @@ +import { TypeDef, CLIInputSchemaGenerator } from 'amplify-cli-core'; + +//ResourceProvider TypeDefs +const DDBStorageTypeDef: TypeDef = { + typeName: 'DynamoDBCLIInputs', + service: 'DynamoDB', +}; +const S3StorageTypeDef: TypeDef = { + typeName: 'S3UserInputs', + service: 'S3', +}; + +// Defines the type names and the paths to the TS files that define them +const storageCategoryTypeDefs: TypeDef[] = [DDBStorageTypeDef]; + +const schemaGenerator = new CLIInputSchemaGenerator(storageCategoryTypeDefs); +schemaGenerator.generateJSONSchemas(); //convert CLI input data into json schemas. diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts new file mode 100644 index 00000000000..95ad315252c --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/override.ts @@ -0,0 +1,7 @@ +/* Add Amplify Helper dependencies */ + +/* TODO: Need to change props to Root-Stack specific props when props are ready */ +export function overrideProps(props: any) { + /* TODO: Add snippet of how to override in comments */ + return props; +} diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/package.json b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/package.json new file mode 100644 index 00000000000..1601dccfc02 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/package.json @@ -0,0 +1,18 @@ +{ + "name": "overrides-for-root-stack", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@types/fs-extra": "^9.0.11", + "fs-extra": "^9.1.0" + }, + "devDependencies": { + "typescript": "^4.2.4" + } + } + \ No newline at end of file diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.json b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.json new file mode 100644 index 00000000000..f48614cbf6b --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "build" + } +} + \ No newline at end of file diff --git a/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.resource.json b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.resource.json new file mode 100644 index 00000000000..6504da80283 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/DynamoDB/tsconfig.resource.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./", + "rootDir": "../" + }, + "include": ["../**/*"] +} diff --git a/packages/amplify-category-storage/resources/overrides-resource/S3/override.ts b/packages/amplify-category-storage/resources/overrides-resource/S3/override.ts new file mode 100644 index 00000000000..c02cee5ebd4 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/S3/override.ts @@ -0,0 +1,6 @@ +/* Add Amplify Helper dependencies */ + +/* TODO: Need to change props to Root-Stack specific props when props are ready */ +export function overrideProps(props: any): void { + /* TODO: Add snippet of how to override in comments */ +} diff --git a/packages/amplify-category-storage/resources/overrides-resource/S3/package.json b/packages/amplify-category-storage/resources/overrides-resource/S3/package.json new file mode 100644 index 00000000000..1601dccfc02 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/S3/package.json @@ -0,0 +1,18 @@ +{ + "name": "overrides-for-root-stack", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@types/fs-extra": "^9.0.11", + "fs-extra": "^9.1.0" + }, + "devDependencies": { + "typescript": "^4.2.4" + } + } + \ No newline at end of file diff --git a/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.json b/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.json new file mode 100644 index 00000000000..f48614cbf6b --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "build" + } +} + \ No newline at end of file diff --git a/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.resource.json b/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.resource.json new file mode 100644 index 00000000000..6504da80283 --- /dev/null +++ b/packages/amplify-category-storage/resources/overrides-resource/S3/tsconfig.resource.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./", + "rootDir": "../" + }, + "include": ["../**/*"] +} diff --git a/packages/amplify-category-storage/resources/schemas/DynamoDB/DynamoDBCLIInputs.schema.json b/packages/amplify-category-storage/resources/schemas/DynamoDB/DynamoDBCLIInputs.schema.json new file mode 100644 index 00000000000..1143f79c937 --- /dev/null +++ b/packages/amplify-category-storage/resources/schemas/DynamoDB/DynamoDBCLIInputs.schema.json @@ -0,0 +1,64 @@ +{ + "type": "object", + "properties": { + "resourceName": { + "type": "string" + }, + "tableName": { + "type": "string" + }, + "partitionKey": { + "$ref": "#/definitions/DynamoDBCLIInputsKeyType" + }, + "sortKey": { + "$ref": "#/definitions/DynamoDBCLIInputsKeyType" + }, + "gsi": { + "type": "array", + "items": { + "$ref": "#/definitions/DynamoDBCLIInputsGSIType" + } + }, + "triggerFunctions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["partitionKey", "resourceName", "tableName"], + "definitions": { + "DynamoDBCLIInputsKeyType": { + "type": "object", + "properties": { + "fieldName": { + "type": "string" + }, + "fieldType": { + "$ref": "#/definitions/FieldType" + } + }, + "required": ["fieldName", "fieldType"] + }, + "FieldType": { + "enum": ["binary", "boolean", "list", "map", "null", "number", "string"], + "type": "string" + }, + "DynamoDBCLIInputsGSIType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "partitionKey": { + "$ref": "#/definitions/DynamoDBCLIInputsKeyType" + }, + "sortKey": { + "$ref": "#/definitions/DynamoDBCLIInputsKeyType" + } + }, + "required": ["name", "partitionKey"] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/packages/amplify-category-storage/resources/schemas/S3/S3UserInputs.schema.json b/packages/amplify-category-storage/resources/schemas/S3/S3UserInputs.schema.json new file mode 100644 index 00000000000..0254f73d206 --- /dev/null +++ b/packages/amplify-category-storage/resources/schemas/S3/S3UserInputs.schema.json @@ -0,0 +1,50 @@ +{ + "type": "object", + "properties": { + "resourceName": { + "type": "string" + }, + "bucketName": { + "type": "string" + }, + "storageAccess": { + "$ref": "#/definitions/S3AccessType" + }, + "selectedGuestPermissions": { + "type": "array", + "items": { + "enum": ["DELETE_OBJECT", "GET_OBJECT", "LIST_BUCKET", "PUT_OBJECT"], + "type": "string" + } + }, + "selectedAuthenticatedPermissions": { + "type": "array", + "items": { + "enum": ["DELETE_OBJECT", "GET_OBJECT", "LIST_BUCKET", "PUT_OBJECT"], + "type": "string" + } + }, + "isTriggerEnabled": { + "type": "boolean" + }, + "triggerFunctionName": { + "type": "string" + } + }, + "required": [ + "bucketName", + "isTriggerEnabled", + "resourceName", + "selectedAuthenticatedPermissions", + "selectedGuestPermissions", + "storageAccess", + "triggerFunctionName" + ], + "definitions": { + "S3AccessType": { + "enum": ["auth", "authAndGuest"], + "type": "string" + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/packages/amplify-category-storage/src/commands/storage/override.ts b/packages/amplify-category-storage/src/commands/storage/override.ts new file mode 100644 index 00000000000..4b56910d6a1 --- /dev/null +++ b/packages/amplify-category-storage/src/commands/storage/override.ts @@ -0,0 +1,83 @@ +/* + entry code for amplify override root +*/ + +import { generateOverrideSkeleton, $TSContext, FeatureFlags, stateManager, pathManager } from 'amplify-cli-core'; +import { printer } from 'amplify-prompts'; +import * as fs from 'fs-extra'; +import inquirer from 'inquirer'; +import { DynamoDBInputState } from '../../provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; +import { DDBStackTransform } from '../../provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; +import * as path from 'path'; +import { categoryName } from '../../constants'; + +export const name = 'override'; + +export const run = async (context: $TSContext) => { + if (FeatureFlags.getBoolean('overrides.project')) { + const { amplify } = context; + const amplifyMeta = stateManager.getMeta(); + + const storageResources: string[] = []; + + Object.keys(amplifyMeta[categoryName]).forEach(resourceName => { + storageResources.push(resourceName); + }); + + if (storageResources.length === 0) { + const errMessage = 'No resources to override. You need to add a resource.'; + printer.error(errMessage); + return; + } + + let selectedResourceName: string = storageResources[0]; + + if (storageResources.length > 1) { + const resourceAnswer = await inquirer.prompt({ + type: 'list', + name: 'resource', + message: 'Which resource would you like to add overrides for?', + choices: storageResources, + }); + selectedResourceName = resourceAnswer.resource; + } + + const destPath = pathManager.getResourceDirectoryPath(undefined, categoryName, selectedResourceName); + fs.ensureDirSync(destPath); + + const srcPath = path.join( + __dirname, + '..', + '..', + '..', + 'resources', + 'overrides-resource', + amplifyMeta[categoryName][selectedResourceName].service, + ); + + // Make sure to migrate first + if (amplifyMeta[categoryName][selectedResourceName].service === 'DynamoDB') { + const resourceInputState = new DynamoDBInputState(selectedResourceName); + if (!resourceInputState.cliInputFileExists()) { + if (await amplify.confirmPrompt('File migration required to continue. Do you want to continue?', true)) { + resourceInputState.migrate(); + const stackGenerator = new DDBStackTransform(selectedResourceName); + stackGenerator.transform(); + } else { + return; + } + } + } else if (amplifyMeta[categoryName][selectedResourceName].service === 'S3') { + // S3 migration logic goes in here + } + + await generateOverrideSkeleton(context, srcPath, destPath); + } else { + printer.info('Storage overrides is currently not turned on. In amplify/cli.json file please include the following:'); + printer.info(`{ + override: { + storage: true + } + }`); + } +}; diff --git a/packages/amplify-category-storage/src/index.ts b/packages/amplify-category-storage/src/index.ts index 5b4d07b74bb..4eca6161280 100644 --- a/packages/amplify-category-storage/src/index.ts +++ b/packages/amplify-category-storage/src/index.ts @@ -1,76 +1,103 @@ -import { $TSAny, $TSContext, $TSObject, stateManager } from 'amplify-cli-core'; -import { printer } from 'amplify-prompts'; import * as path from 'path'; import sequential from 'promise-sequential'; +import { printer } from 'amplify-prompts'; import { updateConfigOnEnvInit } from './provider-utils/awscloudformation'; -import { categoryName } from './constants'; -export { categoryName as category } from './constants'; +import { $TSContext, AmplifyCategories, IAmplifyResource, pathManager } from 'amplify-cli-core'; +import { DDBStackTransform } from './provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform'; +import { DynamoDBInputState } from './provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state'; +export { AmplifyDDBResourceTemplate } from './provider-utils/awscloudformation/cdk-stack-builder/types'; -export async function add(context: $TSContext, providerName: string, service: string) { +async function add(context: any, providerName: any, service: any) { const options = { service, providerPlugin: providerName, }; - const providerController = await import(`./provider-utils/${providerName}`); + const providerController = require(`./provider-utils/${providerName}`); if (!providerController) { printer.error('Provider not configured for this category'); return; } - return providerController.addResource(context, categoryName, service, options); + return providerController.addResource(context, AmplifyCategories.STORAGE, service, options); } -export async function console(context: $TSContext) { - printer.info(`to be implemented: ${categoryName} console`); +async function categoryConsole(context: any) { + printer.info(`to be implemented: ${AmplifyCategories.STORAGE} console`); } -export async function migrate(context: $TSContext) { +async function migrateStorageCategory(context: any) { const { projectPath, amplifyMeta } = context.migrationInfo; - const migrateResourcePromises: Promise<$TSAny>[] = []; - - const categoryResources = amplifyMeta?.[categoryName] || {}; + const migrateResourcePromises: any = []; + + Object.keys(amplifyMeta).forEach(categoryName => { + if (categoryName === AmplifyCategories.STORAGE) { + Object.keys(amplifyMeta[AmplifyCategories.STORAGE]).forEach(resourceName => { + try { + const providerController = require(`./provider-utils/${amplifyMeta[AmplifyCategories.STORAGE][resourceName].providerPlugin}`); + + if (providerController) { + migrateResourcePromises.push( + providerController.migrateResource( + context, + projectPath, + amplifyMeta[AmplifyCategories.STORAGE][resourceName].service, + resourceName, + ), + ); + } else { + printer.error(`Provider not configured for ${AmplifyCategories.STORAGE}: ${resourceName}`); + } + } catch (e) { + printer.warn(`Could not run migration for ${AmplifyCategories.STORAGE}: ${resourceName}`); + throw e; + } + }); + } + }); - for (const resourceName of Object.keys(categoryResources)) { - try { - const providerController = await import(`./provider-utils/${amplifyMeta[categoryName][resourceName].providerPlugin}`); + await Promise.all(migrateResourcePromises); +} - if (providerController) { - migrateResourcePromises.push( - await providerController.migrateResource(context, projectPath, amplifyMeta[categoryName][resourceName].service, resourceName), - ); - } else { - printer.error(`Provider not configured for ${categoryName}: ${resourceName}`); - } - } catch (e) { - printer.warn(`Could not run migration for ${categoryName}: ${resourceName}`); - throw e; +async function transformCategoryStack(context: $TSContext, resource: IAmplifyResource) { + if (resource.service === 'DynamoDB') { + if (canResourceBeTransformed(resource.resourceName)) { + const stackGenerator = new DDBStackTransform(resource.resourceName); + stackGenerator.transform(); } + } else if (resource.service === 'S3') { + // Not yet implemented } +} - await Promise.all(migrateResourcePromises); +function canResourceBeTransformed(resourceName: string) { + const resourceInputState = new DynamoDBInputState(resourceName); + return resourceInputState.cliInputFileExists(); } -export async function getPermissionPolicies(context: $TSContext, resourceOpsMapping: $TSAny) { - const amplifyMeta = stateManager.getMeta(); - const permissionPolicies: $TSAny[] = []; - const resourceAttributes: $TSAny[] = []; +async function getPermissionPolicies(context: any, resourceOpsMapping: any) { + const amplifyMetaFilePath = context.amplify.pathManager.getAmplifyMetaFilePath(); + const amplifyMeta = context.amplify.readJsonFile(amplifyMetaFilePath); + const permissionPolicies: any = []; + const resourceAttributes: any = []; + const storageCategory = AmplifyCategories.STORAGE; - for (const resourceName of Object.keys(resourceOpsMapping)) { + Object.keys(resourceOpsMapping).forEach(resourceName => { try { const providerPlugin = 'providerPlugin' in resourceOpsMapping[resourceName] ? resourceOpsMapping[resourceName].providerPlugin - : amplifyMeta[categoryName][resourceName].providerPlugin; + : amplifyMeta[storageCategory][resourceName].providerPlugin; const service = 'service' in resourceOpsMapping[resourceName] ? resourceOpsMapping[resourceName].service - : amplifyMeta[categoryName][resourceName].service; + : amplifyMeta[storageCategory][resourceName].service; if (providerPlugin) { - const providerController = await import(`./provider-utils/${providerPlugin}`); - const { policy, attributes } = await providerController.getPermissionPolicies( + const providerController = require(`./provider-utils/${providerPlugin}`); + const { policy, attributes } = providerController.getPermissionPolicies( + context, service, resourceName, resourceOpsMapping[resourceName], @@ -80,54 +107,54 @@ export async function getPermissionPolicies(context: $TSContext, resourceOpsMapp } else { permissionPolicies.push(policy); } - resourceAttributes.push({ resourceName, attributes, category: categoryName }); + resourceAttributes.push({ resourceName, attributes, storageCategory }); } else { - printer.error(`Provider not configured for ${categoryName}: ${resourceName}`); + printer.error(`Provider not configured for ${storageCategory}: ${resourceName}`); } } catch (e) { - printer.warn(`Could not get policies for ${categoryName}: ${resourceName}`); + printer.warn(`Could not get policies for ${storageCategory}: ${resourceName}`); throw e; } - } + }); return { permissionPolicies, resourceAttributes }; } -export async function executeAmplifyCommand(context: $TSContext) { +async function executeAmplifyCommand(context: any) { let commandPath = path.normalize(path.join(__dirname, 'commands')); if (context.input.command === 'help') { - commandPath = path.join(commandPath, categoryName); + commandPath = path.join(commandPath, AmplifyCategories.STORAGE); } else { - commandPath = path.join(commandPath, categoryName, context.input.command); + commandPath = path.join(commandPath, AmplifyCategories.STORAGE, context.input.command); } - const commandModule = await import(commandPath); + const commandModule = require(commandPath); await commandModule.run(context); } -export async function handleAmplifyEvent(context: $TSContext, args: $TSAny) { - printer.info(`${categoryName} handleAmplifyEvent to be implemented`); +async function handleAmplifyEvent(context: any, args: any) { + printer.info(`${AmplifyCategories.STORAGE} handleAmplifyEvent to be implemented`); printer.info(`Received event args ${args}`); } -export async function initEnv(context: $TSContext) { - const { resourcesToBeSynced, allResources } = await context.amplify.getResourceStatus(categoryName); +async function initEnv(context: any) { + const { resourcesToBeSynced, allResources } = await context.amplify.getResourceStatus(AmplifyCategories.STORAGE); const isPulling = context.input.command === 'pull' || (context.input.command === 'env' && context.input.subCommands[0] === 'pull'); - let toBeSynced: $TSObject[] = []; + let toBeSynced = []; if (resourcesToBeSynced && resourcesToBeSynced.length > 0) { - toBeSynced = resourcesToBeSynced.filter((b: $TSObject) => b.category === categoryName); + toBeSynced = resourcesToBeSynced.filter((b: any) => b.category === AmplifyCategories.STORAGE); } toBeSynced - .filter(storageResource => storageResource.sync === 'unlink') - .forEach(storageResource => { - context.amplify.removeResourceParameters(context, categoryName, storageResource.resourceName); + .filter((storageResource: any) => storageResource.sync === 'unlink') + .forEach((storageResource: any) => { + context.amplify.removeResourceParameters(context, AmplifyCategories.STORAGE, storageResource.resourceName); }); - let tasks: $TSAny[] = []; + let tasks: Record[] = []; // For pull change detection for import sees a difference, to avoid duplicate tasks we don't // add the syncable resources, as allResources covers it, otherwise it is required for env add @@ -145,11 +172,22 @@ export async function initEnv(context: $TSContext) { const { resourceName, service } = storageResource; return async () => { - const config = await updateConfigOnEnvInit(context, categoryName, resourceName, service); - - context.amplify.saveEnvResourceParameters(context, categoryName, resourceName, config); + const config = await updateConfigOnEnvInit(context, AmplifyCategories.STORAGE, resourceName, service); + context.amplify.saveEnvResourceParameters(context, AmplifyCategories.STORAGE, resourceName, config); }; }); await sequential(storageTasks); } + +module.exports = { + add, + console: categoryConsole, + initEnv, + migrate: migrateStorageCategory, + getPermissionPolicies, + executeAmplifyCommand, + handleAmplifyEvent, + transformCategoryStack, + category: AmplifyCategories.STORAGE, +}; diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-builder.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-builder.ts new file mode 100644 index 00000000000..44dad1f403a --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-builder.ts @@ -0,0 +1,188 @@ +import * as cdk from '@aws-cdk/core'; +import * as ddb from '@aws-cdk/aws-dynamodb'; +import { DynamoDBCLIInputs, DynamoDBCLIInputsKeyType, FieldType } from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { AmplifyDDBResourceTemplate } from './types'; + +const CFN_TEMPLATE_FORMAT_VERSION = '2010-09-09'; +const ROOT_CFN_DESCRIPTION = 'DDB Resource for AWS Amplify CLI'; + +export class AmplifyDDBResourceStack extends cdk.Stack implements AmplifyDDBResourceTemplate { + _scope: cdk.Construct; + dynamoDBTable!: ddb.CfnTable; + _props: DynamoDBCLIInputs; + _cfnParameterMap: Map = new Map(); + + constructor(scope: cdk.Construct, id: string, props: DynamoDBCLIInputs) { + super(scope, id, undefined); + this._scope = scope; + this._props = props; + this.templateOptions.templateFormatVersion = CFN_TEMPLATE_FORMAT_VERSION; + this.templateOptions.description = ROOT_CFN_DESCRIPTION; + } + + /** + * + * @param props :cdk.CfnOutputProps + * @param logicalId: : lodicalId of the Resource + */ + addCfnOutput(props: cdk.CfnOutputProps, logicalId: string): void { + try { + new cdk.CfnOutput(this, logicalId, props); + } catch (error) { + throw new Error(error); + } + } + + /** + * + * @param props + * @param logicalId + */ + addCfnMapping(props: cdk.CfnMappingProps, logicalId: string): void { + try { + new cdk.CfnMapping(this, logicalId, props); + } catch (error) { + throw new Error(error); + } + } + + /** + * + * @param props + * @param logicalId + */ + addCfnCondition(props: cdk.CfnConditionProps, logicalId: string): void { + try { + new cdk.CfnCondition(this, logicalId, props); + } catch (error) { + throw new Error(error); + } + } + /** + * + * @param props + * @param logicalId + */ + addCfnResource(props: cdk.CfnResourceProps, logicalId: string): void { + try { + new cdk.CfnResource(this, logicalId, props); + } catch (error) { + throw new Error(error); + } + } + + /** + * + * @param props + * @param logicalId + */ + addCfnParameter(props: cdk.CfnParameterProps, logicalId: string): void { + try { + if (this._cfnParameterMap.has(logicalId)) { + throw new Error('logical Id already Exists'); + } + this._cfnParameterMap.set(logicalId, new cdk.CfnParameter(this, logicalId, props)); + } catch (error) { + throw new Error(error); + } + } + + generateStackResources = async () => { + let usedAttributes: DynamoDBCLIInputsKeyType[] = []; + let keySchema: ddb.CfnTable.KeySchemaProperty[] = []; + let globalSecondaryIndexes: ddb.CfnTable.GlobalSecondaryIndexProperty[] = []; + + if (this._props.partitionKey) { + usedAttributes.push(this._props.partitionKey); + keySchema.push({ + attributeName: this._props.partitionKey.fieldName, + keyType: 'HASH', + }); + } + if (this._props.sortKey) { + usedAttributes.push(this._props.sortKey); + keySchema.push({ + attributeName: this._props.sortKey.fieldName, + keyType: 'RANGE', + }); + } + if (this._props.gsi && this._props.gsi.length > 0) { + this._props.gsi.forEach(gsi => { + let gsiIndex = { + indexName: gsi.name, + keySchema: [ + { + attributeName: gsi.partitionKey.fieldName, + keyType: 'HASH', + }, + ], + projection: { + projectionType: 'ALL', + }, + provisionedThroughput: { + readCapacityUnits: 5, + writeCapacityUnits: 5, + }, + }; + + if (usedAttributes.findIndex(attr => attr.fieldName === gsi.partitionKey.fieldName) === -1) { + usedAttributes.push(gsi.partitionKey); + } + if (gsi.sortKey) { + gsiIndex.keySchema.push({ + attributeName: gsi.sortKey?.fieldName, + keyType: 'RANGE', + }); + if (usedAttributes.findIndex(attr => attr?.fieldName === gsi.sortKey?.fieldName) == -1) { + usedAttributes.push(gsi.sortKey); + } + } + globalSecondaryIndexes.push(gsiIndex); + }); + } + + const ddbAttrTypeMapping = { + string: 'S', + number: 'N', + binary: 'B', + boolean: 'BOOL', + list: 'L', + map: 'M', + null: 'NULL', + 'string-set': 'SS', + 'number-set': 'NS', + 'binary-set': 'BS', + }; + + let attributeMapping: ddb.CfnTable.AttributeDefinitionProperty[] = []; + + usedAttributes.forEach((attr: DynamoDBCLIInputsKeyType) => { + attributeMapping.push({ + attributeName: attr.fieldName, + attributeType: ddbAttrTypeMapping[attr.fieldType], + }); + }); + + this.dynamoDBTable = new ddb.CfnTable(this, 'DynamoDBTable', { + tableName: cdk.Fn.conditionIf( + 'ShouldNotCreateEnvResources', + cdk.Fn.ref('tableName'), + cdk.Fn.join('', [cdk.Fn.ref('tableName'), '-', cdk.Fn.ref('env')]), + ).toString(), + attributeDefinitions: attributeMapping, + keySchema, + globalSecondaryIndexes, + provisionedThroughput: { + readCapacityUnits: 5, + writeCapacityUnits: 5, + }, + streamSpecification: { + streamViewType: 'NEW_IMAGE', + }, + }); + }; + + public renderCloudFormationTemplate = (): string => { + return JSON.stringify(this._toCloudFormation(), undefined, 2); + }; +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts new file mode 100644 index 00000000000..4342016172b --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/ddb-stack-transform.ts @@ -0,0 +1,225 @@ +import { DynamoDBCLIInputs } from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { DynamoDBInputState } from '../service-walkthroughs/dynamoDB-input-state'; +import { AmplifyDDBResourceStack } from './ddb-stack-builder'; +import { AmplifyDDBResourceInputParameters, AmplifyDDBResourceTemplate } from './types'; +import { App } from '@aws-cdk/core'; +import * as cdk from '@aws-cdk/core'; +import * as fs from 'fs-extra'; +import { JSONUtilities, pathManager, buildOverrideDir, $TSAny } from 'amplify-cli-core'; +import * as path from 'path'; +import { formatter, printer } from 'amplify-prompts'; + +export class DDBStackTransform { + app: App; + _cliInputs: DynamoDBCLIInputs; + _resourceTemplateObj: AmplifyDDBResourceStack | undefined; + _cliInputsState: DynamoDBInputState; + _cfn!: string; + _cfnInputParams!: AmplifyDDBResourceInputParameters; + _resourceName: string; + + constructor(resourceName: string) { + this.app = new App(); + this._resourceName = resourceName; + + // Validate the cli-inputs.json for the resource + this._cliInputsState = new DynamoDBInputState(resourceName); + this._cliInputs = this._cliInputsState.getCliInputPayload(); + this._cliInputsState.isCLIInputsValid(); + } + + async transform() { + // Generate cloudformation stack from cli-inputs.json + await this.generateStack(); + + // Generate cloudformation stack input params from cli-inputs.json + this.generateCfnInputParameters(); + + // Modify cloudformation files based on overrides + await this.applyOverrides(); + + // Save generated cloudformation.json and parameters.json files + this.saveBuildFiles(); + } + + generateCfnInputParameters() { + this._cfnInputParams = { + tableName: this._cliInputs.tableName, + partitionKeyName: this._cliInputs.partitionKey.fieldName, + partitionKeyType: this._cliInputs.partitionKey.fieldType, + }; + if (this._cliInputs.sortKey) { + this._cfnInputParams.sortKeyName = this._cliInputs.sortKey.fieldName; + this._cfnInputParams.sortKeyType = this._cliInputs.sortKey.fieldType; + } + } + + async generateStack() { + this._resourceTemplateObj = new AmplifyDDBResourceStack(this.app, 'AmplifyDDBResourceStack', this._cliInputs); + + // Add Parameters + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'partitionKeyName', + ); + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'partitionKeyType', + ); + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'env', + ); + if (this._cliInputs.sortKey) { + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'sortKeyName', + ); + + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'sortKeyType', + ); + } + this._resourceTemplateObj.addCfnParameter( + { + type: 'String', + }, + 'tableName', + ); + + // Add conditions + + this._resourceTemplateObj.addCfnCondition( + { + expression: cdk.Fn.conditionEquals(cdk.Fn.ref('env'), 'NONE'), + }, + 'ShouldNotCreateEnvResources', + ); + + // Add resources + + await this._resourceTemplateObj.generateStackResources(); + + // Add outputs + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('DynamoDBTable'), + }, + 'Name', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.getAtt('DynamoDBTable', 'Arn').toString(), + }, + 'Arn', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.getAtt('DynamoDBTable', 'StreamArn').toString(), + }, + 'StreamArn', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('partitionKeyName'), + }, + 'PartitionKeyName', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('partitionKeyType'), + }, + 'PartitionKeyType', + ); + + if (this._cliInputs.sortKey) { + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('sortKeyName'), + }, + 'SortKeyName', + ); + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('sortKeyType'), + }, + 'SortKeyType', + ); + } + + this._resourceTemplateObj.addCfnOutput( + { + value: cdk.Fn.ref('AWS::Region'), + }, + 'Region', + ); + } + + async applyOverrides() { + const backendDir = pathManager.getBackendDirPath(); + const overrideFilePath = pathManager.getResourceDirectoryPath(undefined, 'storage', this._resourceName); + + const isBuild = await buildOverrideDir(backendDir, overrideFilePath).catch(error => { + printer.warn(`Skipping build as ${error.message}`); + return false; + }); + // skip if packageManager or override.ts not found + if (isBuild) { + const { overrideProps } = await import(path.join(overrideFilePath, 'build', 'override.js')).catch(error => { + formatter.list(['No override File Found', `To override ${this._resourceName} run amplify override auth ${this._resourceName} `]); + return undefined; + }); + + // pass stack object + const ddbStackTemplateObj = this._resourceTemplateObj as AmplifyDDBResourceTemplate; + //TODO: Check Script Options + if (typeof overrideProps === 'function' && overrideProps) { + try { + this._resourceTemplateObj = overrideProps(this._resourceTemplateObj as AmplifyDDBResourceTemplate); + + //The vm module enables compiling and running code within V8 Virtual Machine contexts. The vm module is not a security mechanism. Do not use it to run untrusted code. + // const script = new vm.Script(overrideCode); + // script.runInContext(vm.createContext(cognitoStackTemplateObj)); + return; + } catch (error: $TSAny) { + throw new Error(error); + } + } + } + } + + saveBuildFiles() { + if (this._resourceTemplateObj) { + this._cfn = JSON.parse(this._resourceTemplateObj.renderCloudFormationTemplate()); + } + + // store files in local-filesysten + + fs.ensureDirSync(this._cliInputsState.buildFilePath); + const cfnFilePath = path.resolve(path.join(this._cliInputsState.buildFilePath, 'cloudformation-template.json')); + try { + JSONUtilities.writeJson(cfnFilePath, this._cfn); + } catch (e) { + throw new Error(e); + } + + fs.ensureDirSync(this._cliInputsState.buildFilePath); + const cfnInputParamsFilePath = path.resolve(path.join(this._cliInputsState.buildFilePath, 'parameters.json')); + try { + JSONUtilities.writeJson(cfnInputParamsFilePath, this._cfnInputParams); + } catch (e) { + throw new Error(e); + } + } +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/s3-stack-builder.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/s3-stack-builder.ts new file mode 100644 index 00000000000..3681e1a45e1 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/s3-stack-builder.ts @@ -0,0 +1,5 @@ +// import * as cdk from '@aws-cdk/core'; +// import * as s3 from '@aws-cdk/aws-s3'; +// import * as iam from '@aws-cdk/aws-iam'; +// import { AmplifyRootStackTemplate } from ''; +// import { IStackSynthesizer, ISynthesisSession } from '@aws-cdk/core'; diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts new file mode 100644 index 00000000000..9aa24d95549 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cdk-stack-builder/types.ts @@ -0,0 +1,20 @@ +import * as cdk from '@aws-cdk/core'; +import * as ddb from '@aws-cdk/aws-dynamodb'; + +export interface AmplifyDDBResourceTemplate { + dynamoDBTable?: ddb.CfnTable; + + addCfnParameter(props: cdk.CfnParameterProps, logicalId: string): void; + addCfnOutput(props: cdk.CfnOutputProps, logicalId: string): void; + addCfnMapping(props: cdk.CfnMappingProps, logicalId: string): void; + addCfnCondition(props: cdk.CfnConditionProps, logicalId: string): void; + addCfnResource(props: cdk.CfnResourceProps, logicalId: string): void; +} + +export interface AmplifyDDBResourceInputParameters { + tableName: string; + partitionKeyName: string; + partitionKeyType: string; + sortKeyName?: string; + sortKeyType?: string; +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cfn-template-utils.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cfn-template-utils.ts index ae869e23d56..4ac56de2658 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/cfn-template-utils.ts +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/cfn-template-utils.ts @@ -3,10 +3,15 @@ import { Template } from 'cloudform-types'; import Table, { AttributeDefinition, GlobalSecondaryIndex } from 'cloudform-types/types/dynamoDb/table'; import _ from 'lodash'; import * as path from 'path'; -const category = 'storage'; +import { AmplifyCategories } from 'amplify-cli-core'; export const getCloudFormationTemplatePath = (resourceName: string) => { - return path.join(pathManager.getBackendDirPath(), category, resourceName, `${resourceName}-cloudformation-template.json`); + return path.join( + pathManager.getBackendDirPath(), + AmplifyCategories.STORAGE, + resourceName, + `${resourceName}-cloudformation-template.json`, + ); }; export const getExistingStorageGSIs = async (resourceName: string) => { diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.js b/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.ts similarity index 80% rename from packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.js rename to packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.ts index fb1983ef6e1..b082c39b563 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.js +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/dynamoDb-defaults.ts @@ -1,6 +1,6 @@ -const uuid = require('uuid'); +import uuid from 'uuid'; -const getAllDefaults = project => { +const getAllDefaults = (project: any) => { const name = project.projectConfig.projectName.toLowerCase(); const [shortId] = uuid().split('-'); const defaults = { diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.js b/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.ts similarity index 91% rename from packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.js rename to packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.ts index 5d1572b7392..54e75718c25 100644 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.js +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/default-values/s3-defaults.ts @@ -1,6 +1,6 @@ -const uuid = require('uuid'); +import uuid from 'uuid'; -const getAllDefaults = project => { +const getAllDefaults = (project: any) => { const name = project.projectConfig.projectName.toLowerCase(); const [shortId] = uuid().split('-'); diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types.ts new file mode 100644 index 00000000000..7fcfad7b663 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/dynamoDB-user-input-types.ts @@ -0,0 +1,41 @@ +export enum DynamoDBImmutableFields { + resourceName = 'resourceName', + tableName = 'tableName', + partitionKey = 'partitionKey', + sortKey = 'sortKey', +} + +export enum FieldType { + string = 'string', + number = 'number', + binary = 'binary', + boolean = 'boolean', + list = 'list', + map = 'map', + null = 'null', +} + +export interface DynamoDBAttributeDefType { + AttributeName: string; + AttributeType: FieldType; +} + +export interface DynamoDBCLIInputsKeyType { + fieldName: string; + fieldType: FieldType; +} + +export interface DynamoDBCLIInputsGSIType { + name: string; + partitionKey: DynamoDBCLIInputsKeyType; + sortKey?: DynamoDBCLIInputsKeyType; +} + +export interface DynamoDBCLIInputs { + resourceName: string; + tableName: string; + partitionKey: DynamoDBCLIInputsKeyType; + sortKey?: DynamoDBCLIInputsKeyType; + gsi?: DynamoDBCLIInputsGSIType[]; + triggerFunctions?: string[]; +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/s3-user-input-types.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/s3-user-input-types.ts new file mode 100644 index 00000000000..ba6ed3abcd5 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthrough-types/s3-user-input-types.ts @@ -0,0 +1,28 @@ +//user input-data types generated by cli + +export function enumToHelp(obj: object) { + return `One of ${Object.values(obj)}`; +} + +export enum S3AccessType { + AUTH_AND_GUEST = 'authAndGuest', + AUTH_ONLY = 'auth', +} + +export enum S3PermissionType { + PUT_OBJECT = 'PUT_OBJECT', + GET_OBJECT = 'GET_OBJECT', + DELETE_OBJECT = 'DELETE_OBJECT', + LIST_BUCKET = 'LIST_BUCKET', +} + +//User input data for S3 service +export interface S3UserInputs { + resourceName: string; + bucketName: string; + storageAccess: S3AccessType; + selectedGuestPermissions: S3PermissionType[]; + selectedAuthenticatedPermissions: S3PermissionType[]; + isTriggerEnabled: boolean; //enable if trigger + triggerFunctionName: string | undefined; +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts new file mode 100644 index 00000000000..f8b111415fc --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDB-input-state.ts @@ -0,0 +1,165 @@ +import { DynamoDBCLIInputs, DynamoDBCLIInputsGSIType } from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { AmplifyCategories, AmplifySupportedService, CLIInputSchemaValidator, JSONUtilities, pathManager } from 'amplify-cli-core'; +import * as fs from 'fs-extra'; +import * as path from 'path'; + +/* Need to move this logic to a base class */ + +export class DynamoDBInputState { + _cliInputsFilePath: string; //cli-inputs.json (output) filepath + _resourceName: string; //user friendly name provided by user + _category: string; //category of the resource + _service: string; //AWS service for the resource + buildFilePath: string; + + constructor(resourceName: string) { + this._category = AmplifyCategories.STORAGE; + this._service = AmplifySupportedService.DYNAMODB; + this._resourceName = resourceName; + + const projectBackendDirPath = pathManager.getBackendDirPath(); + this._cliInputsFilePath = path.resolve(path.join(projectBackendDirPath, AmplifyCategories.STORAGE, resourceName, 'cli-inputs.json')); + this.buildFilePath = path.resolve(path.join(projectBackendDirPath, AmplifyCategories.STORAGE, resourceName, 'build')); + } + + public getCliInputPayload(): DynamoDBCLIInputs { + let cliInputs: DynamoDBCLIInputs; + + // Read cliInputs file if exists + try { + cliInputs = JSONUtilities.readJson(this._cliInputsFilePath) as DynamoDBCLIInputs; + } catch (e) { + throw new Error('cli-inputs.json file missing from the resource directory'); + } + + return cliInputs; + } + + public cliInputFileExists(): boolean { + return fs.existsSync(this._cliInputsFilePath); + } + + public isCLIInputsValid(cliInputs?: DynamoDBCLIInputs) { + if (!cliInputs) { + cliInputs = this.getCliInputPayload(); + } + + const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, 'DynamoDBCLIInputs'); + schemaValidator.validateInput(JSON.stringify(cliInputs)); + } + + public saveCliInputPayload(cliInputs: DynamoDBCLIInputs): void { + this.isCLIInputsValid(cliInputs); + + fs.ensureDirSync(path.join(pathManager.getBackendDirPath(), this._category, this._resourceName)); + try { + JSONUtilities.writeJson(this._cliInputsFilePath, cliInputs); + } catch (e) { + throw new Error(e); + } + } + + public migrate() { + let cliInputs: DynamoDBCLIInputs; + const attrReverseMap: any = { + S: 'string', + N: 'number', + B: 'binary', + BOOL: 'boolean', + L: 'list', + M: 'map', + NULL: null, + SS: 'string-set', + NS: 'number-set', + BS: 'binary-set', + }; + + // migrate the resource to new directory structure if cli-inputs.json is not found for the resource + + const backendDir = pathManager.getBackendDirPath(); + const oldParametersFilepath = path.join(backendDir, 'storage', this._resourceName, 'parameters.json'); + const oldCFNFilepath = path.join(backendDir, 'storage', this._resourceName, `${this._resourceName}-cloudformation-template.json`); + const oldStorageParamsFilepath = path.join(backendDir, 'storage', this._resourceName, `storage-params.json`); + + const oldParameters: any = JSONUtilities.readJson(oldParametersFilepath, { throwIfNotExist: true }); + const oldCFN: any = JSONUtilities.readJson(oldCFNFilepath, { throwIfNotExist: true }); + const oldStorageParams: any = JSONUtilities.readJson(oldStorageParamsFilepath, { throwIfNotExist: false }) || {}; + + const partitionKey = { + fieldName: oldParameters.partitionKeyName, + fieldType: attrReverseMap[oldParameters.partitionKeyType], + }; + + let sortKey; + + if (oldParameters.sortKeyName) { + sortKey = { + fieldName: oldParameters.sortKeyName, + fieldType: attrReverseMap[oldParameters.sortKeyType], + }; + } + + let triggerFunctions = []; + + if (oldStorageParams.triggerFunctions) { + triggerFunctions = oldStorageParams.triggerFunctions; + } + + const getType = (attrList: any, attrName: string) => { + let attrType; + + attrList.forEach((attr: any) => { + if (attr.AttributeName === attrName) { + attrType = attrReverseMap[attr.AttributeType]; + } + }); + + return attrType; + }; + + let gsi: DynamoDBCLIInputsGSIType[] = []; + + if (oldCFN?.Resources?.DynamoDBTable?.Properties?.GlobalSecondaryIndexes) { + oldCFN.Resources.DynamoDBTable.Properties.GlobalSecondaryIndexes.forEach((cfnGSIValue: any) => { + let gsiValue: any = {}; + (gsiValue.name = cfnGSIValue.IndexName), + cfnGSIValue.KeySchema.forEach((keySchema: any) => { + if (keySchema.KeyType === 'HASH') { + gsiValue.partitionKey = { + fieldName: keySchema.AttributeName, + fieldType: getType(oldCFN.Resources.DynamoDBTable.Properties.AttributeDefinitions, keySchema.AttributeName), + }; + } else { + gsiValue.sortKey = { + fieldName: keySchema.AttributeName, + fieldType: getType(oldCFN.Resources.DynamoDBTable.Properties.AttributeDefinitions, keySchema.AttributeName), + }; + } + }); + gsi.push(gsiValue); + }); + } + cliInputs = { + resourceName: this._resourceName, + tableName: oldParameters.tableName, + partitionKey, + sortKey, + triggerFunctions, + gsi, + }; + + this.saveCliInputPayload(cliInputs); + + // Remove old files + + if (fs.existsSync(oldCFNFilepath)) { + fs.removeSync(oldCFNFilepath); + } + if (fs.existsSync(oldParametersFilepath)) { + fs.removeSync(oldParametersFilepath); + } + if (fs.existsSync(oldStorageParamsFilepath)) { + fs.removeSync(oldStorageParamsFilepath); + } + } +} diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.js b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.js deleted file mode 100644 index 994fe5e4dba..00000000000 --- a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.js +++ /dev/null @@ -1,931 +0,0 @@ -const { - getCloudFormationTemplatePath, - getExistingStorageAttributeDefinitions, - getExistingStorageGSIs, - getExistingTableColumnNames, -} = require('../cfn-template-utils'); -const inquirer = require('inquirer'); -const path = require('path'); -const fs = require('fs-extra'); -const uuid = require('uuid'); -const { ResourceDoesNotExistError, exitOnNextTick } = require('amplify-cli-core'); -const { category } = require('../../..'); - -// keep in sync with ServiceName in amplify-category-function, but probably it will not change -const FunctionServiceNameLambdaFunction = 'Lambda'; - -const parametersFileName = 'parameters.json'; -const storageParamsFileName = 'storage-params.json'; -const serviceName = 'DynamoDB'; -const templateFileName = 'dynamoDb-cloudformation-template.json.ejs'; - -async function addWalkthrough(context, defaultValuesFilename, serviceMetadata) { - return configure(context, defaultValuesFilename, serviceMetadata); -} - -async function updateWalkthrough(context, defaultValuesFilename, serviceMetadata) { - // const resourceName = resourceAlreadyExists(context); - const { amplify } = context; - const { amplifyMeta } = amplify.getProjectDetails(); - - const dynamoDbResources = {}; - - Object.keys(amplifyMeta[category]).forEach(resourceName => { - if ( - amplifyMeta[category][resourceName].service === serviceName && - amplifyMeta[category][resourceName].mobileHubMigrated !== true && - amplifyMeta[category][resourceName].serviceType !== 'imported' - ) { - dynamoDbResources[resourceName] = amplifyMeta[category][resourceName]; - } - }); - - if (!amplifyMeta[category] || Object.keys(dynamoDbResources).length === 0) { - const errMessage = 'No resources to update. You need to add a resource.'; - - context.print.error(errMessage); - context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); - exitOnNextTick(0); - return; - } - - const resources = Object.keys(dynamoDbResources); - const question = [ - { - name: 'resourceName', - message: 'Specify the resource that you would want to update', - type: 'list', - choices: resources, - }, - ]; - - const answer = await inquirer.prompt(question); - - return await configure(context, defaultValuesFilename, serviceMetadata, answer.resourceName); -} - -async function configure(context, defaultValuesFilename, serviceMetadata, resourceName) { - const { amplify, print } = context; - const { inputs } = serviceMetadata; - const defaultValuesSrc = path.join(__dirname, '..', 'default-values', defaultValuesFilename); - const { getAllDefaults } = require(defaultValuesSrc); - - const defaultValues = getAllDefaults(amplify.getProjectDetails()); - const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); - - const attributeTypes = { - string: { code: 'S', indexable: true }, - number: { code: 'N', indexable: true }, - binary: { code: 'B', indexable: true }, - boolean: { code: 'BOOL', indexable: false }, - list: { code: 'L', indexable: false }, - map: { code: 'M', indexable: false }, - null: { code: 'NULL', indexable: false }, - 'string set': { code: 'SS', indexable: false }, - 'number set': { code: 'NS', indexable: false }, - 'binary set': { code: 'BS', indexable: false }, - }; - let usedAttributeDefinitions = new Set(); - let storageParams = {}; - - if (resourceName) { - const resourceDirPath = path.join(projectBackendDirPath, category, resourceName); - const parametersFilePath = path.join(resourceDirPath, parametersFileName); - let parameters; - - try { - parameters = context.amplify.readJsonFile(parametersFilePath); - } catch (e) { - parameters = {}; - } - - parameters.resourceName = resourceName; - - Object.assign(defaultValues, parameters); - - // Get storage question params - const storageParamsFilePath = path.join(resourceDirPath, storageParamsFileName); - - try { - storageParams = context.amplify.readJsonFile(storageParamsFilePath); - } catch (e) { - storageParams = {}; - } - } - - const resourceQuestions = [ - { - type: inputs[0].type, - name: inputs[0].key, - message: inputs[0].question, - validate: amplify.inputValidation(inputs[0]), - default: () => { - const defaultValue = defaultValues[inputs[0].key]; - return defaultValue; - }, - }, - { - type: inputs[1].type, - name: inputs[1].key, - message: inputs[1].question, - validate: amplify.inputValidation(inputs[1]), - default: answers => { - const defaultValue = defaultValues[inputs[1].key]; - return answers.resourceName || defaultValue; - }, - }, - ]; - - print.info(''); - print.info('Welcome to the NoSQL DynamoDB database wizard'); - print.info('This wizard asks you a series of questions to help determine how to set up your NoSQL database table.'); - print.info(''); - - // Ask resource and table name question - - let answers = {}; - - if (!resourceName) { - answers = await inquirer.prompt(resourceQuestions); - } - - print.info(''); - print.info('You can now add columns to the table.'); - print.info(''); - - // Ask attribute questions - - const attributeQuestion = { - type: inputs[2].type, - name: inputs[2].key, - message: inputs[2].question, - validate: amplify.inputValidation(inputs[2]), - }; - const attributeTypeQuestion = { - type: inputs[3].type, - name: inputs[3].key, - message: inputs[3].question, - choices: Object.keys(attributeTypes), - }; - - let continueAttributeQuestion = true; - const attributeAnswers = []; - - if (resourceName) { - attributeAnswers.push( - { - AttributeName: defaultValues.partitionKeyName, - AttributeType: defaultValues.partitionKeyType, - }, - { - AttributeName: defaultValues.sortKeyName, - AttributeType: defaultValues.sortKeyType, - }, - ); - - continueAttributeQuestion = await amplify.confirmPrompt('Would you like to add another column?'); - } - - const indexableAttributeList = await getExistingTableColumnNames(resourceName); - - while (continueAttributeQuestion) { - const attributeAnswer = await inquirer.prompt([attributeQuestion, attributeTypeQuestion]); - - if (attributeAnswers.findIndex(attribute => attribute.AttributeName === attributeAnswer[inputs[2].key]) !== -1) { - continueAttributeQuestion = await amplify.confirmPrompt('This attribute was already added. Do you want to add another attribute?'); - continue; - } - - attributeAnswers.push({ - AttributeName: attributeAnswer[inputs[2].key], - AttributeType: attributeTypes[attributeAnswer[inputs[3].key]].code, - }); - - if (attributeTypes[attributeAnswer[inputs[3].key]].indexable) { - indexableAttributeList.push(attributeAnswer[inputs[2].key]); - } - - continueAttributeQuestion = await amplify.confirmPrompt('Would you like to add another column?'); - } - - answers.AttributeDefinitions = attributeAnswers; - - print.info(''); - print.info( - 'Before you create the database, you must specify how items in your table are uniquely organized. You do this by specifying a primary key. The primary key uniquely identifies each item in the table so that no two items can have the same key. This can be an individual column, or a combination that includes a primary key and a sort key.', - ); - print.info(''); - print.info('To learn more about primary keys, see:'); - print.info( - 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey', - ); - print.info(''); - // Ask for primary key - - answers.KeySchema = []; - let partitionKeyName; - let partitionKeyType; - - if (resourceName) { - ({ partitionKeyName } = defaultValues); - ({ partitionKeyType } = defaultValues); - } else { - const primaryKeyQuestion = { - type: inputs[4].type, - name: inputs[4].key, - message: inputs[4].question, - validate: amplify.inputValidation(inputs[3]), - choices: indexableAttributeList, - }; - - const partitionKeyAnswer = await inquirer.prompt([primaryKeyQuestion]); - - partitionKeyName = partitionKeyAnswer[inputs[4].key]; - } - - answers.KeySchema.push({ - AttributeName: partitionKeyName, - KeyType: 'HASH', - }); - - // Get the type for primary index - - const primaryAttrTypeIndex = answers.AttributeDefinitions.findIndex(attr => attr.AttributeName === partitionKeyName); - - partitionKeyType = answers.AttributeDefinitions[primaryAttrTypeIndex].AttributeType; - - usedAttributeDefinitions.add(partitionKeyName); - - let sortKeyName; - let sortKeyType; - - if (resourceName) { - ({ sortKeyName } = defaultValues); - - if (sortKeyName) { - answers.KeySchema.push({ - AttributeName: sortKeyName, - KeyType: 'RANGE', - }); - - usedAttributeDefinitions.add(sortKeyName); - } - } else if (await amplify.confirmPrompt('Do you want to add a sort key to your table?')) { - // Ask for sort key - if (answers.AttributeDefinitions.length > 1) { - const sortKeyQuestion = { - type: inputs[5].type, - name: inputs[5].key, - message: inputs[5].question, - choices: indexableAttributeList.filter(att => att !== partitionKeyName), - }; - const sortKeyAnswer = await inquirer.prompt([sortKeyQuestion]); - - sortKeyName = sortKeyAnswer[inputs[5].key]; - - answers.KeySchema.push({ - AttributeName: sortKeyName, - KeyType: 'RANGE', - }); - - usedAttributeDefinitions.add(sortKeyName); - } else { - context.print.error('You must add additional keys in order to select a sort key.'); - } - } - - if (sortKeyName) { - // Get the type for primary index - const sortKeyAttrTypeIndex = answers.AttributeDefinitions.findIndex(attr => attr.AttributeName === sortKeyName); - sortKeyType = answers.AttributeDefinitions[sortKeyAttrTypeIndex].AttributeType; - } - - answers.KeySchema = answers.KeySchema; - - print.info(''); - print.info( - 'You can optionally add global secondary indexes for this table. These are useful when you run queries defined in a different column than the primary key.', - ); - print.info('To learn more about indexes, see:'); - print.info( - 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.SecondaryIndexes', - ); - print.info(''); - - // Ask for GSI's - - if (await amplify.confirmPrompt('Do you want to add global secondary indexes to your table?')) { - let continuewithGSIQuestions = true; - const gsiList = []; - - while (continuewithGSIQuestions) { - if (indexableAttributeList.length > 0) { - const gsiAttributeQuestion = { - type: inputs[6].type, - name: inputs[6].key, - message: inputs[6].question, - }; - const gsiPrimaryKeyQuestion = { - type: inputs[7].type, - name: inputs[7].key, - message: inputs[7].question, - validate: amplify.inputValidation(inputs[3]), - choices: indexableAttributeList, - }; - - /*eslint-disable*/ - const gsiPrimaryAnswer = await inquirer.prompt([gsiAttributeQuestion, gsiPrimaryKeyQuestion]); - - const gsiPrimaryKeyName = gsiPrimaryAnswer[inputs[7].key]; - - /* eslint-enable */ - const gsiItem = { - ProvisionedThroughput: { - ReadCapacityUnits: '5', - WriteCapacityUnits: '5', - }, - Projection: { - ProjectionType: 'ALL', - }, - IndexName: gsiPrimaryAnswer[inputs[6].key], - KeySchema: [ - { - AttributeName: gsiPrimaryKeyName, - KeyType: 'HASH', - }, - ], - }; - - usedAttributeDefinitions.add(gsiPrimaryKeyName); - - const sortKeyOptions = indexableAttributeList.filter(att => att !== gsiPrimaryKeyName); - - if (sortKeyOptions.length > 0) { - if (await amplify.confirmPrompt('Do you want to add a sort key to your global secondary index?')) { - const sortKeyQuestion = { - type: inputs[8].type, - name: inputs[8].key, - message: inputs[8].question, - choices: sortKeyOptions, - }; - - const sortKeyAnswer = await inquirer.prompt([sortKeyQuestion]); - - gsiItem.KeySchema.push({ - AttributeName: sortKeyAnswer[inputs[8].key], - KeyType: 'RANGE', - }); - - usedAttributeDefinitions.add(sortKeyAnswer[inputs[8].key]); - } - } - - gsiList.push(gsiItem); - - continuewithGSIQuestions = await amplify.confirmPrompt('Do you want to add more global secondary indexes to your table?'); - } else { - context.print.error('You do not have any other attributes remaining to configure'); - break; - } - } - - // if resource name is undefined then it's an 'add storage' we want to check on an update - if (resourceName) { - const existingGSIs = await getExistingStorageGSIs(resourceName); - const existingAttributeDefinitions = await getExistingStorageAttributeDefinitions(resourceName); - const allAttributeDefinitionsMap = new Map([ - ...existingAttributeDefinitions.map(r => [r.AttributeName, r]), - ...answers.AttributeDefinitions.map(r => [r.AttributeName, r]), - ]); - - if ( - !!existingGSIs.length && - (await amplify.confirmPrompt('Do you want to keep existing global seconday indexes created on your table?')) - ) { - existingGSIs.forEach(r => gsiList.push(r)); - answers.AttributeDefinitions = [...allAttributeDefinitionsMap.values()]; - - usedAttributeDefinitions = existingGSIs.reduce((prev, current) => { - current.KeySchema.map(r => prev.add(r.AttributeName)); - return prev; - }, usedAttributeDefinitions); - } - } - - if (gsiList.length > 0) { - answers.GlobalSecondaryIndexes = gsiList; - } - } - - usedAttributeDefinitions = Array.from(usedAttributeDefinitions); - - /* Filter out only attribute - * definitions which have been used - cfn errors out otherwise */ - answers.AttributeDefinitions = answers.AttributeDefinitions.filter( - attributeDefinition => usedAttributeDefinitions.indexOf(attributeDefinition.AttributeName) !== -1, - ); - - Object.assign(defaultValues, answers); - - // Ask Lambda trigger question - if (!storageParams || !storageParams.triggerFunctions || storageParams.triggerFunctions.length === 0) { - if (await amplify.confirmPrompt('Do you want to add a Lambda Trigger for your Table?', false)) { - let triggerName; - - try { - triggerName = await addTrigger(context, defaultValues.resourceName); - - if (!storageParams) { - storageParams = {}; - } - - storageParams.triggerFunctions = [triggerName]; - } catch (e) { - context.print.error(e.message); - } - } - } else { - const triggerOperationQuestion = { - type: 'list', - name: 'triggerOperation', - message: 'Select from the following options', - choices: ['Add a Trigger', 'Remove a trigger', 'Skip Question'], - }; - let triggerName; - let continueWithTriggerOperationQuestion = true; - - while (continueWithTriggerOperationQuestion) { - const triggerOperationAnswer = await inquirer.prompt([triggerOperationQuestion]); - - switch (triggerOperationAnswer.triggerOperation) { - case 'Add a Trigger': { - try { - triggerName = await addTrigger(context, defaultValues.resourceName, storageParams.triggerFunctions); - if (!storageParams) { - storageParams = {}; - } else if (!storageParams.triggerFunctions) { - storageParams.triggerFunctions = [triggerName]; - } else { - storageParams.triggerFunctions.push(triggerName); - } - - continueWithTriggerOperationQuestion = false; - } catch (e) { - context.print.error(e.message); - continueWithTriggerOperationQuestion = true; - } - break; - } - case 'Remove a trigger': { - try { - if (!storageParams || !storageParams.triggerFunctions || storageParams.triggerFunctions.length === 0) { - throw new Error('No triggers found associated with this table'); - } else { - triggerName = await removeTrigger(context, defaultValues.resourceName, storageParams.triggerFunctions); - - const index = storageParams.triggerFunctions.indexOf(triggerName); - - if (index >= 0) { - storageParams.triggerFunctions.splice(index, 1); - continueWithTriggerOperationQuestion = false; - } else { - throw new Error('Could not find trigger function'); - } - } - } catch (e) { - context.print.error(e.message); - continueWithTriggerOperationQuestion = true; - } - - break; - } - case 'Skip Question': { - continueWithTriggerOperationQuestion = false; - break; - } - default: - context.print.error(`${triggerOperationAnswer.triggerOperation} not supported`); - } - } - } - - const resource = defaultValues.resourceName; - const resourceDirPath = path.join(projectBackendDirPath, category, resource); - - delete defaultValues.resourceName; - - fs.ensureDirSync(resourceDirPath); - - const parametersFilePath = path.join(resourceDirPath, parametersFileName); - - // Copy just the table name as parameters - const parameters = { - tableName: defaultValues.tableName, - partitionKeyName, - partitionKeyType, - }; - - if (sortKeyName) { - Object.assign(parameters, { sortKeyName, sortKeyType }); - } - - let jsonString = JSON.stringify(parameters, null, 4); - - fs.writeFileSync(parametersFilePath, jsonString, 'utf8'); - - const storageParamsFilePath = path.join(resourceDirPath, storageParamsFileName); - - jsonString = JSON.stringify(storageParams, null, 4); - - fs.writeFileSync(storageParamsFilePath, jsonString, 'utf8'); - - await copyCfnTemplate(context, resource, defaultValues); - - return resource; -} - -async function removeTrigger(context, resourceName, triggerList) { - const triggerOptionQuestion = { - type: 'list', - name: 'triggerOption', - message: 'Select from the function you would like to remove', - choices: triggerList, - }; - - const triggerOptionAnswer = await inquirer.prompt([triggerOptionQuestion]); - - const functionName = triggerOptionAnswer.triggerOption; - const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); - const functionCFNFilePath = path.join(projectBackendDirPath, 'function', functionName, `${functionName}-cloudformation-template.json`); - - if (fs.existsSync(functionCFNFilePath)) { - const functionCFNFile = context.amplify.readJsonFile(functionCFNFilePath); - - delete functionCFNFile.Resources[`${resourceName}TriggerPolicy`]; - delete functionCFNFile.Resources[`${resourceName}Trigger`]; - - // Update the functions resource - const functionCFNString = JSON.stringify(functionCFNFile, null, 4); - - fs.writeFileSync(functionCFNFilePath, functionCFNString, 'utf8'); - } - - return functionName; -} - -async function addTrigger(context, resourceName, triggerList) { - const triggerTypeQuestion = { - type: 'list', - name: 'triggerType', - message: 'Select from the following options', - choices: ['Choose an existing function from the project', 'Create a new function'], - }; - const triggerTypeAnswer = await inquirer.prompt([triggerTypeQuestion]); - let functionName; - - if (triggerTypeAnswer.triggerType === 'Choose an existing function from the project') { - let lambdaResources = await getLambdaFunctions(context); - - if (triggerList) { - const filteredLambdaResources = []; - - lambdaResources.forEach(lambdaResource => { - if (triggerList.indexOf(lambdaResource) === -1) { - filteredLambdaResources.push(lambdaResource); - } - }); - - lambdaResources = filteredLambdaResources; - } - - if (lambdaResources.length === 0) { - throw new Error("No functions were found in the project. Use 'amplify add function' to add a new function."); - } - - const triggerOptionQuestion = { - type: 'list', - name: 'triggerOption', - message: 'Select from the following options', - choices: lambdaResources, - }; - - const triggerOptionAnswer = await inquirer.prompt([triggerOptionQuestion]); - - functionName = triggerOptionAnswer.triggerOption; - } else { - // Create a new lambda trigger - - const targetDir = context.amplify.pathManager.getBackendDirPath(); - const [shortId] = uuid().split('-'); - - functionName = `${resourceName}Trigger${shortId}`; - - const pluginDir = __dirname; - - const defaults = { - functionName: `${functionName}`, - roleName: `${resourceName}LambdaRole${shortId}`, - }; - - const copyJobs = [ - { - dir: pluginDir, - template: path.join('..', '..', '..', '..', 'resources', 'triggers', 'dynamoDB', 'lambda-cloudformation-template.json.ejs'), - target: path.join(targetDir, 'function', functionName, `${functionName}-cloudformation-template.json`), - }, - { - dir: pluginDir, - template: path.join('..', '..', '..', '..', 'resources', 'triggers', 'dynamoDB', 'event.json'), - target: path.join(targetDir, 'function', functionName, 'src', 'event.json'), - }, - { - dir: pluginDir, - template: path.join('..', '..', '..', '..', 'resources', 'triggers', 'dynamoDB', 'index.js'), - target: path.join(targetDir, 'function', functionName, 'src', 'index.js'), - }, - { - dir: pluginDir, - template: path.join('..', '..', '..', '..', 'resources', 'triggers', 'dynamoDB', 'package.json.ejs'), - target: path.join(targetDir, 'function', functionName, 'src', 'package.json'), - }, - ]; - - // copy over the files - await context.amplify.copyBatch(context, copyJobs, defaults); - - // Update amplify-meta and backend-config - - const backendConfigs = { - service: FunctionServiceNameLambdaFunction, - providerPlugin: 'awscloudformation', - build: true, - }; - - context.amplify.updateamplifyMetaAfterResourceAdd('function', functionName, backendConfigs); - - context.print.success(`Successfully added resource ${functionName} locally`); - } - - const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); - const functionCFNFilePath = path.join(projectBackendDirPath, 'function', functionName, `${functionName}-cloudformation-template.json`); - - if (fs.existsSync(functionCFNFilePath)) { - const functionCFNFile = context.amplify.readJsonFile(functionCFNFilePath); - - // Update parameters block - functionCFNFile.Parameters[`storage${resourceName}Name`] = { - Type: 'String', - Default: `storage${resourceName}Name`, - }; - - functionCFNFile.Parameters[`storage${resourceName}Arn`] = { - Type: 'String', - Default: `storage${resourceName}Arn`, - }; - - functionCFNFile.Parameters[`storage${resourceName}StreamArn`] = { - Type: 'String', - Default: `storage${resourceName}Arn`, - }; - - // Update policies - functionCFNFile.Resources[`${resourceName}TriggerPolicy`] = { - DependsOn: ['LambdaExecutionRole'], - Type: 'AWS::IAM::Policy', - Properties: { - PolicyName: `lambda-trigger-policy-${resourceName}`, - Roles: [ - { - Ref: 'LambdaExecutionRole', - }, - ], - PolicyDocument: { - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Action: ['dynamodb:DescribeStream', 'dynamodb:GetRecords', 'dynamodb:GetShardIterator', 'dynamodb:ListStreams'], - Resource: [ - { - Ref: `storage${resourceName}StreamArn`, - }, - ], - }, - ], - }, - }, - }; - - // Add TriggerResource - - functionCFNFile.Resources[`${resourceName}Trigger`] = { - Type: 'AWS::Lambda::EventSourceMapping', - DependsOn: [`${resourceName}TriggerPolicy`], - Properties: { - BatchSize: 100, - Enabled: true, - EventSourceArn: { - Ref: `storage${resourceName}StreamArn`, - }, - FunctionName: { - 'Fn::GetAtt': ['LambdaFunction', 'Arn'], - }, - StartingPosition: 'LATEST', - }, - }; - - // Update dependsOn - - const amplifyMetaFilePath = context.amplify.pathManager.getAmplifyMetaFilePath(); - const amplifyMeta = context.amplify.readJsonFile(amplifyMetaFilePath); - - const resourceDependsOn = amplifyMeta.function[functionName].dependsOn || []; - let resourceExists = false; - - resourceDependsOn.forEach(resource => { - if (resource.resourceName === resourceName) { - resourceExists = true; - resourceDependsOn.attributes = ['Name', 'Arn', 'StreamArn']; - } - }); - - if (!resourceExists) { - resourceDependsOn.push({ - category: 'storage', - resourceName, - attributes: ['Name', 'Arn', 'StreamArn'], - }); - } - - // Update the functions resource - const functionCFNString = JSON.stringify(functionCFNFile, null, 4); - - fs.writeFileSync(functionCFNFilePath, functionCFNString, 'utf8'); - - context.amplify.updateamplifyMetaAfterResourceUpdate('function', functionName, 'dependsOn', resourceDependsOn); - context.print.success(`Successfully updated resource ${functionName} locally`); - - if (await context.amplify.confirmPrompt(`Do you want to edit the local ${functionName} lambda function now?`)) { - await context.amplify.openEditor(context, `${projectBackendDirPath}/function/${functionName}/src/index.js`); - } - } else { - throw new Error(`Function ${functionName} does not exist`); - } - - return functionName; -} - -async function getLambdaFunctions(context) { - const { allResources } = await context.amplify.getResourceStatus(); - const lambdaResources = allResources - .filter(resource => resource.service === FunctionServiceNameLambdaFunction) - .map(resource => resource.resourceName); - - return lambdaResources; -} - -function copyCfnTemplate(context, resourceName, options) { - const pluginDir = __dirname; - const copyJobs = [ - { - dir: pluginDir, - template: path.join('..', '..', '..', '..', 'resources', 'cloudformation-templates', templateFileName), - target: getCloudFormationTemplatePath(resourceName), - }, - ]; - - // copy over the files - return context.amplify.copyBatch(context, copyJobs, options); -} - -function migrate(context, projectPath, resourceName) { - const resourceDirPath = path.join(projectPath, 'amplify', 'backend', category, resourceName); - const cfnFilePath = path.join(resourceDirPath, `${resourceName}-cloudformation-template.json`); - - // Removes dangling commas from a JSON - const removeDanglingCommas = value => { - const regex = /,(?!\s*?[{["'\w])/g; - - return value.replace(regex, ''); - }; - - /* Current Dynamo CFN's have a trailing comma (accepted by CFN), - but fails on JSON.parse(), hence removing it */ - - let oldcfnString = fs.readFileSync(cfnFilePath, 'utf8'); - oldcfnString = removeDanglingCommas(oldcfnString); - - const oldCfn = JSON.parse(oldcfnString); - - const newCfn = {}; - - Object.assign(newCfn, oldCfn); - - // Add env parameter - if (!newCfn.Parameters) { - newCfn.Parameters = {}; - } - - newCfn.Parameters.env = { - Type: 'String', - }; - - // Add conditions block - if (!newCfn.Conditions) { - newCfn.Conditions = {}; - } - - newCfn.Conditions.ShouldNotCreateEnvResources = { - 'Fn::Equals': [ - { - Ref: 'env', - }, - 'NONE', - ], - }; - - // Add if condition for resource name change - - newCfn.Resources.DynamoDBTable.Properties.TableName = { - 'Fn::If': [ - 'ShouldNotCreateEnvResources', - { - Ref: 'tableName', - }, - { - 'Fn::Join': [ - '', - [ - { - Ref: 'tableName', - }, - '-', - { - Ref: 'env', - }, - ], - ], - }, - ], - }; - - const jsonString = JSON.stringify(newCfn, null, '\t'); - - fs.writeFileSync(cfnFilePath, jsonString, 'utf8'); -} - -function getIAMPolicies(resourceName, crudOptions) { - let policy = {}; - const actions = []; - - crudOptions.forEach(crudOption => { - switch (crudOption) { - case 'create': - actions.push('dynamodb:Put*', 'dynamodb:Create*', 'dynamodb:BatchWriteItem'); - break; - case 'update': - actions.push('dynamodb:Update*', 'dynamodb:RestoreTable*'); - break; - case 'read': - actions.push('dynamodb:Get*', 'dynamodb:BatchGetItem', 'dynamodb:List*', 'dynamodb:Describe*', 'dynamodb:Scan', 'dynamodb:Query'); - break; - case 'delete': - actions.push('dynamodb:Delete*'); - break; - default: - console.log(`${crudOption} not supported`); - } - }); - - policy = { - Effect: 'Allow', - Action: actions, - Resource: crudOptions.customPolicyResource - ? crudOptions.customPolicyResource - : [ - { Ref: `${category}${resourceName}Arn` }, - { - 'Fn::Join': [ - '/', - [ - { - Ref: `${category}${resourceName}Arn`, - }, - 'index/*', - ], - ], - }, - ], - }; - - const attributes = ['Name', 'Arn', 'StreamArn']; - - return { policy, attributes }; -} - -module.exports = { - addWalkthrough, - updateWalkthrough, - migrate, - getIAMPolicies, -}; diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts new file mode 100644 index 00000000000..2934af82a24 --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/dynamoDb-walkthrough.ts @@ -0,0 +1,784 @@ +import * as path from 'path'; +import * as fs from 'fs-extra'; +import uuid from 'uuid'; +import { alphanumeric, printer, prompter, Validator } from 'amplify-prompts'; +import { $TSContext, AmplifyCategories, ResourceDoesNotExistError, exitOnNextTick } from 'amplify-cli-core'; +import { DynamoDBInputState } from './dynamoDB-input-state'; +import { + DynamoDBAttributeDefType, + DynamoDBCLIInputs, + DynamoDBCLIInputsGSIType, + DynamoDBCLIInputsKeyType, +} from '../service-walkthrough-types/dynamoDB-user-input-types'; +import { DDBStackTransform } from '../cdk-stack-builder/ddb-stack-transform'; + +// keep in sync with ServiceName in amplify-AmplifyCategories.STORAGE-function, but probably it will not change +const FunctionServiceNameLambdaFunction = 'Lambda'; +const serviceName = 'DynamoDB'; + +async function addWalkthrough(context: $TSContext, defaultValuesFilename: string) { + printer.blankLine(); + printer.info('Welcome to the NoSQL DynamoDB database wizard'); + printer.info('This wizard asks you a series of questions to help determine how to set up your NoSQL database table.'); + printer.blankLine(); + + const defaultValuesSrc = path.join(__dirname, '..', 'default-values', defaultValuesFilename); + const { getAllDefaults } = require(defaultValuesSrc); + const { amplify } = context; + const defaultValues = getAllDefaults(amplify.getProjectDetails()); + + const resourceName = await askResourceNameQuestion(defaultValues); // Cannot be changed once added + const tableName = await askTableNameQuestion(defaultValues, resourceName); // Cannot be changed once added + + const { attributeAnswers, indexableAttributeList } = await askAttributeListQuestion(); + + const partitionKey = await askPrimaryKeyQuestion(indexableAttributeList, attributeAnswers); // Cannot be changed once added + + let cliInputs: DynamoDBCLIInputs = { + resourceName, + tableName, + partitionKey, + }; + + cliInputs.sortKey = await askSortKeyQuestion(indexableAttributeList, attributeAnswers, cliInputs.partitionKey.fieldName); + + cliInputs.gsi = await askGSIQuestion(indexableAttributeList, attributeAnswers); + + cliInputs.triggerFunctions = await askTriggersQuestion(context, cliInputs.resourceName); + + const cliInputsState = new DynamoDBInputState(cliInputs.resourceName); + cliInputsState.saveCliInputPayload(cliInputs); + + const stackGenerator = new DDBStackTransform(cliInputs.resourceName); + stackGenerator.transform(); + + return cliInputs.resourceName; +} + +async function updateWalkthrough(context: $TSContext) { + // const resourceName = resourceAlreadyExists(context); + const { amplify } = context; + const { amplifyMeta } = amplify.getProjectDetails(); + + const dynamoDbResources: any = {}; + + Object.keys(amplifyMeta[AmplifyCategories.STORAGE]).forEach(resourceName => { + if ( + amplifyMeta[AmplifyCategories.STORAGE][resourceName].service === serviceName && + amplifyMeta[AmplifyCategories.STORAGE][resourceName].mobileHubMigrated !== true && + amplifyMeta[AmplifyCategories.STORAGE][resourceName].serviceType !== 'imported' + ) { + dynamoDbResources[resourceName] = amplifyMeta[AmplifyCategories.STORAGE][resourceName]; + } + }); + + if (!amplifyMeta[AmplifyCategories.STORAGE] || Object.keys(dynamoDbResources).length === 0) { + const errMessage = 'No resources to update. You need to add a resource.'; + + printer.error(errMessage); + context.usageData.emitError(new ResourceDoesNotExistError(errMessage)); + exitOnNextTick(0); + return; + } + + const resources = Object.keys(dynamoDbResources); + const resourceName = await prompter.pick('Specify the resource that you would want to update', resources); + + // Check if we need to migrate to cli-inputs.json + const cliInputsState = new DynamoDBInputState(resourceName); + + if (!cliInputsState.cliInputFileExists()) { + if (context.exeInfo?.forcePush || (await prompter.yesOrNo('File migration required to continue. Do you want to continue?', true))) { + cliInputsState.migrate(); + const stackGenerator = new DDBStackTransform(resourceName); + stackGenerator.transform(); + } else { + return; + } + } + + const cliInputs = cliInputsState.getCliInputPayload(); + + let existingAttributeDefinitions: DynamoDBCLIInputsKeyType[] = []; + + if (cliInputs.partitionKey) { + existingAttributeDefinitions.push(cliInputs.partitionKey); + } + if (cliInputs.sortKey) { + existingAttributeDefinitions.push(cliInputs.sortKey); + } + if (cliInputs.gsi && cliInputs.gsi.length > 0) { + cliInputs.gsi.forEach((gsi: DynamoDBCLIInputsGSIType) => { + if (gsi.partitionKey) { + existingAttributeDefinitions.push(gsi.partitionKey); + } + if (gsi.sortKey) { + existingAttributeDefinitions.push(gsi.sortKey); + } + }); + } + + if (!cliInputs.resourceName) { + throw new Error('resourceName not found in cli-inputs'); + } + + const { attributeAnswers, indexableAttributeList } = await askAttributeListQuestion(existingAttributeDefinitions); + + cliInputs.gsi = await askGSIQuestion(indexableAttributeList, attributeAnswers, cliInputs.gsi); + cliInputs.triggerFunctions = await askTriggersQuestion(context, cliInputs.resourceName, cliInputs.triggerFunctions); + + cliInputsState.saveCliInputPayload(cliInputs); + + const stackGenerator = new DDBStackTransform(cliInputs.resourceName); + stackGenerator.transform(); + + return cliInputs; +} + +async function askTriggersQuestion(context: $TSContext, resourceName: string, existingTriggerFunctions?: string[]): Promise { + let triggerFunctions: string[] = existingTriggerFunctions || []; + + if (!existingTriggerFunctions || existingTriggerFunctions.length === 0) { + if (await prompter.confirmContinue('Do you want to add a Lambda Trigger for your Table?')) { + let triggerName; + try { + // @ts-expect-error ts-migrate(2554) FIXME: Expected 3 arguments, but got 2. + triggerName = await addTrigger(context, resourceName); + return [triggerName]; + } catch (e) { + printer.error(e.message); + } + } + } else { + let triggerName; + let continueWithTriggerOperationQuestion = true; + + while (continueWithTriggerOperationQuestion) { + const triggerOperationAnswer = await prompter.pick('Select from the following options', [ + 'Add a Trigger', + 'Remove a trigger', + `I'm done`, + ]); + + switch (triggerOperationAnswer) { + case 'Add a Trigger': { + try { + triggerName = await addTrigger(context, resourceName, triggerFunctions); + triggerFunctions.push(triggerName); + continueWithTriggerOperationQuestion = false; + } catch (e) { + printer.error(e.message); + continueWithTriggerOperationQuestion = true; + } + break; + } + case 'Remove a trigger': { + try { + if (triggerFunctions.length === 0) { + throw new Error('No triggers found associated with this table'); + } else { + triggerName = await removeTrigger(context, resourceName, triggerFunctions); + + const index = triggerFunctions.indexOf(triggerName); + + if (index >= 0) { + triggerFunctions.splice(index, 1); + continueWithTriggerOperationQuestion = false; + } else { + throw new Error('Could not find trigger function'); + } + } + } catch (e) { + printer.error(e.message); + continueWithTriggerOperationQuestion = true; + } + + break; + } + case `I'm done`: { + continueWithTriggerOperationQuestion = false; + break; + } + default: + printer.error(`${triggerOperationAnswer} not supported`); + } + } + } + return triggerFunctions; +} + +async function askGSIQuestion( + indexableAttributeList: string[], + attributeDefinitions: DynamoDBAttributeDefType[], + existingGSIList?: DynamoDBCLIInputsGSIType[], +) { + printer.blankLine(); + printer.info( + 'You can optionally add global secondary indexes for this table. These are useful when you run queries defined in a different column than the primary key.', + ); + printer.info('To learn more about indexes, see:'); + printer.info( + 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.SecondaryIndexes', + ); + printer.blankLine(); + + let gsiList: DynamoDBCLIInputsGSIType[] = []; + + if ( + existingGSIList && + !!existingGSIList.length && + (await prompter.yesOrNo('Do you want to keep existing global seconday indexes created on your table?', true)) + ) { + gsiList = existingGSIList; + } + + if (await prompter.confirmContinue('Do you want to add global secondary indexes to your table?')) { + let continuewithGSIQuestions = true; + + while (continuewithGSIQuestions) { + if (indexableAttributeList.length > 0) { + const gsiNameValidator = + (message: string): Validator => + (input: string) => + /^[a-zA-Z0-9_-]+$/.test(input) ? true : message; + + const gsiName = await prompter.input('Provide the GSI name', { + validate: gsiNameValidator('You can use the following characters: a-z A-Z 0-9 - _'), + }); + + const gsiPartitionKeyName = await prompter.pick('Choose partition key for the GSI', [...new Set(indexableAttributeList)]); + + const gsiPrimaryKeyIndex = attributeDefinitions.findIndex( + (attr: DynamoDBAttributeDefType) => attr.AttributeName === gsiPartitionKeyName, + ); + + /* eslint-enable */ + let gsiItem: DynamoDBCLIInputsGSIType = { + name: gsiName, + partitionKey: { + fieldName: gsiPartitionKeyName, + fieldType: attributeDefinitions[gsiPrimaryKeyIndex].AttributeType, + }, + }; + + const sortKeyOptions = indexableAttributeList.filter((att: string) => att !== gsiPartitionKeyName); + + if (sortKeyOptions.length > 0) { + if (await prompter.confirmContinue('Do you want to add a sort key to your global secondary index?')) { + const gsiSortKeyName = await prompter.pick('Choose sort key for the GSI', [...new Set(sortKeyOptions)]); + const gsiSortKeyIndex = attributeDefinitions.findIndex( + (attr: DynamoDBAttributeDefType) => attr.AttributeName === gsiSortKeyName, + ); + + gsiItem.sortKey = { + fieldName: gsiSortKeyName, + fieldType: attributeDefinitions[gsiSortKeyIndex].AttributeType, + }; + } + } + + gsiList.push(gsiItem); + continuewithGSIQuestions = await prompter.confirmContinue('Do you want to add more global secondary indexes to your table?'); + } else { + printer.error('You do not have any other attributes remaining to configure'); + break; + } + } + } + return gsiList; +} + +async function askSortKeyQuestion( + indexableAttributeList: string[], + attributeDefinitions: DynamoDBAttributeDefType[], + partitionKeyFieldName: string, +): Promise { + if (await prompter.confirmContinue('Do you want to add a sort key to your table?')) { + // Ask for sort key + if (attributeDefinitions.length > 1) { + const sortKeyName = await prompter.pick( + 'Choose sort key for the table', + indexableAttributeList.filter((att: string) => att !== partitionKeyFieldName), + ); + const sortKeyAttrTypeIndex = attributeDefinitions.findIndex((attr: DynamoDBAttributeDefType) => attr.AttributeName === sortKeyName); + + return { + fieldName: sortKeyName, + fieldType: attributeDefinitions[sortKeyAttrTypeIndex].AttributeType, + }; + } else { + printer.error('You must add additional keys in order to select a sort key.'); + } + } + return; +} + +async function askPrimaryKeyQuestion(indexableAttributeList: string[], attributeDefinitions: DynamoDBAttributeDefType[]) { + printer.blankLine(); + printer.info( + 'Before you create the database, you must specify how items in your table are uniquely organized. You do this by specifying a primary key. The primary key uniquely identifies each item in the table so that no two items can have the same key. This can be an individual column, or a combination that includes a primary key and a sort key.', + ); + printer.blankLine(); + printer.info('To learn more about primary keys, see:'); + printer.info( + 'https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html#HowItWorks.CoreComponents.PrimaryKey', + ); + printer.blankLine(); + + const partitionKeyName = await prompter.pick('Choose partition key for the table', indexableAttributeList); + const primaryAttrTypeIndex = attributeDefinitions.findIndex((attr: DynamoDBAttributeDefType) => attr.AttributeName === partitionKeyName); + + return { + fieldName: partitionKeyName, + fieldType: attributeDefinitions[primaryAttrTypeIndex].AttributeType, + }; +} + +async function askAttributeListQuestion(existingAttributeDefinitions?: DynamoDBCLIInputsKeyType[]) { + const attributeTypes = { + string: { code: 'string', indexable: true }, + number: { code: 'number', indexable: true }, + binary: { code: 'binary', indexable: true }, + boolean: { code: 'boolean', indexable: false }, + list: { code: 'list', indexable: false }, + map: { code: 'map', indexable: false }, + null: { code: 'null', indexable: false }, + 'string-set': { code: 'string-set', indexable: false }, + 'number-set': { code: 'number-set', indexable: false }, + 'binary-set': { code: 'binary-set', indexable: false }, + }; + + printer.blankLine(); + printer.info('You can now add columns to the table.'); + printer.blankLine(); + + let continueAttributeQuestion = true; + let attributeAnswers: DynamoDBAttributeDefType[] = []; + let indexableAttributeList: string[] = []; + let existingAttributes: DynamoDBAttributeDefType[] = []; + + if (existingAttributeDefinitions) { + existingAttributes = existingAttributeDefinitions.map((attr: DynamoDBCLIInputsKeyType) => { + return { + AttributeName: attr.fieldName, + AttributeType: attr.fieldType, + }; + }); + } + + if (existingAttributes.length > 0) { + attributeAnswers = existingAttributes; + indexableAttributeList = attributeAnswers.map((attr: DynamoDBAttributeDefType) => attr.AttributeName); + continueAttributeQuestion = await prompter.confirmContinue('Would you like to add another column?'); + } + + while (continueAttributeQuestion) { + const attributeNameValidator = + (message: string): Validator => + (input: string) => + /^[a-zA-Z0-9_-]+$/.test(input) ? true : message; + + const attributeName = await prompter.input('What would you like to name this column', { + validate: attributeNameValidator('You can use the following characters: a-z A-Z 0-9 - _'), + }); + + const attributeType = await prompter.pick('Choose the data type', Object.keys(attributeTypes)); + + if (attributeAnswers.findIndex((attribute: DynamoDBAttributeDefType) => attribute.AttributeName === attributeName) !== -1) { + continueAttributeQuestion = await prompter.confirmContinue('This attribute was already added. Do you want to add another attribute?'); + continue; + } + + attributeAnswers.push({ + AttributeName: attributeName, + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + AttributeType: attributeTypes[attributeType].code, + }); + + // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + if (attributeTypes[attributeType].indexable) { + indexableAttributeList.push(attributeName); + } + + continueAttributeQuestion = await prompter.confirmContinue('Would you like to add another column?'); + } + + return { attributeAnswers, indexableAttributeList }; +} + +async function askTableNameQuestion(defaultValues: any, resourceName: string) { + const tableNameValidator = + (message: string): Validator => + (input: string) => + /^[a-zA-Z0-9._-]+$/.test(input) ? true : message; + + const tableName = await prompter.input('Provide table name:', { + validate: tableNameValidator('You can use the following characters: a-z A-Z 0-9 . - _'), + initial: resourceName || defaultValues['tableName'], + }); + + return tableName; +} + +async function askResourceNameQuestion(defaultValues: any): Promise { + const resourceName = await prompter.input( + 'Provide a friendly name for your resource that will be used to label this category in the project', + { + validate: alphanumeric(), + initial: defaultValues['resourceName'], + }, + ); + + return resourceName; +} + +async function removeTrigger(context: $TSContext, resourceName: string, triggerList: string[]) { + const functionName = await prompter.pick('Select from the function you would like to remove', triggerList); + + const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); + const functionCFNFilePath = path.join(projectBackendDirPath, 'function', functionName, `${functionName}-cloudformation-template.json`); + + if (fs.existsSync(functionCFNFilePath)) { + const functionCFNFile = context.amplify.readJsonFile(functionCFNFilePath); + + delete functionCFNFile.Resources[`${resourceName}TriggerPolicy`]; + delete functionCFNFile.Resources[`${resourceName}Trigger`]; + + // Update the functions resource + const functionCFNString = JSON.stringify(functionCFNFile, null, 4); + + fs.writeFileSync(functionCFNFilePath, functionCFNString, 'utf8'); + } + + return functionName; +} + +async function addTrigger(context: $TSContext, resourceName: string, triggerList: string[]) { + const triggerTypeAnswer = await prompter.pick('Select from the following options', [ + 'Choose an existing function from the project', + 'Create a new function', + ]); + let functionName; + + if (triggerTypeAnswer === 'Choose an existing function from the project') { + let lambdaResources = await getLambdaFunctions(context); + + if (triggerList) { + const filteredLambdaResources: string[] = []; + + lambdaResources.forEach((lambdaResource: string) => { + if (triggerList.indexOf(lambdaResource) === -1) { + filteredLambdaResources.push(lambdaResource); + } + }); + + lambdaResources = filteredLambdaResources; + } + + if (lambdaResources.length === 0) { + throw new Error("No functions were found in the project. Use 'amplify add function' to add a new function."); + } + + functionName = await prompter.pick('Select from the following options', lambdaResources); + } else { + // Create a new lambda trigger + + const targetDir = context.amplify.pathManager.getBackendDirPath(); + const [shortId] = uuid().split('-'); + + functionName = `${resourceName}Trigger${shortId}`; + + const pluginDir = __dirname; + + const defaults = { + functionName: `${functionName}`, + roleName: `${resourceName}LambdaRole${shortId}`, + }; + + const copyJobs = [ + { + dir: pluginDir, + template: path.join('..', '..', '..', '..', 'resources', 'triggers', 'dynamoDB', 'lambda-cloudformation-template.json.ejs'), + target: path.join(targetDir, 'function', functionName, `${functionName}-cloudformation-template.json`), + }, + { + dir: pluginDir, + template: path.join('..', '..', '..', '..', 'resources', 'triggers', 'dynamoDB', 'event.json'), + target: path.join(targetDir, 'function', functionName, 'src', 'event.json'), + }, + { + dir: pluginDir, + template: path.join('..', '..', '..', '..', 'resources', 'triggers', 'dynamoDB', 'index.js'), + target: path.join(targetDir, 'function', functionName, 'src', 'index.js'), + }, + { + dir: pluginDir, + template: path.join('..', '..', '..', '..', 'resources', 'triggers', 'dynamoDB', 'package.json.ejs'), + target: path.join(targetDir, 'function', functionName, 'src', 'package.json'), + }, + ]; + + // copy over the files + await context.amplify.copyBatch(context, copyJobs, defaults); + + // Update amplify-meta and backend-config + + const backendConfigs = { + service: FunctionServiceNameLambdaFunction, + providerPlugin: 'awscloudformation', + build: true, + }; + + context.amplify.updateamplifyMetaAfterResourceAdd('function', functionName, backendConfigs); + + printer.success(`Successfully added resource ${functionName} locally`); + } + + const projectBackendDirPath = context.amplify.pathManager.getBackendDirPath(); + const functionCFNFilePath = path.join(projectBackendDirPath, 'function', functionName, `${functionName}-cloudformation-template.json`); + + if (fs.existsSync(functionCFNFilePath)) { + const functionCFNFile = context.amplify.readJsonFile(functionCFNFilePath); + + // Update parameters block + functionCFNFile.Parameters[`storage${resourceName}Name`] = { + Type: 'String', + Default: `storage${resourceName}Name`, + }; + + functionCFNFile.Parameters[`storage${resourceName}Arn`] = { + Type: 'String', + Default: `storage${resourceName}Arn`, + }; + + functionCFNFile.Parameters[`storage${resourceName}StreamArn`] = { + Type: 'String', + Default: `storage${resourceName}Arn`, + }; + + // Update policies + functionCFNFile.Resources[`${resourceName}TriggerPolicy`] = { + DependsOn: ['LambdaExecutionRole'], + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: `lambda-trigger-policy-${resourceName}`, + Roles: [ + { + Ref: 'LambdaExecutionRole', + }, + ], + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['dynamodb:DescribeStream', 'dynamodb:GetRecords', 'dynamodb:GetShardIterator', 'dynamodb:ListStreams'], + Resource: [ + { + Ref: `storage${resourceName}StreamArn`, + }, + ], + }, + ], + }, + }, + }; + + // Add TriggerResource + + functionCFNFile.Resources[`${resourceName}Trigger`] = { + Type: 'AWS::Lambda::EventSourceMapping', + DependsOn: [`${resourceName}TriggerPolicy`], + Properties: { + BatchSize: 100, + Enabled: true, + EventSourceArn: { + Ref: `storage${resourceName}StreamArn`, + }, + FunctionName: { + 'Fn::GetAtt': ['LambdaFunction', 'Arn'], + }, + StartingPosition: 'LATEST', + }, + }; + + // Update dependsOn + + const amplifyMetaFilePath = context.amplify.pathManager.getAmplifyMetaFilePath(); + const amplifyMeta = context.amplify.readJsonFile(amplifyMetaFilePath); + + const resourceDependsOn = amplifyMeta.function[functionName].dependsOn || []; + let resourceExists = false; + + resourceDependsOn.forEach((resource: any) => { + if (resource.resourceName === resourceName) { + resourceExists = true; + resourceDependsOn.attributes = ['Name', 'Arn', 'StreamArn']; + } + }); + + if (!resourceExists) { + resourceDependsOn.push({ + category: AmplifyCategories.STORAGE, + resourceName, + attributes: ['Name', 'Arn', 'StreamArn'], + }); + } + + // Update the functions resource + const functionCFNString = JSON.stringify(functionCFNFile, null, 4); + + fs.writeFileSync(functionCFNFilePath, functionCFNString, 'utf8'); + + context.amplify.updateamplifyMetaAfterResourceUpdate('function', functionName, 'dependsOn', resourceDependsOn); + printer.success(`Successfully updated resource ${functionName} locally`); + + if (await prompter.confirmContinue(`Do you want to edit the local ${functionName} lambda function now?`)) { + await context.amplify.openEditor(context, `${projectBackendDirPath}/function/${functionName}/src/index.js`); + } + } else { + throw new Error(`Function ${functionName} does not exist`); + } + + return functionName; +} + +async function getLambdaFunctions(context: $TSContext) { + const { allResources } = await context.amplify.getResourceStatus(); + const lambdaResources = allResources + .filter((resource: any) => resource.service === FunctionServiceNameLambdaFunction) + .map((resource: any) => resource.resourceName); + + return lambdaResources; +} + +function migrateCategory(context: $TSContext, projectPath: any, resourceName: any) { + const resourceDirPath = path.join(projectPath, 'amplify', 'backend', AmplifyCategories.STORAGE, resourceName); + const cfnFilePath = path.join(resourceDirPath, `${resourceName}-cloudformation-template.json`); + + // Removes dangling commas from a JSON + const removeDanglingCommas = (value: any) => { + const regex = /,(?!\s*?[{["'\w])/g; + + return value.replace(regex, ''); + }; + + /* Current Dynamo CFN's have a trailing comma (accepted by CFN), + but fails on JSON.parse(), hence removing it */ + + let oldcfnString = fs.readFileSync(cfnFilePath, 'utf8'); + oldcfnString = removeDanglingCommas(oldcfnString); + + const oldCfn = JSON.parse(oldcfnString); + + const newCfn = {}; + + Object.assign(newCfn, oldCfn); + + // Add env parameter + if (!(newCfn as any).Parameters) { + (newCfn as any).Parameters = {}; + } + + (newCfn as any).Parameters.env = { + Type: 'String', + }; + + // Add conditions block + if (!(newCfn as any).Conditions) { + (newCfn as any).Conditions = {}; + } + + (newCfn as any).Conditions.ShouldNotCreateEnvResources = { + 'Fn::Equals': [ + { + Ref: 'env', + }, + 'NONE', + ], + }; + + // Add if condition for resource name change + (newCfn as any).Resources.DynamoDBTable.Properties.TableName = { + 'Fn::If': [ + 'ShouldNotCreateEnvResources', + { + Ref: 'tableName', + }, + { + 'Fn::Join': [ + '', + [ + { + Ref: 'tableName', + }, + '-', + { + Ref: 'env', + }, + ], + ], + }, + ], + }; + + const jsonString = JSON.stringify(newCfn, null, '\t'); + + fs.writeFileSync(cfnFilePath, jsonString, 'utf8'); +} + +function getIAMPolicies(resourceName: any, crudOptions: any) { + let policy = {}; + const actions: any = []; + + crudOptions.forEach((crudOption: any) => { + switch (crudOption) { + case 'create': + actions.push('dynamodb:Put*', 'dynamodb:Create*', 'dynamodb:BatchWriteItem'); + break; + case 'update': + actions.push('dynamodb:Update*', 'dynamodb:RestoreTable*'); + break; + case 'read': + actions.push('dynamodb:Get*', 'dynamodb:BatchGetItem', 'dynamodb:List*', 'dynamodb:Describe*', 'dynamodb:Scan', 'dynamodb:Query'); + break; + case 'delete': + actions.push('dynamodb:Delete*'); + break; + default: + console.log(`${crudOption} not supported`); + } + }); + + policy = { + Effect: 'Allow', + Action: actions, + Resource: crudOptions.customPolicyResource + ? crudOptions.customPolicyResource + : [ + { Ref: `${AmplifyCategories.STORAGE}${resourceName}Arn` }, + { + 'Fn::Join': [ + '/', + [ + { + Ref: `${AmplifyCategories.STORAGE}${resourceName}Arn`, + }, + 'index/*', + ], + ], + }, + ], + }; + + const attributes = ['Name', 'Arn', 'StreamArn']; + + return { policy, attributes }; +} + +module.exports = { + addWalkthrough, + updateWalkthrough, + migrate: migrateCategory, + getIAMPolicies, +}; diff --git a/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/s3-user-input-state.ts b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/s3-user-input-state.ts new file mode 100644 index 00000000000..be2288064ca --- /dev/null +++ b/packages/amplify-category-storage/src/provider-utils/awscloudformation/service-walkthroughs/s3-user-input-state.ts @@ -0,0 +1,162 @@ +import { S3AccessType, S3PermissionType, S3UserInputs } from '../service-walkthrough-types/s3-user-input-types'; +import { AmplifyCategories, AmplifySupportedService } from 'amplify-cli-core'; +import { JSONUtilities, pathManager } from 'amplify-cli-core'; +import { CLIInputSchemaValidator } from 'amplify-cli-core'; + +import * as fs from 'fs-extra'; +import * as path from 'path'; + +type ResourcRefType = { + Ref: string; +}; + +export enum S3CFNPermissionType { + PUT_OBJECT = 's3:PutObject', + GET_OBJECT = 's3:GetObject', + DELETE_OBJECT = 's3:DeleteObject', + LIST_BUCKET = 's3:ListBucket', +} + +export type S3CFNDependsOn = { + category: string; + resourceName: string; + attributes: string[]; +}; + +export type S3CLIWalkthroughParams = { + resourceName: string; + bucketName: string; + authPolicyName: string; + unauthPolicyName: string; + authRoleName: ResourcRefType; + unauthRoleName: ResourcRefType; + storageAccess: S3AccessType; + selectedGuestPermissions: S3CFNPermissionType[]; + selectedAuthenticatedPermissions: S3CFNPermissionType[]; + s3PermissionsAuthenticatedPublic: string; + s3PublicPolicy: string; + s3PermissionsAuthenticatedUploads: string; + s3UploadsPolicy: string; + s3PermissionsAuthenticatedProtected: string; + s3ProtectedPolicy: string; + s3PermissionsAuthenticatedPrivate: string; + s3PrivatePolicy: string; + AuthenticatedAllowList: string; + s3ReadPolicy: string; + s3PermissionsGuestPublic: string; + s3PermissionsGuestUploads: string; + GuestAllowList: string; + triggerFunction: string; + service: string; + providerPlugin: string; + dependsOn: S3CFNDependsOn[]; +}; + +export type S3InputStateOptions = { + userInputDataFilename: string; //cli-inputs.json + resourceName: string; + inputPayload?: S3UserInputs; +}; + +export class S3InputState { + static s3InputState: S3InputState; + _cliInputsFilePath: string; //cli-inputs.json (output) filepath + _resourceName: string; //user friendly name provided by user + _category: string; //category of the resource + _service: string; //AWS service for the resource + _inputPayload: S3UserInputs | undefined; //S3 options selected by user + + constructor(props: S3InputStateOptions) { + this._category = AmplifyCategories.STORAGE; + this._service = AmplifySupportedService.S3; + this._cliInputsFilePath = props.userInputDataFilename; + this._resourceName = props.resourceName; + + // Read cliInputs file if exists + try { + this._inputPayload = props.inputPayload ?? JSONUtilities.readJson(this._cliInputsFilePath, { throwIfNotExist: true }); + } catch (e) { + throw new Error('migrate project with command : amplify migrate '); + } + + // validate cli-inputs.json + const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, 'S3UserInputs'); + schemaValidator.validateInput(JSON.stringify(this._inputPayload!)); + } + + public static getPermissionTypeFromCfnType(s3pPrmissionCfnType: S3CFNPermissionType): S3PermissionType { + switch (s3pPrmissionCfnType) { + case S3CFNPermissionType.PUT_OBJECT: + return S3PermissionType.PUT_OBJECT; + case S3CFNPermissionType.GET_OBJECT: + return S3PermissionType.GET_OBJECT; + case S3CFNPermissionType.DELETE_OBJECT: + return S3PermissionType.DELETE_OBJECT; + case S3CFNPermissionType.LIST_BUCKET: + return S3PermissionType.LIST_BUCKET; + default: + throw new Error(`Unknown CFN Type: ${s3pPrmissionCfnType}`); + } + } + + public static getInputPermissionsFromCfnPermissions(selectedGuestPermissions: S3CFNPermissionType[] | undefined) { + if (selectedGuestPermissions) { + return selectedGuestPermissions.map(S3InputState.getPermissionTypeFromCfnType); + } else { + return []; + } + } + + public static cliWalkThroughToCliInputParams(cliInputsFilePath: string, options: S3CLIWalkthroughParams) { + const inputProps: S3InputStateOptions = { + userInputDataFilename: cliInputsFilePath, + resourceName: options.resourceName, + inputPayload: { + resourceName: options.resourceName, + bucketName: options.bucketName, + storageAccess: options.storageAccess, + selectedGuestPermissions: S3InputState.getInputPermissionsFromCfnPermissions(options.selectedGuestPermissions), + selectedAuthenticatedPermissions: S3InputState.getInputPermissionsFromCfnPermissions(options.selectedAuthenticatedPermissions), + isTriggerEnabled: options.triggerFunction !== 'NONE' ? true : false, //enable if trigger + triggerFunctionName: options.triggerFunction !== 'NONE' ? options.triggerFunction : undefined, + }, + }; + return inputProps; + } + + updateInputPayload(props: S3InputStateOptions) { + // Overwrite + this._inputPayload = props.inputPayload; + + // validate cli-inputs.json + const schemaValidator = new CLIInputSchemaValidator(this._service, this._category, 'S3UserInputs'); + schemaValidator.validateInput(JSON.stringify(this._inputPayload!)); + } + + public static getInstance(props: S3InputStateOptions): S3InputState { + const projectPath = pathManager.findProjectRoot(); + if (!S3InputState.s3InputState) { + S3InputState.s3InputState = new S3InputState(props); + } + S3InputState.s3InputState.updateInputPayload(props); + return S3InputState.s3InputState; + } + + public getCliInputPayload(): S3UserInputs { + if (this._inputPayload) { + return this._inputPayload; + } else { + throw new Error('cli-inputs not present. Either add category or migrate project to support extensibility'); + } + } + + public saveCliInputPayload(): void { + const backend = pathManager.getBackendDirPath(); + fs.ensureDirSync(path.join(pathManager.getBackendDirPath(), this._category, this._resourceName)); + try { + JSONUtilities.writeJson(this._cliInputsFilePath, this._inputPayload); + } catch (e) { + throw new Error(e); + } + } +} diff --git a/packages/amplify-cli-core/src/cliConstants.ts b/packages/amplify-cli-core/src/cliConstants.ts index e60538dfdac..ca60614aac3 100644 --- a/packages/amplify-cli-core/src/cliConstants.ts +++ b/packages/amplify-cli-core/src/cliConstants.ts @@ -26,7 +26,7 @@ export const AmplifySupportedService = { COGNITO: 'Cognito', }; -export const overriddenCategories = [AmplifyCategories.AUTH]; +export const overriddenCategories = [AmplifyCategories.AUTH, AmplifyCategories.STORAGE]; export type IAmplifyResource = { category: string; diff --git a/packages/amplify-cli-core/src/feature-flags/featureFlags.ts b/packages/amplify-cli-core/src/feature-flags/featureFlags.ts index 8e6655a7b40..9a802f35fa4 100644 --- a/packages/amplify-cli-core/src/feature-flags/featureFlags.ts +++ b/packages/amplify-cli-core/src/feature-flags/featureFlags.ts @@ -702,6 +702,12 @@ export class FeatureFlags { // FF for overrides this.registerFlag('overrides', [ + { + name: 'storage', + type: 'boolean', + defaultValueForExistingProjects: false, + defaultValueForNewProjects: true, + }, { name: 'auth', type: 'boolean', diff --git a/packages/amplify-cli-core/src/state-manager/pathManager.ts b/packages/amplify-cli-core/src/state-manager/pathManager.ts index 268aad89753..00a8d9d0716 100644 --- a/packages/amplify-cli-core/src/state-manager/pathManager.ts +++ b/packages/amplify-cli-core/src/state-manager/pathManager.ts @@ -53,6 +53,7 @@ export const PathConstants = { CLIJsonWithEnvironmentFileName: (env: string) => `cli.${env}.json`, CfnFileName: (resourceName: string) => `${resourceName}-awscloudformation-template.json`, + cliInputsFileName: 'cli-inputs.json', }; export class PathManager { @@ -89,6 +90,16 @@ export class PathManager { getBackendDirPath = (projectPath?: string): string => this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.BackendDirName]); + getCliInputsPath = (projectPath: string, category: string, resourceName: string): string => { + return this.constructPath(projectPath, [ + PathConstants.AmplifyDirName, + PathConstants.BackendDirName, + category, + resourceName, + PathConstants.cliInputsFileName, + ]); + }; + getCurrentCloudBackendDirPath = (projectPath?: string): string => this.constructPath(projectPath, [PathConstants.AmplifyDirName, PathConstants.CurrentCloudBackendDirName]); diff --git a/packages/amplify-cli/amplify-plugin.json b/packages/amplify-cli/amplify-plugin.json index 2849e512d89..dc90102dda9 100644 --- a/packages/amplify-cli/amplify-plugin.json +++ b/packages/amplify-cli/amplify-plugin.json @@ -19,7 +19,8 @@ "status", "uninstall", "upgrade", - "version" + "version", + "build-override" ], "commandAliases": { "h": "help", diff --git a/packages/amplify-cli/package.json b/packages/amplify-cli/package.json index c892a0c9b4e..22b0edb4790 100644 --- a/packages/amplify-cli/package.json +++ b/packages/amplify-cli/package.json @@ -44,7 +44,7 @@ "amplify-category-interactions": "2.6.5", "amplify-category-notifications": "2.19.4", "amplify-category-predictions": "2.9.12", - "amplify-category-storage": "2.12.9", + "@aws-amplify/amplify-category-storage": "1.0.0", "amplify-category-xr": "2.8.21", "amplify-cli-core": "1.29.0", "amplify-cli-logger": "1.1.0", @@ -211,7 +211,7 @@ "storage": { "name": "storage", "type": "category", - "packageName": "amplify-category-storage" + "packageName": "@aws-amplify/amplify-category-storage" }, "xr": { "name": "xr",