Skip to content

Commit

Permalink
feat(amplify): Add Amplify asset deployment resource (#16922)
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


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
samkio authored Dec 14, 2021
1 parent c633529 commit 499ba85
Show file tree
Hide file tree
Showing 11 changed files with 1,429 additions and 2 deletions.
10 changes: 10 additions & 0 deletions packages/@aws-cdk/aws-amplify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,13 @@ const amplifyApp = new amplify.App(stack, 'App', {
],
});
```

## Deploying Assets

`sourceCodeProvider` is optional; when this is not specified the Amplify app can be deployed to using `.zip` packages. The `asset` property 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", { asset: asset });
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 interface IsCompleteResponse {
/**
* Indicates if the resource operation is complete or should we retry.
*/
readonly IsComplete: boolean;

/**
* Additional/changes to resource attributes.
*/
readonly Data?: { [name: string]: any };
};

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<AmplifyJobId>;
protected abstract async onDelete(): Promise<void>;
protected abstract async onUpdate(): Promise<AmplifyJobId>;
protected abstract async isCreateComplete(): Promise<IsCompleteResponse>;
protected abstract async isDeleteComplete(): Promise<IsCompleteResponse>;
protected abstract async isUpdateComplete(): Promise<IsCompleteResponse>;
}
136 changes: 136 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,136 @@
// aws-sdk available at runtime for lambdas
// eslint-disable-next-line import/no-extraneous-dependencies
import { Amplify, S3 } from 'aws-sdk';
import { AmplifyJobId, IsCompleteResponse, 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<AmplifyJobId> {
// 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.find(summary => summary.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<void> {
// We can't delete this resource as it's a deployment.
return;
}

protected async isDeleteComplete(): Promise<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<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,35 @@
// eslint-disable-next-line import/no-extraneous-dependencies
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}"`);
}
}
112 changes: 111 additions & 1 deletion packages/@aws-cdk/aws-amplify/lib/branch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import * as path from 'path';
import * as codebuild from '@aws-cdk/aws-codebuild';
import { IResource, Lazy, Resource } from '@aws-cdk/core';
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 { Asset } from '@aws-cdk/aws-s3-assets';
import {
CustomResource,
IResource,
Lazy,
Resource,
Duration,
NestedStack,
Stack,
} from '@aws-cdk/core';
import { Provider } from '@aws-cdk/custom-resources';
import { Construct } from 'constructs';
import { CfnBranch } from './amplify.generated';
import { IApp } from './app';
Expand Down Expand Up @@ -90,6 +104,16 @@ export interface BranchOptions {
* @default - no stage
*/
readonly stage?: string;

/**
* Asset for deployment.
*
* 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.
*
* @default - no asset
*/
readonly asset?: Asset
}

/**
Expand Down Expand Up @@ -148,6 +172,19 @@ export class Branch extends Resource implements IBranch {

this.arn = branch.attrArn;
this.branchName = branch.attrBranchName;

if (props.asset) {
new CustomResource(this, 'DeploymentResource', {
serviceToken: AmplifyAssetDeploymentProvider.getOrCreate(this),
resourceType: 'Custom::AmplifyAssetDeployment',
properties: {
AppId: props.app.appId,
BranchName: branchName,
S3ObjectKey: props.asset.s3ObjectKey,
S3BucketName: props.asset.s3BucketName,
},
});
}
}

/**
Expand All @@ -161,3 +198,76 @@ export class Branch extends Resource implements IBranch {
return this;
}
}

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 499ba85

Please sign in to comment.