diff --git a/packages/@aws-cdk/aws-amplify/README.md b/packages/@aws-cdk/aws-amplify/README.md index a706cb73bb39b..55060dacf1604 100644 --- a/packages/@aws-cdk/aws-amplify/README.md +++ b/packages/@aws-cdk/aws-amplify/README.md @@ -178,3 +178,19 @@ const amplifyApp = new amplify.App(this, 'MyApp', { autoBranchDeletion: true, // Automatically disconnect a branch when you delete a branch from your repository }); ``` + +## Deploying Assets + +`sourceCodeProvider` is optional; when this is not specified the Amplify app can be deployed to using `.zip` packages. The `AmplifyAssetDeployment` construct can be used to deploy S3 assets to Amplify as part of the CDK: + +```ts +const asset = new assets.Asset(this, "SampleAsset", {}); +const amplifyApp = new amplify.App(this, 'MyApp', {}); +const branch = amplifyApp.addBranch("dev"); +new AmplifyAssetDeployment(this, "AmplifyAssetDeployment", { + app: amplifyApp, + branch: branch, + s3BucketName: asset.s3BucketName, + s3ObjectKey: asset.s3ObjectKey, +}); +``` diff --git a/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/common.ts b/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/common.ts new file mode 100644 index 0000000000000..d46f71172efd5 --- /dev/null +++ b/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/common.ts @@ -0,0 +1,69 @@ +import { + IsCompleteResponse, + OnEventResponse, +} from '@aws-cdk/custom-resources/lib/provider-framework/types'; + +export interface AmplifyJobId { + /** + * If this field is included in an event passed to "IsComplete", it means we + * initiated an Amplify deployment that should be monitored using + * amplify:GetJob + */ + AmplifyJobId?: string; +} + +export type ResourceEvent = AWSLambda.CloudFormationCustomResourceEvent & AmplifyJobId; + +export abstract class ResourceHandler { + protected readonly requestId: string; + protected readonly logicalResourceId: string; + protected readonly requestType: 'Create' | 'Update' | 'Delete'; + protected readonly physicalResourceId?: string; + protected readonly event: ResourceEvent; + + constructor(event: ResourceEvent) { + this.requestType = event.RequestType; + this.requestId = event.RequestId; + this.logicalResourceId = event.LogicalResourceId; + this.physicalResourceId = (event as any).PhysicalResourceId; + this.event = event; + } + + public onEvent() { + switch (this.requestType) { + case 'Create': + return this.onCreate(); + case 'Update': + return this.onUpdate(); + case 'Delete': + return this.onDelete(); + } + + throw new Error(`Invalid request type ${this.requestType}`); + } + + public isComplete() { + switch (this.requestType) { + case 'Create': + return this.isCreateComplete(); + case 'Update': + return this.isUpdateComplete(); + case 'Delete': + return this.isDeleteComplete(); + } + + throw new Error(`Invalid request type ${this.requestType}`); + } + + protected log(x: any) { + // eslint-disable-next-line no-console + console.log(JSON.stringify(x, undefined, 2)); + } + + protected abstract async onCreate(): Promise; + protected abstract async onDelete(): Promise; + protected abstract async onUpdate(): Promise<(OnEventResponse & AmplifyJobId) | void>; + protected abstract async isCreateComplete(): Promise; + protected abstract async isDeleteComplete(): Promise; + protected abstract async isUpdateComplete(): Promise; +} diff --git a/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/handler.ts b/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/handler.ts new file mode 100644 index 0000000000000..574a801077da1 --- /dev/null +++ b/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/handler.ts @@ -0,0 +1,139 @@ +import { + IsCompleteResponse, + OnEventResponse, +} from '@aws-cdk/custom-resources/lib/provider-framework/types'; +// aws-sdk available at runtime for lambdas +// eslint-disable-next-line import/no-extraneous-dependencies +import { Amplify, S3 } from 'aws-sdk'; +import { ResourceEvent, ResourceHandler } from './common'; + +export interface AmplifyAssetDeploymentProps { + AppId: string; + BranchName: string; + S3BucketName: string; + S3ObjectKey: string; + TimeoutSeconds: number; +} + +export class AmplifyAssetDeploymentHandler extends ResourceHandler { + private readonly props: AmplifyAssetDeploymentProps; + protected readonly amplify: Amplify; + protected readonly s3: S3; + + constructor(amplify: Amplify, s3: S3, event: ResourceEvent) { + super(event); + + this.props = parseProps(this.event.ResourceProperties); + this.amplify = amplify; + this.s3 = s3; + } + + // ------ + // CREATE + // ------ + + protected async onCreate(): Promise { + // eslint-disable-next-line no-console + console.log('deploying to Amplify with options:', JSON.stringify(this.props, undefined, 2)); + + // Verify no jobs are currently running. + const jobs = await this.amplify + .listJobs({ + appId: this.props.AppId, + branchName: this.props.BranchName, + maxResults: 1, + }) + .promise(); + + if ( + jobs.jobSummaries && + jobs.jobSummaries.length > 0 && + jobs.jobSummaries[0].status == 'PENDING' + ) { + return Promise.reject('Amplify job already running. Aborting deployment.'); + } + + // Create a pre-signed get URL of the asset so Amplify can retrieve it. + const assetUrl = this.s3.getSignedUrl('getObject', { + Bucket: this.props.S3BucketName, + Key: this.props.S3ObjectKey, + }); + + // Deploy the asset to Amplify. + const deployment = await this.amplify + .startDeployment({ + appId: this.props.AppId, + branchName: this.props.BranchName, + sourceUrl: assetUrl, + }) + .promise(); + + return { + AmplifyJobId: deployment.jobSummary.jobId, + }; + } + + protected async isCreateComplete() { + return this.isActive(this.event.AmplifyJobId); + } + + // ------ + // DELETE + // ------ + + protected async onDelete(): Promise { + // We can't delete this resource as it's a deployment. + return {}; + } + + protected async isDeleteComplete(): Promise { + // We can't delete this resource as it's a deployment. + return { + IsComplete: true, + }; + } + + // ------ + // UPDATE + // ------ + + protected async onUpdate() { + return this.onCreate(); + } + + protected async isUpdateComplete() { + return this.isActive(this.event.AmplifyJobId); + } + + private async isActive(jobId?: string): Promise { + if (!jobId) { + throw new Error('Unable to determine Amplify job status without job id'); + } + + const job = await this.amplify + .getJob({ + appId: this.props.AppId, + branchName: this.props.BranchName, + jobId: jobId, + }) + .promise(); + + if (job.job.summary.status === 'SUCCEED') { + return { + IsComplete: true, + Data: { + JobId: jobId, + Status: job.job.summary.status, + }, + }; + } else { + return { + IsComplete: false, + }; + } + } +} + +function parseProps(props: any): AmplifyAssetDeploymentProps { + return props; +} diff --git a/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/index.ts b/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/index.ts new file mode 100644 index 0000000000000..30ef1acdce0ae --- /dev/null +++ b/packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/index.ts @@ -0,0 +1,34 @@ +import { IsCompleteResponse } from '@aws-cdk/custom-resources/lib/provider-framework/types'; +// aws-sdk available at runtime for lambdas +// eslint-disable-next-line import/no-extraneous-dependencies +import { Amplify, S3, config } from 'aws-sdk'; +import { ResourceEvent } from './common'; +import { AmplifyAssetDeploymentHandler } from './handler'; + +const AMPLIFY_ASSET_DEPLOYMENT_RESOURCE_TYPE = 'Custom::AmplifyAssetDeployment'; + +config.logger = console; + +const amplify = new Amplify(); +const s3 = new S3(); + +export async function onEvent(event: ResourceEvent) { + const provider = createResourceHandler(event); + return provider.onEvent(); +} + +export async function isComplete( + event: ResourceEvent, +): Promise { + const provider = createResourceHandler(event); + return provider.isComplete(); +} + +function createResourceHandler(event: ResourceEvent) { + switch (event.ResourceType) { + case AMPLIFY_ASSET_DEPLOYMENT_RESOURCE_TYPE: + return new AmplifyAssetDeploymentHandler(amplify, s3, event); + default: + throw new Error(`Unsupported resource type "${event.ResourceType}"`); + } +} diff --git a/packages/@aws-cdk/aws-amplify/lib/asset-deployment.ts b/packages/@aws-cdk/aws-amplify/lib/asset-deployment.ts new file mode 100644 index 0000000000000..aa5a3f8800256 --- /dev/null +++ b/packages/@aws-cdk/aws-amplify/lib/asset-deployment.ts @@ -0,0 +1,140 @@ +import * as path from 'path'; +import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { NodejsFunction } from '@aws-cdk/aws-lambda-nodejs'; +import { CustomResource, Duration, NestedStack, Stack } from '@aws-cdk/core'; + +import { Provider } from '@aws-cdk/custom-resources'; + +import { Construct } from 'constructs'; +import { IApp } from './app'; +import { IBranch } from './branch'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Properties for AmplifyAssetDeployment + */ +export interface AmplifyAssetDeploymentProps { + /** + * The Amplify app to deploy to. + */ + readonly app: IApp; + + /** + * The Amplify branch to deploy to. + */ + readonly branch: IBranch; + + /** + * The s3 bucket of the asset. + */ + readonly s3BucketName: string; + + /** + * The s3 object key of the asset. + */ + readonly s3ObjectKey: string; +} + +/** + * Allows deployment of S3 assets to Amplify via a custom resource. + * + * The Amplify app must not have a sourceCodeProvider configured as this resource uses Amplify's + * startDeployment API to initiate and deploy a S3 asset onto the App. + */ +export class AmplifyAssetDeployment extends CoreConstruct { + constructor( + scope: Construct, + id: string, + props: AmplifyAssetDeploymentProps, + ) { + super(scope, id); + + new CustomResource(this, 'Resource', { + serviceToken: AmplifyAssetDeploymentProvider.getOrCreate(this), + resourceType: 'Custom::AmplifyAssetDeployment', + properties: { + AppId: props.app.appId, + BranchName: props.branch.branchName, + S3ObjectKey: props.s3ObjectKey, + S3BucketName: props.s3BucketName, + }, + }); + } +} + +class AmplifyAssetDeploymentProvider extends NestedStack { + /** + * Returns the singleton provider. + */ + public static getOrCreate(scope: Construct) { + const providerId = + 'com.amazonaws.cdk.custom-resources.amplify-asset-deployment-provider'; + const stack = Stack.of(scope); + const group = + (stack.node.tryFindChild(providerId) as AmplifyAssetDeploymentProvider) ?? new AmplifyAssetDeploymentProvider(stack, providerId); + return group.provider.serviceToken; + } + + private readonly provider: Provider; + + constructor(scope: Construct, id: string) { + super(scope, id); + + const onEvent = new NodejsFunction( + this, + 'amplify-asset-deployment-on-event', + { + entry: path.join( + __dirname, + 'asset-deployment-handler/index.ts', + ), + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'onEvent', + initialPolicy: [ + new iam.PolicyStatement({ + resources: ['*'], + actions: [ + 's3:GetObject', + 's3:GetSignedUrl', + 'amplify:ListJobs', + 'amplify:StartDeployment', + ], + }), + ], + }, + ); + + const isComplete = new NodejsFunction( + this, + 'amplify-asset-deployment-is-complete', + { + entry: path.join( + __dirname, + 'asset-deployment-handler/index.ts', + ), + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'isComplete', + initialPolicy: [ + new iam.PolicyStatement({ + resources: ['*'], + actions: ['amplify:GetJob*'], + }), + ], + }, + ); + + this.provider = new Provider( + this, + 'amplify-asset-deployment-handler-provider', + { + onEventHandler: onEvent, + isCompleteHandler: isComplete, + totalTimeout: Duration.minutes(5), + }, + ); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-amplify/lib/index.ts b/packages/@aws-cdk/aws-amplify/lib/index.ts index 5a89be4af844c..eda383d8578e8 100644 --- a/packages/@aws-cdk/aws-amplify/lib/index.ts +++ b/packages/@aws-cdk/aws-amplify/lib/index.ts @@ -3,6 +3,7 @@ export * from './branch'; export * from './domain'; export * from './basic-auth'; export * from './source-code-providers'; +export * from './asset-deployment'; // AWS::Amplify CloudFormation Resources: export * from './amplify.generated'; diff --git a/packages/@aws-cdk/aws-amplify/package.json b/packages/@aws-cdk/aws-amplify/package.json index 13bbcc0ec61d4..151567d696223 100644 --- a/packages/@aws-cdk/aws-amplify/package.json +++ b/packages/@aws-cdk/aws-amplify/package.json @@ -79,15 +79,19 @@ "@aws-cdk/cdk-integ-tools": "0.0.0", "@aws-cdk/cfn2ts": "0.0.0", "@aws-cdk/pkglint": "0.0.0", - "@types/jest": "^26.0.24" + "@types/jest": "^26.0.24", + "aws-sdk": "^2.848.0" }, "dependencies": { "@aws-cdk/aws-codebuild": "0.0.0", "@aws-cdk/aws-codecommit": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-lambda-nodejs": "0.0.0", "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/core": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "constructs": "^3.3.69" }, "peerDependencies": { @@ -95,8 +99,11 @@ "@aws-cdk/aws-codecommit": "0.0.0", "@aws-cdk/aws-iam": "0.0.0", "@aws-cdk/aws-kms": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-lambda-nodejs": "0.0.0", "@aws-cdk/aws-secretsmanager": "0.0.0", "@aws-cdk/core": "0.0.0", + "@aws-cdk/custom-resources": "0.0.0", "constructs": "^3.3.69" }, "engines": { diff --git a/packages/@aws-cdk/aws-amplify/test/asset-deployment-handler/index.test.ts b/packages/@aws-cdk/aws-amplify/test/asset-deployment-handler/index.test.ts new file mode 100644 index 0000000000000..2f76dfed32e6c --- /dev/null +++ b/packages/@aws-cdk/aws-amplify/test/asset-deployment-handler/index.test.ts @@ -0,0 +1,631 @@ +/* eslint-disable jest/no-try-expect */ +const getSignedUrlResponse = jest.fn(); +const mockS3 = { + getSignedUrl: getSignedUrlResponse, +}; +const listJobsResponse = jest.fn(); +const listJobsRequest = jest.fn().mockImplementation(() => { + return { + promise: listJobsResponse, + }; +}); +const startDeploymentResponse = jest.fn(); +const startDeploymentRequest = jest.fn().mockImplementation(() => { + return { + promise: startDeploymentResponse, + }; +}); +const getJobResponse = jest.fn(); +const getJobRequest = jest.fn().mockImplementation(() => { + return { + promise: getJobResponse, + }; +}); +const mockAmplify = { + listJobs: listJobsRequest, + startDeployment: startDeploymentRequest, + getJob: getJobRequest, +}; + +jest.mock('aws-sdk', () => { + return { + S3: jest.fn(() => mockS3), + Amplify: jest.fn(() => mockAmplify), + config: { logger: '' }, + }; +}); + +import { + onEvent, + isComplete, +} from '../../lib/asset-deployment-handler'; + +describe('handler', () => { + + let oldConsoleLog: any; + + beforeAll(() => { + oldConsoleLog = global.console.log; + global.console.log = jest.fn(); + }); + + afterAll(() => { + global.console.log = oldConsoleLog; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('onEvent CREATE success', async () => { + // GIVEN + listJobsResponse.mockImplementation(() => { + return { + jobSummaries: [], + }; + }); + getSignedUrlResponse.mockImplementation(() => { + return 'signedUrlValue'; + }); + startDeploymentResponse.mockImplementation(() => { + return { + jobSummary: { jobId: 'jobIdValue' }, + }; + }); + + // WHEN + const response = await onEvent({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Create', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + }); + + // THEN + expect(response).toEqual({ + AmplifyJobId: 'jobIdValue', + }); + + expect(listJobsRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + maxResults: 1, + }); + expect(listJobsResponse).toBeCalled(); + expect(getSignedUrlResponse).toHaveBeenCalledWith('getObject', { + Bucket: 's3BucketNameValue', + Key: 's3ObjectKeyValue', + }); + expect(startDeploymentRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + sourceUrl: 'signedUrlValue', + }); + expect(startDeploymentResponse).toBeCalled(); + }); + + it('onEvent CREATE pending job', async () => { + // GIVEN + listJobsResponse.mockImplementation(() => { + return { + jobSummaries: [{ status: 'PENDING' }], + }; + }); + + // WHEN + try { + await onEvent({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Create', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + }); + } catch (e) { + // THEN + expect(e).toEqual('Amplify job already running. Aborting deployment.'); + + expect(listJobsRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + maxResults: 1, + }); + expect(listJobsResponse).toBeCalled(); + expect(getSignedUrlResponse).not.toHaveBeenCalled(); + expect(startDeploymentRequest).not.toHaveBeenCalled(); + expect(startDeploymentResponse).not.toHaveBeenCalled(); + } + }); + + it('isComplete CREATE success', async () => { + // GIVEN + getJobResponse.mockImplementation(() => { + return { + job: { summary: { status: 'SUCCEED' } }, + }; + }); + + // WHEN + const response = await isComplete({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Create', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + AmplifyJobId: 'amplifyJobIdValue', + }); + + // THEN + expect(response).toEqual({ + Data: { + JobId: 'amplifyJobIdValue', + Status: 'SUCCEED', + }, + IsComplete: true, + }); + + expect(getJobRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + jobId: 'amplifyJobIdValue', + }); + expect(getJobResponse).toBeCalled(); + }); + + it('isComplete CREATE pending', async () => { + // GIVEN + getJobResponse.mockImplementation(() => { + return { + job: { summary: { status: 'PENDING' } }, + }; + }); + + // WHEN + const response = await isComplete({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Create', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + AmplifyJobId: 'amplifyJobIdValue', + }); + + // THEN + expect(response).toEqual({ + IsComplete: false, + }); + + expect(getJobRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + jobId: 'amplifyJobIdValue', + }); + expect(getJobResponse).toBeCalled(); + }); + + it('isComplete CREATE no JobId', async () => { + // GIVEN + getJobResponse.mockImplementation(() => { + return { + job: { summary: { status: 'PENDING' } }, + }; + }); + + // WHEN + try { + await isComplete({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Create', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + }); + } catch (e) { + // THEN + expect(e).toBeInstanceOf(Error); + expect((e as Error).message).toEqual( + 'Unable to determine Amplify job status without job id', + ); + expect(getJobRequest).not.toHaveBeenCalled(); + expect(getJobResponse).not.toHaveBeenCalled(); + } + }); + + it('onEvent UPDATE success', async () => { + // GIVEN + listJobsResponse.mockImplementation(() => { + return { + jobSummaries: [], + }; + }); + getSignedUrlResponse.mockImplementation(() => { + return 'signedUrlValue'; + }); + startDeploymentResponse.mockImplementation(() => { + return { + jobSummary: { jobId: 'jobIdValue' }, + }; + }); + + // WHEN + const response = await onEvent({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Update', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + OldResourceProperties: { ServiceToken: 'serviceTokenValue' }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + + // THEN + expect(response).toEqual({ + AmplifyJobId: 'jobIdValue', + }); + + expect(listJobsRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + maxResults: 1, + }); + expect(listJobsResponse).toBeCalled(); + expect(getSignedUrlResponse).toHaveBeenCalledWith('getObject', { + Bucket: 's3BucketNameValue', + Key: 's3ObjectKeyValue', + }); + expect(startDeploymentRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + sourceUrl: 'signedUrlValue', + }); + expect(startDeploymentResponse).toBeCalled(); + }); + + it('onEvent UPDATE pending job', async () => { + // GIVEN + listJobsResponse.mockImplementation(() => { + return { + jobSummaries: [{ status: 'PENDING' }], + }; + }); + + // WHEN + try { + await onEvent({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Update', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + OldResourceProperties: { ServiceToken: 'serviceTokenValue' }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + } catch (e) { + // THEN + expect(e).toEqual('Amplify job already running. Aborting deployment.'); + + expect(listJobsRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + maxResults: 1, + }); + expect(listJobsResponse).toBeCalled(); + expect(getSignedUrlResponse).not.toHaveBeenCalled(); + expect(startDeploymentRequest).not.toHaveBeenCalled(); + expect(startDeploymentResponse).not.toHaveBeenCalled(); + } + }); + + it('isComplete UPDATE success', async () => { + // GIVEN + getJobResponse.mockImplementation(() => { + return { + job: { summary: { status: 'SUCCEED' } }, + }; + }); + + // WHEN + const response = await isComplete({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Update', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + OldResourceProperties: {}, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + AmplifyJobId: 'amplifyJobIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + + // THEN + expect(response).toEqual({ + Data: { + JobId: 'amplifyJobIdValue', + Status: 'SUCCEED', + }, + IsComplete: true, + }); + + expect(getJobRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + jobId: 'amplifyJobIdValue', + }); + expect(getJobResponse).toBeCalled(); + }); + + it('isComplete UPDATE pending', async () => { + // GIVEN + getJobResponse.mockImplementation(() => { + return { + job: { summary: { status: 'PENDING' } }, + }; + }); + + // WHEN + const response = await isComplete({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Update', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + OldResourceProperties: {}, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + AmplifyJobId: 'amplifyJobIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + + // THEN + expect(response).toEqual({ + IsComplete: false, + }); + + expect(getJobRequest).toHaveBeenCalledWith({ + appId: 'appIdValue', + branchName: 'branchNameValue', + jobId: 'amplifyJobIdValue', + }); + expect(getJobResponse).toBeCalled(); + }); + + it('isComplete UPDATE no JobId', async () => { + // GIVEN + getJobResponse.mockImplementation(() => { + return { + job: { summary: { status: 'PENDING' } }, + }; + }); + + // WHEN + try { + await isComplete({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Update', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + OldResourceProperties: {}, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + } catch (e) { + // THEN + expect(e).toBeInstanceOf(Error); + expect((e as Error).message).toEqual( + 'Unable to determine Amplify job status without job id', + ); + expect(getJobRequest).not.toHaveBeenCalled(); + expect(getJobResponse).not.toHaveBeenCalled(); + } + }); + + it('onEvent DELETE success', async () => { + // GIVEN + + // WHEN + const response = await onEvent({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Delete', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + + // THEN + expect(response).toEqual({}); + }); + + it('isComplete DELETE success', async () => { + // GIVEN + + // WHEN + const response = await isComplete({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Delete', + ResourceType: 'Custom::AmplifyAssetDeployment', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + + // THEN + expect(response).toEqual({ + IsComplete: true, + }); + }); + + it('onEvent unsupported resource type', async () => { + // GIVEN + + // WHEN + try { + await onEvent({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Update', + ResourceType: 'Custom::BadResourceType', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + OldResourceProperties: {}, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + } catch (e) { + // THEN + expect(e).toBeInstanceOf(Error); + expect((e as Error).message).toEqual( + 'Unsupported resource type "Custom::BadResourceType"', + ); + expect(getJobRequest).not.toHaveBeenCalled(); + expect(getJobResponse).not.toHaveBeenCalled(); + } + }); + + it('isComplete unsupported resource type', async () => { + // GIVEN + + // WHEN + try { + await isComplete({ + ServiceToken: 'serviceTokenValue', + RequestType: 'Update', + ResourceType: 'Custom::BadResourceType', + ResourceProperties: { + ServiceToken: 'serviceTokenValue', + AppId: 'appIdValue', + BranchName: 'branchNameValue', + S3BucketName: 's3BucketNameValue', + S3ObjectKey: 's3ObjectKeyValue', + }, + OldResourceProperties: {}, + ResponseURL: 'responseUrlValue', + StackId: 'stackIdValue', + RequestId: 'requestIdValue', + LogicalResourceId: 'logicalResourceIdValue', + PhysicalResourceId: 'physicalResourceIdValue', + }); + } catch (e) { + // THEN + expect(e).toBeInstanceOf(Error); + expect((e as Error).message).toEqual( + 'Unsupported resource type "Custom::BadResourceType"', + ); + expect(getJobRequest).not.toHaveBeenCalled(); + expect(getJobResponse).not.toHaveBeenCalled(); + } + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-amplify/test/asset-deployment.test.ts b/packages/@aws-cdk/aws-amplify/test/asset-deployment.test.ts new file mode 100644 index 0000000000000..63dae2ddfeffd --- /dev/null +++ b/packages/@aws-cdk/aws-amplify/test/asset-deployment.test.ts @@ -0,0 +1,53 @@ +import { Template } from '@aws-cdk/assertions'; +import { SecretValue, Stack } from '@aws-cdk/core'; +import { App, GitHubSourceCodeProvider, IBranch } from '../lib'; +import { AmplifyAssetDeployment } from '../lib/asset-deployment'; + +let stack: Stack; +let app: App; +let branch: IBranch; +beforeEach(() => { + stack = new Stack(); + app = new App(stack, 'App', { + sourceCodeProvider: new GitHubSourceCodeProvider({ + owner: 'aws', + repository: 'aws-cdk', + oauthToken: SecretValue.plainText('secret'), + }), + }); + branch = app.addBranch('master'); +}); + +test('Creates custom resource', () => { + // WHEN + new AmplifyAssetDeployment(stack, 'Test', { + app: app, + branch: branch, + s3ObjectKey: 's3ObjectKeyValue', + s3BucketName: 's3BucketNameValue', + }); + + // THEN + Template.fromStack(stack).hasResourceProperties('Custom::AmplifyAssetDeployment', { + ServiceToken: { + 'Fn::GetAtt': [ + 'comamazonawscdkcustomresourcesamplifyassetdeploymentproviderNestedStackcomamazonawscdkcustomresourcesamplifyassetdeploymentproviderNestedStackResource89BDFEB2', + 'Outputs.comamazonawscdkcustomresourcesamplifyassetdeploymentprovideramplifyassetdeploymenthandlerproviderframeworkonEventA449D9A9Arn', + ], + }, + AppId: { + 'Fn::GetAtt': [ + 'AppF1B96344', + 'AppId', + ], + }, + BranchName: { + 'Fn::GetAtt': [ + 'Appmaster71597E87', + 'BranchName', + ], + }, + S3ObjectKey: 's3ObjectKeyValue', + S3BucketName: 's3BucketNameValue', + }); +}); \ No newline at end of file