Skip to content

Commit

Permalink
fix(cmd-api-server): plugins interfere with API server deps hyperledg…
Browse files Browse the repository at this point in the history
…er-cacti#1192

Migrates to the live-plugin-manager package to install plugins
instead of doing it via vanilla npm which was causing problems
with conflicting dependency versions where the API server would
want semver 7.x and one of the plugins (through some transient
dependency of the plugin itself) would install semver 5.x which
would then cause the API server to break down at runtime due to
the breaking changes between semver 7 and 5.

The hope with the new live-plugin-manager package is that using
this will provide sufficient isolation so that these kind of issues
are non-existent and also that it does not introduce other different
types of issues stemming from exactly said isolation. With that said
if isolation problems do occur we'll have to fix that anyway because
the plugins should not depend on the API server and vica versa.

Fixes hyperledger-cacti#1192

Depends on hyperledger-cacti#1203

Signed-off-by: Peter Somogyvari <peter.somogyvari@accenture.com>
  • Loading branch information
petermetz committed Aug 10, 2021
1 parent ab9c6d5 commit 30e74d2
Show file tree
Hide file tree
Showing 14 changed files with 292 additions and 506 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"@openapitools/openapi-generator-cli": "2.3.3",
"@types/fs-extra": "9.0.11",
"@types/jasminewd2": "2.0.10",
"@types/node": "15.14.7",
"@types/node-fetch": "2.5.4",
"@types/tape": "4.13.0",
"@types/tape-promise": "4.0.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/cactus-cmd-api-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ EXPOSE 3000 4000

USER $APP_USER

