From 8140057beaa51f4ee2ee8b12a97de501bfe1f5a5 Mon Sep 17 00:00:00 2001 From: Nick O'Leary Date: Thu, 15 Apr 2021 15:11:45 +0100 Subject: [PATCH] Add pre/postInstall hooks to module install path --- .../@node-red/registry/lib/externalModules.js | 19 +++- .../@node-red/registry/lib/installer.js | 101 ++++++++++++------ .../runtime/locales/en-US/runtime.json | 1 + .../registry/lib/externalModules_spec.js | 23 +++- .../@node-red/registry/lib/installer_spec.js | 62 ++++++++++- 5 files changed, 170 insertions(+), 36 deletions(-) diff --git a/packages/node_modules/@node-red/registry/lib/externalModules.js b/packages/node_modules/@node-red/registry/lib/externalModules.js index 9587adf60..2bedcce1d 100644 --- a/packages/node_modules/@node-red/registry/lib/externalModules.js +++ b/packages/node_modules/@node-red/registry/lib/externalModules.js @@ -9,6 +9,7 @@ const path = require("path"); const clone = require("clone"); const exec = require("@node-red/util").exec; const log = require("@node-red/util").log; +const hooks = require("@node-red/util").hooks; const BUILTIN_MODULES = require('module').builtinModules; const EXTERNAL_MODULES_DIR = "externalModules"; @@ -190,12 +191,22 @@ async function installModule(moduleDetails) { await ensureModuleDir(); var args = ["install", installSpec, "--production"]; - return exec.run(NPM_COMMAND, args, { - cwd: installDir - },true).then(result => { + let triggerPayload = { + "module": moduleDetails.module, + "version": moduleDetails.version, + "dir": installDir, + } + return hooks.trigger("preInstall", triggerPayload).then(() => { + // preInstall passed + // - run install + log.trace(NPM_COMMAND + JSON.stringify(args)); + return exec.run(NPM_COMMAND, args, { cwd: installDir },true) + }).then(() => { + return hooks.trigger("postInstall", triggerPayload) + }).then(() => { log.info(log._("server.install.installed", { name: installSpec })); }).catch(result => { - var output = result.stderr; + var output = result.stderr || result.toString(); var e; if (/E404/.test(output) || /ETARGET/.test(output)) { log.error(log._("server.install.install-failed-not-found",{name:installSpec})); diff --git a/packages/node_modules/@node-red/registry/lib/installer.js b/packages/node_modules/@node-red/registry/lib/installer.js index 459220774..ef38b89a2 100644 --- a/packages/node_modules/@node-red/registry/lib/installer.js +++ b/packages/node_modules/@node-red/registry/lib/installer.js @@ -23,7 +23,7 @@ const tar = require("tar"); const registry = require("./registry"); const registryUtil = require("./util"); const library = require("./library"); -const {exec,log,events} = require("@node-red/util"); +const {exec,log,events,hooks} = require("@node-red/util"); const child_process = require('child_process'); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; let installerEnabled = false; @@ -169,10 +169,23 @@ async function installModule(module,version,url) { var installDir = settings.userDir || process.env.NODE_RED_HOME || "."; var args = ['install','--no-audit','--no-update-notifier','--no-fund','--save','--save-prefix=~','--production',installName]; - log.trace(npmCommand + JSON.stringify(args)); - return exec.run(npmCommand,args,{ - cwd: installDir - }, true).then(result => { + let triggerPayload = { + "module": module, + "version": version, + "url": url, + "dir": installDir, + "isExisting": isExisting, + "isUpgrade": isUpgrade + } + + return hooks.trigger("preInstall", triggerPayload).then(() => { + // preInstall passed + // - run install + log.trace(npmCommand + JSON.stringify(args)); + return exec.run(npmCommand,args,{ cwd: installDir}, true) + }).then(() => { + return hooks.trigger("postInstall", triggerPayload) + }).then(() => { if (isExisting) { // This is a module we already have installed as a non-user module. // That means it was discovered when loading, but was not listed @@ -191,29 +204,45 @@ async function installModule(module,version,url) { events.emit("runtime-event",{id:"restart-required",payload:{type:"warning",text:"notification.warnings.restartRequired"},retain:true}); return require("./registry").setModulePendingUpdated(module,version); } - }).catch(result => { - var output = result.stderr; - var e; - var lookFor404 = new RegExp(" 404 .*"+module,"m"); - var lookForVersionNotFound = new RegExp("version not found: "+module+"@"+version,"m"); - if (lookFor404.test(output)) { - log.warn(log._("server.install.install-failed-not-found",{name:module})); - e = new Error("Module not found"); - e.code = 404; - throw e; - } else if (isUpgrade && lookForVersionNotFound.test(output)) { - log.warn(log._("server.install.upgrade-failed-not-found",{name:module})); - e = new Error("Module not found"); - e.code = 404; - throw e; - } else { + }).catch(err => { + let e; + if (err.hook) { + // preInstall failed log.warn(log._("server.install.install-failed-long",{name:module})); log.warn("------------------------------------------"); - log.warn(output); + log.warn(err.toString()); log.warn("------------------------------------------"); - throw new Error(log._("server.install.install-failed")); + e = new Error(log._("server.install.install-failed")+": "+err.toString()); + if (err.hook === "postInstall") { + return exec.run(npmCommand,["remove",module],{ cwd: installDir}, false).finally(() => { + throw e; + }) + } + } else { + // npm install failed + let output = err.stderr; + let lookFor404 = new RegExp(" 404 .*"+module,"m"); + let lookForVersionNotFound = new RegExp("version not found: "+module+"@"+version,"m"); + if (lookFor404.test(output)) { + log.warn(log._("server.install.install-failed-not-found",{name:module})); + e = new Error("Module not found"); + e.code = 404; + } else if (isUpgrade && lookForVersionNotFound.test(output)) { + log.warn(log._("server.install.upgrade-failed-not-found",{name:module})); + e = new Error("Module not found"); + e.code = 404; + } else { + log.warn(log._("server.install.install-failed-long",{name:module})); + log.warn("------------------------------------------"); + log.warn(output); + log.warn("------------------------------------------"); + e = new Error(log._("server.install.install-failed")); + } } - }) + if (e) { + throw e; + } + }); }).catch(err => { // In case of error, reset activePromise to be resolvable activePromise = Promise.resolve(); @@ -412,17 +441,29 @@ function uninstallModule(module) { log.info(log._("server.install.uninstalling",{name:module})); var args = ['remove','--no-audit','--no-update-notifier','--no-fund','--save',module]; - log.trace(npmCommand + JSON.stringify(args)); - exec.run(npmCommand,args,{ - cwd: installDir, - },true).then(result => { + let triggerPayload = { + "module": module, + "dir": installDir, + } + return hooks.trigger("preUninstall", triggerPayload).then(() => { + // preUninstall passed + // - run uninstall + log.trace(npmCommand + JSON.stringify(args)); + return exec.run(npmCommand,args,{ cwd: installDir}, true) + }).then(() => { log.info(log._("server.install.uninstalled",{name:module})); reportRemovedModules(list); library.removeExamplesDir(module); - resolve(list); + return hooks.trigger("postUninstall", triggerPayload).catch((err)=>{ + log.warn("------------------------------------------"); + log.warn(err.toString()); + log.warn("------------------------------------------"); + }).finally(() => { + resolve(list); + }) }).catch(result => { - var output = result.stderr; + let output = result.stderr || result; log.warn(log._("server.install.uninstall-failed-long",{name:module})); log.warn("------------------------------------------"); log.warn(output.toString()); diff --git a/packages/node_modules/@node-red/runtime/locales/en-US/runtime.json b/packages/node_modules/@node-red/runtime/locales/en-US/runtime.json index 1a29f32b4..029d36b16 100644 --- a/packages/node_modules/@node-red/runtime/locales/en-US/runtime.json +++ b/packages/node_modules/@node-red/runtime/locales/en-US/runtime.json @@ -34,6 +34,7 @@ "install-failed-not-found": "$t(server.install.install-failed-long) module not found", "install-failed-name": "$t(server.install.install-failed-long) invalid module name: __name__", "install-failed-url": "$t(server.install.install-failed-long) invalid url: __url__", + "post-install-error": "Error running 'postInstall' hook:", "upgrading": "Upgrading module: __name__ to version: __version__", "upgraded": "Upgraded module: __name__. Restart Node-RED to use the new version", "upgrade-failed-not-found": "$t(server.install.install-failed-long) version not found", diff --git a/test/unit/@node-red/registry/lib/externalModules_spec.js b/test/unit/@node-red/registry/lib/externalModules_spec.js index d49129bc4..99c74786f 100644 --- a/test/unit/@node-red/registry/lib/externalModules_spec.js +++ b/test/unit/@node-red/registry/lib/externalModules_spec.js @@ -14,6 +14,7 @@ const os = require("os"); const NR_TEST_UTILS = require("nr-test-utils"); const externalModules = NR_TEST_UTILS.require("@node-red/registry/lib/externalModules"); const exec = NR_TEST_UTILS.require("@node-red/util/lib/exec"); +const hooks = NR_TEST_UTILS.require("@node-red/util/lib/hooks"); let homeDir; @@ -40,6 +41,7 @@ describe("externalModules api", function() { await createUserDir() }) afterEach(async function() { + hooks.clear(); await fs.remove(homeDir); }) describe("checkFlowDependencies", function() { @@ -102,6 +104,25 @@ describe("externalModules api", function() { fs.existsSync(path.join(homeDir,"externalModules")).should.be.true(); }) + + it("calls pre/postInstall hooks", async function() { + externalModules.init({userDir: homeDir}); + externalModules.register("function", "libs"); + let receivedPreEvent,receivedPostEvent; + hooks.add("preInstall", function(event) { receivedPreEvent = event; }) + hooks.add("postInstall", function(event) { receivedPostEvent = event; }) + + await externalModules.checkFlowDependencies([ + {type: "function", libs:[{module: "foo"}]} + ]) + exec.run.called.should.be.true(); + receivedPreEvent.should.have.property("module","foo") + receivedPreEvent.should.have.property("version") + receivedPreEvent.should.have.property("dir") + receivedPreEvent.should.eql(receivedPostEvent) + fs.existsSync(path.join(homeDir,"externalModules")).should.be.true(); + }) + it("installs missing modules from inside subflow module", async function() { externalModules.init({userDir: homeDir}); externalModules.register("function", "libs"); @@ -299,4 +320,4 @@ describe("externalModules api", function() { } }) }) -}); \ No newline at end of file +}); diff --git a/test/unit/@node-red/registry/lib/installer_spec.js b/test/unit/@node-red/registry/lib/installer_spec.js index 4cba4b812..a9b6b71b8 100644 --- a/test/unit/@node-red/registry/lib/installer_spec.js +++ b/test/unit/@node-red/registry/lib/installer_spec.js @@ -25,7 +25,7 @@ var NR_TEST_UTILS = require("nr-test-utils"); var installer = NR_TEST_UTILS.require("@node-red/registry/lib/installer"); var registry = NR_TEST_UTILS.require("@node-red/registry/lib/index"); var typeRegistry = NR_TEST_UTILS.require("@node-red/registry/lib/registry"); -const { events, exec, log } = NR_TEST_UTILS.require("@node-red/util"); +const { events, exec, log, hooks } = NR_TEST_UTILS.require("@node-red/util"); describe('nodes/registry/installer', function() { @@ -68,6 +68,7 @@ describe('nodes/registry/installer', function() { fs.statSync.restore(); } exec.run.restore(); + hooks.clear(); }); describe("installs module", function() { @@ -251,6 +252,65 @@ describe('nodes/registry/installer', function() { }).catch(done); }); + it("triggers preInstall and postInstall hooks", function(done) { + let receivedPreEvent,receivedPostEvent; + hooks.add("preInstall", function(event) { receivedPreEvent = event; }) + hooks.add("postInstall", function(event) { receivedPostEvent = event; }) + var nodeInfo = {nodes:{module:"foo",types:["a"]}}; + var res = {code: 0,stdout:"",stderr:""} + var p = Promise.resolve(res); + p.catch((err)=>{}); + execResponse = p; + + var addModule = sinon.stub(registry,"addModule",function(md) { + return Promise.resolve(nodeInfo); + }); + + installer.installModule("this_wont_exist","1.2.3").then(function(info) { + info.should.eql(nodeInfo); + should.exist(receivedPreEvent) + receivedPreEvent.should.have.property("module","this_wont_exist") + receivedPreEvent.should.have.property("version","1.2.3") + receivedPreEvent.should.have.property("dir") + receivedPreEvent.should.have.property("url") + receivedPreEvent.should.have.property("isExisting") + receivedPreEvent.should.have.property("isUpgrade") + receivedPreEvent.should.eql(receivedPostEvent) + done(); + }).catch(done); + }); + + it("fails install if preInstall hook fails", function(done) { + let receivedEvent; + hooks.add("preInstall", function(event) { throw new Error("preInstall-error"); }) + var nodeInfo = {nodes:{module:"foo",types:["a"]}}; + + installer.installModule("this_wont_exist","1.2.3").catch(function(err) { + exec.run.called.should.be.false(); + done(); + }).catch(done); + }); + + it("fails install if preInstall hook fails", function(done) { + let receivedEvent; + hooks.add("preInstall", function(event) { throw new Error("preInstall-error"); }) + var nodeInfo = {nodes:{module:"foo",types:["a"]}}; + + installer.installModule("this_wont_exist","1.2.3").catch(function(err) { + exec.run.called.should.be.false(); + done(); + }).catch(done); + }); + it("rollsback install if postInstall hook fails", function(done) { + hooks.add("postInstall", function(event) { throw new Error("fail"); }) + installer.installModule("this_wont_exist","1.2.3").catch(function(err) { + exec.run.calledTwice.should.be.true(); + exec.run.firstCall.args[1].includes("install").should.be.true(); + exec.run.secondCall.args[1].includes("remove").should.be.true(); + done(); + }).catch(done); + }); + }); describe("uninstalls module", function() { it("rejects invalid module names", function(done) {