diff --git a/src/main/orcad/orcad-artifact-cache-retention.ts b/src/main/orcad/orcad-artifact-cache-retention.ts index 193c8d78169..8dd3e38eb01 100644 --- a/src/main/orcad/orcad-artifact-cache-retention.ts +++ b/src/main/orcad/orcad-artifact-cache-retention.ts @@ -7,7 +7,7 @@ */ import { readdir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' -import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin' +import { nodeRuntimeAsset } from '../../shared/node-runtime-pin' import { ORCAD_LOCK_FILE_NAME, readOrcadInstanceLockRecord } from './orcad-instance-lock' import { materializedOrcadArtifactVersions } from '../ssh/orcad-artifact-materializer' import { isProcessAlive } from '../daemon/daemon-process-inspection' @@ -33,7 +33,7 @@ export async function pruneOrcadArtifactCache( const removed: string[] = [] for (const target of await listNames(cacheRoot)) { // Only target directories hold slots; `node/` is the runtime archive cache. - if (!Object.hasOwn(NODE_RUNTIME_ASSETS, target)) { + if (!nodeRuntimeAsset(target)) { continue } const targetRoot = join(cacheRoot, target) diff --git a/src/main/orcad/orcad-artifact-identity.ts b/src/main/orcad/orcad-artifact-identity.ts index 86140acdceb..27b7c905daa 100644 --- a/src/main/orcad/orcad-artifact-identity.ts +++ b/src/main/orcad/orcad-artifact-identity.ts @@ -8,13 +8,13 @@ import { ORCAD_VERSION, orcadArtifactFilenames } from '../../shared/orcad-artifacts' -import { SERVER_TARGETS } from '../../shared/node-runtime-pin' +import { COMPAT_SERVER_TARGETS, SERVER_TARGETS } from '../../shared/node-runtime-pin' import { orcadAgentBrowserNativeName } from '../../shared/orcad-agent-browser-name' /** Hash installed bytes in the build's order; a version marker is not proof of delivery. */ export async function readOrcadArtifactIdentity(directory: string): Promise { const target = z - .enum(SERVER_TARGETS) + .enum([...SERVER_TARGETS, ...COMPAT_SERVER_TARGETS]) .parse((await readFile(join(directory, ORCAD_SERVER_TARGET_FILENAME), 'utf8')).trim()) const platform = target.startsWith('win32-') ? 'win32' diff --git a/src/main/orcad/orcad-bundled-runtime.test.ts b/src/main/orcad/orcad-bundled-runtime.test.ts index e4328fb833b..9fe9b96afb7 100644 --- a/src/main/orcad/orcad-bundled-runtime.test.ts +++ b/src/main/orcad/orcad-bundled-runtime.test.ts @@ -2,7 +2,11 @@ import { EventEmitter } from 'node:events' import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { handoffToBundledOrcad, resolveBundledOrcadSlot } from './orcad-bundled-runtime' -import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { + NODE_RUNTIME_ASSETS, + NODE_RUNTIME_COMPAT_ASSETS, + NODE_RUNTIME_PIN +} from '../../shared/node-runtime-pin' import { ORCAD_NODE_RUNTIME_MARKER_FILENAME, ORCAD_SERVER_TARGET_FILENAME, @@ -84,6 +88,26 @@ describe('bundled Orca runtime handoff', () => { expect(fixture.spawn).not.toHaveBeenCalled() }) + it('hands a glibc 2.17 compat slot to the compat runtime it names', () => { + const compatSha = NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256 + fixture.read.mockImplementation((path) => + path.endsWith(ORCAD_SERVER_TARGET_FILENAME) ? 'linux-x64-glibc217\n' : `${compatSha}\n` + ) + expect(handoffToBundledOrcad()).toBe(true) + expect(fixture.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + program: join('/slot', '..', 'runtimes', `node-${compatSha}`, 'bin', 'node') + }) + ) + }) + + it('refuses a compat slot that names the default runtime', () => { + fixture.read.mockImplementation((path) => + path.endsWith(ORCAD_SERVER_TARGET_FILENAME) ? 'linux-x64-glibc217\n' : `${SHA}\n` + ) + expect(() => handoffToBundledOrcad()).toThrow(`does not name Node ${NODE_RUNTIME_PIN.version}`) + }) + it('refuses a slot without its runtime reference', () => { fixture.exists.mockImplementation((path) => !path.endsWith(ORCAD_NODE_RUNTIME_MARKER_FILENAME)) expect(() => handoffToBundledOrcad()).toThrow('bundled Orca runtime reference is missing') diff --git a/src/main/orcad/orcad-bundled-runtime.ts b/src/main/orcad/orcad-bundled-runtime.ts index 230cc5f326f..497e8a1120f 100644 --- a/src/main/orcad/orcad-bundled-runtime.ts +++ b/src/main/orcad/orcad-bundled-runtime.ts @@ -9,20 +9,11 @@ import { ORCAD_VERSION_FILENAME, orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts' -import { - NODE_RUNTIME_ASSETS, - NODE_RUNTIME_PIN, - SERVER_TARGETS, - type ServerTarget -} from '../../shared/node-runtime-pin' +import { NODE_RUNTIME_PIN, nodeRuntimeAsset } from '../../shared/node-runtime-pin' export class OrcadBundledRuntimeError extends Error {} export const ORCAD_BUNDLED_LAUNCHER_ENV = 'ORCA_BUNDLED_LAUNCHER_CHANNEL' -function isServerTarget(value: string): value is ServerTarget { - return SERVER_TARGETS.some((target) => target === value) -} - /** * The pinned Node a packaged slot in `directory` runs on, or null for an unpackaged entry. * Throws when the slot is packaged but its runtime reference is torn or foreign. @@ -42,13 +33,15 @@ export function resolveBundledOrcadRuntime(directory: string): string | null { throw new OrcadBundledRuntimeError('The bundled Orca runtime reference is missing') } const target = readFileSync(targetPath, 'utf8').trim() - if (!isServerTarget(target)) { + // A compat target (design D6 rung B) runs the same Node version built for an older glibc. + const asset = nodeRuntimeAsset(target) + if (!asset) { throw new OrcadBundledRuntimeError(`The bundled Orca runtime target is invalid: ${target}`) } const executableSha256 = readFileSync(markerPath, 'utf8').trim() // Why the pin and not only a digest shape: the marker becomes a path segment, and a slot // naming another runtime was not built by this code. - if (executableSha256 !== NODE_RUNTIME_ASSETS[target].executableSha256) { + if (executableSha256 !== asset.executableSha256) { throw new OrcadBundledRuntimeError( `The bundled Orca runtime reference does not name Node ${NODE_RUNTIME_PIN.version}` ) diff --git a/src/main/ssh/orcad-artifact-materializer.test.ts b/src/main/ssh/orcad-artifact-materializer.test.ts index f889e440ba4..301d023b668 100644 --- a/src/main/ssh/orcad-artifact-materializer.test.ts +++ b/src/main/ssh/orcad-artifact-materializer.test.ts @@ -188,6 +188,10 @@ describe('assembleOrcadArtifact', () => { expect( readFileSync(join(artifactDir, 'node_modules/node-pty/build/Release/pty.node'), 'utf8') ).toBe(`${target}:node_modules/node-pty/build/Release/pty.node`) + // The host-side preflight hashes it the same way, so managed orcad can run it. + expect(await readOrcadArtifactIdentity(artifactDir)).toBe( + readFileSync(join(artifactDir, ORCAD_VERSION_FILENAME), 'utf8').trim() + ) }) it('refuses a compat slot that names the default runtime', async () => { diff --git a/src/main/ssh/orcad-deployment-target.test.ts b/src/main/ssh/orcad-deployment-target.test.ts index 2293078ae33..cdf54d438e0 100644 --- a/src/main/ssh/orcad-deployment-target.test.ts +++ b/src/main/ssh/orcad-deployment-target.test.ts @@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { parseGlibcVersion, parseOrcadLinuxLibc, - resolveOrcadDeploymentTarget, resolveOrcadDeploymentTargetFacts } from './orcad-deployment-target' import { SshConnection } from './ssh-connection' @@ -11,6 +10,12 @@ import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform } from './ssh-remote-platform' vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) + +async function resolveOrcadDeploymentTarget( + options: Parameters[0] +): Promise { + return (await resolveOrcadDeploymentTargetFacts(options)).target +} beforeEach(() => vi.mocked(execCommand).mockReset()) describe('deployment C library selection', () => { diff --git a/src/main/ssh/orcad-deployment-target.ts b/src/main/ssh/orcad-deployment-target.ts index c0207e7e458..b943c1dfa84 100644 --- a/src/main/ssh/orcad-deployment-target.ts +++ b/src/main/ssh/orcad-deployment-target.ts @@ -1,6 +1,10 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { SERVER_TARGETS, type ServerTarget } from '../../shared/node-runtime-pin' +import { + isNodeRuntimeTarget, + type NodeRuntimeTarget, + type ServerTarget +} from '../../shared/node-runtime-pin' import { ORCAD_SERVER_TARGET_FILENAME } from '../../shared/orcad-artifacts' import type { SshConnection } from './ssh-connection' import { execCommand } from './ssh-relay-deploy-helpers' @@ -39,15 +43,6 @@ export function parseGlibcVersion(output: string): GlibcVersion | null { return match ? { major: Number(match[1]), minor: Number(match[2]) } : null } -export async function resolveOrcadDeploymentTarget(options: { - conn: SshConnection - host: RemoteHostPlatform - signal?: AbortSignal - exec?: (command: string) => Promise -}): Promise { - return (await resolveOrcadDeploymentTargetFacts(options)).target -} - export async function resolveOrcadDeploymentTargetFacts(options: { conn: SshConnection host: RemoteHostPlatform @@ -81,12 +76,11 @@ function linuxTargetFacts(host: RemoteHostPlatform, output: string): OrcadDeploy } } -/** The server target an assembled bundle was built for. */ -export function readOrcadBundleTarget(localOrcadDir: string): ServerTarget { +/** The runtime target an assembled bundle was built for, a compat one included. */ +export function readOrcadBundleTarget(localOrcadDir: string): NodeRuntimeTarget { const recorded = readFileSync(join(localOrcadDir, ORCAD_SERVER_TARGET_FILENAME), 'utf8').trim() - const target = SERVER_TARGETS.find((candidate) => candidate === recorded) - if (!target) { + if (!isNodeRuntimeTarget(recorded)) { throw new Error(`The orcad bundle names no known server target: ${recorded}`) } - return target + return recorded } diff --git a/src/main/ssh/orcad-managed-version-gc.test.ts b/src/main/ssh/orcad-managed-version-gc.test.ts index 9e6cbf7ef95..1f93e2dbe39 100644 --- a/src/main/ssh/orcad-managed-version-gc.test.ts +++ b/src/main/ssh/orcad-managed-version-gc.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ServeReadiness } from '../server/serve-readiness' +import { NODE_RUNTIME_ASSETS, NODE_RUNTIME_COMPAT_ASSETS } from '../../shared/node-runtime-pin' const { gcMock, readRecordMock } = vi.hoisted(() => ({ gcMock: vi.fn(), @@ -59,7 +60,11 @@ describe('managed orcad version GC after a deploy', () => { expect.objectContaining({ liveDaemonVersion: '1.0.0+a', record: { active: '2.0.0+b' }, - nodeRuntimePins: [expect.stringMatching(/^[0-9a-f]{64}$/u)] + // The compat runtime stays pinned: a compat orcad and a rung A relay share the store. + nodeRuntimePins: [ + NODE_RUNTIME_ASSETS['linux-x64-glibc'].executableSha256, + NODE_RUNTIME_COMPAT_ASSETS['linux-x64-glibc217'].executableSha256 + ] }) ) diff --git a/src/main/ssh/orcad-managed-version-gc.ts b/src/main/ssh/orcad-managed-version-gc.ts index 2ad50223385..b4c3a9a1341 100644 --- a/src/main/ssh/orcad-managed-version-gc.ts +++ b/src/main/ssh/orcad-managed-version-gc.ts @@ -4,11 +4,12 @@ * rollback target, journaled versions, and the version the live terminal daemon was forked * from. Anything the host cannot answer about is kept. */ -import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin' +import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' import type { ServeReadiness } from '../server/serve-readiness' import { readOrcadActivationRecord } from './orcad-activation-record-store' import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' import { gcOldOrcadVersions } from './orcad-remote-gc' +import { relayRuntimeStorePins } from './ssh-relay-runtime-ladder' /** The daemon's build when the readiness proves it; undefined when it cannot say. */ export function provenLiveDaemonVersion(readiness: ServeReadiness): string | null | undefined { @@ -22,7 +23,7 @@ export function provenLiveDaemonVersion(readiness: ServeReadiness): string | nul export async function pruneManagedOrcadVersions(args: { slot: OrcadSlotOptions - serverTarget: ServerTarget + serverTarget: NodeRuntimeTarget activeVersion: string readiness: ServeReadiness }): Promise { @@ -39,7 +40,8 @@ export async function pruneManagedOrcadVersions(args: { currentDirAbsPath: orcadSlotDir(args.slot, args.activeVersion), record: await readOrcadActivationRecord(args.slot), liveDaemonVersion, - nodeRuntimePins: [NODE_RUNTIME_ASSETS[args.serverTarget].executableSha256], + // Why the relay's pins: a compat orcad and a rung A relay share one runtime store. + nodeRuntimePins: relayRuntimeStorePins(args.serverTarget), signal: args.slot.signal }) } catch (error) { diff --git a/src/main/ssh/orcad-remote-context.ts b/src/main/ssh/orcad-remote-context.ts index cb0663ce1ea..8ed4300c2a6 100644 --- a/src/main/ssh/orcad-remote-context.ts +++ b/src/main/ssh/orcad-remote-context.ts @@ -1,9 +1,9 @@ /** Everything a managed-orcad operation needs to know about one SSH host before it acts. */ -import type { ServerTarget } from '../../shared/node-runtime-pin' +import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' import type { SshTarget } from '../../shared/ssh-types' import type { OrcadActivationRecord } from './orcad-activation-record' import { readOrcadActivationRecord } from './orcad-activation-record-store' -import { resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { resolveOrcadRuntimeTarget } from './orcad-runtime-target' import { prepareWindowsOrcadHost } from './orcad-windows-host-preparation' import { execOrcadRemote } from './orcad-remote-runtime-control' import type { SshConnection } from './ssh-connection' @@ -21,7 +21,8 @@ import { rememberSshHostPlatform } from './ssh-host-platform-memo' export type OrcadRemoteContext = { activationRecord: OrcadActivationRecord - serverTarget: ServerTarget + /** A compat runtime on hosts below the default runtime's glibc floor (design D6 rung B). */ + serverTarget: NodeRuntimeTarget connection: SshConnection host: RemoteHostPlatform remoteHome: string @@ -47,7 +48,7 @@ export async function resolveOrcadRemoteContext( if (!validateRemoteHome(remoteHome, host)) { throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`) } - const serverTarget = await resolveOrcadDeploymentTarget({ conn: connection, host, signal }) + const serverTarget = await resolveOrcadRuntimeTarget({ conn: connection, host, signal }) rememberSshHostPlatform(target.id, host, serverTarget) if (isWindowsRemoteHost(host)) { // Every Windows host op, the activation record read included, runs on the pinned node.exe. diff --git a/src/main/ssh/orcad-remote-deploy.ts b/src/main/ssh/orcad-remote-deploy.ts index 45ec21050aa..35a435e2b82 100644 --- a/src/main/ssh/orcad-remote-deploy.ts +++ b/src/main/ssh/orcad-remote-deploy.ts @@ -16,10 +16,11 @@ import type { OrcadTerminalCensus } from './orcad-update-plan' import type { RemoteHostPlatform } from './ssh-remote-platform' import { installOrcadBundle } from './orcad-remote-install' import { getAppEnvironment } from '../../shared/app-environment' -import type { ServerTarget } from '../../shared/node-runtime-pin' +import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' import { ORCAD_STARTUP_READINESS_TIMEOUT_MS } from '../../shared/orcad-profile-preflight' import { materializeOrcadArtifact } from './orcad-artifact-materializer' -import { readOrcadBundleTarget, resolveOrcadDeploymentTarget } from './orcad-deployment-target' +import { readOrcadBundleTarget } from './orcad-deployment-target' +import { resolveOrcadRuntimeTarget } from './orcad-runtime-target' import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' import { resolveOrcadActivationReadinessTimeout, @@ -33,8 +34,8 @@ export type OrcadDeployOptions = { remoteHome: string /** An already assembled bundle; otherwise materialize the packaged template for this host. */ localOrcadDir?: string - /** The bundle's server target; read from `localOrcadDir` or probed when absent. */ - target?: ServerTarget + /** The bundle's runtime target; read from `localOrcadDir` or probed when absent. */ + target?: NodeRuntimeTarget /** Where the pinned runtime archive is cached; defaults beside the orcad artifact cache. */ runtimeCacheRoot?: string nodePath: string @@ -67,7 +68,7 @@ export async function deployOrcad(input: OrcadDeployOptions): Promise Promise signal?: AbortSignal @@ -94,7 +94,7 @@ export async function installOrcadBundle( function executablePermissionsCommand( host: RemoteHostPlatform, directory: string, - target: ServerTarget + target: NodeRuntimeTarget ): string { const required = [orcadRipgrepArtifact(target), ...orcadNodePtyNativeArtifacts(target)] .filter((artifact) => /\/(?:rg|spawn-helper)$/.test(artifact)) diff --git a/src/main/ssh/orcad-runtime-target.test.ts b/src/main/ssh/orcad-runtime-target.test.ts new file mode 100644 index 00000000000..242a7023aa1 --- /dev/null +++ b/src/main/ssh/orcad-runtime-target.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveOrcadRuntimeTarget } from './orcad-runtime-target' +import { SshConnection } from './ssh-connection' +import { createCallbacks, createTarget } from './ssh-connection-test-fixtures' +import { execCommand } from './ssh-relay-deploy-helpers' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn() })) +beforeEach(() => vi.mocked(execCommand).mockReset()) + +function resolveOn(platform: Parameters[0], ldd: string) { + vi.mocked(execCommand).mockResolvedValueOnce(ldd) + return resolveOrcadRuntimeTarget({ + conn: new SshConnection(createTarget(), createCallbacks()), + host: getRemoteHostPlatform(platform) + }) +} + +describe('managed orcad runtime target', () => { + it.each([ + ['CentOS 7 (glibc 2.17)', 'ldd (GNU libc) 2.17', 'linux-x64-glibc217'], + ['glibc 2.27, still below the default floor', 'ldd (GNU libc) 2.27', 'linux-x64-glibc217'], + ['Debian 10 (glibc 2.28)', 'ldd (Debian GLIBC 2.28-10+deb10u2) 2.28', 'linux-x64-glibc'], + ['Ubuntu 22.04 (glibc 2.35)', 'ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35', 'linux-x64-glibc'], + ['Alpine (musl)', 'musl libc (x86_64)\nVersion 1.2.5', 'linux-x64-musl'] + ])('deploys %s on the runtime the relay ladder would pick', async (_host, ldd, target) => { + await expect(resolveOn('linux-x64', ldd)).resolves.toBe(target) + }) + + it('keeps the host target when the glibc version is unreadable; the self-test decides', async () => { + await expect(resolveOn('linux-x64', 'ldd (GNU libc) unknown')).resolves.toBe('linux-x64-glibc') + }) + + it('refuses as unsupported where no runtime serves the glibc, so the connect keeps the relay', async () => { + await expect(resolveOn('linux-arm64', 'ldd (GNU libc) 2.17')).rejects.toMatchObject({ + name: 'OrcadHostUnsupportedError', + message: expect.stringContaining('linux-arm64-glibc with glibc 2.17') + }) + await expect(resolveOn('linux-x64', 'ldd (GNU libc) 2.12')).rejects.toMatchObject({ + name: 'OrcadHostUnsupportedError' + }) + }) + + it('needs no libc probe off Linux', async () => { + await expect( + resolveOrcadRuntimeTarget({ + conn: new SshConnection(createTarget(), createCallbacks()), + host: getRemoteHostPlatform('darwin-arm64') + }) + ).resolves.toBe('darwin-arm64') + expect(execCommand).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/orcad-runtime-target.ts b/src/main/ssh/orcad-runtime-target.ts new file mode 100644 index 00000000000..509725d7517 --- /dev/null +++ b/src/main/ssh/orcad-runtime-target.ts @@ -0,0 +1,27 @@ +import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' +import { resolveOrcadDeploymentTargetFacts } from './orcad-deployment-target' +import { OrcadHostUnsupportedError } from './orcad-host-unavailable' +import type { SshConnection } from './ssh-connection' +import { pinnedRuntimeTargetForHost } from './ssh-relay-runtime-ladder' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +/** + * The runtime target managed orcad deploys on a host, picked by glibc exactly as the relay ladder + * picks rung A or B: the host's own target, or its compat runtime below the default's glibc floor. + */ +export async function resolveOrcadRuntimeTarget(options: { + conn: SshConnection + host: RemoteHostPlatform + signal?: AbortSignal + exec?: (command: string) => Promise +}): Promise { + const facts = await resolveOrcadDeploymentTargetFacts(options) + const target = pinnedRuntimeTargetForHost(facts) + if (!target) { + const glibc = facts.glibc ? `${facts.glibc.major}.${facts.glibc.minor}` : 'unknown' + throw new OrcadHostUnsupportedError( + `No Orca runtime supports ${facts.target} with glibc ${glibc}.` + ) + } + return target +} diff --git a/src/main/ssh/orcad-windows-host-preparation.ts b/src/main/ssh/orcad-windows-host-preparation.ts index 55b6d7531a5..e18f3d80dc6 100644 --- a/src/main/ssh/orcad-windows-host-preparation.ts +++ b/src/main/ssh/orcad-windows-host-preparation.ts @@ -7,7 +7,7 @@ */ import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' -import type { ServerTarget } from '../../shared/node-runtime-pin' +import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' import { ensureRemoteOrcadNodeRuntime } from './orcad-remote-node-runtime' import { installOrcadWindowsHostScript, orcadRemoteBaseDir } from './orcad-remote-windows-node' import { materializeNodeRuntimeArchive } from './pinned-runtime-materializer' @@ -18,7 +18,7 @@ export async function prepareWindowsOrcadHost(options: { conn: SshConnection host: RemoteHostPlatform remoteHome: string - serverTarget: ServerTarget + serverTarget: NodeRuntimeTarget signal?: AbortSignal }): Promise { const baseDir = orcadRemoteBaseDir(options.host, options.remoteHome) diff --git a/src/main/ssh/ssh-host-platform-memo.ts b/src/main/ssh/ssh-host-platform-memo.ts index eaedbc647df..c8c5b99660f 100644 --- a/src/main/ssh/ssh-host-platform-memo.ts +++ b/src/main/ssh/ssh-host-platform-memo.ts @@ -2,7 +2,7 @@ * The platform each SSH host last reported this app session, so connect telemetry can name it * without a probe of its own. Keyed by the local target id, which never leaves this process. */ -import type { ServerTarget } from '../../shared/node-runtime-pin' +import type { NodeRuntimeTarget } from '../../shared/node-runtime-pin' import type { RemoteHostPlatform } from './ssh-remote-platform' export type SshHostPlatformFacts = { @@ -15,7 +15,7 @@ const known = new Map() function libcOf( host: RemoteHostPlatform, - target: ServerTarget | null + target: NodeRuntimeTarget | null ): SshHostPlatformFacts['libc'] { if (host.os !== 'linux') { return 'none' @@ -29,7 +29,7 @@ function libcOf( export function rememberSshHostPlatform( targetId: string, host: RemoteHostPlatform, - target: ServerTarget | null + target: NodeRuntimeTarget | null ): void { const libc = libcOf(host, target) const previous = known.get(targetId) diff --git a/src/main/ssh/ssh-hostile-host-cells.test.ts b/src/main/ssh/ssh-hostile-host-cells.test.ts index 3bff2efe449..0b1ffb85d85 100644 --- a/src/main/ssh/ssh-hostile-host-cells.test.ts +++ b/src/main/ssh/ssh-hostile-host-cells.test.ts @@ -68,6 +68,12 @@ describe('hostile-host cells', () => { ).toEqual(new Set(['A', 'B'])) }) + it('deploys managed orcad on CentOS 7 with the compat runtime its relay rung B runs', () => { + const centos = cell('centos7-glibc217') + expect(centos.managed?.runtime).toBe('linux-x64-glibc217') + expect(centos.expect).toMatchObject({ runtime: centos.managed?.runtime }) + }) + it('pins every base image by digest and installs no compiler or host Node for rung A', () => { for (const { dockerfile, expect: expectation } of DOCKER_CELLS) { for (const from of dockerfile.filter((line) => line.startsWith('FROM '))) { diff --git a/src/main/ssh/ssh-hostile-host-cells.ts b/src/main/ssh/ssh-hostile-host-cells.ts index 26bb029f184..3c3abb288fa 100644 --- a/src/main/ssh/ssh-hostile-host-cells.ts +++ b/src/main/ssh/ssh-hostile-host-cells.ts @@ -47,8 +47,15 @@ export type HostileHostExpectation = export type HostileHostCellCore = { id: string expect: HostileHostExpectation + /** Also deploy managed orcad on a fresh host, on this runtime target. */ + managed?: ManagedOrcadExpectation } +export type ManagedOrcadExpectation = + | { outcome: 'activated'; runtime: NodeRuntimeTarget } + /** The candidate is refused with this deferral code, and the relay that follows settles on `relayRung`. */ + | { outcome: 'refused'; runtime: NodeRuntimeTarget; code: string; relayRung: 'A' | 'B' } + export type DockerHostileHostCell = HostileHostCellCore & { host?: 'docker' /** Dockerfile lines, FROM included; the harness appends sshd and the toolchain shims. */ @@ -177,6 +184,14 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ target: 'linux-x64-glibc', runtime: 'linux-x64-glibc217', refusals: [{ step: 'A', reason: 'libc_floor' }] + }, + // Managed orcad picks the same compat runtime, but the template's @parcel/watcher needs a newer + // libstdc++ than CentOS 7 ships, so its preflight refuses and the host keeps relay rung B. + managed: { + outcome: 'refused', + runtime: 'linux-x64-glibc217', + code: 'orcad_candidate_preflight_failed', + relayRung: 'B' } }, { diff --git a/src/main/ssh/ssh-hostile-host-managed-orcad.ts b/src/main/ssh/ssh-hostile-host-managed-orcad.ts new file mode 100644 index 00000000000..a05fa794405 --- /dev/null +++ b/src/main/ssh/ssh-hostile-host-managed-orcad.ts @@ -0,0 +1,119 @@ +/** + * The managed-orcad half of a hostile-host cell: on a fresh host, resolve the context, deploy + * orcad, and prove it picked the cell's runtime and uploaded no other. An activated server must + * be live; a refused one must be classified as unable to run, so the relay that follows runs the + * pinned ladder and lands on the cell's rung. + */ +import { posix } from 'node:path' +import { expect } from 'vitest' +import { NODE_RUNTIME_ASSETS, pinnedNodeRuntimeAsset } from '../../shared/node-runtime-pin' +import { ORCAD_MANAGED_REMOTE_PORT } from '../../shared/orcad-managed-runtime' +import { ORCAD_RUNTIMES_DIRNAME } from '../../shared/orcad-artifacts' +import type { SshTarget } from '../../shared/ssh-types' +import { readOrcadActivationRecord } from './orcad-activation-record-store' +import { classifyOrcadHostUnavailable } from './orcad-host-unavailable' +import { managedOrcadSlot } from './orcad-managed-runtime-context' +import { orcadSlotDir, type OrcadSlotOptions } from './orcad-recovery-slot' +import { resolveOrcadRemoteContext } from './orcad-remote-context' +import { deployOrcad } from './orcad-remote-deploy' +import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch' +import { execOrcadRemote } from './orcad-remote-runtime-control' +import { decommissionRemoteOrcad } from './orcad-remote-stop' +import type { HostileHostCellCore, ManagedOrcadExpectation } from './ssh-hostile-host-cells' +import { connectHostileHost, deployOnce } from './ssh-hostile-host-test-harness' +import { hostExecStatus, type HostileHostTarget } from './ssh-hostile-host-test-fixture' + +const EMPTY_CENSUS = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null } + +export async function proveManagedOrcadCell( + cell: HostileHostCellCore, + target: HostileHostTarget, + sshTarget: SshTarget +): Promise { + const expected = cell.managed + if (!expected) { + throw new Error(`${cell.id} names no managed expectation`) + } + const conn = await connectHostileHost(sshTarget) + try { + const context = await resolveOrcadRemoteContext(sshTarget, conn) + expect(context.serverTarget).toBe(expected.runtime) + const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT) + const deployed = await deployOrcad({ + ...slot, + target: context.serverTarget, + census: EMPTY_CENSUS + }) + const slotDir = orcadSlotDir(slot, deployed.fullVersion) + await assertOnlyRuntimeUploaded(target, slotDir, expected) + if (expected.outcome === 'refused') { + // The message carries the full reason, which toMatchObject's diff omits. + expect(deployed, JSON.stringify(deployed)).toMatchObject({ + outcome: 'installed-not-activated', + code: expected.code + }) + expect(classifyOrcadHostUnavailable(deployed)).toBe('native_preflight') + await assertRelayFallback(sshTarget, expected.relayRung) + return + } + expect(deployed, JSON.stringify(deployed)).toMatchObject({ outcome: 'installed-and-activated' }) + await assertLiveThenDecommissioned(slot, slotDir, deployed.fullVersion) + } finally { + await conn.disconnect().catch(() => {}) + } +} + +/** A default runtime beside the expected one is the wrong upload this cell guards against. */ +async function assertOnlyRuntimeUploaded( + target: HostileHostTarget, + slotDir: string, + expected: ManagedOrcadExpectation +): Promise { + const runtimes = posix.join(posix.dirname(slotDir), ORCAD_RUNTIMES_DIRNAME) + const runtimeDir = (sha: string): string => posix.join(runtimes, `node-${sha}`) + const { executableSha256 } = pinnedNodeRuntimeAsset(expected.runtime) + expect(await hostExecStatus(target, `test -x '${runtimeDir(executableSha256)}/bin/node'`)).toBe(0) + for (const asset of Object.values(NODE_RUNTIME_ASSETS)) { + if (asset.executableSha256 !== executableSha256) { + expect( + await hostExecStatus(target, `test -e '${runtimeDir(asset.executableSha256)}'`) + ).not.toBe(0) + } + } +} + +/** The connect's next step: no runtime setting, but orcad recorded unable to run on this host. */ +async function assertRelayFallback(sshTarget: SshTarget, rung: 'A' | 'B'): Promise { + const { remoteRuntime: _setting, ...withoutSetting } = sshTarget + const conn = await connectHostileHost({ + ...withoutSetting, + managedServerUnavailable: { reason: 'native_preflight', appVersion: 'hostile-hosts' } + }) + try { + const attempt = await deployOnce(conn) + expect(attempt.error).toBeNull() + expect(attempt.settledRung).toBe(rung) + } finally { + await conn.disconnect().catch(() => {}) + } +} + +async function assertLiveThenDecommissioned( + slot: OrcadSlotOptions, + slotDir: string, + version: string +): Promise { + const liveness = async (): Promise => + parseOrcadLiveness(await execOrcadRemote(slot, orcadLivenessProbeCommand(slot.host, slotDir))) + expect(await liveness()).toBe('LIVE') + const decommission = await decommissionRemoteOrcad({ + ...slot, + record: await readOrcadActivationRecord(slot), + census: EMPTY_CENSUS + }) + expect(decommission, JSON.stringify(decommission)).toMatchObject({ + outcome: 'decommissioned', + version + }) + expect(await liveness()).toBe('DEAD') +} diff --git a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts index 2edfdbe3e3c..a8b0be31048 100644 --- a/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts +++ b/src/main/ssh/ssh-relay-deploy-pinned-node.test.ts @@ -358,6 +358,35 @@ describe('deployAndLaunchRelay on the pinned Node runtime', () => { warn.mockRestore() }) + it('runs the ladder to rung B where this connect just recorded that managed orcad cannot run', async () => { + const registry = { + getTarget: vi.fn(() => ({ + id: 'target-1', + managedServerUnavailable: { reason: 'runtime_self_test', appVersion: '1.0.0' } + })), + updateTarget: vi.fn(() => null) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ladder reads and writes only these two registry members. + vi.mocked(getSshTargetRegistryStore).mockReturnValue(registry as unknown as SshConnectionStore) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const oldGlibc = { major: 2, minor: 17 } + vi.mocked(resolvePinnedRelayTargetFacts).mockResolvedValue({ + target: 'linux-x64-glibc', + glibc: oldGlibc + }) + vi.mocked(planPinnedNodeRelay) + .mockResolvedValueOnce({ kind: 'host-node', fallbackReason: 'libc_floor' }) + .mockResolvedValueOnce({ ...pinnedPlan(), target: 'linux-x64-glibc217', glibc: oldGlibc }) + queueInstalledPinnedLaunch() + + // The connection's own target predates the record and carries no runtime setting. + const result = await deployAndLaunchRelay(makeConnection(), undefined, undefined, 'target-1') + + expect(resolveRemoteNodePath).not.toHaveBeenCalled() + expect(result.nodePath).toBe(COMPAT_NODE) + expect(planHostNodeAddonRelay).not.toHaveBeenCalled() + }) + it('skips rung B on a current glibc when rung A refused for a reason B cannot answer', async () => { const conn = makeConnection('pinned-node') const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 8c5e4dee71b..47d91746bd0 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs' import { app } from 'electron' import { relayBundleCandidates } from './relay-bundle-paths' -import { PinnedRelayFallbackError, resolveSshRemoteRuntime } from './ssh-relay-pinned-node' +import { PinnedRelayFallbackError, resolveConnectRemoteRuntime } from './ssh-relay-pinned-node' import { ensurePinnedRelayRuntime, prebuiltRelayNodePath, @@ -424,8 +424,10 @@ async function deployAndLaunchRelayInner( deploySignal?: AbortSignal ): Promise { const target = typeof conn.getTarget === 'function' ? conn.getTarget() : undefined - const ladder = relayRuntimeLadder(resolveSshRemoteRuntime(target)) const registry = getSshTargetRegistryStore() + const ladder = relayRuntimeLadder( + resolveConnectRemoteRuntime(target, target && registry?.getTarget(target.id)) + ) const run = new RelayRuntimeLadderRun( target?.id ?? relayInstanceId ?? '', target && registry ? sshTargetRelayRuntimeDecisionStore(registry) : null diff --git a/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts b/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts index 6f16208ac5c..524131b2b83 100644 --- a/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts +++ b/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts @@ -1,6 +1,6 @@ // Design D5/D6 hostile-host matrix: the real client-side relay deploy against container SSH // targets, and against a macOS runner's own loopback sshd, asserting which rung of the runtime -// ladder each host lands on. +// ladder each host lands on, and for cells naming one, the runtime managed orcad deploys on. // // Run: ORCA_RUN_SSH_HOSTILE_HOSTS=1 pnpm test src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts // Needs `pnpm build:relay` and an orcad template holding each selected cell's slot. Docker cells @@ -17,6 +17,7 @@ import { posix } from 'node:path' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' import type { SshConnection } from './ssh-connection' import { HOSTILE_HOST_CELLS, selectHostileHostCells } from './ssh-hostile-host-cells' +import { proveManagedOrcadCell } from './ssh-hostile-host-managed-orcad' import { assertCell, connectHostileHost, @@ -121,4 +122,21 @@ describe('SSH relay hostile-host matrix', () => { CELL_TIMEOUT_MS ) } + + // A fresh host per cell: the relay pass above leaves a relay and its runtime behind. + for (const cell of HOSTILE_HOST_CELLS.filter((candidate) => candidate.managed)) { + it.skipIf(!SELECTED.has(cell.id))( + `${cell.id} deploys managed orcad on ${cell.managed?.runtime} (${cell.managed?.outcome})`, + async () => { + let target: HostileHostTarget | null = null + try { + target = await startHostileHostTarget(cell) + await proveManagedOrcadCell(cell, target, hostileHostSshTarget(target)) + } finally { + await stopHostileHostTarget(target) + } + }, + CELL_TIMEOUT_MS + ) + } }) diff --git a/src/main/ssh/ssh-relay-pinned-node.test.ts b/src/main/ssh/ssh-relay-pinned-node.test.ts index a7e8e6073ed..17cca644fe8 100644 --- a/src/main/ssh/ssh-relay-pinned-node.test.ts +++ b/src/main/ssh/ssh-relay-pinned-node.test.ts @@ -78,6 +78,25 @@ describe('remote runtime setting', () => { ) ).toBe('legacy') }) + + it('runs the pinned ladder where managed orcad was found unable to run, unless the host opts out', () => { + const unavailable = { reason: 'runtime_self_test', appVersion: '1.0.0' } + expect(resolveSshRemoteRuntime({ managedServerUnavailable: unavailable }, {})).toBe( + 'pinned-node' + ) + expect( + resolveSshRemoteRuntime( + { managedServerUnavailable: unavailable, remoteRuntime: 'legacy' }, + {} + ) + ).toBe('legacy') + expect( + resolveSshRemoteRuntime( + { managedServerUnavailable: unavailable }, + { [SSH_REMOTE_RUNTIME_ENV]: 'legacy' } + ) + ).toBe('legacy') + }) }) describe('pinned relay version (D8.1)', () => { diff --git a/src/main/ssh/ssh-relay-pinned-node.ts b/src/main/ssh/ssh-relay-pinned-node.ts index c6b09204812..def3d8b36fa 100644 --- a/src/main/ssh/ssh-relay-pinned-node.ts +++ b/src/main/ssh/ssh-relay-pinned-node.ts @@ -2,9 +2,9 @@ * 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`). 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. + * 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' @@ -52,14 +52,25 @@ export const PINNED_NODE_GLIBC_FLOOR: GlibcVersion = { major: 2, minor: 28 } export const SSH_REMOTE_RUNTIME_ENV = 'ORCA_SSH_REMOTE_RUNTIME' export function resolveSshRemoteRuntime( - target: Pick | undefined, + target: Pick | 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]) - return fromEnv ?? DEFAULT_SSH_REMOTE_RUNTIME + // 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 { diff --git a/src/shared/node-runtime-pin.ts b/src/shared/node-runtime-pin.ts index c3fb3d8324d..3f24eb7fae4 100644 --- a/src/shared/node-runtime-pin.ts +++ b/src/shared/node-runtime-pin.ts @@ -153,6 +153,10 @@ export function isCompatServerTarget(target: string): target is CompatServerTarg return COMPAT_SERVER_TARGETS.some((known) => known === target) } +export function isNodeRuntimeTarget(target: string): target is NodeRuntimeTarget { + return isCompatServerTarget(target) || SERVER_TARGETS.some((known) => known === target) +} + /** The pinned asset for a known default or compat target. */ export function pinnedNodeRuntimeAsset(target: NodeRuntimeTarget): NodeRuntimeAsset { return isCompatServerTarget(target)