RUN npm i @hyperledger/cactus-cmd-api-server@${NPM_PKG_VERSION} --production
RUN npm i @elenaizaguirre/cactus-cmd-api-server@${NPM_PKG_VERSION}

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "node_modules/@hyperledger/cactus-cmd-api-server/dist/lib/main/typescript/cmd/cactus-api.js"]
CMD ["node", "node_modules/@elenaizaguirre/cactus-cmd-api-server/dist/lib/main/typescript/cmd/cactus-api.js"]
HEALTHCHECK --interval=5s --timeout=5s --start-period=1s --retries=30 CMD /healthcheck.sh
3 changes: 1 addition & 2 deletions packages/cactus-cmd-api-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@
"express-openapi-validator": "3.10.0",
"http-status-codes": "2.1.4",
"jose": "1.28.1",
"live-plugin-manager": "0.15.1",
"node-forge": "0.10.0",
"npm": "7.19.1",
"prom-client": "13.1.0",
"rxjs": "7.1.0",
"semver": "7.3.2",
Expand All @@ -111,7 +111,6 @@
"@types/jsonwebtoken": "8.5.1",
"@types/multer": "1.4.5",
"@types/node-forge": "0.9.3",
"@types/npm": "2.0.32",
"@types/passport": "1.0.6",
"@types/passport-oauth2": "1.4.10",
"@types/passport-saml": "1.1.2",
Expand Down
64 changes: 15 additions & 49 deletions packages/cactus-cmd-api-server/src/main/typescript/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { Server, createServer } from "http";
import type { Server as SecureServer } from "https";
import { createServer as createSecureServer } from "https";
import { gte } from "semver";
import npm from "npm";
import { PluginManager } from "live-plugin-manager";
import type { PluginManagerOptions } from "live-plugin-manager";
import expressHttpProxy from "express-http-proxy";
import type { Application, Request, Response, RequestHandler } from "express";
import express from "express";
Expand Down Expand Up @@ -41,7 +42,9 @@ import { PrometheusExporter } from "./prometheus-exporter/prometheus-exporter";
import { AuthorizerFactory } from "./authzn/authorizer-factory";
import { WatchHealthcheckV1 } from "./generated/openapi/typescript-axios";
import { WatchHealthcheckV1Endpoint } from "./web-services/watch-healthcheck-v1-endpoint";
import { RuntimeError } from "run-time-error";
export interface IApiServerConstructorOptions {
pluginManagerOptions?: PluginManagerOptions;
pluginRegistry?: PluginRegistry;
httpServerApi?: Server | SecureServer;
wsServerApi?: SocketIoServer;
Expand Down Expand Up @@ -71,6 +74,7 @@ export class ApiServer {
private readonly wsApi: SocketIoServer;
private readonly expressApi: Application;
private readonly expressCockpit: Application;
private readonly pluginManager: PluginManager;
public prometheusExporter: PrometheusExporter;

public get className(): string {
Expand Down Expand Up @@ -123,6 +127,11 @@ export class ApiServer {
this.prometheusExporter.startMetricsCollection();
this.prometheusExporter.setTotalPluginImports(this.getPluginImportsCount());

this.pluginManager = new PluginManager({
...JSON.parse(this.options.config.pluginManagerOptionsJson),
...this.options.pluginManagerOptions,
});

this.log = LoggerProvider.getOrCreate({
label: "api-server",
level: options.config.logLevel,
Expand Down Expand Up @@ -256,8 +265,7 @@ export class ApiServer {

await this.installPluginPackage(pluginImport);

// eslint-disable-next-line @typescript-eslint/no-var-requires
const pluginPackage = require(/* webpackIgnore: true */ packageName);
const pluginPackage = this.pluginManager.require(packageName);
const createPluginFactory = pluginPackage.createPluginFactory as PluginFactoryFactory;

const pluginFactoryOptions: IPluginFactoryOptions = {
Expand All @@ -276,54 +284,12 @@ export class ApiServer {
const fnTag = `ApiServer#installPluginPackage()`;
const { packageName: pkgName } = pluginImport;

const npmLogHandler = (message: unknown) => {
this.log.debug(`${fnTag} [npm-log]:`, message);
};

const cleanUpNpmLogHandler = () => {
npm.off("log", npmLogHandler);
};

try {
this.log.info(`Installing ${pkgName} for plugin import`, pluginImport);
npm.on("log", npmLogHandler);

await new Promise<void>((resolve, reject) => {
npm.load((err?: Error) => {
if (err) {
this.log.error(`${fnTag} npm load fail:`, err);
const { message, stack } = err;
reject(new Error(`${fnTag} npm load fail: ${message}: ${stack}`));
} else {
// do not touch package.json
npm.config.set("save", false);
// do not touch package-lock.json
npm.config.set("package-lock", false);
// do not waste resources on running an audit
npm.config.set("audit", false);
// do not wast resources on rendering a progress bar
npm.config.set("progress", false);
resolve();
}
});
});

await new Promise<unknown>((resolve, reject) => {
const npmInstallHandler = (errInstall?: Error, result?: unknown) => {
if (errInstall) {
this.log.error(`${fnTag} npm install failed:`, errInstall);
const { message: m, stack } = errInstall;
reject(new Error(`${fnTag} npm install fail: ${m}: ${stack}`));
} else {
this.log.info(`Installed ${pkgName} OK`, result);
resolve(result);
}
};

npm.commands.install([pkgName], npmInstallHandler);
});
} finally {
cleanUpNpmLogHandler();
await this.pluginManager.install(pkgName);
this.log.info(`Installed ${pkgName} OK`);
} catch (ex) {
throw new RuntimeError(`${fnTag} plugin install fail: ${pkgName}`, ex);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ convict.addFormat(FORMAT_PLUGIN_ARRAY);
convict.addFormat(ipaddress);

export interface ICactusApiServerOptions {
pluginManagerOptionsJson: string;
authorizationProtocol: AuthorizationProtocol;
authorizationConfigJson: IAuthorizationConfig;
configFile: string;
Expand Down Expand Up @@ -88,6 +89,14 @@ export class ConfigService {

private static getConfigSchema(): Schema<ICactusApiServerOptions> {
return {
pluginManagerOptionsJson: {
doc:
"Can be used to override npm registry and authentication details for example. See https://www.npmjs.com/package/live-plugin-manager#pluginmanagerconstructoroptions-partialpluginmanageroptions for further details.",
format: "*",
default: "{}",
env: "PLUGIN_MANAGER_OPTIONS_JSON",
arg: "plugin-manager-options-json",
},
authorizationProtocol: {
doc:
"The name of the authorization protocol to use. Accepted values" +
Expand Down Expand Up @@ -518,6 +527,7 @@ export class ConfigService {
};

return {
pluginManagerOptionsJson: "{}",
authorizationProtocol: AuthorizationProtocol.JSON_WEB_TOKEN,
authorizationConfigJson,
configFile: ".config.json",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import { promisify } from "util";
import { unlinkSync, readFileSync } from "fs";

import { exec } from "child_process";
import path from "path";

import test, { Test } from "tape-promise/tape";
import { v4 as uuidv4 } from "uuid";
import { JWK } from "jose";
Expand All @@ -23,11 +29,6 @@ const log = LoggerProvider.getOrCreate({
label: "logger-test",
});

import { promisify } from "util";
import { unlinkSync, readFileSync } from "fs";

import { exec } from "child_process";

const shell_exec = promisify(exec);

const artilleryScriptLocation =
Expand All @@ -47,9 +48,18 @@ test("Start API server, and run Artillery benchmark test.", async (t: Test) => {

log.info("Generating Config...");

const pluginsPath = path.join(
__dirname, // start at the current file's path
"../../../../../../", // walk back up to the project root
".tmp/test/cmd-api-server/artillery-api-benchmark_test", // the dir path from the root
uuidv4(), // then a random directory to ensure proper isolation
);
const pluginManagerOptionsJson = JSON.stringify({ pluginsPath });

const configService = new ConfigService();
const apiServerOptions = configService.newExampleConfig();
apiServerOptions.authorizationProtocol = AuthorizationProtocol.NONE;
apiServerOptions.pluginManagerOptionsJson = pluginManagerOptionsJson;
apiServerOptions.configFile = "";
apiServerOptions.apiCorsDomainCsv = "*";
apiServerOptions.apiPort = 4000;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "path";
import test, { Test } from "tape-promise/tape";
import { v4 as uuidv4 } from "uuid";
import { JWK, JWT } from "jose";
Expand Down Expand Up @@ -64,9 +65,18 @@ test(testCase, async (t: Test) => {
},
};

const pluginsPath = path.join(
__dirname, // start at the current file's path
"../../../../../../", // walk back up to the project root
".tmp/test/cmd-api-server/jwt-endpoint-authorization_test", // the dir path from the root
uuidv4(), // then a random directory to ensure proper isolation
);
const pluginManagerOptionsJson = JSON.stringify({ pluginsPath });

const configService = new ConfigService();
const apiSrvOpts = configService.newExampleConfig();
apiSrvOpts.authorizationProtocol = AuthorizationProtocol.JSON_WEB_TOKEN;
apiSrvOpts.pluginManagerOptionsJson = pluginManagerOptionsJson;
apiSrvOpts.authorizationConfigJson = authorizationConfig;
apiSrvOpts.configFile = "";
apiSrvOpts.apiCorsDomainCsv = "*";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "path";
import test, { Test } from "tape-promise/tape";
import { v4 as uuidv4 } from "uuid";
import { JWK } from "jose";
Expand All @@ -9,6 +10,7 @@ import {
ConsortiumDatabase,
ICactusPlugin,
Configuration,
IPluginConsortium,
} from "@hyperledger/cactus-core-api";

import {
Expand All @@ -20,10 +22,14 @@ import {
import { K_CACTUS_API_SERVER_TOTAL_PLUGIN_IMPORTS } from "../../../main/typescript/prometheus-exporter/metrics";

import { DefaultApi as ApiServerApi } from "../../../main/typescript/public-api";
import { PluginManagerOptions } from "live-plugin-manager";

const logLevel: LogLevelDesc = "TRACE";

test("can instal plugins at runtime based on imports", async (t: Test) => {
const keychainId = uuidv4();
const consortiumPluginInstanceId = uuidv4();

// Adding a new plugin to update the prometheus metric K_CACTUS_API_SERVER_TOTAL_PLUGIN_IMPORTS
const keyPair = await JWK.generate("EC", "secp256k1", { use: "sig" }, true);
const keyPairPem = keyPair.toPEM(true);
Expand All @@ -35,8 +41,20 @@ test("can instal plugins at runtime based on imports", async (t: Test) => {
pluginInstance: [],
};

const pluginsPath = path.join(
__dirname, // start at the current file's path
"../../../../../../", // walk back up to the project root
".tmp/test/cmd-api-server/plugin-import-with-npm-install_test", // the dir path from the root
uuidv4(), // then a random directory to ensure proper isolation
);

const configService = new ConfigService();

const apiServerOptions = configService.newExampleConfig();
const pluginManagerOptions: Partial<PluginManagerOptions> = { pluginsPath };
const pluginManagerOptionsJson = JSON.stringify(pluginManagerOptions);

apiServerOptions.pluginManagerOptionsJson = pluginManagerOptionsJson;
apiServerOptions.authorizationProtocol = AuthorizationProtocol.NONE;
apiServerOptions.configFile = "";
apiServerOptions.apiCorsDomainCsv = "*";
Expand All @@ -49,15 +67,15 @@ test("can instal plugins at runtime based on imports", async (t: Test) => {
type: PluginImportType.Local,
options: {
instanceId: uuidv4(),
keychainId: uuidv4(),
keychainId,
logLevel,
},
},
{
packageName: "@hyperledger/cactus-plugin-consortium-manual",
type: PluginImportType.Local,
options: {
instanceId: uuidv4(),
instanceId: consortiumPluginInstanceId,
keyPairPem: keyPairPem,
consortiumDatabase: db,
},
Expand All @@ -69,11 +87,10 @@ test("can instal plugins at runtime based on imports", async (t: Test) => {
config: config.getProperties(),
});

test.onFinish(() => apiServer.shutdown());

const startResponse = apiServer.start();
await t.doesNotReject(
startResponse,
"failed to start API server with dynamic plugin imports configured for it...",
);
await t.doesNotReject(startResponse, "started API server OK");
t.ok(startResponse, "startResponse truthy OK");

const addressInfoApi = (await startResponse).addressInfoApi;
Expand Down Expand Up @@ -141,5 +158,38 @@ test("can instal plugins at runtime based on imports", async (t: Test) => {
// e.g. remove the dummy plugin instance we just pushed
pluginRegistry.plugins.pop();

test.onFinish(() => apiServer.shutdown());
const keychain = pluginRegistry.findOneByKeychainId(keychainId);

const hasX1 = await keychain.has("x");
t.false(hasX1, "hasX1 === false OK");

await keychain.set("x", "y");

const hasX2 = await keychain.has("x");
t.true(hasX2, "hasX2 === true OK");

type DummyConsortiumPlugin = IPluginConsortium<
unknown,
unknown,
unknown,
unknown
>;

// TODO - use the new getOneById implementation once
// https://github.com/hyperledger/cactus/issues/1197
// has been resolved
const consortiumPlugin = pluginRegistry
.getPlugins()
.find(
(it) => it.getInstanceId() === consortiumPluginInstanceId,
) as DummyConsortiumPlugin;

t.ok(consortiumPlugin, "consortiumPlugin located via instance ID truthy OK");

// FIXME - uncomment this once https://github.com/hyperledger/cactus/issues/1199
// has been resolved (and also published to npm)
// const nodeJwsRes = await consortiumPlugin.getNodeJws({});
// t.ok(nodeJwsRes, "nodeJwsRes truthy OK");

t.end();
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {

import { DefaultApi } from "@hyperledger/cactus-plugin-keychain-vault";
import { Configuration, PluginImportType } from "@hyperledger/cactus-core-api";
import path from "path";

test("NodeJS API server + Rust plugin work together", async (t: Test) => {
const vaultTestContainer = new VaultTestServer({});
Expand Down Expand Up @@ -57,9 +58,18 @@ test("NodeJS API server + Rust plugin work together", async (t: Test) => {
});
const apiClient = new DefaultApi(configuration);

const pluginsPath = path.join(
__dirname, // start at the current file's path
"../../../../../../", // walk back up to the project root
".tmp/test/cmd-api-server/remote-plugin-imports_test", // the dir path from the root
uuidv4(), // then a random directory to ensure proper isolation
);
const pluginManagerOptionsJson = JSON.stringify({ pluginsPath });

const configService = new ConfigService();
const apiServerOptions = configService.newExampleConfig();
apiServerOptions.authorizationProtocol = AuthorizationProtocol.NONE;
apiServerOptions.pluginManagerOptionsJson = pluginManagerOptionsJson;
apiServerOptions.configFile = "";
apiServerOptions.apiCorsDomainCsv = "*";
apiServerOptions.apiPort = 0;
Expand Down
Loading

0 comments on commit 30e74d2

Please sign in to comment.