diff --git a/components/express-api/Cdk.js b/components/express-api/Cdk.js new file mode 100644 index 0000000..866787e --- /dev/null +++ b/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} 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} 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>} + */ + 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; diff --git a/components/express-api/serverless.js b/components/express-api/serverless.js new file mode 100644 index 0000000..6197307 --- /dev/null +++ b/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; diff --git a/package.json b/package.json index cf4af62..f97e9c5 100644 --- a/package.json +++ b/package.json @@ -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",