Skip to content

Commit

Permalink
feat: Draft a CDK-based component
Browse files Browse the repository at this point in the history
  • Loading branch information
mnapoli committed Mar 22, 2022
1 parent ff7431e commit f5f0ae4
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 0 deletions.
181 changes: 181 additions & 0 deletions components/express-api/Cdk.js
@@ -0,0 +1,181 @@
const {Mode} = require('aws-cdk/lib/api');
const crypto = require('crypto');
const {App} = require('aws-cdk-lib');
const { Bootstrapper } = require("aws-cdk/lib/api/bootstrap");
const { SdkProvider } = require("aws-cdk/lib/api/aws-auth/sdk-provider");
const { CloudFormationDeployments } = require("aws-cdk/lib/api/cloudformation-deployments");
const {CloudFormationClient, DescribeStacksCommand} = require('@aws-sdk/client-cloudformation');

// Silence CDK output
const logging = require("aws-cdk/lib/logging");
// @ts-ignore
logging.print = function() {};
// @ts-ignore
logging.data = function() {};
// @ts-ignore
logging.warning = function() {};

class Cdk {
toolkitStackName = "serverless-cdk-toolkit";

/**
* @param {(string) => void} logVerbose
* @param {Record<string, any>} state
* @param {string} stackName
* @param {string} [region]
*/
constructor(logVerbose, state, stackName, region) {
this.logVerbose = logVerbose;
this.state = state;
this.stackName = stackName;
this.region = region;
}

/**
* @param {App} app
* @return {Promise<boolean>} Whether changes were deployed.
*/
async deploy(app) {
this.logVerbose("Deploying the CloudFormation stack");

// @see https://github.com/aws/aws-cdk/blob/fa16f7a9c11981da75e44ffc83adcdc6edad94fc/packages/aws-cdk/lib/cli.ts#L257-L264
const sdkProvider = await SdkProvider.withAwsCliCompatibleDefaults();
const accountId = (await sdkProvider.defaultAccount())?.accountId;
if (accountId === undefined) {
throw new Error("No AWS account ID could be found via the AWS credentials");
}

await this.bootstrapCdk(sdkProvider, accountId);

this.logVerbose(`Deploying ${this.stackName}`);
const stackArtifact = app.synth().getStackByName(this.stackName);
const cloudFormationTemplateHash = crypto.createHash('md5').update(JSON.stringify(stackArtifact.template)).digest('hex');

if (this.state.cloudFormationTemplateHash === cloudFormationTemplateHash) {
this.logVerbose("Nothing to deploy, the stack is up to date");
return false;
}

const cloudFormation = new CloudFormationDeployments({ sdkProvider });
const deployResult = await cloudFormation.deployStack({
stack: stackArtifact,
toolkitStackName: "serverless-cdk-toolkit",
});

this.state.cloudFormationTemplateHash = cloudFormationTemplateHash;

if (deployResult.noOp) {
this.logVerbose('Nothing to deploy, the stack is up to date');
return false;
}
this.logVerbose('Deployment success');
}

/**
* @private
*/
async bootstrapCdk(sdkProvider, accountId) {
if (this.state.cdkBootstrapped) {
this.logVerbose("The CDK is already set up, moving on");
return;
}

// Setup the bootstrap stack
// Ideally we don't do that every time
this.logVerbose("Setting up the CDK");
const cdkBootstrapper = new Bootstrapper({
source: "default",
});
const bootstrapDeployResult = await cdkBootstrapper.bootstrapEnvironment(
{
account: accountId,
name: "dev",
region: "us-east-1",
},
sdkProvider,
{
/**
* We use a CDK toolkit stack dedicated to Serverless.
* The reason for this is:
* - to keep complete control over that stack
* - because there are multiple versions, we don't want to force
* one specific version on users
* (see https://docs.aws.amazon.com/cdk/latest/guide/bootstrapping.html#bootstrapping-templates)
*/
toolkitStackName: this.toolkitStackName,
/**
* In the same spirit as the custom stack name, we must provide
* a different "qualifier": this ID will be used in CloudFormation
* exports to provide a unique export name.
*/
parameters: {
qualifier: "serverless",
},
}
);
if (bootstrapDeployResult.noOp) {
this.logVerbose("The CDK is already set up, moving on");
}
this.state.cdkBootstrapped = true;
}

/**
* @return {Promise<Record<string, any>>}
*/
async getStackOutputs() {
this.logVerbose(`Fetching outputs of stack "${this.stackName}"`);

const cloudFormation = new CloudFormationClient(await this.sdkConfig());
let data;
try {
data = await cloudFormation.send(new DescribeStacksCommand({
StackName: this.stackName,
}));
} catch (e) {
if (e instanceof Error && e.message === `Stack with id ${this.stackName} does not exist`) {
this.logVerbose(e.message);
return {};
}
throw e;
}
if (!data?.Stacks?.[0]?.Outputs) return {};

const outputs = {};
for (const item of data.Stacks[0].Outputs) {
const id = this.lowercaseFirstLetter(item.OutputKey);
outputs[id] = item.OutputValue;
}
return outputs;
}

lowercaseFirstLetter(string) {
return string.charAt(0).toLowerCase() + string.slice(1);
}

/**
* Public method that returns a SDK configuration (with credentials and all).
*/
async sdkConfig() {
// The CDK has a tool that creates a preconfigured SDK (SdkProvider)
// using credentials resolution compatible with the AWS CLI, and that
// supports the AssumeRole of the ToolkitStack.
// Not sure if we want to keep all of that, but for now let's use it.

// @see https://github.com/aws/aws-cdk/blob/fa16f7a9c11981da75e44ffc83adcdc6edad94fc/packages/aws-cdk/lib/cli.ts#L257-L264
const sdkProvider = await SdkProvider.withAwsCliCompatibleDefaults();
const accountId = (await sdkProvider.defaultAccount())?.accountId;
if (accountId === undefined) {
throw new Error("No AWS account ID could be found via the AWS credentials");
}
const limitedCdkSdk = (await sdkProvider.forEnvironment({
account: accountId,
region: this.region,
}, Mode.ForReading)).sdk;

// TODO we need something better later
// @ts-ignore
return limitedCdkSdk.config;
}
}

