Files
orca/src/main/ssh/remote-install-previous-version.ts
T
OrcaWinandm4air 9ddcc9b9f0 fix(ssh): collect relay versions only when provably exited; runtimes/ store GC (#24130)
* fix(ssh): relay version GC deletes only on an exited verdict and keeps the previous build

The relay records .relay-pid in its version dir once it owns its socket. GC calls a
relay version dir exited only when that PID is provably dead and every relay-*.sock
refuses a connection; a dir without a PID file keeps the test -S rule. The most
recently completed other relay build is pinned like orcad's rollback target.
Design D5 GC liveness.

* feat(ssh): collect the shared runtimes/ Node store and give it its own owner

runtimes/ gets its own owner in the install model, so no version-dir GC (new or old
clients, whose listings are prefix-scoped) can list or delete it. A store pass
removes node-<sha> only when no retained dir references it, it is neither a current
pin nor the newest other verified runtime, and a ps or /proc check ran and found no
process using it. Legacy relay-*/orcad-* dirs are read for references and reported
as diagnostics only (design D10 two-step). Wired behind orcad GC's nodeRuntimePins.

* fix(ssh): runtime store process check holds runtimes reached through a symlinked home

/proc exe resolves symlinks and argv keeps whatever spelling launched the runtime, so
filtering on the exact $root path missed in-use runtimes on hosts like /home -> /var/home.
Filter on the store segment instead; the parser already attributes holds root-agnostically.

* test(ssh): wait for the holder process to spawn instead of a fixed delay

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
2026-10-01 01:10:47 -07:00

107 lines
3.7 KiB
TypeScript

/**
* The version dir a model ran before the current one, pinned against GC like orcad's
* rollback target (design D5 "plus the relay's previous version as a pin").
*
* The relay keeps no activation record, so "previous" is the most recently completed install
* of the same model other than the current one, by `.install-complete` mtime.
*/
import type { SshConnection } from './ssh-connection'
import { shellEscape } from './ssh-connection-utils'
import { execCommand } from './ssh-relay-deploy-helpers'
import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command'
import {
remoteInstallGcPermits,
remoteInstallVersionDirRegex,
type RemoteInstallModel
} from './remote-install-model'
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
export const REMOTE_INSTALL_ORDER_OK = 'ORCA_INSTALL_ORDER_OK'
export function listCompletedInstallsNewestFirstCommand(
host: RemoteHostPlatform,
baseDir: string,
model: RemoteInstallModel
): string {
const prefix = `${model.dirPrefix}-`
if (isWindowsRemoteHost(host)) {
return powerShellCommand(
[
"$ErrorActionPreference = 'Stop'",
`$base = ${powerShellLiteral(baseDir)}`,
'if (Test-Path -LiteralPath $base -PathType Container) {',
`Get-ChildItem -LiteralPath $base -Directory -Filter '${prefix}*' | ForEach-Object { ` +
`$marker = Join-Path $_.FullName ${powerShellLiteral(model.installCompleteFilename)}; ` +
'if (Test-Path -LiteralPath $marker -PathType Leaf) { Get-Item -LiteralPath $marker } ' +
'} | Sort-Object LastWriteTimeUtc -Descending | ForEach-Object { $_.Directory.Name }',
'}',
`'${REMOTE_INSTALL_ORDER_OK}'`
].join('\n')
)
}
return [
`base=${shellEscape(baseDir)}`,
`set -- "$base"/${prefix}*/${shellEscape(model.installCompleteFilename)}`,
`[ -e "$1" ] || { echo ${REMOTE_INSTALL_ORDER_OK}; exit 0; }`,
'ls -1t -- "$@" || exit 1',
`echo ${REMOTE_INSTALL_ORDER_OK}`
].join('\n')
}
/** Dir names newest first, or null when the host could not answer. */
export function parseCompletedInstallsNewestFirst(
output: string,
model: RemoteInstallModel
): string[] | null {
const lines = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
if (!lines.includes(REMOTE_INSTALL_ORDER_OK)) {
return null
}
const versionDirRegex = remoteInstallVersionDirRegex(model)
const names: string[] = []
for (const line of lines) {
// POSIX prints `<base>/<dir>/.install-complete`; PowerShell prints `<dir>`.
const segments = line.split(/[\\/]/)
const name = segments.length > 1 ? segments.at(-2) : segments[0]
if (name && versionDirRegex.test(name) && remoteInstallGcPermits(model, name)) {
names.push(name)
}
}
return names
}
export type PreviousInstallResult = { state: 'ok'; dirName: string | null } | { state: 'unknown' }
export async function findPreviousRemoteInstall(
conn: SshConnection,
host: RemoteHostPlatform,
baseDir: string,
model: RemoteInstallModel,
currentDirName: string
): Promise<PreviousInstallResult> {
let output: string
try {
output = await execCommand(
conn,
listCompletedInstallsNewestFirstCommand(host, baseDir, model),
{
wrapCommand: host.commandDialect !== 'powershell'
}
)
} catch (err) {
if (isUnconfirmedSshCommandTermination(err)) {
throw err
}
return { state: 'unknown' }
}
const ordered = parseCompletedInstallsNewestFirst(output, model)
if (!ordered) {
return { state: 'unknown' }
}
return { state: 'ok', dirName: ordered.find((name) => name !== currentDirName) ?? null }
}