From 3839298dd07dd8a639ec375f708a0f1597321b86 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 6 Sep 2026 19:12:58 +0200 Subject: [PATCH] fix(docker): fall back to image's default userDir via NODE_RED_CLI_DEFAULT_USERDIR (#31) --- README.md | 9 ++ bin/node-red-cli-sandbox-entry.js | 6 + bin/node-red-cli.js | 4 +- src/run-envelope.js | 46 ++++++- test/integration/docker.integration.test.js | 113 ++++++++++++++++++ .../run-envelope.integration.test.js | 61 ++++++++++ test/unit/run-envelope.unit.test.js | 87 ++++++++++++++ 7 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 test/unit/run-envelope.unit.test.js diff --git a/README.md b/README.md index 6ab12e0..d7b795c 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,15 @@ deterministic **named Docker volume** (derived from the `--user-dir` value) mounted inside the container, never a host bind mount — so "no stray host files" holds even for persistent installs. +For images/derived images that pre-install Node-RED node packages into +their own conventional userDir, setting the `NODE_RED_CLI_DEFAULT_USERDIR` +environment variable (inside the image, e.g. via `ENV`) to that path lets +`--docker` discover it automatically whenever `--user-dir` isn't given — +that directory is used as `userDir` and, like an explicit `--user-dir`, +never deleted afterward. If the path doesn't exist or isn't a directory, +`--docker` logs a warning and falls back to the normal ephemeral `userDir` +rather than failing the invocation. + Fails fast with a clear `node-red-cli: docker unavailable: ...` error if the Docker CLI/daemon isn't reachable, or `node-red-cli: docker build failed: ...` if the image build fails (e.g. the local version isn't yet diff --git a/bin/node-red-cli-sandbox-entry.js b/bin/node-red-cli-sandbox-entry.js index 8b6bd60..09ebce0 100644 --- a/bin/node-red-cli-sandbox-entry.js +++ b/bin/node-red-cli-sandbox-entry.js @@ -7,6 +7,12 @@ * envelope as JSON from stdin, runs it against a real Node-RED runtime via * the shared `runFlowInvocation` (the exact same logic the host CLI uses * for its non-Docker path), and writes the formatted result to stdout. + * + * The `NODE_RED_CLI_DEFAULT_USERDIR` env-var convention (see #31), which + * lets an image's own pre-populated default userDir be discovered when + * `--user-dir` isn't given, is resolved entirely inside the shared + * `runFlowInvocation` (`src/run-envelope.js`) -- nothing to do here beyond + * the existing pass-through of `envelope.options`. */ const { runFlowInvocation } = require("../src/run-envelope"); diff --git a/bin/node-red-cli.js b/bin/node-red-cli.js index cea7ec0..c5f39c5 100755 --- a/bin/node-red-cli.js +++ b/bin/node-red-cli.js @@ -74,7 +74,9 @@ const HELP_TEXT = [ "network access independent of installing any package), --read-only", "rootfs with a /tmp tmpfs, --cap-drop=ALL, --security-opt=no-new-privileges.", "When combined with --user-dir, persistence uses a named Docker volume,", - "never a host bind mount.", + "never a host bind mount. Image authors can set", + "NODE_RED_CLI_DEFAULT_USERDIR= so a pre-installed userDir is", + "discovered automatically when --user-dir isn't given.", "", "Example:", ' echo \'{"payload":{"x":4,"y":5}}\' | node-red-cli flows.json calculate', diff --git a/src/run-envelope.js b/src/run-envelope.js index 832d6b3..47291f3 100644 --- a/src/run-envelope.js +++ b/src/run-envelope.js @@ -132,6 +132,34 @@ function waitForFlowsSettled(RED) { }); } +/** + * Resolves the image/host-provided default `userDir` from the + * `NODE_RED_CLI_DEFAULT_USERDIR` environment variable (see #31): community + * Docker images that pre-install Node-RED node packages into their own + * conventional userDir can set this variable so `--docker` (without an + * explicit `--user-dir`) discovers it automatically. Returns `undefined` if + * unset. Fails open, not closed: if the path doesn't exist, isn't a + * directory, or isn't accessible, logs a one-line stderr warning and returns + * `undefined` so the caller falls back to its normal ephemeral tmpdir, + * rather than aborting the invocation. + */ +function resolveDefaultUserDir() { + const configuredPath = process.env.NODE_RED_CLI_DEFAULT_USERDIR; + if (!configuredPath) return undefined; + + try { + if (fs.statSync(configuredPath).isDirectory()) return configuredPath; + console.error( + `node-red-cli: NODE_RED_CLI_DEFAULT_USERDIR='${configuredPath}' is not usable (not a directory), falling back to an ephemeral userDir` + ); + } catch (error) { + console.error( + `node-red-cli: NODE_RED_CLI_DEFAULT_USERDIR='${configuredPath}' is not usable (${error.message}), falling back to an ephemeral userDir` + ); + } + return undefined; +} + /** * Runs a single link-call invocation against a real, freshly booted * Node-RED runtime: installs any missing `--node-modules`, boots RED with @@ -146,8 +174,13 @@ function waitForFlowsSettled(RED) { * * `options.userDir`, when set, is treated as a persistent directory and is * never removed afterward (host: an explicit `--user-dir`; container: the - * fixed mount path of a named Docker volume). When omitted, an ephemeral - * tmpdir is created and removed again after the call. + * fixed mount path of a named Docker volume). When omitted, and the + * `NODE_RED_CLI_DEFAULT_USERDIR` environment variable points at an existing + * directory (see `resolveDefaultUserDir`), that directory is used instead — + * also treated as persistent and never removed afterward, letting a Docker + * image's own pre-populated default userDir be discovered automatically + * (see #31). Otherwise an ephemeral tmpdir is created and removed again + * after the call. */ async function runFlowInvocation({ flow, flowFile, msg, options }) { const { @@ -160,7 +193,10 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) { } = options; const persistentUserDir = Boolean(fixedUserDir); - const userDir = fixedUserDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-")); + const imageDefaultUserDir = !persistentUserDir ? resolveDefaultUserDir() : undefined; + const userDir = + fixedUserDir || imageDefaultUserDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-")); + const managedUserDir = !persistentUserDir && !imageDefaultUserDir; try { if (nodeModules.length > 0) { @@ -194,8 +230,8 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) { await RED.stop(); } } finally { - if (!persistentUserDir) fs.rmSync(userDir, { recursive: true, force: true }); + if (managedUserDir) fs.rmSync(userDir, { recursive: true, force: true }); } } -module.exports = { runFlowInvocation, stderrLogHandler }; +module.exports = { runFlowInvocation, stderrLogHandler, resolveDefaultUserDir }; diff --git a/test/integration/docker.integration.test.js b/test/integration/docker.integration.test.js index 91e3d72..0357097 100644 --- a/test/integration/docker.integration.test.js +++ b/test/integration/docker.integration.test.js @@ -31,6 +31,10 @@ const { buildRunArgs } = require("../../src/docker-run"); const REPO_ROOT = path.join(__dirname, "..", ".."); const TEST_IMAGE = "node-red-cli-sandbox-integration-test:local"; +const DEFAULT_USERDIR_TEST_IMAGE = "node-red-cli-sandbox-default-userdir-test:local"; +const BAKED_USERDIR_PATH = "/opt/preinstalled-userdir"; +const BAKED_MODULE_NAME = "node-red-contrib-cli-issue-31-dummy"; +const BAKED_NODE_TYPE = "cli-issue-31-dummy"; function dockerAvailable() { const result = spawnSync("docker", ["info"], { stdio: ["ignore", "ignore", "ignore"] }); @@ -83,11 +87,68 @@ before(async function () { if (build.status !== 0) { throw new Error(`failed to build the throwaway test image: ${build.stderr}`); } + + // Second throwaway image (issue #31): derived from the same base, but also bakes + // a dummy Node-RED node module into a fixed path and sets + // NODE_RED_CLI_DEFAULT_USERDIR to it, simulating a community image that + // pre-installs node packages into its own conventional default userDir. + const moduleDir = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-baked-module-")); + fs.writeFileSync( + path.join(moduleDir, "package.json"), + JSON.stringify({ + name: BAKED_MODULE_NAME, + version: "1.0.0", + "node-red": { nodes: { dummy: "index.js" } } + }) + ); + fs.writeFileSync( + path.join(moduleDir, "index.js"), + [ + "module.exports = function (RED) {", + " function DummyNode(config) {", + " RED.nodes.createNode(this, config);", + " const node = this;", + " node.on('input', function (msg) { node.send(msg); });", + " }", + ` RED.nodes.registerType(${JSON.stringify(BAKED_NODE_TYPE)}, DummyNode);`, + "};", + "" + ].join("\n") + ); + + const defaultUserDirDockerfile = [ + `FROM ${TEST_IMAGE}`, + `RUN mkdir -p ${BAKED_USERDIR_PATH}/node_modules/${BAKED_MODULE_NAME}`, + `COPY baked-module/ ${BAKED_USERDIR_PATH}/node_modules/${BAKED_MODULE_NAME}/`, + `ENV NODE_RED_CLI_DEFAULT_USERDIR=${BAKED_USERDIR_PATH}`, + "" + ].join("\n"); + + const defaultUserDirBuildDir = fs.mkdtempSync( + path.join(os.tmpdir(), "node-red-cli-docker-default-userdir-test-") + ); + fs.cpSync(moduleDir, path.join(defaultUserDirBuildDir, "baked-module"), { recursive: true }); + fs.writeFileSync(path.join(defaultUserDirBuildDir, "Dockerfile"), defaultUserDirDockerfile); + fs.rmSync(moduleDir, { recursive: true, force: true }); + + const defaultUserDirBuild = spawnSync( + "docker", + ["build", "-t", DEFAULT_USERDIR_TEST_IMAGE, defaultUserDirBuildDir], + { stdio: ["ignore", "pipe", "pipe"], timeout: 5 * 60 * 1000 } + ); + fs.rmSync(defaultUserDirBuildDir, { recursive: true, force: true }); + + if (defaultUserDirBuild.status !== 0) { + throw new Error( + `failed to build the throwaway default-userdir test image: ${defaultUserDirBuild.stderr}` + ); + } }); after(() => { if (skip) return; spawnSync("docker", ["image", "rm", "-f", TEST_IMAGE], { stdio: "ignore" }); + spawnSync("docker", ["image", "rm", "-f", DEFAULT_USERDIR_TEST_IMAGE], { stdio: "ignore" }); }); test( @@ -255,6 +316,58 @@ test("docker integration: --network alone (no --node-modules) enables network ac ); }); +test( + "docker integration: --docker discovers the pre-baked module without --user-dir/--node-modules (issue #31)", + { skip }, + async () => { + const flow = JSON.stringify([ + { id: "tab", type: "tab", label: "t" }, + { id: "ask", type: "link in", z: "tab", name: "ask", wires: [["dummy"]] }, + { id: "dummy", type: BAKED_NODE_TYPE, z: "tab", name: "d", wires: [["return"]] }, + { id: "return", type: "link out", z: "tab", name: "return", mode: "return" } + ]); + const { code, stdout, stderr } = await runCli( + ["--flow-json", flow, "ask", "--docker", DEFAULT_USERDIR_TEST_IMAGE, "--format=json"], + JSON.stringify({ payload: "hi" }) + ); + + assert.equal(code, 0, stderr); + assert.deepEqual(JSON.parse(stdout).payload, "hi"); + } +); + +test( + "docker integration: explicit --user-dir takes precedence over NODE_RED_CLI_DEFAULT_USERDIR (the pre-baked module is not used)", + { skip }, + async () => { + const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-docker-default-userdir-precedence-")); + fs.rmSync(userDir, { recursive: true, force: true }); // deterministic never-used path for volume naming + const flow = JSON.stringify([ + { id: "tab", type: "tab", label: "t" }, + { id: "ask", type: "link in", z: "tab", name: "ask", wires: [["dummy"]] }, + { id: "dummy", type: BAKED_NODE_TYPE, z: "tab", name: "d", wires: [["return"]] }, + { id: "return", type: "link out", z: "tab", name: "return", mode: "return" } + ]); + const { code, stdout, stderr } = await runCli( + [ + "--flow-json", + flow, + "ask", + "--docker", + DEFAULT_USERDIR_TEST_IMAGE, + "--user-dir", + userDir, + "--format=json" + ], + JSON.stringify({ payload: "hi" }) + ); + + assert.notEqual(code, 0); + assert.equal(stdout, ""); + assert.match(stderr, /not instantiated in the runtime/); + } +); + test("docker integration: an unreachable Docker daemon fails fast with a clear 'docker unavailable' error", async () => { const flowsPath = path.join(REPO_ROOT, "test", "fixtures", "single-link-in.flows.json"); const { code, stdout, stderr } = await runCli([flowsPath, "calculate", "--docker"], "", { diff --git a/test/integration/run-envelope.integration.test.js b/test/integration/run-envelope.integration.test.js index dad5e97..770e7cf 100644 --- a/test/integration/run-envelope.integration.test.js +++ b/test/integration/run-envelope.integration.test.js @@ -87,3 +87,64 @@ test("integration: runFlowInvocation deploys and calls a flow whose nodes omit e }); assert.deepEqual(JSON.parse(result.output).payload, "hi"); }); + +/** + * Integration coverage for issue #31: `NODE_RED_CLI_DEFAULT_USERDIR` lets a + * Docker image's own pre-populated default userDir be discovered by + * `runFlowInvocation` when `--user-dir` isn't given. Exercised against the + * real embedded Node-RED runtime since `runFlowInvocation` boots one + * directly; not feasibly unit-testable against a fake runtime. + */ +const ENV_VAR = "NODE_RED_CLI_DEFAULT_USERDIR"; + +test("integration: runFlowInvocation uses NODE_RED_CLI_DEFAULT_USERDIR as userDir and never removes it", async () => { + const originalEnvValue = process.env[ENV_VAR]; + const defaultUserDir = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-default-userdir-")); + const markerFile = path.join(defaultUserDir, "marker.txt"); + fs.writeFileSync(markerFile, "keep-me"); + process.env[ENV_VAR] = defaultUserDir; + + try { + const result = await runFlowInvocation({ + flow: SELF_NAMED_LINK_FLOW, + msg: { payload: "hi" }, + options: { target: "ask", timeoutMs: 5000, format: "json" } + }); + assert.deepEqual(JSON.parse(result.output).payload, "hi"); + assert.equal(fs.existsSync(defaultUserDir), true); + assert.equal(fs.readFileSync(markerFile, "utf8"), "keep-me"); + } finally { + if (originalEnvValue === undefined) { + delete process.env[ENV_VAR]; + } else { + process.env[ENV_VAR] = originalEnvValue; + } + fs.rmSync(defaultUserDir, { recursive: true, force: true }); + } +}); + +test("integration: explicit userDir takes precedence over NODE_RED_CLI_DEFAULT_USERDIR", async () => { + const originalEnvValue = process.env[ENV_VAR]; + const defaultUserDir = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-default-userdir-")); + const explicitUserDir = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-explicit-userdir-")); + process.env[ENV_VAR] = defaultUserDir; + + try { + const result = await runFlowInvocation({ + flow: SELF_NAMED_LINK_FLOW, + msg: { payload: "hi" }, + options: { target: "ask", timeoutMs: 5000, format: "json", userDir: explicitUserDir } + }); + assert.deepEqual(JSON.parse(result.output).payload, "hi"); + // The default userDir must be left untouched -- proof it was never loaded. + assert.deepEqual(fs.readdirSync(defaultUserDir), []); + } finally { + if (originalEnvValue === undefined) { + delete process.env[ENV_VAR]; + } else { + process.env[ENV_VAR] = originalEnvValue; + } + fs.rmSync(defaultUserDir, { recursive: true, force: true }); + fs.rmSync(explicitUserDir, { recursive: true, force: true }); + } +}); diff --git a/test/unit/run-envelope.unit.test.js b/test/unit/run-envelope.unit.test.js new file mode 100644 index 0000000..c47b20e --- /dev/null +++ b/test/unit/run-envelope.unit.test.js @@ -0,0 +1,87 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { test, beforeEach, afterEach } = require("node:test"); +const { resolveDefaultUserDir } = require("../../src/run-envelope"); + +const ENV_VAR = "NODE_RED_CLI_DEFAULT_USERDIR"; +let originalEnvValue; + +beforeEach(() => { + originalEnvValue = process.env[ENV_VAR]; + delete process.env[ENV_VAR]; +}); + +afterEach(() => { + if (originalEnvValue === undefined) { + delete process.env[ENV_VAR]; + } else { + process.env[ENV_VAR] = originalEnvValue; + } +}); + +/** + * Unit coverage for issue #31's `resolveDefaultUserDir` helper -- the + * env-var-driven fallback that lets a Docker image's own pre-populated + * default userDir be discovered by `runFlowInvocation` when `--user-dir` + * isn't given. Full precedence/non-deletion behavior of `runFlowInvocation` + * itself is covered at the integration level (real Node-RED runtime boot + * required), since `runFlowInvocation` isn't feasibly unit-testable against + * a fake runtime without mocking the `node-red` module. + */ +test("unit: resolveDefaultUserDir returns undefined when NODE_RED_CLI_DEFAULT_USERDIR is unset", () => { + assert.equal(resolveDefaultUserDir(), undefined); +}); + +test("unit: resolveDefaultUserDir returns the path when it exists and is a directory", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-default-userdir-")); + try { + process.env[ENV_VAR] = dir; + assert.equal(resolveDefaultUserDir(), dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("unit: resolveDefaultUserDir falls back to undefined and warns when the path doesn't exist", () => { + const missingPath = path.join(os.tmpdir(), "node-red-cli-does-not-exist-", String(Date.now())); + process.env[ENV_VAR] = missingPath; + + const originalConsoleError = console.error; + const warnings = []; + console.error = (msg) => warnings.push(msg); + try { + assert.equal(resolveDefaultUserDir(), undefined); + } finally { + console.error = originalConsoleError; + } + + assert.equal(warnings.length, 1); + assert.match(warnings[0], /NODE_RED_CLI_DEFAULT_USERDIR='.*' is not usable/); + assert.match(warnings[0], /falling back to an ephemeral userDir/); +}); + +test("unit: resolveDefaultUserDir falls back to undefined and warns when the path is a file, not a directory", () => { + const filePath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-default-userdir-file-")), + "not-a-dir" + ); + fs.writeFileSync(filePath, ""); + process.env[ENV_VAR] = filePath; + + const originalConsoleError = console.error; + const warnings = []; + console.error = (msg) => warnings.push(msg); + try { + assert.equal(resolveDefaultUserDir(), undefined); + } finally { + console.error = originalConsoleError; + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + + assert.equal(warnings.length, 1); + assert.match(warnings[0], /not a directory/); +});