@swarmion/serverless-plugin
Version:
A plugin to safely deploy Serverless microservices.
384 lines (370 loc) • 13.3 kB
JavaScript
// src/plugin/utils/artifactDirectory.ts
var getTimestampFromArtifactDirectoryName = (artifactDirectoryName) => {
const [, , , timestamp] = artifactDirectoryName.split("/");
if (timestamp === void 0) {
throw new Error(
`${artifactDirectoryName} is not of the form 'serverless/{service}/{stage}/{timestamp}'`
);
}
return timestamp;
};
var buildPreviousDeploymentArtifactDirectoryName = (prefix, service, stage, previousTimestamp) => {
return [prefix, service, stage, previousTimestamp].join("/");
};
// src/plugin/utils/constants.ts
var COMPILED_CONTRACTS_FILE_NAME = "serverless-contracts.json";
var LATEST_DEPLOYED_TIMESTAMP_TAG_NAME = "LATEST_DEPLOYED_TIMESTAMP";
var CONTRACTS_VERSION = "1.0.0";
// src/plugin/utils/generateOpenApiDocumentation.ts
import { getOpenApiDocumentation } from "@swarmion/serverless-contracts";
var generateOpenApiDocumentation = (serverless) => {
const { provides } = (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
serverless.service.initialServerlessConfig.contracts
);
const openApiDocumentation = getOpenApiDocumentation({
title: serverless.service.getServiceName(),
description: serverless.service.initialServerlessConfig.resources.Description,
contracts: Object.values(provides)
});
console.log(JSON.stringify(openApiDocumentation, null, 2));
};
// src/plugin/utils/listLocalContractSchemas.ts
import isUndefined from "lodash/isUndefined.js";
import mapValues from "lodash/mapValues.js";
import omitBy from "lodash/omitBy.js";
import { getContractFullSchema } from "@swarmion/serverless-contracts";
var listLocalContractSchemas = (serverless) => {
const { provides, consumes } = (
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
serverless.service.initialServerlessConfig.contracts
);
return {
provides: omitBy(
mapValues(provides, (contract) => getContractFullSchema(contract)),
isUndefined
),
consumes: omitBy(
mapValues(consumes, (contract) => getContractFullSchema(contract)),
isUndefined
)
};
};
// src/plugin/utils/getLatestDeployedTimestamp.ts
var getLatestDeployedTimestamp = async (provider) => {
if (provider.naming.getStackName === void 0) {
throw new Error("Could not get stack name");
}
const stackName = provider.naming.getStackName();
try {
const { Stacks } = await provider.request(
"CloudFormation",
"describeStacks",
{
StackName: stackName
}
);
return Stacks !== void 0 ? Stacks[0]?.Tags?.find(
({ Key }) => Key === LATEST_DEPLOYED_TIMESTAMP_TAG_NAME
)?.Value : void 0;
} catch {
return void 0;
}
};
// src/plugin/utils/listRemoteContractSchemas.ts
var listRemoteContractSchemas = async (serverless) => {
const provider = serverless.getProvider("aws");
const latestDeployedTimestamp = await getLatestDeployedTimestamp(provider);
if (latestDeployedTimestamp === void 0) {
return;
}
const previousArtifactDirectoryName = buildPreviousDeploymentArtifactDirectoryName(
"serverless",
serverless.service.getServiceName(),
serverless.service.provider.stage,
latestDeployedTimestamp
);
const bucketName = await provider.getServerlessDeploymentBucketName();
const params = {
Bucket: bucketName,
Key: `${previousArtifactDirectoryName}/${COMPILED_CONTRACTS_FILE_NAME}`
};
const { Body: remoteContractsBuffer } = await provider.request(
"S3",
"getObject",
params
);
if (remoteContractsBuffer === void 0) {
return {
provides: {},
consumes: {},
gitCommit: "",
contractsVersion: CONTRACTS_VERSION
};
}
const contractSchemas = JSON.parse(
// eslint-disable-next-line @typescript-eslint/no-base-to-string
remoteContractsBuffer.toString()
);
return contractSchemas;
};
// src/plugin/utils/printContractSchemas.ts
var printContractSchemas = ({ provides, consumes }, contractSchemasLocation) => {
console.log(
`--- Serverless contract schemas for location ${contractSchemasLocation} ---`
);
console.log();
console.log("-- Provides --");
console.log(JSON.stringify(provides, null, 2));
console.log();
console.log("-- Consumes --");
console.log(JSON.stringify(consumes, null, 2));
};
// src/plugin/utils/uploadContractSchemas.ts
import crypto from "crypto";
import { simpleGit } from "simple-git";
var uploadContractSchemas = async (serverless, log) => {
if (serverless.service.provider.shouldNotDeploy) {
log.info("Service files not changed. Skipping contract schemas upload...");
}
const provider = serverless.getProvider("aws");
const bucketName = await provider.getServerlessDeploymentBucketName();
const artifactDirectoryName = serverless.service.package.artifactDirectoryName;
const contractSchemas = listLocalContractSchemas(serverless);
const git = simpleGit();
const gitCommit = await git.revparse("HEAD");
const contractSchemasToUpload = {
...contractSchemas,
gitCommit,
contractsVersion: CONTRACTS_VERSION
};
const fileHash = crypto.createHash("sha256").update(JSON.stringify(contractSchemasToUpload)).digest("base64");
const params = {
Bucket: bucketName,
Key: `${artifactDirectoryName}/${COMPILED_CONTRACTS_FILE_NAME}`,
Body: JSON.stringify(contractSchemasToUpload),
ContentType: "application/json",
Metadata: {
filesha256: fileHash
}
};
log.info("Uploading contract schemas file to S3...");
await provider.request("S3", "upload", params);
};
// src/plugin/utils/validateDeployment.ts
import JsonSchemaDiff from "json-schema-diff";
// src/types/deploymentTypes.ts
var DeploymentStrategies = /* @__PURE__ */ ((DeploymentStrategies2) => {
DeploymentStrategies2["PROVIDER_FIRST"] = "PROVIDER_FIRST";
DeploymentStrategies2["CONSUMER_FIRST"] = "CONSUMER_FIRST";
return DeploymentStrategies2;
})(DeploymentStrategies || {});
// src/plugin/utils/validateDeployment.ts
var validateDeployment = async (localContracts, remoteContracts, deploymentStrategy) => {
if (deploymentStrategy === "PROVIDER_FIRST" /* PROVIDER_FIRST */) {
await validateProviderFirstDeployment(localContracts, remoteContracts);
} else {
await validateConsumerFirstDeployment(localContracts, remoteContracts);
}
};
var validateProviderFirstDeployment = async (localContractSchemas, remoteContractSchemas) => {
const { provides: localProvides } = localContractSchemas;
const { provides: remoteProvides } = remoteContractSchemas;
await Promise.all(
Object.entries(remoteProvides).map(
async ([contractName, remoteContractSchema]) => {
const localContractSchema = localProvides[contractName];
if (localContractSchema === void 0) {
throw new Error(`Expected to find local contract: ${contractName}`);
}
const { removalsFound } = await JsonSchemaDiff.diffSchemas({
// @ts-ignore this is not well typed
sourceSchema: remoteContractSchema,
// @ts-ignore this is not well typed
destinationSchema: localContractSchema
});
if (removalsFound) {
throw new Error(
`Unexpected removal in provided contract schema: ${contractName}`
);
}
}
)
);
};
var validateConsumerFirstDeployment = async (localContractSchemas, remoteContractSchemas) => {
const { consumes: localConsumes } = localContractSchemas;
const { consumes: remoteConsumes } = remoteContractSchemas;
await Promise.all(
Object.entries(localConsumes).map(
async ([contractName, localContractSchema]) => {
const remoteContractSchema = remoteConsumes[contractName];
if (remoteContractSchema === void 0) {
throw new Error(`Expected to find remote contract: ${contractName}`);
}
const { additionsFound } = await JsonSchemaDiff.diffSchemas({
// @ts-ignore this is not well typed
sourceSchema: remoteContractSchema,
// @ts-ignore this is not well typed
destinationSchema: localContractSchema
});
if (additionsFound) {
throw new Error(
`Unexpected addition in consumed contract schema: ${contractName}`
);
}
}
)
);
};
// src/types/serviceOptions.ts
var serviceOptionsSchema = {
type: "object",
properties: {
provides: {
type: "object",
patternProperties: {
"^[a-zA-Z0-9]{1,255}$": {
type: "object",
properties: {}
}
}
},
consumes: {
type: "object",
patternProperties: {
"^[a-zA-Z0-9]{1,255}$": {
type: "object",
properties: {}
}
}
}
},
required: ["provides", "consumes"]
};
// src/plugin/serverlessContractsPlugin.ts
var ServerlessContractsPlugin = class {
cliOptions;
serverless;
hooks;
commands;
log;
constructor(serverless, cliOptions, { log }) {
this.cliOptions = cliOptions;
this.log = log;
if (this.cliOptions.strategy !== void 0 && !Object.values(DeploymentStrategies).includes(this.cliOptions.strategy)) {
throw new Error(
`Invalid deployment strategy. Choices are ${JSON.stringify(
Object.values(DeploymentStrategies)
)}`
);
}
this.serverless = serverless;
serverless.configSchemaHandler.defineTopLevelProperty(
"contracts",
serviceOptionsSchema
);
this.commands = {
localContracts: {
usage: "Show local Serverless contracts",
lifecycleEvents: ["run"]
},
remoteContracts: {
usage: "Show currently deployed Serverless contracts",
lifecycleEvents: ["run"]
},
safeDeploy: {
usage: "Deploy you service and specify the deployment strategy",
lifecycleEvents: ["run"],
options: {
// Define the '--strategy' option with the '-s' shortcut
strategy: {
usage: "Specify the deployment strategy",
shortcut: "s",
required: true,
// @ts-ignore mistype in @types/serverless
type: "string"
}
}
},
generateOpenApiDocumentation: {
usage: "Generate OpenAPI with local Serverless contracts provided by the service",
lifecycleEvents: ["run"]
}
};
this.hooks = {
"localContracts:run": this.printLocalServerlessContractSchemas.bind(this),
"remoteContracts:run": this.printRemoteServerlessContractSchemas.bind(this),
"safeDeploy:run": this.deployWithContractSchemasValidation.bind(this),
"before:deploy:deploy": this.validateDeployment.bind(this),
"before:package:finalize": this.tagStackWithTimestamp.bind(this),
"after:aws:deploy:deploy:uploadArtifacts": this.uploadContractSchemas.bind(this),
"generateOpenApiDocumentation:run": this.generateOpenApiDocumentation.bind(this)
};
}
/**
* This command is merely a wrapper around the `deploy` command from the serverless Framework,
* leveraging the use of the `--strategy` option.
* Therefore, while this option has been set in the constructor, all we need to to is
* launch the serverless framework deployment
*/
async deployWithContractSchemasValidation() {
await this.serverless.pluginManager.spawn("deploy");
}
listLocalContractSchemas() {
return listLocalContractSchemas(this.serverless);
}
printLocalServerlessContractSchemas() {
const contractSchemas = this.listLocalContractSchemas();
printContractSchemas(contractSchemas, "LOCAL" /* LOCAL */);
}
async printRemoteServerlessContractSchemas() {
const contractSchemas = await this.listRemoteContractSchemas();
if (contractSchemas === void 0) {
this.log.error("Unable to retrieve remote contract schemas");
return;
}
printContractSchemas(contractSchemas, "REMOTE" /* REMOTE */);
}
async listRemoteContractSchemas() {
return listRemoteContractSchemas(this.serverless);
}
tagStackWithTimestamp() {
const artifactDirectoryName = this.serverless.service.package.artifactDirectoryName;
const timestamp = getTimestampFromArtifactDirectoryName(
artifactDirectoryName
);
this.serverless.service.provider.stackTags = {
...this.serverless.service.provider.stackTags,
[LATEST_DEPLOYED_TIMESTAMP_TAG_NAME]: timestamp
};
}
async uploadContractSchemas() {
await uploadContractSchemas(this.serverless, this.log);
}
async validateDeployment() {
const localContractSchemas = listLocalContractSchemas(this.serverless);
const remoteContractSchemas = await listRemoteContractSchemas(
this.serverless
);
if (remoteContractSchemas === void 0) {
this.log.warning(
"Contracts: Unable to retrieve remote contract schemas, deployment is unsafe"
);
return;
}
if (this.cliOptions.strategy !== void 0) {
this.log.info("Validating contract schemas...");
await validateDeployment(
localContractSchemas,
remoteContractSchemas,
this.cliOptions.strategy
);
}
}
generateOpenApiDocumentation() {
generateOpenApiDocumentation(this.serverless);
}
};
// src/index.ts
module.exports = ServerlessContractsPlugin;