module.exports = Cdk;
67 changes: 67 additions & 0 deletions components/express-api/serverless.js
@@ -0,0 +1,67 @@
'use strict';

require('fs-extra');
require('crypto');
const Component = require('../../src/Component');
require('../aws-cloudformation/serverless');
const CdkDeploy = require('./Cdk');
const path = require('path');
const {App, Stack} = require('aws-cdk-lib');

class ExpressApi extends Component {
/** @type {string|undefined} */
region;

constructor(id, context, inputs) {
super(id, context, inputs);

this.stackName = `${this.appName}-${this.id}-${this.stage}`;
this.region = this.inputs.region;
}

async deploy() {
this.startProgress('deploying');

// Load the CDK construct and turn it into a proper "CDK App"
const app = new App();
let ConstructClass
if (typeof this.inputs.construct === 'string') {
ConstructClass = require(path.join(process.cwd(), this.inputs.construct));
} else {
ConstructClass = this.inputs.construct;
}
if (ConstructClass.prototype instanceof Stack) {
new ConstructClass(app, this.stackName, this.inputs);
} else {
let stack = new Stack(app, this.stackName);
new ConstructClass(stack, 'Construct', this.inputs);
}

const cdk = new CdkDeploy(this.logVerbose, this.state, this.stackName, this.region);
const hasChanges = await cdk.deploy(app);

if (hasChanges) {
// Save updated state
await this.save();
await this.updateOutputs(await cdk.getStackOutputs());
this.successProgress('deployed');
} else {
this.successProgress('no changes');
}
}

async remove() {
this.startProgress('removing');

const cdk = new CdkDeploy(this.logVerbose, this.state, this.stackName, this.region);
await cdk.remove();

this.state = {};
await this.save();
await this.updateOutputs({});

this.successProgress('removed');
}
}

module.exports = ExpressApi;
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -21,9 +21,12 @@
"dependencies": {
"@aws-sdk/client-cloudformation": "^3.54.1",
"@serverless/utils": "^6.0.3",
"aws-cdk": "^2.1.0",
"aws-cdk-lib": "^2.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.3.0",
"cli-cursor": "^3",
"constructs": "^10.0.77",
"fs-extra": "^10.0.1",
"globby": "^11.1.0",
"graphlib": "^2.1.8",
Expand Down

0 comments on commit f5f0ae4

Please sign in to comment.