Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions bin/node-red-cli-sandbox-entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 3 additions & 1 deletion bin/node-red-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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=<path> 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',
Expand Down
46 changes: 41 additions & 5 deletions src/run-envelope.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
113 changes: 113 additions & 0 deletions test/integration/docker.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"] });
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -255,6 +316,58 @@ test("docker integration: --network alone (no --node-modules) enables network ac
);
});

test(
"docker integration: --docker <image with NODE_RED_CLI_DEFAULT_USERDIR> 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"], "", {
Expand Down
61 changes: 61 additions & 0 deletions test/integration/run-envelope.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
Loading