Skip to content

Commit

Permalink
fix(cmd-api-server): plugins interfere with API server deps #1192
Browse files Browse the repository at this point in the history
Migrates to the lmify package to install plugins at runtime
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 magic sauce is the --prefix option of npm which, when specified
instructs npm to ignore the usual parent directory traversal algorithm
when evaluating/resolving dependency trees and instead just do a full
installation to the specified directory path as dictated by the
--prefix option. This means that we can install each plugin in their
own directory the code being isolated from the API server and also
from other plugins that might also interfere.

Fixes #1192

Depends on #1203

Signed-off-by: Peter Somogyvari <peter.somogyvari@accenture.com>
  • Loading branch information
petermetz committed Aug 13, 2021
1 parent 7b424d4 commit a96ce68
Show file tree
Hide file tree
Showing 18 changed files with 487 additions and 514 deletions.
5 changes: 3 additions & 2 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"Keycloak",
"Knetic",
"LEDGERBLOCKACK",
"lmify",
"LOCALMSPID",
"miekg",
"mitchellh",
Expand All @@ -72,6 +73,7 @@
"OpenAPI",
"openethereum",
"organisation",
"parameterizable",
"protos",
"RUSTC",
"Secp",
Expand All @@ -91,8 +93,7 @@
"uuidv",
"vscc",
"wasm",
"Xdai",
"parameterizable"
"Xdai"
],
"dictionaries": [
"typescript,node,npm,go,rust"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,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
4 changes: 2 additions & 2 deletions packages/cactus-cmd-api-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,11 @@
"express-jwt": "6.0.0",
"express-jwt-authz": "2.4.1",
"express-openapi-validator": "3.10.0",
"fs-extra": "10.0.0",
"http-status-codes": "2.1.4",
"jose": "1.28.1",
"lmify": "0.3.0",
"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 +112,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
116 changes: 65 additions & 51 deletions packages/cactus-cmd-api-server/src/main/typescript/api-server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import path from "path";
import type { AddressInfo } from "net";
import type { Server as SecureServer } from "https";
import os from "os";
import path from "path";
import tls from "tls";
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 lmify from "lmify";
import fs from "fs-extra";
import expressHttpProxy from "express-http-proxy";
import type { Application, Request, Response, RequestHandler } from "express";
import express from "express";
Expand Down Expand Up @@ -41,7 +43,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?: { pluginsPath: string };
pluginRegistry?: PluginRegistry;
httpServerApi?: Server | SecureServer;
wsServerApi?: SocketIoServer;
Expand Down Expand Up @@ -71,6 +75,7 @@ export class ApiServer {
private readonly wsApi: SocketIoServer;
private readonly expressApi: Application;
private readonly expressCockpit: Application;
private readonly pluginsPath: string;
public prometheusExporter: PrometheusExporter;

public get className(): string {
Expand Down Expand Up @@ -127,6 +132,23 @@ export class ApiServer {
label: "api-server",
level: options.config.logLevel,
});

const defaultPluginsPath = path.join(
os.tmpdir(),
"org",
"hyperledger",
"cactus",
"plugins",
);

const { pluginsPath } = {
...{ pluginsPath: defaultPluginsPath },
...JSON.parse(this.options.config.pluginManagerOptionsJson),
...this.options.pluginManagerOptions,
} as { pluginsPath: string };

this.pluginsPath = pluginsPath;
this.log.debug("pluginsPath: %o", pluginsPath);
}

public getPrometheusExporter(): PrometheusExporter {
Expand Down Expand Up @@ -256,8 +278,16 @@ export class ApiServer {

await this.installPluginPackage(pluginImport);

const packagePath = path.join(
this.pluginsPath,
options.instanceId,
"node_modules",
packageName,
);
this.log.debug("Package path: %o", packagePath);

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

const pluginFactoryOptions: IPluginFactoryOptions = {
Expand All @@ -276,54 +306,38 @@ 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);
};

const instanceId = pluginImport.options.instanceId;
const pluginPackageDir = path.join(this.pluginsPath, instanceId);
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 fs.mkdirp(pluginPackageDir);
this.log.debug(`${pkgName} plugin package dir: %o`, pluginPackageDir);
} catch (ex) {
const errorMessage =
"Could not create plugin installation directory, check the file-system permissions.";
throw new RuntimeError(errorMessage, ex);
}
try {
lmify.setPackageManager("npm");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
lmify.setRootDir(pluginPackageDir);
this.log.debug(`Installing ${pkgName} for plugin import`, pluginImport);
const out = await lmify.install([
pkgName,
"--production",
"--audit=false",
"--progress=false",
"--fund=false",
`--prefix=${pluginPackageDir}`,
// "--ignore-workspace-root-check",
]);
this.log.debug("%o install result: %o", pkgName, out);
if (out.exitCode !== 0) {
throw new RuntimeError("Non-zero exit code: ", JSON.stringify(out));
}
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
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
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
import { LoggerProvider } from "@hyperledger/cactus-common";
import path from "path";
import test, { Test } from "tape-promise/tape";
import { v4 as uuidv4 } from "uuid";
import { LoggerProvider } from "@hyperledger/cactus-common";

import { IAuthorizationConfig } from "../../../../main/typescript/public-api";
import { ApiServer } from "../../../../main/typescript/public-api";
import { ConfigService } from "../../../../main/typescript/public-api";

test("Generates valid example config for the API server", async (t: Test) => {
const pluginsPath = path.join(
__dirname,
"../../../../../../", // walk back up to the project root
".tmp/test/test-cmd-api-server/config-service-example-config-validity_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();
t.ok(configService, "Instantiated ConfigService truthy OK");

const exampleConfig = configService.newExampleConfig();
t.ok(exampleConfig, "configService.newExampleConfig() truthy OK");

exampleConfig.pluginManagerOptionsJson = pluginManagerOptionsJson;

// FIXME - this hack should not be necessary, we need to re-think how we
// do configuration parsing. The convict library may not be the path forward.
exampleConfig.authorizationConfigJson = (JSON.stringify(
Expand Down
Loading

0 comments on commit a96ce68

Please sign in to comment.