mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
* fix(orcad): deploy the glibc 2.17 runtime on old-glibc hosts, and fall back to the pinned relay Managed orcad picked its runtime from the host's libc flavour alone, so a CentOS 7 host (glibc 2.17) got the default linux-x64-glibc Node, whose self-test fails there. The deploy now picks the runtime by glibc the same way the relay ladder picks rung A or B, and the host-side slot checks accept the compat target it ships. When orcad still can't run, the host is recorded as such and the relay connect that follows now runs the pinned-runtime ladder instead of defaulting to the host-Node relay, so a host with no Node lands on rung B rather than failing. The hostile-host lane deploys managed orcad on a fresh CentOS 7 host and asserts it runs on the compat runtime with no default runtime uploaded. * test(ssh): CentOS 7 cell asserts the compat runtime pick, then the refusal and relay rung B fallback The compat template still ships the base @parcel/watcher binary, which needs GLIBCXX_3.4.20; CentOS 7's libstdc++ stops at 3.4.19, so the candidate's preflight refuses. The cell now pins that end-to-end behaviour: compat runtime picked and uploaded alone, refusal classified native_preflight, and the relay that follows settles on rung B with no runtime setting. --------- Co-authored-by: m4air <m4air@Mac.localdomain>
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
/**
|
|
* Plan a relay launch on Orca's pinned Node with the orcad slot's prebuilt addons, instead
|
|
* of the host's Node plus a host-side npm install (design D5, D6 rung A, D8.1).
|
|
*
|
|
* Opt-in per host (`SshTarget.remoteRuntime`), and on by default where managed orcad can't run.
|
|
* Anything this module cannot establish on the client, and every classified refusal from the
|
|
* host, falls back to the legacy host-Node path with a logged reason.
|
|
*/
|
|
import { createHash } from 'node:crypto'
|
|
import { chmod, copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { getAppEnvironment } from '../../shared/app-environment'
|
|
import {
|
|
isWindowsServerTarget,
|
|
pinnedNodeRuntimeAsset,
|
|
type CompatServerTarget,
|
|
type NodeRuntimeTarget
|
|
} from '../../shared/node-runtime-pin'
|
|
import {
|
|
ORCAD_NODE_PTY_JS_ARTIFACTS,
|
|
ORCAD_PARCEL_WATCHER_ENTRY,
|
|
ORCAD_PARCEL_WATCHER_NATIVE,
|
|
ORCAD_WINDOWS_PROCESS_TREE_FILENAME,
|
|
orcadNodePtyNativeArtifacts,
|
|
orcadNodeRuntimeExecutable
|
|
} from '../../shared/orcad-artifacts'
|
|
import {
|
|
DEFAULT_SSH_REMOTE_RUNTIME,
|
|
SSH_REMOTE_RUNTIMES,
|
|
type SshRemoteRuntime,
|
|
type SshTarget
|
|
} from '../../shared/ssh-types'
|
|
import { materializeOrcadArtifact } from './orcad-artifact-materializer'
|
|
import {
|
|
resolveOrcadDeploymentTargetFacts,
|
|
UnidentifiedHostLibcError,
|
|
type GlibcVersion,
|
|
type OrcadDeploymentTargetFacts
|
|
} from './orcad-deployment-target'
|
|
import { remoteNodeRuntimeDir } from './orcad-remote-node-runtime'
|
|
import { fileSha256, materializeNodeRuntimeArchive } from './pinned-runtime-materializer'
|
|
import type { SshConnection } from './ssh-connection'
|
|
import { PINNED_RUNTIME_REFUSALS, type PinnedRuntimeRefusal } from './ssh-relay-runtime-self-test'
|
|
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
|
|
|
/** Content-keyed like ripgrep's refs, so runtime GC can find holders without a new concept (D5). */
|
|
export const RELAY_RUNTIME_REF_PREFIX = '.runtime-ref-node-'
|
|
/** Official linux builds of the pinned Node need glibc 2.28; older hosts are rung B/C territory. */
|
|
export const PINNED_NODE_GLIBC_FLOOR: GlibcVersion = { major: 2, minor: 28 }
|
|
/** Developer override when no per-host setting is saved. */
|
|
export const SSH_REMOTE_RUNTIME_ENV = 'ORCA_SSH_REMOTE_RUNTIME'
|
|
|
|
export function resolveSshRemoteRuntime(
|
|
target: Pick<SshTarget, 'remoteRuntime' | 'managedServerUnavailable'> | undefined,
|
|
env: NodeJS.ProcessEnv = process.env
|
|
): SshRemoteRuntime {
|
|
if (target?.remoteRuntime) {
|
|
return target.remoteRuntime
|
|
}
|
|
const fromEnv = SSH_REMOTE_RUNTIMES.find((runtime) => runtime === env[SSH_REMOTE_RUNTIME_ENV])
|
|
// Why: where managed orcad can't run the relay is the host's server; the ladder still ends at legacy.
|
|
return fromEnv ?? (target?.managedServerUnavailable ? 'pinned-node' : DEFAULT_SSH_REMOTE_RUNTIME)
|
|
}
|
|
|
|
/** Why `recorded`: this connect's host-server decision may have just recorded orcad unavailable. */
|
|
export function resolveConnectRemoteRuntime(
|
|
target: SshTarget | undefined,
|
|
recorded: SshTarget | undefined
|
|
): SshRemoteRuntime {
|
|
const managedServerUnavailable =
|
|
recorded?.managedServerUnavailable ?? target?.managedServerUnavailable
|
|
return resolveSshRemoteRuntime(target && { ...target, managedServerUnavailable })
|
|
}
|
|
|
|
export function isGlibcBelow(version: GlibcVersion, floor: GlibcVersion): boolean {
|
|
return (
|
|
version.major < floor.major || (version.major === floor.major && version.minor < floor.minor)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Folds the runtime and addon bytes into the relay's content version, so a pinned build and a
|
|
* host-Node build of the same relay never share a directory or a socket (D8.1).
|
|
*/
|
|
export function pinnedNodeRelayFullVersion(
|
|
baseVersion: string,
|
|
executableSha256: string,
|
|
addonDigest: string
|
|
): string {
|
|
const match = /^(v?[0-9]+\.[0-9]+\.[0-9]+)(?:\+[0-9a-f]+)?$/.exec(baseVersion)
|
|
if (!match) {
|
|
throw new Error(`Relay version is not <semver>[+<hex>]: ${baseVersion}`)
|
|
}
|
|
const digest = createHash('sha256')
|
|
.update(`${baseVersion}\0node-runtime\0${executableSha256}\0${addonDigest}`)
|
|
.digest('hex')
|
|
return `${match[1]}+${digest.slice(0, 12)}`
|
|
}
|
|
|
|
export function pinnedRelayAddonFiles(target: NodeRuntimeTarget): string[] {
|
|
return [
|
|
...ORCAD_NODE_PTY_JS_ARTIFACTS,
|
|
...orcadNodePtyNativeArtifacts(target),
|
|
ORCAD_PARCEL_WATCHER_ENTRY,
|
|
ORCAD_PARCEL_WATCHER_NATIVE,
|
|
// The slot's patched copy, so the process table never falls back to a PowerShell scan.
|
|
...(isWindowsServerTarget(target) ? [ORCAD_WINDOWS_PROCESS_TREE_FILENAME] : [])
|
|
]
|
|
}
|
|
|
|
export type PinnedRelayAddons = {
|
|
/** Upload this directory's contents into the relay version dir. */
|
|
dir: string
|
|
digest: string
|
|
dispose: () => Promise<void>
|
|
}
|
|
|
|
/** Copies this target's node-pty and watcher out of a verified orcad slot, beside a runtime ref. */
|
|
export async function stagePinnedRelayAddons(
|
|
orcadDir: string,
|
|
target: NodeRuntimeTarget,
|
|
stagingParent: string = tmpdir(),
|
|
/** Rung C runs on the host's Node, so its dir must not hold a pinned runtime against GC. */
|
|
options: { runtimeRef: boolean } = { runtimeRef: true }
|
|
): Promise<PinnedRelayAddons> {
|
|
const dir = await mkdtemp(join(stagingParent, 'orca-relay-addons-'))
|
|
try {
|
|
const hash = createHash('sha256')
|
|
for (const file of pinnedRelayAddonFiles(target).sort()) {
|
|
const source = join(orcadDir, ...file.split('/'))
|
|
const sha = await fileSha256(source)
|
|
if (!sha) {
|
|
throw new Error(`The orcad slot for ${target} has no ${file}`)
|
|
}
|
|
const destination = join(dir, ...file.split('/'))
|
|
await mkdir(dirname(destination), { recursive: true })
|
|
await copyFile(source, destination)
|
|
if (file.endsWith('/spawn-helper')) {
|
|
await chmod(destination, 0o755)
|
|
}
|
|
hash.update(`${file}\0${sha}\n`)
|
|
}
|
|
if (options.runtimeRef) {
|
|
const executableSha256 = pinnedNodeRuntimeAsset(target).executableSha256
|
|
await writeFile(
|
|
join(dir, `${RELAY_RUNTIME_REF_PREFIX}${executableSha256}`),
|
|
`${executableSha256}\n`
|
|
)
|
|
}
|
|
return {
|
|
dir,
|
|
digest: hash.digest('hex'),
|
|
dispose: () => rm(dir, { recursive: true, force: true })
|
|
}
|
|
} catch (error) {
|
|
await rm(dir, { recursive: true, force: true })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/** `~/.orca-remote/runtimes/node-<sha>/bin/node` (`…\\node.exe` on Windows), shared with orcad slots. */
|
|
export function pinnedRelayNodePath(
|
|
host: RemoteHostPlatform,
|
|
remoteRelayDir: string,
|
|
target: NodeRuntimeTarget
|
|
): string {
|
|
return joinRemotePath(
|
|
host,
|
|
remoteNodeRuntimeDir(host, remoteRelayDir, target),
|
|
...orcadNodeRuntimeExecutable(target).split('/')
|
|
)
|
|
}
|
|
|
|
export type PinnedRelayPlan = {
|
|
kind: 'pinned-node'
|
|
/** A compat target (rung B) when the plan runs a compat runtime on an older-glibc host. */
|
|
target: NodeRuntimeTarget
|
|
glibc: GlibcVersion | null
|
|
fullVersion: string
|
|
addons: PinnedRelayAddons
|
|
runtimeArchive: () => Promise<string>
|
|
}
|
|
|
|
export type RelayRuntimeFallbackReason =
|
|
| PinnedRuntimeRefusal
|
|
/** Rung C only: the host-Node addon relay is POSIX-only. */
|
|
| 'windows_host_unsupported'
|
|
| 'target_unresolved'
|
|
| 'artifacts_unavailable'
|
|
/** No runtime exists for this rung and host (rung B before a compat build ships). */
|
|
| 'runtime_unavailable'
|
|
/** Rung C found no host Node >= 18 with the addons' N-API level. */
|
|
| 'host_node_missing'
|
|
|
|
export type HostNodeRelayPlan = {
|
|
kind: 'host-node'
|
|
fallbackReason?: RelayRuntimeFallbackReason
|
|
/** Replayed from a cache rather than proved on this connect. */
|
|
remembered?: boolean
|
|
}
|
|
|
|
/** The pinned path cannot run on this host or client; the deploy retries on the host's Node. */
|
|
export class PinnedRelayFallbackError extends Error {
|
|
constructor(
|
|
readonly reason: RelayRuntimeFallbackReason,
|
|
readonly detail: string,
|
|
/** A remembered refusal skips its rung but never proves anything about the next one. */
|
|
readonly remembered = false
|
|
) {
|
|
super(`Orca's pinned Node relay is unavailable (${reason}): ${detail}`)
|
|
this.name = 'PinnedRelayFallbackError'
|
|
}
|
|
}
|
|
|
|
export function isPinnedRuntimeRefusal(reason: string): reason is PinnedRuntimeRefusal {
|
|
return PINNED_RUNTIME_REFUSALS.some((refusal) => refusal === reason)
|
|
}
|
|
|
|
// Why also in memory: the persisted decision is written only once the ladder settles.
|
|
const refusals = new Map<string, PinnedRuntimeRefusal>()
|
|
|
|
function refusalKey(targetId: string, target: NodeRuntimeTarget): string {
|
|
return `${targetId}\0${pinnedNodeRuntimeAsset(target).executableSha256}`
|
|
}
|
|
|
|
export function recordPinnedRuntimeRefusal(
|
|
targetId: string,
|
|
target: NodeRuntimeTarget,
|
|
refusal: PinnedRuntimeRefusal
|
|
): void {
|
|
refusals.set(refusalKey(targetId, target), refusal)
|
|
}
|
|
|
|
/** Forgets a refusal a later rung has disproved, so the next connect retries rung A. */
|
|
export function forgetPinnedRuntimeRefusal(targetId: string, target: NodeRuntimeTarget): void {
|
|
refusals.delete(refusalKey(targetId, target))
|
|
}
|
|
|
|
export function resetPinnedRuntimeRefusalsForTests(): void {
|
|
refusals.clear()
|
|
}
|
|
|
|
export function logPinnedRelayFallback(
|
|
reason: RelayRuntimeFallbackReason,
|
|
detail: string
|
|
): HostNodeRelayPlan {
|
|
console.warn(`[ssh-relay] Pinned Node relay unavailable (${reason}): ${detail}; using host Node`)
|
|
return { kind: 'host-node', fallbackReason: reason }
|
|
}
|
|
|
|
/**
|
|
* The host's server target, or a fallback when the libc probe answered with nothing known.
|
|
* Why only an answered probe falls back: a lost channel says nothing about the host, and
|
|
* descending would launch a second daemon beside a running pinned one, stranding its sessions.
|
|
*/
|
|
export async function resolvePinnedRelayTargetFacts(options: {
|
|
conn: SshConnection
|
|
host: RemoteHostPlatform
|
|
signal?: AbortSignal
|
|
}): Promise<OrcadDeploymentTargetFacts | HostNodeRelayPlan> {
|
|
try {
|
|
return await resolveOrcadDeploymentTargetFacts(options)
|
|
} catch (error) {
|
|
if (!(error instanceof UnidentifiedHostLibcError)) {
|
|
throw error
|
|
}
|
|
return logPinnedRelayFallback('target_unresolved', error.message)
|
|
}
|
|
}
|
|
|
|
export async function planPinnedNodeRelay(options: {
|
|
conn: SshConnection
|
|
host: RemoteHostPlatform
|
|
baseVersion: string
|
|
targetId: string
|
|
signal?: AbortSignal
|
|
/** Already resolved by the ladder; resolved here when absent. */
|
|
facts?: OrcadDeploymentTargetFacts
|
|
/** A refusal persisted for this host under a still-matching key (D6). */
|
|
persistedRefusal?: (facts: OrcadDeploymentTargetFacts) => PinnedRuntimeRefusal | null
|
|
/** Rung B: run this compat runtime and its slot instead of the host target's own. */
|
|
compat?: { target: CompatServerTarget; glibcFloor: GlibcVersion | null }
|
|
materializeOrcad?: (target: NodeRuntimeTarget, signal?: AbortSignal) => Promise<string>
|
|
runtimeCacheRoot?: () => string
|
|
}): Promise<PinnedRelayPlan | HostNodeRelayPlan> {
|
|
const { host, signal } = options
|
|
const facts =
|
|
options.facts ?? (await resolvePinnedRelayTargetFacts({ conn: options.conn, host, signal }))
|
|
if ('kind' in facts) {
|
|
return facts
|
|
}
|
|
const { glibc } = facts
|
|
const { compat } = options
|
|
const target: NodeRuntimeTarget = compat?.target ?? facts.target
|
|
const cached = refusals.get(refusalKey(options.targetId, target))
|
|
if (cached) {
|
|
return { ...logPinnedRelayFallback(cached, 'refused earlier this session'), remembered: true }
|
|
}
|
|
// Why rung A only: the persisted decision is keyed by the default runtime's hash.
|
|
const persisted = compat ? null : options.persistedRefusal?.(facts)
|
|
if (persisted) {
|
|
return {
|
|
...logPinnedRelayFallback(persisted, 'refused on an earlier connect'),
|
|
remembered: true
|
|
}
|
|
}
|
|
const floor = compat ? compat.glibcFloor : PINNED_NODE_GLIBC_FLOOR
|
|
if (glibc && floor && isGlibcBelow(glibc, floor)) {
|
|
recordPinnedRuntimeRefusal(options.targetId, target, 'libc_floor')
|
|
return logPinnedRelayFallback(
|
|
'libc_floor',
|
|
`host glibc ${glibc.major}.${glibc.minor} is below ${floor.major}.${floor.minor}`
|
|
)
|
|
}
|
|
let addons: PinnedRelayAddons
|
|
try {
|
|
const materialize =
|
|
options.materializeOrcad ?? ((t, s) => materializeOrcadArtifact(t, { signal: s }))
|
|
addons = await stagePinnedRelayAddons(await materialize(target, signal), target)
|
|
} catch (error) {
|
|
signal?.throwIfAborted()
|
|
return logPinnedRelayFallback(
|
|
'artifacts_unavailable',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
}
|
|
const cacheRoot =
|
|
options.runtimeCacheRoot ??
|
|
((): string => join(getAppEnvironment().getPath('userData'), 'orcad-artifacts'))
|
|
let fullVersion: string
|
|
try {
|
|
fullVersion = pinnedNodeRelayFullVersion(
|
|
options.baseVersion,
|
|
pinnedNodeRuntimeAsset(target).executableSha256,
|
|
addons.digest
|
|
)
|
|
} catch (error) {
|
|
await addons.dispose()
|
|
throw error
|
|
}
|
|
return {
|
|
kind: 'pinned-node',
|
|
target,
|
|
glibc,
|
|
fullVersion,
|
|
addons,
|
|
runtimeArchive: () =>
|
|
materializeNodeRuntimeArchive(target, cacheRoot(), { signal }).catch((error: unknown) => {
|
|
signal?.throwIfAborted()
|
|
throw new PinnedRelayFallbackError(
|
|
'artifacts_unavailable',
|
|
error instanceof Error ? error.message : String(error)
|
|
)
|
|
})
|
|
}
|
|
}
|