Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(amplify): Add Amplify asset deployment resource #16922

Merged
merged 12 commits into from
Dec 14, 2021
11 changes: 11 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,14 @@ 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 `addAssetDeployment` branch method 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");
branch.addAssetDeployment(asset);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I suggested this addAssetDeployment() but thinking more about this, can we add more than one asset deployment to a branch? I don't think so. So maybe it should be moved to a construction prop in Branch/option in addBranch()?

Copy link
Contributor Author

@samkio samkio Dec 14, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good; amended.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to update the README for the latest change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

```
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.');
MrArnoldPalmer marked this conversation as resolved.
Show resolved Hide resolved
}

// 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(
MrArnoldPalmer marked this conversation as resolved.
Show resolved Hide resolved
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