379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
vi.mock('fs', () => ({
|
|
existsSync: vi.fn(),
|
|
readFileSync: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./ssh-relay-deploy-helpers', () => ({
|
|
execCommand: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./ssh-connection-utils', () => ({
|
|
shellEscape: (s: string) => `'${s}'`
|
|
}))
|
|
|
|
import { existsSync, readFileSync } from 'node:fs'
|
|
import {
|
|
readLocalFullVersion,
|
|
computeRemoteRelayDir,
|
|
isRelayAlreadyInstalled,
|
|
acquireInstallLock,
|
|
finalizeInstall,
|
|
abandonInstall,
|
|
gcOldRelayVersions
|
|
} from './ssh-relay-versioned-install'
|
|
import { execCommand } from './ssh-relay-deploy-helpers'
|
|
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
|
import type { SshConnection } from './ssh-connection'
|
|
|
|
const conn = {} as SshConnection
|
|
const mockExec = vi.mocked(execCommand)
|
|
const mockExists = vi.mocked(existsSync)
|
|
const mockRead = vi.mocked(readFileSync)
|
|
|
|
function decodePowerShellCommand(command: string): string {
|
|
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
|
|
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : ''
|
|
}
|
|
|
|
describe('readLocalFullVersion', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns trimmed contents of the .version file', () => {
|
|
mockExists.mockReturnValue(true)
|
|
mockRead.mockReturnValue('0.1.0+deadbeef\n')
|
|
expect(readLocalFullVersion('/local/relay')).toBe('0.1.0+deadbeef')
|
|
})
|
|
|
|
it('throws an actionable error when the .version file is missing', () => {
|
|
mockExists.mockReturnValue(false)
|
|
expect(() => readLocalFullVersion('/local/relay')).toThrow(/missing its version marker/)
|
|
})
|
|
|
|
it('throws when the .version file is empty', () => {
|
|
mockExists.mockReturnValue(true)
|
|
mockRead.mockReturnValue(' \n')
|
|
expect(() => readLocalFullVersion('/local/relay')).toThrow(/is empty/)
|
|
})
|
|
})
|
|
|
|
describe('computeRemoteRelayDir', () => {
|
|
it('joins remoteHome with .orca-remote and the version-keyed dir name', () => {
|
|
expect(computeRemoteRelayDir('/home/u', '0.1.0+abc')).toBe(
|
|
'/home/u/.orca-remote/relay-0.1.0+abc'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('isRelayAlreadyInstalled', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns true only when the OK probe succeeds', async () => {
|
|
mockExec.mockResolvedValueOnce('OK')
|
|
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(true)
|
|
})
|
|
|
|
it('returns false when the probe reports MISSING', async () => {
|
|
mockExec.mockResolvedValueOnce('MISSING')
|
|
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false)
|
|
})
|
|
|
|
it('returns false on exec error', async () => {
|
|
mockExec.mockRejectedValueOnce(new Error('boom'))
|
|
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false)
|
|
})
|
|
|
|
it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => {
|
|
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
|
reason: 4
|
|
})
|
|
mockExec.mockRejectedValueOnce(sessionLimitError)
|
|
|
|
await expect(isRelayAlreadyInstalled(conn, '/r')).resolves.toBe(false)
|
|
})
|
|
|
|
it('rethrows SSH session-limit errors in strict mode', async () => {
|
|
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
|
|
reason: 4
|
|
})
|
|
mockExec.mockRejectedValueOnce(sessionLimitError)
|
|
|
|
await expect(
|
|
isRelayAlreadyInstalled(conn, '/r', undefined, { rethrowSessionLimitErrors: true })
|
|
).rejects.toBe(sessionLimitError)
|
|
})
|
|
|
|
it('checks for relay.js AND .install-complete in addition to the dir', async () => {
|
|
mockExec.mockResolvedValueOnce('OK')
|
|
await isRelayAlreadyInstalled(conn, '/r')
|
|
const cmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
|
|
expect(cmd).toContain('relay.js')
|
|
expect(cmd).toContain('.install-complete')
|
|
})
|
|
})
|
|
|
|
describe('acquireInstallLock', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('returns when mkdir reports OK', async () => {
|
|
// 1st call: mkdir -p remoteRelayDir
|
|
// 2nd call: mkdir lockDir → OK
|
|
mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('OK')
|
|
await acquireInstallLock(conn, '/r')
|
|
expect(mockExec).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
// Sequence:
|
|
// 1. mkdir -p (parent dir prep)
|
|
// 2. mkdir lockDir → BUSY (someone else holds it)
|
|
// 3. mkdir lockDir → BUSY again
|
|
// 4. mkdir lockDir → OK (concurrent installer released)
|
|
mockExec
|
|
.mockResolvedValueOnce('')
|
|
.mockResolvedValueOnce('BUSY')
|
|
.mockResolvedValueOnce('BUSY')
|
|
.mockResolvedValueOnce('OK')
|
|
|
|
const promise = acquireInstallLock(conn, '/r')
|
|
// Drive the polling loop: each iteration awaits a 1s timer.
|
|
for (let i = 0; i < 5; i++) {
|
|
await vi.advanceTimersByTimeAsync(1_000)
|
|
}
|
|
await promise
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
const mkdirAttempts = cmds.filter((c) => c.includes('mkdir') && c.includes('.install-lock'))
|
|
expect(mkdirAttempts.length).toBeGreaterThanOrEqual(3)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('steals a stale lock and retries with a reset timeout window', async () => {
|
|
vi.useFakeTimers({ now: 1_700_000_000_000 })
|
|
try {
|
|
let mkdirCalls = 0
|
|
mockExec.mockImplementation(async (_conn: unknown, cmd: string) => {
|
|
if (cmd.startsWith('mkdir -p')) {
|
|
return ''
|
|
}
|
|
if (cmd.includes('mkdir') && cmd.includes('.install-lock')) {
|
|
mkdirCalls++
|
|
return mkdirCalls > 200 ? 'OK' : 'BUSY'
|
|
}
|
|
if (cmd.includes('stat')) {
|
|
return `${Math.floor((Date.now() - 10 * 60 * 1000) / 1000)}\n`
|
|
}
|
|
if (cmd.startsWith('rm -rf')) {
|
|
mkdirCalls = 1000
|
|
return ''
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const promise = acquireInstallLock(conn, '/r')
|
|
// Drive through the full timeout (120s) so the stale-recovery branch
|
|
// fires, then drive a few more seconds for the post-recovery retry.
|
|
await vi.advanceTimersByTimeAsync(125_000)
|
|
await promise
|
|
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
expect(cmds.some((c) => c.includes('rm -rf') && c.includes('.install-lock'))).toBe(true)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('throws if the timeout elapses and the lock is fresh', async () => {
|
|
vi.useFakeTimers({ now: 1_700_000_000_000 })
|
|
try {
|
|
mockExec.mockImplementation(async (_conn: unknown, cmd: string) => {
|
|
if (cmd.startsWith('mkdir -p')) {
|
|
return ''
|
|
}
|
|
if (cmd.includes('mkdir') && cmd.includes('.install-lock')) {
|
|
return 'BUSY'
|
|
}
|
|
if (cmd.includes('stat')) {
|
|
return `${Math.floor(Date.now() / 1000)}\n`
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow(/not yet stale/i)
|
|
await vi.advanceTimersByTimeAsync(125_000)
|
|
await rejection
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('finalizeInstall writes .install-complete then removes the lock', async () => {
|
|
mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('')
|
|
await finalizeInstall(conn, '/r')
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
expect(cmds[0]).toContain('touch')
|
|
expect(cmds[0]).toContain('.install-complete')
|
|
expect(cmds[1]).toContain('rm -rf')
|
|
expect(cmds[1]).toContain('.install-lock')
|
|
})
|
|
|
|
it('abandonInstall removes the lock without writing the sentinel', async () => {
|
|
mockExec.mockResolvedValueOnce('')
|
|
await abandonInstall(conn, '/r')
|
|
const cmd = mockExec.mock.calls[0]?.[1] ?? ''
|
|
expect(cmd).toContain('rm -rf')
|
|
expect(cmd).toContain('.install-lock')
|
|
expect(cmd).not.toContain('.install-complete')
|
|
})
|
|
})
|
|
|
|
describe('gcOldRelayVersions', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('removes a sibling that is complete, unlocked, and has no live socket', async () => {
|
|
// ls listing
|
|
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n')
|
|
// For sibling "aaa": LOCKED probe → OPEN, COMPLETE probe → COMPLETE, sock probe → empty (no ALIVE), then rm -rf
|
|
mockExec
|
|
.mockResolvedValueOnce('OPEN')
|
|
.mockResolvedValueOnce('COMPLETE')
|
|
.mockResolvedValueOnce('')
|
|
.mockResolvedValueOnce('')
|
|
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
|
|
|
|
const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
|
|
expect(lastCmd).toContain('rm -rf')
|
|
expect(lastCmd).toContain('relay-0.1.0+aaa')
|
|
})
|
|
|
|
it('skips siblings that are missing .install-complete (mid-install or partial)', async () => {
|
|
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
|
|
mockExec
|
|
.mockResolvedValueOnce('OPEN') // not locked
|
|
.mockResolvedValueOnce('PARTIAL') // missing .install-complete
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
|
|
})
|
|
|
|
it('skips siblings whose .install-lock is held and fresh', async () => {
|
|
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
|
|
mockExec.mockResolvedValueOnce('LOCKED')
|
|
// isLockStale: mtime ~now → not stale.
|
|
mockExec.mockResolvedValueOnce(`${Math.floor(Date.now() / 1000)}\n`)
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
|
|
})
|
|
|
|
it('removes a sibling with a stale lock + .install-complete (rm-lock failed mid-finalize)', async () => {
|
|
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
|
|
mockExec.mockResolvedValueOnce('LOCKED')
|
|
// isLockStale: mtime well in the past → stale.
|
|
const staleSec = Math.floor((Date.now() - 10 * 60 * 1000) / 1000)
|
|
mockExec.mockResolvedValueOnce(`${staleSec}\n`)
|
|
mockExec.mockResolvedValueOnce('COMPLETE') // .install-complete present
|
|
mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE
|
|
mockExec.mockResolvedValueOnce('') // rm -rf
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
|
|
const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
|
|
expect(lastCmd).toContain('rm -rf')
|
|
expect(lastCmd).toContain('relay-0.1.0+aaa')
|
|
})
|
|
|
|
it('GCs a legacy relay-v0.1.0 dir whose daemon is dead (no .install-complete required)', async () => {
|
|
mockExec.mockResolvedValueOnce('relay-v0.1.0\n')
|
|
mockExec.mockResolvedValueOnce('OPEN') // not locked
|
|
mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE (no completeProbe — legacy)
|
|
mockExec.mockResolvedValueOnce('') // rm -rf
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
expect(cmds.some((c) => c.includes('rm -rf') && c.includes('relay-v0.1.0'))).toBe(true)
|
|
// critically: no .install-complete probe on legacy dirs
|
|
expect(cmds.some((c) => c.includes('.install-complete'))).toBe(false)
|
|
})
|
|
|
|
it('keeps a legacy relay-v0.1.0 dir whose daemon is still serving', async () => {
|
|
mockExec.mockResolvedValueOnce('relay-v0.1.0\n')
|
|
mockExec.mockResolvedValueOnce('OPEN')
|
|
mockExec.mockResolvedValueOnce('ALIVE') // socket alive → keep
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
|
|
})
|
|
|
|
it('skips siblings with a live relay-*.sock', async () => {
|
|
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
|
|
mockExec
|
|
.mockResolvedValueOnce('OPEN')
|
|
.mockResolvedValueOnce('COMPLETE')
|
|
.mockResolvedValueOnce('ALIVE')
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
|
|
})
|
|
|
|
it('probes Windows GC liveness by connecting to named pipes, not process command lines', async () => {
|
|
const windows = getRemoteHostPlatform('win32-x64')
|
|
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
|
|
mockExec
|
|
.mockResolvedValueOnce('OPEN')
|
|
.mockResolvedValueOnce('COMPLETE')
|
|
.mockResolvedValueOnce('WAITING')
|
|
.mockResolvedValueOnce('')
|
|
|
|
await gcOldRelayVersions(
|
|
conn,
|
|
'C:/Users/u',
|
|
'C:/Users/u/.orca-remote/relay-0.1.0+bbb',
|
|
windows,
|
|
{
|
|
windowsNodePath: 'C:/Program Files/nodejs/node.exe',
|
|
windowsSockNames: ['relay-target.sock']
|
|
}
|
|
)
|
|
|
|
const livenessCommand = mockExec.mock.calls[3]?.[1] ?? ''
|
|
const script = decodePowerShellCommand(livenessCommand ?? '')
|
|
expect(script).toContain('net.connect(pipe)')
|
|
expect(script).toContain('.windows-active-pipe-')
|
|
expect(script).toContain('\\\\.\\pipe\\orca-relay-')
|
|
expect(script).not.toContain('Win32_Process')
|
|
})
|
|
|
|
it('does not consider the current dir as a GC candidate', async () => {
|
|
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+aaa')
|
|
expect(mockExec.mock.calls.length).toBe(1) // only the listing
|
|
})
|
|
|
|
it('ignores entries that do not match the relay version dir regex (allowlist)', async () => {
|
|
mockExec.mockResolvedValueOnce('logs\nbackup\nrelay-0.1.0+aaa\n')
|
|
mockExec
|
|
.mockResolvedValueOnce('OPEN')
|
|
.mockResolvedValueOnce('COMPLETE')
|
|
.mockResolvedValueOnce('')
|
|
.mockResolvedValueOnce('')
|
|
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
|
|
const cmds = mockExec.mock.calls.map(([, c]) => c)
|
|
const rmCmds = cmds.filter((c) => c.includes('rm -rf'))
|
|
expect(rmCmds).toHaveLength(1)
|
|
expect(rmCmds[0]).toContain('relay-0.1.0+aaa')
|
|
expect(rmCmds[0]).not.toContain('logs')
|
|
expect(rmCmds[0]).not.toContain('backup')
|
|
})
|
|
})
|