refactor(cli): replace deprecated vm2 with native node:vm runner - #2310
mbiernacik wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Thanks for taking this on — removing vm2 is clearly the right call, and I think VmRunner is a good shape for the replacement.
Most of my inline comments are suggestions rather than objections. The one I'd genuinely flag is the module cache entry being retained when a module throws (cli/vm/vm_runner.ts:148), since that can turn a load failure into a silently incomplete compiledGraph rather than a clear error.
A few things that didn't fit on a specific line:
- Security framing. The description says this "eliminates security vulnerabilities" and describes a "hermetic" runner with a "secure module resolver".
node:vmdoesn't really provide isolation — Node's docs are fairly explicit that it isn't a security mechanism, and vm2 existed because of that gap. If the CLI only ever compiles the invoking user's own repo on their own machine, then vm2's CVEs probably weren't reachable here either, so the security angle may not be doing much work in either direction. "Removes an unmaintained dependency and is meaningfully faster" seems like ample justification on its own. Would you consider rewording that part? - Feature list. I couldn't find implementations for three things the description mentions: execution timeouts,
node:vm.Script(onlyvm.compileFunctionis used), and script caching (moduleCacheholds module exports rather than compiled scripts). Possibly planned and dropped, or I'm looking in the wrong place — either way it'd be good to bring the description back in line. - Benchmarks. Could the benchmark script land under
cli/vm/? The numbers are a nice result and it'd be good to be able to re-run them. I did notice the vm2 baselines all end in.00while thenode:vmfigures carry two decimals, which made me wonder whether the "before" column was measured or estimated. - Leftover vm2 references.
core/session.ts:72and:634,core/main_session_test.ts:164andcli/index_compile_test.ts:102still explain live workarounds in terms of "vm2's sandbox stack stripping". Sincecompile.ts:65-68now says the opposite holds, is the__df_enter/__dataform_current_filefile-stack machinery still needed for@dataform/core >= 3.0.57?
And lets make sure we are properly rebased on top of main branch now :)
| builtinModules: ["path"], | ||
| resolve: (moduleName, parentDirName) => | ||
| path.join(parentDirName, path.relative(parentDirName, compileConfig.projectDir), moduleName), | ||
| sourceExtensions: ["js", "sql", "sqlx", "yaml", "yml", "ipynb", "md"], |
There was a problem hiding this comment.
ipynb and md look unrelated to the vm2 removal
Adding these two isn't mentioned in the description and doesn't have a test, and it has a couple of knock-on effects: notebook and markdown files now go through the SQLX compiler, and both extensions join allExtensions, so they participate in extension-less resolution and directory-index lookup.
Is this by accident?
There was a problem hiding this comment.
Required for Dataform notebook actions (actions.yaml), which load .ipynb/.md via nativeRequire().asJson. Without registering these extensions, VmRunner tries to evaluate them as JS and throws a syntax error on "cells": [...]. Added a test.
There was a problem hiding this comment.
This should be an extension and part of another PR then - adding support for Notebooks.
|
After my review I would also like @Ikolina or someone who is longer in Dataform team to also take a look here. |
95db3da to
3fc433c
Compare
|
Thanks for addressing all the previous feedback so thoroughly — moving Just one small thing I noticed on |
| // Check project node_modules directory directly (e.g. @dataform/core) | ||
| const nodeModulesCandidate = path.resolve(this.projectDir, "node_modules", moduleName); | ||
| const resolvedNodeModules = this.tryResolvePath(nodeModulesCandidate); | ||
| if (resolvedNodeModules) { |
There was a problem hiding this comment.
should you also call isPathContained here?
There was a problem hiding this comment.
Done. Added containment check here as well.
| e && | ||
| e.code === "MODULE_NOT_FOUND" && | ||
| e.message && | ||
| e.message.includes("outside of project directory") |
There was a problem hiding this comment.
these checks are a bit clumsy taking into account that you throw an error just above so you could store a boolean flag and check it here
There was a problem hiding this comment.
Done. Moved containment check outside try/catch so we don't catch/re-throw our own error.
| if (!this.isPathContained(resolved)) { | ||
| const err: any = new Error( | ||
| `Cannot require '${moduleName}' outside of project directory '${this.projectDir}'`, | ||
| ); | ||
| err.code = "MODULE_NOT_FOUND"; | ||
| throw err; | ||
| } | ||
| this.resolveCache.set(cacheKey, resolved); | ||
| return resolved; |
There was a problem hiding this comment.
this logic is repeated in a lot of places, could we deduplicate it?
There was a problem hiding this comment.
Done. Deduplicated into helper checkContainmentAndCache().
| e && | ||
| e.code === "MODULE_NOT_FOUND" && | ||
| e.message && | ||
| e.message.includes("outside of project directory") | ||
| ) { | ||
| throw e; | ||
| } |
There was a problem hiding this comment.
same: let's save into boolean flag before throwing error above to avoid this complicated condition
There was a problem hiding this comment.
Done. Containment check moved outside try/catch.
| const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); | ||
| if (pkg.main) { | ||
| const mainPath = path.resolve(candidatePath, pkg.main); | ||
| const resolvedMain = this.tryResolvePath(mainPath); |
There was a problem hiding this comment.
can this theoretically create infinite recursion?
There was a problem hiding this comment.
Good catch. Added visitedDirs cycle tracking and guarded against mainPath === candidatePath.
| source = patchOldCoreCallerFile(source); | ||
| } | ||
| const compiledCode = compiler(source, filePath); | ||
| return ` |
There was a problem hiding this comment.
If v8 preserves call sites, we could conditionally apply this file-stack shim only for old versions?
There was a problem hiding this comment.
Done. Gated by semver.lt(dataformCoreVersion, "3.0.57").
| const graphError = (fileName: string, message: string, extra: object = {}) => ({ | ||
| fileName, | ||
| message, | ||
| stack: `Error: ${message}${graphStackTail}`, |
There was a problem hiding this comment.
you don't validate graphStackTail in new test version, is it intended?
There was a problem hiding this comment.
Yes — graphStackTail was vm2's mocked CallSite stub (\n at CallSite {}). Under native node:vm we get real V8 call frames, so we now assert /\n\s+at /.
| if (e && e.code === "MODULE_NOT_FOUND") { | ||
| throw e; | ||
| } |
There was a problem hiding this comment.
you swallow other errors here, is it intended?
There was a problem hiding this comment.
Fixed. We now suppress only MODULE_NOT_FOUND and re-throw all unexpected errors.
| require: { | ||
| builtin: [], | ||
| context: "sandbox", | ||
| external: { modules: ["@dataform/*"], transitive: false }, |
There was a problem hiding this comment.
is this intended that you don't pass these external modules in new version?
There was a problem hiding this comment.
Restored. Added allowedModules option to VmRunner and passed ["@dataform/*"] here.
| } | ||
| } | ||
| } else { | ||
| env = { ...process.env }; |
There was a problem hiding this comment.
not sure if default to all environment variables is a good default (unless we already have such test)
There was a problem hiding this comment.
Done. Defaulted process.env to {}.
|
Please start with feature set we use from vm2 - both explicitly and implicitly. Mark with features are crucial and required to remain. Verify if NodeVM fully covers that feature set. Have you considered other alternatives that would satisfy the requirements? Please present the report back. |
| builtinModules: ["path"], | ||
| resolve: (moduleName, parentDirName) => | ||
| path.join(parentDirName, path.relative(parentDirName, compileConfig.projectDir), moduleName), | ||
| sourceExtensions: ["js", "sql", "sqlx", "yaml", "yml", "ipynb", "md"], |
There was a problem hiding this comment.
This should be an extension and part of another PR then - adding support for Notebooks.
| ...(options.sandbox || {}), | ||
| }; | ||
|
|
||
| this.context = vm.createContext(sandbox); |
There was a problem hiding this comment.
The process shim exposes only env, cwd, version, versions, platform, arch. vm2's NodeVM proxied the real process. If @dataform/core's prebuilt bundle (or any user code) touches process.hrtime, process.exit, process.on, process.emit, process.nextTick, process.stdout, process.stderr it now TypeErrors. No integration test verifies the real core bundle boots against this shim in either the CLI or the JiT worker path. Please either widen the shim to a superset of what core needs, or add a smoke test that runs the current core bundle end-to-end through VmRunner.
| const vm = new VmRunner({ | ||
| projectDir, | ||
| builtinModules: [], | ||
| allowedModules: ["@dataform/*"], |
There was a problem hiding this comment.
vm2 had external: { modules: ["@dataform/"], transitive: false }. The new allowedModules: ["@dataform/"] drops the transitive-blocking semantics and means that if @dataform/core does ANY dynamic require("some-runtime-dep") (not a static bundled import), it'll be rejected. Confirm the core bundle has no dynamic requires at runtime, or widen the allowlist. Add a test covering the hasProjectLocalCore = true path (currently only a unit test of the VmRunner primitive exists - no test wires jit_worker end-to-end).
| this.nodeBuiltinSet = new Set(nodeBuiltins); | ||
|
|
||
| let env: Record<string, string | undefined>; | ||
| if (options.env !== undefined) { |
There was a problem hiding this comment.
Priority env wins over envAllowlist wins over empty default is undocumented, and if a caller passes BOTH env and envAllowlist, envAllowlist is silently ignored. Add a JSDoc line, or throw when both are set.
Add the test.
| }); | ||
| global.workflowSettingsYaml = (function() { try { return require("./workflow_settings.yaml"); } catch(e) { console.error("YAML require failed run_core:", e); } })(); | ||
| global.dataformJson = (function() { try { return require("./dataform.json"); } catch(e) {} })(); | ||
| ${hasWorkflowSettingsYaml ? 'global.workflowSettingsYaml = require("./workflow_settings.yaml");' : ""} |
There was a problem hiding this comment.
Removed the diagnostic console.error("YAML require failed run_core:", e) when replacing try/catch require("./workflow_settings.yaml") with a hasWorkflowSettingsYaml guard. If workflow_settings.yaml exists but is malformed, the failure surface changes silently (old: swallowed with a log; new: thrown as compile error). Was it indended?
| import { suite, test } from "df/testing"; | ||
| import { TmpDirFixture } from "df/testing/fixtures"; | ||
|
|
||
| suite("VmRunner", ({ afterEach }) => { |
There was a problem hiding this comment.
Missing coverage:
(a) require("node:path") prefixed builtin
(b) console: "off" swallowing logs
(c) mocked @dataform/core path where hasProjectLocalCore=false and a call inside the vm tries to re-require("@dataform/core") (jit_worker scenario).
Add three tiny cases.
| options.builtinModules !== undefined ? options.builtinModules : ["path"], | ||
| ); | ||
| this.mockModules = options.mockModules || {}; | ||
| this.customResolve = options.resolve; |
There was a problem hiding this comment.
Any reason for names not matching?
| }); | ||
| } | ||
|
|
||
| private checkContainmentAndCache( |
There was a problem hiding this comment.
Any reason to mix two responsibilities?
Technical Comparison: vm2 vs. node:vm (VmRunner), Alternatives, and Security Analysis1. Feature Matrix: vm2 (NodeVM) vs. node:vm (VmRunner)
2. Evaluation of Alternatives
3. Threat Model & Security AnalysisA. Compromised / Infected Third-Party npm Packages
B. Dynamic Code Download in
|
6c1f2c9 to
6c6cd2a
Compare
Summary
This PR replaces the deprecated and unmaintained
vm2library with nativenode:vm(VmRunner) across Dataform CLI, Core testing, and build targets.Migrating to native
node:vmremoves an unmaintained third-party dependency, addresses deprecation warnings and known CVEs associated withvm2, and delivers substantial performance gains across compilation workflows.Key Changes Across the Codebase
Native
VmRunnerImplementation (common/vm/vm_runner.ts&common/vm/BUILD):VmRunnerunder//common/vm(preventing circular layering with//cliand//testing).node:vm.createContextandnode:vm.compileFunctionwith scoped CommonJS require simulation.projectDirboundary containment (isPathContained) with an opt-inallowedExternalPathsconfiguration.moduleCacheon throw so that errors re-throw consistently on subsequentrequire()calls.envandenvAllowlistoptions.Uint8ArrayandArrayBufferin sandbox globals to ensure cross-realminstanceof Uint8Arraychecks pass inprotobufjs/ JIT compilation.resolveCacheand captures suppressed resolution errors aserr.causeonMODULE_NOT_FOUND.common/vm/vm_runner_test.tsvia//common/vm:tests).CLI Compilation & JIT Worker Migration (
cli/vm/compile.ts,cli/vm/jit_worker.ts):cli/vm/compile.tsfromNodeVMtoVmRunner..ipynb,.md) viasourceExtensions.cli/vm/jit_worker.tstoVmRunner.Core Test Harness & Property Graphs (
testing/run_core.ts,testing/BUILD,core/main_property_graphs_test.ts):testing/run_core.ts(runMainInVm) to execute tests throughVmRunner.core/main_property_graphs_test.tsby removing the artificialvm2empty CallSite stack workaround and unifying error assertions viaasPlainGraph.Packaging & Dependency Cleanup (
package.json,yarn.lock,packages/@dataform/cli/BUILD,packages/rollup.config.js):vm2frompackage.jsonandyarn.lock.vm2fromexternalsinpackages/@dataform/cli/BUILD.vmandmoduletoknownNodeBuiltinsinpackages/rollup.config.js.Performance Benchmark (
common/vm/vm_runner_benchmark.ts):bazel run //common/vm:benchmark(~10,300 module requires/sec).