Skip to content

Commit

Permalink
feat(amplify): Add Amplify asset deployment resource
Browse files Browse the repository at this point in the history
This change adds a custom resource that allows users
to publish S3 assets to AWS Amplify.

fixes #16208
  • Loading branch information
samkio committed Oct 13, 2021
1 parent 7c73880 commit 03a7ebe
Show file tree
Hide file tree
Showing 12 changed files with 1,516 additions and 1 deletion.
16 changes: 16 additions & 0 deletions packages/@aws-cdk/aws-amplify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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<AWSCDKAsyncCustomResource.OnEventResponse>;
protected abstract async onDelete(): Promise<AWSCDKAsyncCustomResource.OnEventResponse | void>;
protected abstract async onUpdate(): Promise<(AWSCDKAsyncCustomResource.OnEventResponse & AmplifyJobId) | void>;
protected abstract async isCreateComplete(): Promise<AWSCDKAsyncCustomResource.IsCompleteResponse>;
protected abstract async isDeleteComplete(): Promise<AWSCDKAsyncCustomResource.IsCompleteResponse>;
protected abstract async isUpdateComplete(): Promise<AWSCDKAsyncCustomResource.IsCompleteResponse>;
}
137 changes: 137 additions & 0 deletions packages/@aws-cdk/aws-amplify/lib/asset-deployment-handler/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// 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<AWSCDKAsyncCustomResource.OnEventResponse> {
// 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<AWSCDKAsyncCustomResource.OnEventResponse> {
// We can't delete this resource as it's a deployment.
return {};
}

protected async isDeleteComplete(): Promise<AWSCDKAsyncCustomResource.IsCompleteResponse> {
// 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<AWSCDKAsyncCustomResource.IsCompleteResponse> {
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,
},
};
} if (job.job.summary.status === 'FAILED' || job.job.summary.status === 'CANCELLED') {
throw new Error(`Amplify job failed with status: ${job.job.summary.status}`);
} else {
return {
IsComplete: false,
};
}
}
}

function parseProps(props: any): AmplifyAssetDeploymentProps {
return props;
}
Original file line number Diff line number Diff line change
@@ -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({ signatureVersion: 'v4' });

export async function onEvent(event: ResourceEvent) {
const provider = createResourceHandler(event);
return provider.onEvent();
}

export async function isComplete(
event: ResourceEvent,
): Promise<IsCompleteResponse> {
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}"`);
}
}
140 changes: 140 additions & 0 deletions packages/@aws-cdk/aws-amplify/lib/asset-deployment.ts
Original file line number Diff line number Diff line change
@@ -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),
},
);
}
}
Loading

0 comments on commit 03a7ebe

Please sign in to comment.