mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
fix(ssh): compile node-pty from the host's own Node headers instead of nodejs.org
STA-6674: a Linux SSH host that cannot reach nodejs.org never came up. node-pty ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp's default is to download node-v<ver>-headers.tar.gz before configuring. The host refused that connection (ECONNREFUSED) and the relay deploy failed inside npm install, which the UI showed only as "Disconnected". Every official Node build and every version manager that unpacks one already has those exact headers at <prefix>/include/node. Export node-gyp's nodedir to that prefix, on every command that can compile node-pty (npm install, npm rebuild, the cloexec patch's rebuild), when the shipped node_version.h matches the running Node. Both npm_config_nodedir (node-gyp 10, Node 20) and npm_package_config_node_gyp_nodedir (node-gyp >= 11.4) are set so every Node the relay runs on reads it. A version mismatch leaves it unset, which is the existing behaviour. When a host is both header-less and offline, name that in the deploy error instead of forty lines of gyp http output, with the two remedies. Reproduced and verified with a Docker sshd whose nodejs.org resolves to 127.0.0.1, on node:24.12.0 (the user's version), node:20 and node:26: ssh-relay-offline-node-headers.docker.test.ts.
This commit is contained in:
@@ -163,3 +163,40 @@ export function formatMissingToolchainError(
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const NODE_HEADERS_TARBALL_RE = /node-v[0-9.]+-headers\.tar\.gz/i
|
||||
|
||||
/**
|
||||
* Whether a native-deps failure is node-gyp failing to download Node headers from nodejs.org.
|
||||
*
|
||||
* Why it needs naming: the raw output is forty lines of `gyp http` and stack frames around one
|
||||
* `ECONNREFUSED`, and it reads as a broken host or a broken Orca. It is neither -- the host's Node
|
||||
* ships no headers at `<prefix>/include/node` (so the local-headers export found nothing) AND it
|
||||
* cannot reach nodejs.org. Both are things the operator can fix; neither is visible from the log.
|
||||
*/
|
||||
export function isNodeHeadersDownloadFailure(message: string): boolean {
|
||||
return (
|
||||
message.toLowerCase().includes('gyp') &&
|
||||
NODE_HEADERS_TARBALL_RE.test(message) &&
|
||||
/\b(ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|fetch failed|FetchError)\b/i.test(
|
||||
message
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function formatNodeHeadersDownloadError(underlyingError: string): string {
|
||||
return [
|
||||
'The remote host could not download the Node.js headers needed to compile node-pty, and its ' +
|
||||
'Node install does not ship them locally. node-pty has no prebuilt binary for Linux, so it ' +
|
||||
'must be compiled on the remote host, and node-gyp fetches the headers from nodejs.org ' +
|
||||
'unless the Node install provides them at <prefix>/include/node.',
|
||||
'',
|
||||
'Fix one of the following on the remote host, then reconnect:',
|
||||
' - Install Node.js from an official build or a version manager (nvm, fnm, volta, n), ' +
|
||||
'which include the headers; or',
|
||||
' - Allow outbound HTTPS to nodejs.org, or point npm at a mirror: ' +
|
||||
'npm config set disturl https://<mirror>/dist',
|
||||
'',
|
||||
`Underlying install error: ${underlyingError}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
buildToolchainProbeCommand,
|
||||
parseBuildToolchainProbe,
|
||||
formatMissingToolchainError,
|
||||
formatNodeHeadersDownloadError,
|
||||
formatSkippedNodePtyWarning,
|
||||
isNodeHeadersDownloadFailure,
|
||||
shouldProbeBuildToolchainAfterNativeDepsFailure
|
||||
} from './ssh-relay-build-toolchain'
|
||||
|
||||
@@ -125,3 +127,47 @@ describe('formatSkippedNodePtyWarning', () => {
|
||||
expect(warning).toContain('install a C/C++ toolchain')
|
||||
})
|
||||
})
|
||||
|
||||
// Verbatim shape of the STA-6674 failure: node-gyp on a host whose nodejs.org is refused.
|
||||
const HEADERS_REFUSED =
|
||||
'npm error gyp http GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz\n' +
|
||||
'npm error gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNREFUSED\n' +
|
||||
'npm error gyp ERR! configure error\n' +
|
||||
'npm error gyp ERR! stack FetchError: request to https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz failed, reason: connect ECONNREFUSED 127.0.0.1:443'
|
||||
|
||||
describe('isNodeHeadersDownloadFailure', () => {
|
||||
it('matches node-gyp failing to fetch the Node headers tarball', () => {
|
||||
expect(isNodeHeadersDownloadFailure(HEADERS_REFUSED)).toBe(true)
|
||||
expect(
|
||||
isNodeHeadersDownloadFailure(
|
||||
'gyp http fetch GET https://nodejs.org/download/release/v20.19.0/node-v20.19.0-headers.tar.gz attempt 1 failed with ENOTFOUND'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is not the toolchain diagnosis, and does not fire on other network failures', () => {
|
||||
expect(shouldProbeBuildToolchainAfterNativeDepsFailure(HEADERS_REFUSED)).toBe(false)
|
||||
// The registry, not nodejs.org: a different remedy.
|
||||
expect(
|
||||
isNodeHeadersDownloadFailure(
|
||||
'npm error network request to https://registry.npmjs.org/node-pty failed, reason: connect ECONNREFUSED'
|
||||
)
|
||||
).toBe(false)
|
||||
// Headers named but the build failed for another reason.
|
||||
expect(
|
||||
isNodeHeadersDownloadFailure(
|
||||
'gyp info using node-v24.12.0-headers.tar.gz\ngyp ERR! build error make failed with exit code: 2'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatNodeHeadersDownloadError', () => {
|
||||
it('names both remedies and keeps the underlying error', () => {
|
||||
const msg = formatNodeHeadersDownloadError(HEADERS_REFUSED)
|
||||
expect(msg).toContain('<prefix>/include/node')
|
||||
expect(msg).toContain('nodejs.org')
|
||||
expect(msg).toContain('disturl')
|
||||
expect(msg).toContain('ECONNREFUSED')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,9 @@ export {
|
||||
shouldProbeBuildToolchainAfterNativeDepsFailure,
|
||||
toolchainInstallHintLines,
|
||||
formatSkippedNodePtyWarning,
|
||||
formatMissingToolchainError
|
||||
formatMissingToolchainError,
|
||||
formatNodeHeadersDownloadError,
|
||||
isNodeHeadersDownloadFailure
|
||||
} from './build-toolchain-diagnosis'
|
||||
export type { BuildToolchainStatus } from './build-toolchain-diagnosis'
|
||||
|
||||
|
||||
@@ -53,11 +53,14 @@ import {
|
||||
} from './ssh-relay-deploy-timing'
|
||||
import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils'
|
||||
import { isWindowsRelayPlatform } from '../../shared/relay-artifacts'
|
||||
import { exportLocalNodeHeadersPrefix } from './ssh-relay-node-headers'
|
||||
import {
|
||||
probeBuildToolchain,
|
||||
formatMissingToolchainError,
|
||||
formatSkippedNodePtyWarning,
|
||||
shouldProbeBuildToolchainAfterNativeDepsFailure
|
||||
shouldProbeBuildToolchainAfterNativeDepsFailure,
|
||||
formatNodeHeadersDownloadError,
|
||||
isNodeHeadersDownloadFailure
|
||||
} from './ssh-relay-build-toolchain'
|
||||
import {
|
||||
commandWithNodePath,
|
||||
@@ -1174,7 +1177,7 @@ async function installNativeDeps(
|
||||
hostPlatform,
|
||||
nodePath,
|
||||
remoteDir,
|
||||
`${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1`
|
||||
`${exportLocalNodeHeadersPrefix(nodePath)}${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1`
|
||||
)
|
||||
await execHostCommand(conn, hostPlatform, command, {
|
||||
timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS,
|
||||
@@ -1236,6 +1239,11 @@ async function installNativeDeps(
|
||||
return
|
||||
}
|
||||
}
|
||||
// Why: the local-headers export already found nothing on this host, so what is left is a host
|
||||
// that is both header-less and offline -- name it, or the log reads as a broken relay.
|
||||
if (platform.startsWith('linux') && isNodeHeadersDownloadFailure(msg)) {
|
||||
throw new Error(formatNodeHeadersDownloadError(msg), { cause: err })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -1347,7 +1355,7 @@ async function applyNodePtyMasterCloexecPatch(
|
||||
hostPlatform,
|
||||
nodePath,
|
||||
remoteDir,
|
||||
`${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1`
|
||||
`${exportLocalNodeHeadersPrefix(nodePath)}${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1`
|
||||
)
|
||||
const output = await execHostCommand(conn, hostPlatform, command, {
|
||||
timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS,
|
||||
@@ -1529,7 +1537,7 @@ async function rebuildNativeDeps(
|
||||
hostPlatform,
|
||||
nodePath,
|
||||
remoteDir,
|
||||
`npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1`
|
||||
`${exportLocalNodeHeadersPrefix(nodePath)}npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1`
|
||||
)
|
||||
await execHostCommand(conn, hostPlatform, command, {
|
||||
timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS,
|
||||
|
||||
@@ -154,6 +154,47 @@ describe('installNativeDeps staged uploads', () => {
|
||||
expect(writeObservedAt).toBeLessThanOrEqual(npmInstallIdx)
|
||||
})
|
||||
|
||||
it('exports the host Node headers dir to node-gyp on every command that can compile node-pty (STA-6674)', async () => {
|
||||
const conn = makeMockConnection(sftpCapture)
|
||||
// Install succeeds, the probe fails, the rebuild repairs it, then the cloexec patch rebuilds again.
|
||||
feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' }))
|
||||
|
||||
await deployAndLaunchRelay(conn)
|
||||
|
||||
const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command)
|
||||
const compiling = ['npm install', 'npm rebuild', 'node-pty-1.1.0-master-cloexec-patch.cjs']
|
||||
for (const compileStep of compiling) {
|
||||
const command = commands.find((candidate) => candidate.includes(compileStep))
|
||||
expect(command, compileStep).toBeDefined()
|
||||
// Both spellings: node-gyp 10 (Node 20) reads only npm_config_, node-gyp >= 11.4 prefers the other.
|
||||
expect(command).toContain('export npm_config_nodedir=')
|
||||
expect(command).toContain('npm_package_config_node_gyp_nodedir=')
|
||||
// The export precedes the compile on the same command line, and only when the probe found headers.
|
||||
expect(command!.indexOf('npm_config_nodedir')).toBeLessThan(command!.indexOf(compileStep))
|
||||
expect(command).toContain('node_version.h')
|
||||
}
|
||||
})
|
||||
|
||||
it('names the fix when node-gyp cannot download headers and the host ships none (STA-6674)', async () => {
|
||||
const conn = makeMockConnection(sftpCapture)
|
||||
feed(
|
||||
makeExecResponses({
|
||||
npmInstall: {
|
||||
reject:
|
||||
'Command "npm install" failed (exit 1): npm error gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNREFUSED\nnpm error gyp ERR! configure error'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const error = await deployAndLaunchRelay(conn).catch((e: Error) => e)
|
||||
expect((error as Error).message).toContain('could not download the Node.js headers')
|
||||
expect((error as Error).message).toContain('ECONNREFUSED')
|
||||
// A full toolchain: the toolchain probe must not run, and this is not a "build tools" error.
|
||||
expect((error as Error).message).not.toContain('build tools')
|
||||
const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command)
|
||||
expect(commands.some((command) => command.includes('command -v "$t"'))).toBe(false)
|
||||
})
|
||||
|
||||
it('promotes only after the first-install lock is acquired', async () => {
|
||||
const conn = makeMockConnection(sftpCapture)
|
||||
feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' }))
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { exportLocalNodeHeadersPrefix } from './ssh-relay-node-headers'
|
||||
|
||||
const POSIX = process.platform !== 'win32'
|
||||
|
||||
/** Runs the prefix under /bin/sh exactly as the relay does, then prints what node-gyp would see. */
|
||||
function runPrefix(nodePath: string): { nodedir: string; pkgNodedir: string } {
|
||||
const script = `${exportLocalNodeHeadersPrefix(nodePath)}printf '%s\\n%s\\n' "$npm_config_nodedir" "$npm_package_config_node_gyp_nodedir"`
|
||||
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' })
|
||||
expect(result.status).toBe(0)
|
||||
const [nodedir = '', pkgNodedir = ''] = result.stdout.split('\n')
|
||||
return { nodedir, pkgNodedir }
|
||||
}
|
||||
|
||||
/** A fake `<prefix>/bin/node` whose `include/node/node_version.h` claims `version`. */
|
||||
function fakeNodePrefix(root: string, version: string): string {
|
||||
const prefix = join(root, 'prefix')
|
||||
mkdirSync(join(prefix, 'bin'), { recursive: true })
|
||||
mkdirSync(join(prefix, 'include', 'node'), { recursive: true })
|
||||
const [major, minor, patch] = version.split('.')
|
||||
writeFileSync(
|
||||
join(prefix, 'include', 'node', 'node_version.h'),
|
||||
`#define NODE_MAJOR_VERSION ${major}\n#define NODE_MINOR_VERSION ${minor}\n#define NODE_PATCH_VERSION ${patch}\n`
|
||||
)
|
||||
// Why a symlink to the real binary: the probe reads process.execPath, which Node resolves
|
||||
// through symlinks -- so this stands in for `/usr/bin/node -> /opt/node/bin/node` shims too.
|
||||
symlinkSync(process.execPath, join(prefix, 'bin', 'node'))
|
||||
return join(prefix, 'bin', 'node')
|
||||
}
|
||||
|
||||
describe.skipIf(!POSIX)('exportLocalNodeHeadersPrefix', () => {
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('exports nodedir when the running Node ships headers for its own version', () => {
|
||||
// The test runner's Node is an official build, so its prefix has include/node.
|
||||
const prefix = dirname(dirname(process.execPath))
|
||||
const { nodedir, pkgNodedir } = runPrefix(process.execPath)
|
||||
expect(nodedir).toBe(prefix)
|
||||
expect(pkgNodedir).toBe(prefix)
|
||||
})
|
||||
|
||||
it('leaves nodedir unset when the shipped headers are for another Node version', () => {
|
||||
// A symlinked node resolves execPath to the real binary, whose prefix is the real one; so
|
||||
// to stage a mismatch the probe must run a node whose execPath lands in the fake prefix.
|
||||
// A copy does that.
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-'))
|
||||
roots.push(root)
|
||||
const prefix = join(root, 'prefix')
|
||||
mkdirSync(join(prefix, 'bin'), { recursive: true })
|
||||
mkdirSync(join(prefix, 'include', 'node'), { recursive: true })
|
||||
writeFileSync(
|
||||
join(prefix, 'include', 'node', 'node_version.h'),
|
||||
'#define NODE_MAJOR_VERSION 1\n#define NODE_MINOR_VERSION 0\n#define NODE_PATCH_VERSION 0\n'
|
||||
)
|
||||
const copied = join(prefix, 'bin', 'node')
|
||||
spawnSync('cp', [process.execPath, copied])
|
||||
const { nodedir, pkgNodedir } = runPrefix(copied)
|
||||
expect(nodedir).toBe('')
|
||||
expect(pkgNodedir).toBe('')
|
||||
})
|
||||
|
||||
it('leaves nodedir unset when the prefix has no headers at all', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-'))
|
||||
roots.push(root)
|
||||
const copied = join(root, 'bin', 'node')
|
||||
mkdirSync(dirname(copied), { recursive: true })
|
||||
spawnSync('cp', [process.execPath, copied])
|
||||
const { nodedir } = runPrefix(copied)
|
||||
expect(nodedir).toBe('')
|
||||
})
|
||||
|
||||
it('follows a symlinked node to the install that owns the headers', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-'))
|
||||
roots.push(root)
|
||||
const shim = fakeNodePrefix(root, '0.0.0')
|
||||
// The shim's own fake headers are ignored: execPath resolves to the real binary, and the
|
||||
// real prefix's headers are the ones that match.
|
||||
const { nodedir } = runPrefix(shim)
|
||||
expect(nodedir).toBe(dirname(dirname(process.execPath)))
|
||||
})
|
||||
|
||||
it('does not fail the command line when node itself cannot run', () => {
|
||||
const script = `${exportLocalNodeHeadersPrefix('/nonexistent/node')}echo "after:$npm_config_nodedir"`
|
||||
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' })
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.trim()).toBe('after:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Point node-gyp at the headers the host's Node install already ships, so compiling node-pty
|
||||
* needs nothing from nodejs.org.
|
||||
*
|
||||
* Why: node-pty has no Linux prebuild, so every Linux relay compiles it, and node-gyp's default
|
||||
* is to download `node-v<ver>-headers.tar.gz` before configuring. Every official Node build, and
|
||||
* every version manager that unpacks one (nvm, fnm, volta, mise, n), already has those exact
|
||||
* headers at `<prefix>/include/node`. The download was the only step that needed the internet,
|
||||
* so a firewalled host failed with ECONNREFUSED on work that never had to happen (STA-6674).
|
||||
*
|
||||
* Why both variables: node-gyp >= 11.4 prefers `npm_package_config_node_gyp_<key>` and npm 11+
|
||||
* warns that arbitrary `npm_config_<key>` is deprecated, but node-gyp 10 (bundled with Node 20)
|
||||
* reads only `npm_config_<key>`. Both together cover every Node the relay runs on.
|
||||
*
|
||||
* Why the version check: node-gyp trusts `nodedir` blindly. A distro `/usr/include/node` left by
|
||||
* an older headers package would compile a binding for the wrong ABI, which loads and crashes
|
||||
* instead of failing at install. A mismatch leaves the variables unset, which is today's path.
|
||||
*/
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
|
||||
/** Shell variable the probe answers into; namespaced so it cannot collide with npm's own. */
|
||||
const NODEDIR_SHELL_VAR = 'ORCA_NODE_HEADERS_DIR'
|
||||
|
||||
/**
|
||||
* Prints the running Node's install prefix when `<prefix>/include/node/node_version.h` matches
|
||||
* `process.versions.node`, and nothing otherwise. `process.execPath` is symlink-resolved, so a
|
||||
* `/usr/bin/node` -> `/opt/node/bin/node` shim still finds `/opt/node/include`.
|
||||
*/
|
||||
export const LOCAL_NODE_HEADERS_PROBE_JS = [
|
||||
'const p=require("path"),f=require("fs");',
|
||||
'const d=p.dirname(p.dirname(process.execPath));',
|
||||
'try{',
|
||||
'const h=f.readFileSync(p.join(d,"include","node","node_version.h"),"utf8");',
|
||||
'const v=["MAJOR","MINOR","PATCH"].map(k=>(h.match(new RegExp("#define NODE_"+k+"_VERSION ([0-9]+)"))||[])[1]).join(".");',
|
||||
'if(v===process.versions.node)process.stdout.write(d)',
|
||||
'}catch{}'
|
||||
].join('')
|
||||
|
||||
/**
|
||||
* POSIX-sh prefix (`...; `) that exports node-gyp's `nodedir` for the rest of the command line
|
||||
* when the host's Node ships matching headers. Prepend to any command that may compile node-pty:
|
||||
* `npm install`, `npm rebuild`, and the cloexec patch (its `npm rebuild` inherits the env).
|
||||
*/
|
||||
export function exportLocalNodeHeadersPrefix(nodePath: string): string {
|
||||
const probe = `${shellEscape(nodePath)} -e ${shellEscape(LOCAL_NODE_HEADERS_PROBE_JS)} 2>/dev/null`
|
||||
return (
|
||||
`${NODEDIR_SHELL_VAR}=$(${probe}); ` +
|
||||
`if [ -n "$${NODEDIR_SHELL_VAR}" ]; then ` +
|
||||
`export npm_config_nodedir="$${NODEDIR_SHELL_VAR}" npm_package_config_node_gyp_nodedir="$${NODEDIR_SHELL_VAR}"; ` +
|
||||
`fi; `
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Why this exists (STA-6674): a Linux host whose only unreachable endpoint is nodejs.org could
|
||||
// not run a relay. node-pty ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp
|
||||
// downloads `node-v<ver>-headers.tar.gz` unless told the host already has the headers -- which
|
||||
// every official Node install does, at `<prefix>/include/node`. This drives the real deploy at a
|
||||
// Docker sshd whose nodejs.org resolves to 127.0.0.1 (ECONNREFUSED, exactly what the user saw).
|
||||
//
|
||||
// Run: ORCA_REVIEW_SSH_OFFLINE_HEADERS=1 pnpm test src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts
|
||||
// Needs Docker and `pnpm build:relay`. ORCA_REVIEW_SSH_NODE_IMAGE picks the Node image
|
||||
// (default node:24.12.0-bookworm, the user's version); ORCA_REVIEW_SSH_TARGET_HOST overrides
|
||||
// the address the app connects to (default 127.0.0.1).
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } }))
|
||||
|
||||
import { SshConnection } from './ssh-connection'
|
||||
import { deployAndLaunchRelay } from './ssh-relay-deploy'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
|
||||
const RUN_REVIEW_ORACLE = process.env.ORCA_REVIEW_SSH_OFFLINE_HEADERS === '1'
|
||||
const NODE_IMAGE = process.env.ORCA_REVIEW_SSH_NODE_IMAGE ?? 'node:24.12.0-bookworm'
|
||||
const TARGET_HOST = process.env.ORCA_REVIEW_SSH_TARGET_HOST ?? '127.0.0.1'
|
||||
|
||||
type TargetFixture = {
|
||||
containerName: string
|
||||
identityFile: string
|
||||
port: number
|
||||
tempDir: string
|
||||
}
|
||||
|
||||
function run(command: string, args: string[], timeout = 30_000, input?: string): string {
|
||||
return execFileSync(command, args, {
|
||||
encoding: 'utf8',
|
||||
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
||||
timeout,
|
||||
input
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function dockerExec(fixture: TargetFixture, command: string): string {
|
||||
return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 60_000)
|
||||
}
|
||||
|
||||
function startTarget(): TargetFixture {
|
||||
const image = `orca-review-offline-headers:${NODE_IMAGE.replace(/[^A-Za-z0-9_.-]/g, '-')}`
|
||||
run(
|
||||
'docker',
|
||||
['build', '-q', '-t', image, '-'],
|
||||
600_000,
|
||||
[
|
||||
`FROM ${NODE_IMAGE}`,
|
||||
'RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server git && rm -rf /var/lib/apt/lists/* && mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'orca-offline-headers-ssh-'))
|
||||
const identityFile = join(tempDir, 'id_ed25519')
|
||||
run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q'])
|
||||
const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim()
|
||||
const containerName = `orca-offline-headers-${randomUUID().slice(0, 12)}`
|
||||
// Why a refused connection and not a dropped one: a timeout takes node-gyp's retry path and
|
||||
// burns the deploy budget; the user's host refused, and that is the path under test.
|
||||
run(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
containerName,
|
||||
'--add-host',
|
||||
'nodejs.org:127.0.0.1',
|
||||
'-p',
|
||||
'0.0.0.0::22',
|
||||
'-e',
|
||||
`AUTHORIZED_KEY=${publicKey}`,
|
||||
image,
|
||||
'bash',
|
||||
'-lc',
|
||||
'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e'
|
||||
],
|
||||
120_000
|
||||
)
|
||||
const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1))
|
||||
return { containerName, identityFile, port, tempDir }
|
||||
}
|
||||
|
||||
function stopTarget(fixture: TargetFixture | null): void {
|
||||
if (!fixture) {
|
||||
return
|
||||
}
|
||||
spawnSync('docker', ['rm', '-f', fixture.containerName], { stdio: 'ignore', timeout: 30_000 })
|
||||
rmSync(fixture.tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function createConnection(fixture: TargetFixture): SshConnection {
|
||||
const target: SshTarget = {
|
||||
id: `offline-headers-${randomUUID()}`,
|
||||
label: 'Offline node headers Docker SSH target',
|
||||
source: 'manual',
|
||||
host: TARGET_HOST,
|
||||
port: fixture.port,
|
||||
username: 'root',
|
||||
identityFile: fixture.identityFile,
|
||||
identitiesOnly: true
|
||||
}
|
||||
return new SshConnection(target, { onStateChange: vi.fn() })
|
||||
}
|
||||
|
||||
describe.skipIf(!RUN_REVIEW_ORACLE)(
|
||||
'SSH relay deploy on a host that cannot reach nodejs.org',
|
||||
() => {
|
||||
let fixture: TargetFixture | null = null
|
||||
|
||||
beforeAll(() => {
|
||||
fixture = startTarget()
|
||||
}, 900_000)
|
||||
|
||||
afterAll(() => {
|
||||
stopTarget(fixture)
|
||||
})
|
||||
|
||||
it('compiles node-pty from the host Node install headers instead of downloading them', async () => {
|
||||
const activeFixture = fixture as TargetFixture
|
||||
expect(dockerExec(activeFixture, 'getent hosts nodejs.org')).toContain('127.0.0.1')
|
||||
const connection = createConnection(activeFixture)
|
||||
await connection.connect()
|
||||
try {
|
||||
const result = await deployAndLaunchRelay(connection, undefined, 60)
|
||||
expect(result.remoteRelayDir).toBeTruthy()
|
||||
|
||||
const evidence = dockerExec(
|
||||
activeFixture,
|
||||
[
|
||||
`cd '${result.remoteRelayDir}'`,
|
||||
'test -f node_modules/node-pty/build/Release/pty.node && echo PTY_NODE=built',
|
||||
'test -d /root/.cache/node-gyp && echo HEADERS=downloaded || echo HEADERS=local',
|
||||
`node -e "require('node-pty'); require('@parcel/watcher'); console.log('NATIVE=loadable')"`
|
||||
].join('; ')
|
||||
)
|
||||
console.log(`[offline-node-headers] ${NODE_IMAGE}: ${evidence.replace(/\n/g, ' ')}`)
|
||||
expect(evidence).toContain('PTY_NODE=built')
|
||||
expect(evidence).toContain('HEADERS=local')
|
||||
expect(evidence).toContain('NATIVE=loadable')
|
||||
} finally {
|
||||
await connection.disconnect()
|
||||
}
|
||||
}, 600_000)
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user