From 59d2d0f1a95fe68fb26bfa60bb7251c168f38284 Mon Sep 17 00:00:00 2001 From: Joseph Lawson Date: Mon, 29 May 2023 13:15:48 -0400 Subject: [PATCH] add option to associate exported backend with amplify app - if given an amplifyAppId and amplifyEnvironment, then created the associated backend environment - ensure amplify environment name is valid - ensure exception tests are properly accounted for - add more details for AmplifyExportedBackendProps - make AmplifyCategoryNotFoundError a little more clear - emulate root stack cfn parameters that cli uses --- API.md | 42 +++++++++-- README.md | 13 ++-- src/amplify-exported-backend-props.ts | 34 +++++++-- src/base-exported-backend.ts | 26 ++++--- src/export-backend-asset-handler/index.ts | 2 +- src/export-backend.ts | 69 ++++++++++++++++--- .../api-rest/api-rest-include-stack.ts | 8 ++- .../auth/auth-include-nested-stack.ts | 7 +- src/types/category-stack-mapping.ts | 2 +- test/export-backend.test.ts | 66 ++++++++++++++++-- 10 files changed, 220 insertions(+), 49 deletions(-) diff --git a/API.md b/API.md index 78bc235..6258d18 100644 --- a/API.md +++ b/API.md @@ -317,28 +317,56 @@ Whether to enable termination protection for this stack. --- -##### `amplifyEnvironment`Required +##### `path`Required ```typescript -public readonly amplifyEnvironment: string; +public readonly path: string; ``` - *Type:* `string` -- *Default:* is 'dev' -The Amplify CLI environment deploy to The amplify backend requires a stage to deploy. +The path to the exported folder that contains the artifacts for the Amplify CLI backend ex: ./amplify-synth-out/. --- -##### `path`Required +##### `amplifyAppId`Optional ```typescript -public readonly path: string; +public readonly amplifyAppId: string; ``` - *Type:* `string` -The path to the exported folder that contains the artifacts for the Amplify CLI backend ex: ./amplify-synth-out/. +The Amplify App ID to which the new backend environment will be added. + +If the Amplify environment is created and managed by CDK exclusively +then provide an AmplifyAppId to ensure the backend environment +shows up in the AWS Amplify App homepage. + +If the Amplify environment is created via Amplify CLI, do not +provide an AmplifyAppId. Trying to create an Amplify backend +via CDK which has already been created by the Amplify CLI will result +in the CDK failing to create the backend and automatically deleting +the existing backend when it deletes the Amplify environment it failed +to deploy. + +--- + +##### `amplifyEnvironment`Optional + +```typescript +public readonly amplifyEnvironment: string; +``` + +- *Type:* `string` +- *Default:* is 'dev' + +An environment name to contain Amplify CLI backend resources. + +An Amplify backend is a collection of various AWS +resources organized into categories (api, function, custom, etc) which are deployed +together into an environment. Environments sometimes reflect deployment +stages such as 'dev', 'test', and 'prod'. --- diff --git a/README.md b/README.md index 2146cd3..035ad78 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,7 @@ Add to your CDK app: import { AmplifyExportedBackend } from '@aws-amplify/cdk-exported-backend'; ... const amplifyExport = new AmplifyExportedBackend(app, 'AmplifyExportedBackend', { - path: './amplify-export-myAmplifyApp', - amplifyEnvironment: 'dev', + path: './amplify-export-myAmplifyApp' }); @@ -45,10 +44,12 @@ const amplifyExport = new AmplifyExportedBackend(app, 'AmplifyExportedBackend', The construct props extend [stack props](https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_core.StackProps.html) and can be used to override the root stack properties. -|Name |Type |Description |Required |Default | -|--- |--- |--- |--- |--- | -|path |String |You can use the absolute or the relative path to the location of the folder. When using relative paths it's important to note that the path is relative to the root of your CDK application |Yes |undefined | -|stage |String |This works similar to Amplify CLI's environment names. The construct makes modification to be able to integrate into the CDK app. |Yes | undefined | +| Name | Type | Description | Required | Default | +|--------------------|--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-----------| +| path | String | You can use the absolute or the relative path to the location of the folder. When using relative paths it's important to note that the path is relative to the root of your CDK application. | Yes | undefined | +| amplifyEnvironment | String | The construct emulates Amplify CLI's environment name convention to integrate Amplify exported resources into the CDK app. | No | dev | +| amplifyAppId | String | Create an Amplify backend environment for the specified Amplify App Id. Do not provide an amplifyAppId if the environment was already added by the Amplify CLI. | No | undefined | + Deploy this to your account diff --git a/src/amplify-exported-backend-props.ts b/src/amplify-exported-backend-props.ts index b76ebae..463c5de 100644 --- a/src/amplify-exported-backend-props.ts +++ b/src/amplify-exported-backend-props.ts @@ -1,16 +1,36 @@ import { StackProps } from 'aws-cdk-lib'; export interface AmplifyExportedBackendProps extends StackProps { - /** - * The Amplify CLI environment deploy to - * The amplify backend requires a stage to deploy - * @default is 'dev' - */ - readonly amplifyEnvironment: string; - /** * The path to the exported folder that contains the artifacts for the Amplify CLI backend * ex: ./amplify-synth-out/ */ readonly path: string; + + /** + * The Amplify App ID to which the new backend environment will be added. + * + * If the Amplify environment is created and managed by CDK exclusively + * then provide an AmplifyAppId to ensure the backend environment + * shows up in the AWS Amplify App homepage. + * + * If the Amplify environment is created via Amplify CLI, do not + * provide an AmplifyAppId. Trying to create an Amplify backend + * via CDK which has already been created by the Amplify CLI will result + * in the CDK failing to create the backend and automatically deleting + * the existing backend when it deletes the Amplify environment it failed + * to deploy. + */ + readonly amplifyAppId?: string + + /** + * An environment name to contain Amplify CLI backend resources. + * + * An Amplify backend is a collection of various AWS + * resources organized into categories (api, function, custom, etc) which are deployed + * together into an environment. Environments sometimes reflect deployment + * stages such as 'dev', 'test', and 'prod'. + * @default is 'dev' + */ + readonly amplifyEnvironment?: string; } diff --git a/src/base-exported-backend.ts b/src/base-exported-backend.ts index b5e6085..3eaceb4 100644 --- a/src/base-exported-backend.ts +++ b/src/base-exported-backend.ts @@ -14,9 +14,18 @@ const { AMPLIFY_EXPORT_TAG_FILE, AMPLIFY_CATEGORY_MAPPING_FILE, } = Constants; + +// conform to Amplify conventions: https://github.com/aws-amplify/amplify-cli/blob/v12.0.3/packages/amplify-cli/src/init-steps/s0-analyzeProject.ts#L206-L208 +const isEnvNameValid = (inputEnvName: string): boolean => /^[a-z]{2,10}$/.test(inputEnvName); + +function validateEnv(env: string = 'dev') { + if(!isEnvNameValid(env)) throw new Error(`param 'amplifyEnvironment' must be between 2 and 10 characters, and lowercase only. Got '${env}'.`); + return env; +} + class AmplifyCategoryNotFoundError extends Error { constructor(category: string, service?: string) { - super(`The category: ${category} ${service ? 'of service: ' + service: '' } not found.`); + super(`${service ? `service '${service}' of category '${category}'` : `category '${category}'`} not found`); } } @@ -30,16 +39,19 @@ export class BaseAmplifyExportedBackend extends Construct { protected exportBackendManifest: ExportManifest; protected exportTags: ExportTag[]; protected auxiliaryDeployment?: BucketDeployment; - protected env?: string + protected env: string; + protected appId?: string; constructor( scope: Construct, id: string, exportPath: string, - amplifyEnvironment: string, + amplifyEnvironment?: string, + amplifyAppId?: string ) { super(scope, id); - this.env = amplifyEnvironment; + this.env = validateEnv(amplifyEnvironment); + this.appId = amplifyAppId; this.exportPath = this.validatePath(exportPath); @@ -140,11 +152,7 @@ export class BaseAmplifyExportedBackend extends Construct { private modifyEnv(nameWithEnv: string): string { let splitValues = nameWithEnv.split('-'); - if (this.env) { - splitValues[2] = this.env; - } else { - splitValues.splice(2, 1); - } + splitValues[2] = this.env; return splitValues.join('-'); } diff --git a/src/export-backend-asset-handler/index.ts b/src/export-backend-asset-handler/index.ts index 7f73d6c..e9c8598 100644 --- a/src/export-backend-asset-handler/index.ts +++ b/src/export-backend-asset-handler/index.ts @@ -49,7 +49,7 @@ export class AmplifyExportAssetHandler extends Construct { private categoryStackWithDeployment: CategoryStackMappingWithDeployment[]; private exportPath: string; private rootStack: Stack; - private env?: string; + private env: string; private auxiliaryDeployment: BucketDeployment | undefined; diff --git a/src/export-backend.ts b/src/export-backend.ts index 0c0e067..b0acb72 100644 --- a/src/export-backend.ts +++ b/src/export-backend.ts @@ -17,6 +17,10 @@ import { LambdaFunctionIncludedNestedStack, } from './include-nested-stacks/lambda-function/lambda-function-nested-stack'; import { CategoryStackMapping } from './types/category-stack-mapping'; +import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from "aws-cdk-lib/custom-resources"; +import { CfnParameter } from 'aws-cdk-lib'; +import { CfnRole } from 'aws-cdk-lib/aws-iam'; +import { CfnBucket } from 'aws-cdk-lib/aws-s3'; const { API_CATEGORY, AUTH_CATEGORY, FUNCTION_CATEGORY } = Constants; @@ -46,7 +50,7 @@ export class AmplifyExportedBackend id: string, props: AmplifyExportedBackendProps, ) { - super(scope, id, props.path, props.amplifyEnvironment); + super(scope, id, props.path, props.amplifyEnvironment, props.amplifyAppId); this.rootStack = new cdk.Stack(scope, `${id}-amplify-backend-stack`, { ...props, @@ -59,7 +63,7 @@ export class AmplifyExportedBackend { backendPath: props.path, categoryStackMapping: this.categoryStackMappings, - env: props.amplifyEnvironment ? props.amplifyEnvironment : 'dev', + env: this.env, exportManifest: this.exportBackendManifest, }, ); @@ -76,11 +80,57 @@ export class AmplifyExportedBackend amplifyExportHandler.setDependencies(include); - this.applyTags(this.rootStack, props.amplifyEnvironment); + // used to emulate the input parameters that the Amplify CLI uses + const deploymentBucket = this.cfnInclude.getResource('DeploymentBucket') as CfnBucket; + const authRole = this.cfnInclude.getResource('AuthRole') as CfnRole; + const unauthRole = this.cfnInclude.getResource('UnauthRole') as CfnRole; + new CfnParameter(this.rootStack, 'AuthRoleName', { + type: 'String', + default: authRole.roleName + }); + new CfnParameter(this.rootStack, 'DeploymentBucketName', { + type: 'String', + default: deploymentBucket.bucketName + }); + new CfnParameter(this.rootStack, 'UnauthRoleName', { + type: 'String', + default: unauthRole.roleName + }); + // just like in amplify env add, we add the backend to the amplify application + // and then deploy the CFN. This also ensures the amplify env only deletes when + // the CFN is gone too. + if(this.appId) { + new AwsCustomResource(this.rootStack, 'CreateBackendEnvironment', { + onCreate: { + service: 'Amplify', + action: 'createBackendEnvironment', + parameters: { + appId: this.appId, + environmentName: this.env, + stackName: this.rootStack.stackName, + deploymentArtifacts: deploymentBucket.bucketName, + }, + physicalResourceId: PhysicalResourceId.of(`${this.appId}-${this.env}-backendEnvironment`) + }, + onDelete: { + service: 'Amplify', + action: 'deleteBackendEnvironment', + parameters: { + appId: this.appId, + environmentName: this.env + } + }, + policy: AwsCustomResourcePolicy.fromSdkCalls({ + resources: AwsCustomResourcePolicy.ANY_RESOURCE, + }), + }); + } + + this.applyTags(this.rootStack, this.env); } - private applyTags(rootStack: cdk.Stack, env: string = 'dev') { + private applyTags(rootStack: cdk.Stack, env: string) { this.exportTags.forEach((tag) => { rootStack.tags.setTag(tag.key, tag.value.replace('{project-env}', env)); }); @@ -88,7 +138,7 @@ export class AmplifyExportedBackend /** * Method to get the auth stack - * @returns the nested stack of type {IAuthIncludeNestedStack} + * @returns the nested stack of type {@link AuthIncludedNestedStack} * @throws {AmplifyCategoryNotFoundError} if the auth stack doesn't exist * @method * @function @@ -104,8 +154,7 @@ export class AmplifyExportedBackend /** * Use this to get the api graphql stack from the backend - * @returns the nested stack of type {IAPIGraphQLIncludeNestedStack} - * @ + * @returns the nested stack of type {@link APIGraphQLIncludedNestedStack} * @throws {AmplifyCategoryNotFoundError} if the API graphql stack doesn't exist */ graphqlNestedStacks(): APIGraphQLIncludedNestedStack { @@ -120,7 +169,7 @@ export class AmplifyExportedBackend /** * Use this to get all the lambda functions from the backend - * @returns {ILambdaFunctionIncludedNestedStack[]} + * @returns {LambdaFunctionIncludedNestedStack[]} * @throws {AmplifyCategoryNotFoundError} if the no Lambda Function stacks are found */ lambdaFunctionNestedStacks(): LambdaFunctionIncludedNestedStack[] { @@ -134,7 +183,7 @@ export class AmplifyExportedBackend /** * Use this to get a specific lambda function from the backend - * @returns {ILambdaFunctionIncludedNestedStack} + * @returns {LambdaFunctionIncludedNestedStack} * @param functionName the function name to get from the nested stack * @throws {AmplifyCategoryNotFoundError} if the lambda function stack doesn't exist */ @@ -172,7 +221,7 @@ export class AmplifyExportedBackend /** * Use this to get rest api stack from the backend * @param resourceName - * @return {IAPIRestIncludedStack} the nested of type Rest API + * @return {APIRestIncludedStack} the nested of type Rest API * @throws {AmplifyCategoryNotFoundError} if the API Rest stack doesn't exist */ apiRestNestedStack(resourceName: string): APIRestIncludedStack { diff --git a/src/include-nested-stacks/api-rest/api-rest-include-stack.ts b/src/include-nested-stacks/api-rest/api-rest-include-stack.ts index 30e8384..ec86966 100644 --- a/src/include-nested-stacks/api-rest/api-rest-include-stack.ts +++ b/src/include-nested-stacks/api-rest/api-rest-include-stack.ts @@ -13,6 +13,7 @@ export class APIRestIncludedStack /** * Gets the RestApi of the API stack * @returns {CfnRestApi} + * @throws {CfnResourceNotFoundError} if not found */ restAPI(): CfnRestApi { return this.getResourceConstruct(this.resourceName); @@ -20,9 +21,10 @@ export class APIRestIncludedStack /** - * Gets the Deployment of the Rest API - * @returns {CfnDeployment} - */ + * Gets the Deployment of the Rest API + * @returns {CfnDeployment} + * @throws {CfnResourceNotFoundError} if not found + */ apiDeployment(): CfnDeployment { return this.getResourceConstruct( `DeploymentAPIGW${this.resourceName}`, diff --git a/src/include-nested-stacks/auth/auth-include-nested-stack.ts b/src/include-nested-stacks/auth/auth-include-nested-stack.ts index ff200d5..0b4c96a 100644 --- a/src/include-nested-stacks/auth/auth-include-nested-stack.ts +++ b/src/include-nested-stacks/auth/auth-include-nested-stack.ts @@ -9,6 +9,10 @@ export interface ProviderCredential { } export class AuthIncludedNestedStack extends BaseIncludedStack { + /** + * attaches third party auth provider credentials to identity pool + * @throws {CfnResourceNotFoundError} if + */ hostedUiProviderCredentials(credentials: ProviderCredential[]): void { const hostedUICustomResourceInputs = this.getResourceConstruct< CfnCustomResource @@ -31,6 +35,7 @@ export class AuthIncludedNestedStack extends BaseIncludedStack { } /** * @returns {CfnIdentityPool} of the auth stack + * @throws {CfnResourceNotFoundError} if not found */ userPool(): CfnUserPool { return this.getResourceConstruct('UserPool'); @@ -38,7 +43,7 @@ export class AuthIncludedNestedStack extends BaseIncludedStack { /** * @returns Cognito UserPool {CfnUserPool} of the auth stack - * @throws {} + * @throws {CfnResourceNotFoundError} if not found */ identityPool(): CfnIdentityPool { return this.getResourceConstruct('IdentityPool'); diff --git a/src/types/category-stack-mapping.ts b/src/types/category-stack-mapping.ts index 25cacd4..1e1b004 100644 --- a/src/types/category-stack-mapping.ts +++ b/src/types/category-stack-mapping.ts @@ -5,7 +5,7 @@ export interface CategoryStackMapping { readonly category: string; readonly resourceName: string; readonly service: string; -}; +} export type CategoryStackMappingWithDeployment = CategoryStackMapping & { bucketDeployment? :BucketDeployment; diff --git a/test/export-backend.test.ts b/test/export-backend.test.ts index 6bb97f9..f22e0d7 100644 --- a/test/export-backend.test.ts +++ b/test/export-backend.test.ts @@ -4,6 +4,7 @@ import * as fs from 'fs-extra'; import { AmplifyExportedBackend } from '../src'; import { Constants } from '../src/constants'; import { manifest_test, stack_mapping_test } from './test-constants'; +import { AWS_CUSTOM_RESOURCE_LATEST_SDK_DEFAULT } from 'aws-cdk-lib/cx-api'; const { AMPLIFY_EXPORT_MANIFEST_FILE, AMPLIFY_CATEGORY_MAPPING_FILE, @@ -34,9 +35,11 @@ fs_mock.statSync.mockReturnValue({ isDirectory: jest.fn().mockReturnValue(true), } as unknown as fs.Stats) +const mockAssetReturn = jest.fn(); + jest.mock('../src/export-backend-asset-handler', () => ({ AmplifyExportAssetHandler: jest.fn().mockReturnValue({ - createAssetsAndUpdateParameters: jest.fn().mockReturnValue({ props: {} }), + createAssetsAndUpdateParameters: () => mockAssetReturn(), setDependencies: jest.fn(), }), })); @@ -58,8 +61,29 @@ JSON.parse = jest.fn().mockImplementation((val) => { return {}; }); +cfnInclude_mock.prototype.getResource = jest.fn().mockImplementation((name: string) => { + if (name === 'AuthRoleName') { + return { + roleName: manifest_test.props.parameters.AuthRoleName + } + } + if (name === 'DeploymentBucketName') { + return { + bucketName: manifest_test.props.parameters.DeploymentBucketName + } + } + + if (name === 'UnauthRoleName') { + return { + roleName: manifest_test.props.parameters.UnauthRoleName + } + } + return {} +}); + describe('test export backend', () => { test('test export backend', () => { + mockAssetReturn.mockReturnValue({props: {}}) const app = new App(); const amplifyBackend = new AmplifyExportedBackend(app, 'test-construct', { path: 'dummy-path', @@ -90,11 +114,45 @@ describe('test export backend', () => { expect(lambdaStack.length).toBe(1); expect(amplifyBackend.cfnInclude.getNestedStack).toBeCalledWith('functionamplifyexportest13c53bd0'); - try { + // testing an exception so wrap in a function per https://jestjs.io/docs/expect#tothrowerror + function getMissingResource() { amplifyBackend.apiRestNestedStack('noresource'); - } catch (ex) { - expect(ex).toBeDefined(); } + expect(getMissingResource) + .toThrowError("service 'API Gateway' of category 'api' not found"); }); + test('Amplify Backend Created', () => { + mockAssetReturn.mockReturnValue(manifest_test); + const appId = 'testAppId'; + const env = "testenv"; + const app = new App({context: {[AWS_CUSTOM_RESOURCE_LATEST_SDK_DEFAULT]: false}}); + const backend = new AmplifyExportedBackend(app, "testExport", { + amplifyEnvironment: env, + amplifyAppId: appId, + path: 'dummy-path', + }); + + expect(backend).toBeDefined(); + expect(cfnInclude_mock).toBeDefined(); + expect(backend.rootStack).toBeDefined(); + expect(backend.rootStack.stackName).toContain('testenv'); + const customBackend = backend.rootStack.node.tryFindChild('CreateBackendEnvironment'); + expect(customBackend).toBeDefined(); + }); + + test('Environment name must conform', () => { + const app = new App(); + function makeBadlyNamedBackend() { + new AmplifyExportedBackend(app, 'test-construct', { + path: 'dummy-path', + amplifyEnvironment: 'testEnv', + }) + } + + expect(makeBadlyNamedBackend) + .toThrowError( + "param 'amplifyEnvironment' must be between 2 and 10 characters, and lowercase only. Got 'testEnv'." + ); + }) });