mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
feat(orcad): deploy, activate and roll back orcad on Windows SSH hosts (W3) (#24563)
* feat(orcad): deploy, activate and roll back orcad on Windows SSH hosts (W3) * test(orcad): exhaustive op switch in the Windows lifecycle fake * test(ssh): narrow the Windows host-cell descriptor by lane before building a relay cell --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -33,6 +33,7 @@ on:
|
||||
- 'src/shared/windows-breakaway-launch*.ts'
|
||||
- '!src/**/*.test.ts'
|
||||
- 'src/main/ssh/ssh-relay-windows-host-lane.test.ts'
|
||||
- 'src/main/ssh/orcad-windows-host-lane.test.ts'
|
||||
- 'config/ci/windows-ssh-provider/**'
|
||||
- '.github/workflows/ssh-windows-hosts.yml'
|
||||
workflow_dispatch:
|
||||
@@ -118,7 +119,7 @@ jobs:
|
||||
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
|
||||
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
|
||||
$cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_})
|
||||
if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out')}
|
||||
if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')}
|
||||
$archive=''
|
||||
if('${{ matrix.server }}' -eq 'preview'){
|
||||
$archive=Join-Path $env:RUNNER_TEMP 'preview-${{ matrix.archive }}'
|
||||
|
||||
@@ -6,11 +6,11 @@ param(
|
||||
[Parameter(Mandatory=$true)][hashtable]$Context,
|
||||
[Parameter(Mandatory=$true)][ValidateSet('win32-arm64','win32-x64')][string]$Target,
|
||||
[Parameter(Mandatory=$true)][string]$ReceiptRoot,
|
||||
[ValidateSet('pinned-cmd','pinned-powershell','legacy-opt-out')][string[]]$Cells=@('pinned-cmd','pinned-powershell','legacy-opt-out')
|
||||
[ValidateSet('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')][string[]]$Cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1'){throw 'Disposable CI only'}
|
||||
$shells=@{'pinned-cmd'='cmd';'pinned-powershell'='powershell';'legacy-opt-out'='cmd'}
|
||||
$shells=@{'pinned-cmd'='cmd';'pinned-powershell'='powershell';'legacy-opt-out'='cmd';'orcad-cmd'='cmd';'orcad-powershell'='powershell'}
|
||||
if($Context.accounts.Count -lt $Cells.Count){throw 'Each cell needs its own private account'}
|
||||
if(-not $Context.forbiddenToolLog){throw 'Run the provisioning with -HiddenTools so toolchain calls are logged'}
|
||||
$openSshKey='HKLM:\SOFTWARE\OpenSSH'
|
||||
@@ -78,11 +78,13 @@ try {
|
||||
@{cell=$cell;target=$Target;host='127.0.0.1';port=[int]$Context.port;username=$account.name;identityFile=$Context.identityFile;home=$account.home;forbiddenToolLog=$Context.forbiddenToolLog;receipt=(Join-Path $ReceiptRoot "$cell.json")} | ConvertTo-Json | Set-Content -LiteralPath $descriptor -Encoding utf8NoBOM
|
||||
$env:ORCA_RUN_SSH_WINDOWS_HOST='1';$env:ORCA_SSH_WINDOWS_HOST_CELL=$descriptor
|
||||
Write-Host "Windows host cell $cell ($Target, DefaultShell $shell, account $($account.name))"
|
||||
& node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/ssh/ssh-relay-windows-host-lane.test.ts --reporter=verbose 2>&1 | Tee-Object -FilePath (Join-Path $ReceiptRoot "$cell.log")
|
||||
# orcad cells deploy managed orcad instead of the relay; same account and descriptor shape.
|
||||
$lane=if($cell.StartsWith('orcad-')){'src/main/ssh/orcad-windows-host-lane.test.ts'}else{'src/main/ssh/ssh-relay-windows-host-lane.test.ts'}
|
||||
& node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts $lane --reporter=verbose 2>&1 | Tee-Object -FilePath (Join-Path $ReceiptRoot "$cell.log")
|
||||
# Why global: under the workflow's GetNewClosure callback, bare $LASTEXITCODE reads a stale captured copy.
|
||||
$code=$global:LASTEXITCODE
|
||||
# The relay's own log is the only record of why it closed a client.
|
||||
foreach($log in @(Get-ChildItem -Path (Join-Path $account.home '.orca-remote\relay-*\relay*.log') -File -ErrorAction SilentlyContinue)){Copy-Item -LiteralPath $log.FullName -Destination (Join-Path $ReceiptRoot "$cell.$($log.Directory.Name).$($log.Name)")}
|
||||
foreach($log in @(Get-ChildItem -Path (Join-Path $account.home '.orca-remote\relay-*\relay*.log'),(Join-Path $account.home '.orca-remote\orcad-*\orcad.log') -File -ErrorAction SilentlyContinue)){Copy-Item -LiteralPath $log.FullName -Destination (Join-Path $ReceiptRoot "$cell.$($log.Directory.Name).$($log.Name)")}
|
||||
if(Test-Path -LiteralPath $Context.forbiddenToolLog){Copy-Item -LiteralPath $Context.forbiddenToolLog -Destination (Join-Path $ReceiptRoot "$cell.forbidden-tool-calls.log")}
|
||||
$summary.Add(@{cell=$cell;shell=$shell;account=$account.name;exitCode=$code})
|
||||
if($code -ne 0){$failed.Add($cell)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# -HiddenTools: the private accounts are denied every machine PATH directory holding one of these
|
||||
# executables, and their own PATH carries logging shims for them, so SSH sessions have no host toolchain.
|
||||
# -HostCellProbe receives a context hashtable (accounts, port, keys, shim log) once provisioning passes.
|
||||
param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,4)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe)
|
||||
param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,5)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$target=@{arm64=@{os='Arm64';folder='OpenSSH-ARM64';machine='0xAA64';archive='698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42'};x64=@{os='X64';folder='OpenSSH-Win64';machine='0x8664';archive='23f50f3458c4c5d0b12217c6a5ddfde0137210a30fa870e98b29827f7b43aba5'}}[$Arch]
|
||||
$scopeServer=if($Server -eq 'inbox'){'Windows inbox OpenSSH.Server capability binaries'}else{'Microsoft Win32-OpenSSH 10.0.0.0p2-Preview'}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import {
|
||||
WINDOWS_FORBIDDEN_TOOLS,
|
||||
WINDOWS_HOST_CELL_IDS
|
||||
WINDOWS_HOST_CELL_IDS,
|
||||
WINDOWS_ORCAD_CELL_IDS
|
||||
} from '../../src/main/ssh/ssh-windows-host-cells.ts'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
@@ -70,14 +71,16 @@ describe('SSH Windows-host workflow', () => {
|
||||
it('defaults to every cell the TypeScript lane knows', () => {
|
||||
const defaults = /\{\$cells=@\(([^)]*)\)\}/.exec(runStep.run)?.[1]
|
||||
expect(defaults?.split(',').map((id) => id.trim().replaceAll("'", ''))).toEqual([
|
||||
...WINDOWS_HOST_CELL_IDS
|
||||
...WINDOWS_HOST_CELL_IDS,
|
||||
...WINDOWS_ORCAD_CELL_IDS
|
||||
])
|
||||
const invoker = readFileSync(
|
||||
join(projectDir, 'config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1'),
|
||||
'utf8'
|
||||
)
|
||||
for (const id of WINDOWS_HOST_CELL_IDS) {
|
||||
for (const id of [...WINDOWS_HOST_CELL_IDS, ...WINDOWS_ORCAD_CELL_IDS]) {
|
||||
expect(invoker).toContain(`'${id}'`)
|
||||
}
|
||||
expect(invoker).toContain('src/main/ssh/orcad-windows-host-lane.test.ts')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,8 @@ import { acquireInstallLock, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install
|
||||
import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands'
|
||||
import { RELAY_REMOTE_DIR } from './relay-protocol'
|
||||
import { removeRemoteFileCommand, removeRemoteTreeCommand } from './ssh-remote-commands'
|
||||
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { orcadRemoteBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node'
|
||||
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import {
|
||||
ORCAD_ACTIVATION_TRANSACTION_DIRNAME,
|
||||
ORCAD_ACTIVATION_TRANSACTION_FILENAME
|
||||
@@ -147,8 +148,22 @@ function releaseActivationFence(
|
||||
// Journal first: a release cut short must leave a lock without a journal, never the reverse.
|
||||
const journal = joinRemotePath(options.host, lockRoot, ORCAD_ACTIVATION_TRANSACTION_FILENAME)
|
||||
// Why no signal: a cancelled run must still be able to drop a fence it proved unnecessary.
|
||||
const target = withoutAbortSignal(options)
|
||||
if (isWindowsRemoteHost(options.host)) {
|
||||
// `&&` does not parse under a PowerShell DefaultShell, so the two steps are two execs.
|
||||
const baseDir = orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
return execOrcadRemote(
|
||||
target,
|
||||
orcadWindowsHostOpCommand(options.host, baseDir, 'remove-file', [journal])
|
||||
).then(() =>
|
||||
execOrcadRemote(
|
||||
target,
|
||||
orcadWindowsHostOpCommand(options.host, baseDir, 'remove-tree', [lockRoot])
|
||||
).then(() => undefined)
|
||||
)
|
||||
}
|
||||
return execOrcadRemote(
|
||||
withoutAbortSignal(options),
|
||||
target,
|
||||
`${removeRemoteFileCommand(options.host, journal)} && ${removeRemoteTreeCommand(options.host, lockRoot)}`
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* when the change provably carries no terminals — the slot exposed RPC, so a pre-launch census
|
||||
* cannot vouch for it.
|
||||
*/
|
||||
import { orcadRemoteBaseDir } from './orcad-remote-windows-node'
|
||||
import type { ServeReadiness } from '../server/serve-readiness'
|
||||
import { RELAY_REMOTE_DIR } from './relay-protocol'
|
||||
import { ORCAD_STATE_SNAPSHOT_DIR } from './orcad-activation-record'
|
||||
@@ -98,7 +99,12 @@ async function decideChangedStateRestore(
|
||||
// Read-only, so a lost answer is just "changed".
|
||||
const comparison = await execOrcadRemote(
|
||||
options,
|
||||
compareOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir)
|
||||
compareOrcadStateSnapshotCommand(
|
||||
options.host,
|
||||
options.userDataDir,
|
||||
snapshotDir,
|
||||
orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
)
|
||||
).catch(() => '')
|
||||
if (orcadSnapshotIsUnchanged(comparison)) {
|
||||
return 'unchanged'
|
||||
@@ -143,9 +149,14 @@ async function restoreState(options: OrcadSlotOptions, state: OrcadSnapshotVerdi
|
||||
? restoreOrcadStateSnapshotCommand(
|
||||
options.host,
|
||||
options.userDataDir,
|
||||
orcadSnapshotPath(options, state.dirName)
|
||||
orcadSnapshotPath(options, state.dirName),
|
||||
orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
)
|
||||
: clearOrcadStateSnapshotMembersCommand(
|
||||
options.host,
|
||||
options.userDataDir,
|
||||
orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
)
|
||||
: clearOrcadStateSnapshotMembersCommand(options.host, options.userDataDir)
|
||||
const restored = parseOrcadSnapshotRestore(await execOrcadRemote(options, command))
|
||||
if (restored !== 'restored') {
|
||||
throw new Error(`The prelaunch state could not be restored (${restored}).`)
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ORCAD_LOCK_FILE_NAME } from '../orcad/orcad-instance-lock'
|
||||
import { PRIMARY_RUNTIME_METADATA_FILE } from '../../shared/runtime-bootstrap'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import { selectOrcadSlotRuntimeCommand } from './orcad-remote-runtime'
|
||||
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { orcadWindowsBaseDir, orcadWindowsHostOpCommand } from './orcad-remote-windows-node'
|
||||
|
||||
const OWNER_RECORD_MAX_BYTES = 64 * 1024
|
||||
|
||||
@@ -24,6 +25,15 @@ export function initialOrcadActivationAdmissionCommand(
|
||||
remoteInstallDir: string,
|
||||
legacyNodePath: string
|
||||
): string {
|
||||
if (isWindowsRemoteHost(host)) {
|
||||
// Windows has no O_NOFOLLOW; the host script refuses links by lstat instead.
|
||||
return orcadWindowsHostOpCommand(
|
||||
host,
|
||||
orcadWindowsBaseDir(host, remoteInstallDir),
|
||||
'owner-admission',
|
||||
[userDataDir]
|
||||
)
|
||||
}
|
||||
const owners = [ORCAD_LOCK_FILE_NAME, PRIMARY_RUNTIME_METADATA_FILE].map((name) => ({
|
||||
name,
|
||||
path: joinRemotePath(host, userDataDir, name)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* the host for `recoverInterruptedOrcadActivation` to finish or undo it. A run that ends with
|
||||
* the host provably back on one slot drops the fence; one that cannot prove it keeps it.
|
||||
*/
|
||||
import { orcadRemoteBaseDir } from './orcad-remote-windows-node'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { OrcadDeployOptions, OrcadDeployResult } from './orcad-remote-deploy'
|
||||
import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers'
|
||||
@@ -176,7 +177,12 @@ export async function activateInstalledOrcad(
|
||||
const capture = parseOrcadSnapshotCapture(
|
||||
await execOrcadRemote(
|
||||
options,
|
||||
captureOrcadStateSnapshotCommand(options.host, options.userDataDir, snapshotDir)
|
||||
captureOrcadStateSnapshotCommand(
|
||||
options.host,
|
||||
options.userDataDir,
|
||||
snapshotDir,
|
||||
orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
)
|
||||
).catch((error: unknown) => {
|
||||
if (isUnconfirmedSshCommandTermination(error)) {
|
||||
throw error
|
||||
|
||||
@@ -4,11 +4,12 @@ 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 { assertPosixOrcadHost } from './orcad-remote-host-support'
|
||||
import { prepareWindowsOrcadHost } from './orcad-windows-host-preparation'
|
||||
import { execOrcadRemote } from './orcad-remote-runtime-control'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { readRemoteHomeCommand } from './ssh-remote-commands'
|
||||
import {
|
||||
isWindowsRemoteHost,
|
||||
joinRemotePath,
|
||||
normalizeRemoteHome,
|
||||
validateRemoteHome,
|
||||
@@ -35,8 +36,6 @@ export async function resolveOrcadRemoteContext(
|
||||
if (!host) {
|
||||
throw new Error('This SSH host platform is not supported by managed orcad.')
|
||||
}
|
||||
// Why first: every lifecycle step after this is POSIX-only, so refuse before probing further.
|
||||
assertPosixOrcadHost(host)
|
||||
const remote = { conn: connection, host, signal }
|
||||
const remoteHome = normalizeRemoteHome(
|
||||
await execOrcadRemote(remote, readRemoteHomeCommand(host)),
|
||||
@@ -46,6 +45,10 @@ export async function resolveOrcadRemoteContext(
|
||||
throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`)
|
||||
}
|
||||
const serverTarget = await resolveOrcadDeploymentTarget({ conn: connection, host, signal })
|
||||
if (isWindowsRemoteHost(host)) {
|
||||
// Every Windows host op, the activation record read included, runs on the pinned node.exe.
|
||||
await prepareWindowsOrcadHost({ conn: connection, host, remoteHome, serverTarget, signal })
|
||||
}
|
||||
const activationRecord = await readOrcadActivationRecord({ ...remote, remoteHome })
|
||||
return {
|
||||
activationRecord,
|
||||
|
||||
@@ -14,7 +14,6 @@ import { readOrcadActivationRecord } from './orcad-activation-record-store'
|
||||
import type { OrcadActivationVerdict } from './orcad-activation-gate'
|
||||
import type { OrcadTerminalCensus } from './orcad-update-plan'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { assertPosixOrcadHost } from './orcad-remote-host-support'
|
||||
import { installOrcadBundle } from './orcad-remote-install'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import type { ServerTarget } from '../../shared/node-runtime-pin'
|
||||
@@ -62,7 +61,6 @@ export type OrcadDeployResult =
|
||||
|
||||
/** Activate on a healthy verdict; retain changed candidate state for explicit recovery. */
|
||||
export async function deployOrcad(input: OrcadDeployOptions): Promise<OrcadDeployResult> {
|
||||
assertPosixOrcadHost(input.host)
|
||||
const target =
|
||||
input.target ??
|
||||
(input.localOrcadDir
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* discovered at runtime.
|
||||
*
|
||||
* The install transaction is host-agnostic — it is the relay's, and the relay runs on
|
||||
* Windows. Launch, readiness, liveness, stop, build hash and record files have Windows
|
||||
* branches; snapshots, the deploy and rollback drivers, preflight and managed stop do not yet,
|
||||
* so the managed lifecycle still refuses Windows hosts at context resolution.
|
||||
* Windows. Deploy, rollback and their recovery run on Windows through the host script
|
||||
* (`orcad-windows-host-script.ts`); managed stop, decommission and GC do not yet, and refuse.
|
||||
*/
|
||||
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
|
||||
@@ -14,8 +13,8 @@ export class OrcadRemoteLaunchUnsupportedError extends Error {
|
||||
constructor(hostLabel: string) {
|
||||
super(
|
||||
`Deploying orcad to a ${hostLabel} host is not implemented. The install transaction is ` +
|
||||
'host-agnostic, but state snapshots, preflight, rollback and managed stop are ' +
|
||||
'POSIX-only. Use the relay for this host.'
|
||||
'host-agnostic, but managed stop, decommission and version GC are POSIX-only. ' +
|
||||
'Use the relay for this host.'
|
||||
)
|
||||
this.name = 'OrcadRemoteLaunchUnsupportedError'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* The deploy and rollback drivers end to end against a Windows host whose every host op is answered by a
|
||||
* fake: no POSIX command, no `-EncodedCommand` and no PowerShell hop on the orcad path.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as RecordFile from './orcad-remote-record-file'
|
||||
|
||||
vi.mock('./ssh-relay-deploy-helpers', () => ({
|
||||
execCommand: vi.fn(),
|
||||
isUnconfirmedSshCommandTermination: () => false
|
||||
}))
|
||||
vi.mock('./ssh-relay-install-lock', () => ({
|
||||
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
|
||||
RELAY_INSTALL_LOCK_NAME: '.install-lock'
|
||||
}))
|
||||
vi.mock('./ssh-relay-install-transfers', () => ({
|
||||
uploadRelayDirectory: vi.fn().mockResolvedValue(undefined),
|
||||
writeRelayFile: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
vi.mock('./orcad-remote-record-file', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof RecordFile>()),
|
||||
writeAtomicOrcadRemoteRecord: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
vi.mock('./orcad-remote-node-runtime', () => ({
|
||||
ensureRemoteOrcadNodeRuntime: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
vi.mock('./orcad-local-build-hash', () => ({
|
||||
computeLocalOrcadBuildHash: () => 'abc123def4567890'
|
||||
}))
|
||||
vi.mock('./ssh-relay-versioned-install', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
readLocalFullVersion: () => '0.2.0+bb0100000000',
|
||||
isRemoteInstallComplete: vi.fn().mockResolvedValue(false),
|
||||
finalizeInstall: vi.fn().mockResolvedValue(undefined),
|
||||
abandonInstall: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { deployOrcad, type OrcadDeployOptions } from './orcad-remote-deploy'
|
||||
import { rollbackOrcad } from './orcad-remote-rollback'
|
||||
import { emptyOrcadActivationRecord } from './orcad-activation-record'
|
||||
import { writeAtomicOrcadRemoteRecord } from './orcad-remote-record-file'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin'
|
||||
|
||||
const mockExec = vi.mocked(execCommand)
|
||||
const host = getRemoteHostPlatform('win32-x64')
|
||||
const VERSION = '0.2.0+bb0100000000'
|
||||
const SLOT_NODE = 'C:\\Users\\u\\.orca-remote\\runtimes\\node-ab\\node.exe'
|
||||
|
||||
const encoded = (marker: string, value: string): string =>
|
||||
`${marker} ${Buffer.from(value).toString('base64')}\r\n`
|
||||
|
||||
const TARGET = '0.1.0+aa01'
|
||||
|
||||
function readyLine(version = VERSION): string {
|
||||
return `${JSON.stringify({
|
||||
type: 'orca_server_ready',
|
||||
runtimeId: 'r1',
|
||||
boundEndpoint: 'ws://127.0.0.1:7777',
|
||||
advertisedEndpoint: null,
|
||||
managedWslCliReconciliation: 'settled',
|
||||
pairing: { available: false, reason: 'disabled_by_operator', guidance: 'n/a' },
|
||||
health: {
|
||||
buildHash: 'abc123def4567890',
|
||||
buildVersion: version,
|
||||
nodeVersion: NODE_RUNTIME_PIN.version,
|
||||
nodeAbi: '137',
|
||||
platform: 'win32',
|
||||
arch: 'x64',
|
||||
pid: 4242,
|
||||
stopRequests: 1,
|
||||
terminalDaemon: {
|
||||
state: 'live',
|
||||
ownsFreshSessions: true,
|
||||
pid: 2,
|
||||
buildVersion: version,
|
||||
entryPath: 'C:/x/daemon-entry.js',
|
||||
protocolVersion: 3,
|
||||
selfTest: { ok: true, coverage: 'handshake', verdict: 'healthy', durationMs: 5 }
|
||||
}
|
||||
}
|
||||
})}\n`
|
||||
}
|
||||
|
||||
function scriptWindowsHost(log: string[], activeRecord: string | null = null): void {
|
||||
mockExec.mockImplementation(async (_conn, command: string) => {
|
||||
const text = String(command)
|
||||
log.push(text)
|
||||
const op = /\.js ([a-z-]+)(?: |$)/u.exec(text)?.[1] ?? ''
|
||||
switch (op) {
|
||||
case 'record-read':
|
||||
return activeRecord && text.includes('orcad-active.json')
|
||||
? encoded('__ORCAD_RECORD_PRESENT__', activeRecord)
|
||||
: '__ORCAD_RECORD_ABSENT__\r\n'
|
||||
case 'build-hash':
|
||||
return '__ORCAD_BUILD_HASH__ abc123def4567890\r\n'
|
||||
case 'owner-admission':
|
||||
return 'CLEAR'
|
||||
case 'slot-runtime':
|
||||
return encoded('__ORCAD_RUNTIME__', SLOT_NODE)
|
||||
case 'readiness-wait':
|
||||
return encoded('__ORCAD_READINESS__', readyLine(text.includes(TARGET) ? TARGET : VERSION))
|
||||
case 'stop':
|
||||
return 'STOPPED'
|
||||
case 'snapshot-probe':
|
||||
return 'PRESENT'
|
||||
case 'snapshot-restore':
|
||||
return 'RESTORED'
|
||||
case 'state-newest-mtime':
|
||||
return 'UNKNOWN'
|
||||
case 'snapshot-capture':
|
||||
return 'CAPTURED'
|
||||
case 'remove-file':
|
||||
case 'remove-tree':
|
||||
return ''
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (text.includes('--orcad-profile-state-preflight')) {
|
||||
return JSON.stringify({
|
||||
type: 'orca_profile_state_ready',
|
||||
nonce: text.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/)?.[0],
|
||||
runtime: 'node',
|
||||
runtimeVersion: NODE_RUNTIME_PIN.version,
|
||||
sqliteVersion: '3.51.0',
|
||||
artifactVersion: VERSION,
|
||||
revision: 1
|
||||
})
|
||||
}
|
||||
if (text.includes('--windows-breakaway-launch')) {
|
||||
return 'ORCA_ORCAD_LAUNCH {"method":"breakaway","pid":4242,"inJob":false}\r\n'
|
||||
}
|
||||
// The relay's shared install fence is the one pre-existing PowerShell site left on this path.
|
||||
if (text.startsWith('powershell.exe ')) {
|
||||
return 'OPEN'
|
||||
}
|
||||
throw new Error(`unexpected Windows command: ${text}`)
|
||||
})
|
||||
}
|
||||
|
||||
function options(): OrcadDeployOptions {
|
||||
return {
|
||||
conn: Object.assign(Object.create(null), { writeFile: vi.fn().mockResolvedValue(undefined) }),
|
||||
host,
|
||||
remoteHome: 'C:/Users/u',
|
||||
localOrcadDir: '/local/out/orcad',
|
||||
target: 'win32-x64',
|
||||
nodePath: 'C:/host/node.exe',
|
||||
userDataDir: 'C:/Users/u/.orca',
|
||||
bindHost: '127.0.0.1',
|
||||
port: 7777,
|
||||
census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 3 },
|
||||
readinessTimeoutMs: 1_000,
|
||||
sleep: async () => {},
|
||||
now: () => new Date('2026-10-02T00:00:00.000Z')
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockExec.mockReset()
|
||||
vi.mocked(writeAtomicOrcadRemoteRecord).mockClear()
|
||||
})
|
||||
|
||||
describe('deployOrcad on a Windows host', () => {
|
||||
it('activates a first install through node.exe host ops only', async () => {
|
||||
const log: string[] = []
|
||||
scriptWindowsHost(log)
|
||||
const result = await deployOrcad(options())
|
||||
expect(result).toMatchObject({ outcome: 'installed-and-activated', fullVersion: VERSION })
|
||||
const orcadOps = log.filter((command) => !command.startsWith('powershell.exe '))
|
||||
expect(orcadOps.length).toBeGreaterThan(0)
|
||||
for (const command of orcadOps) {
|
||||
expect(command).not.toMatch(/EncodedCommand|nohup|kill |head -c|tar |\bsh -c\b/u)
|
||||
}
|
||||
const ops = orcadOps.map((command) => /\.js ([a-z-]+)/u.exec(command)?.[1] ?? command)
|
||||
expect(ops).toContain('owner-admission')
|
||||
expect(ops).toContain('snapshot-capture')
|
||||
expect(ops.indexOf('slot-runtime')).toBeLessThan(
|
||||
ops.findIndex((entry) => entry.includes('--windows-breakaway-launch'))
|
||||
)
|
||||
expect(ops).toContain('readiness-wait')
|
||||
const record = vi
|
||||
.mocked(writeAtomicOrcadRemoteRecord)
|
||||
.mock.calls.find(([, path]) => path.endsWith('orcad-active.json'))
|
||||
expect(JSON.parse(record?.[2] ?? '{}')).toMatchObject({ active: VERSION })
|
||||
})
|
||||
|
||||
it('rolls back by stop request, directory snapshot and node.exe launch', async () => {
|
||||
const log: string[] = []
|
||||
const record = {
|
||||
...emptyOrcadActivationRecord(),
|
||||
active: VERSION,
|
||||
previous: TARGET,
|
||||
activatedAt: '2026-10-01T00:00:00.000Z',
|
||||
snapshot: {
|
||||
dirName: `pre-${VERSION}-1000`,
|
||||
takenBeforeVersion: VERSION,
|
||||
readableByVersion: TARGET,
|
||||
takenAt: '2026-10-01T00:00:00.000Z'
|
||||
}
|
||||
}
|
||||
scriptWindowsHost(log, JSON.stringify(record))
|
||||
const { localOrcadDir: _dir, target: _target, force: _force, ...base } = options()
|
||||
const result = await rollbackOrcad({
|
||||
...base,
|
||||
record,
|
||||
targetBuildHash: 'abc123def4567890',
|
||||
targetDaemonProtocol: { protocolVersion: 3, previousProtocolVersions: [1, 2] }
|
||||
})
|
||||
expect(result).toMatchObject({ outcome: 'rolled-back', target: TARGET })
|
||||
const ops = log.map((command) => /\.js ([a-z-]+)/u.exec(command)?.[1] ?? command)
|
||||
// Stop before any state is touched; the target starts only after its state is back.
|
||||
expect(ops.indexOf('stop')).toBeLessThan(ops.indexOf('snapshot-restore'))
|
||||
expect(ops).toEqual([
|
||||
'record-read',
|
||||
'record-read',
|
||||
'snapshot-probe',
|
||||
'state-newest-mtime',
|
||||
'build-hash',
|
||||
'stop',
|
||||
'snapshot-capture',
|
||||
'snapshot-restore',
|
||||
'slot-runtime',
|
||||
'--windows-breakaway-launch',
|
||||
'readiness-wait',
|
||||
'record-read',
|
||||
'remove-file',
|
||||
'remove-tree'
|
||||
])
|
||||
for (const command of log) {
|
||||
expect(command).not.toMatch(/EncodedCommand|kill |tar |nohup/u)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -7,10 +7,17 @@ import {
|
||||
parseOrcadProfilePreflight
|
||||
} from '../../shared/orcad-profile-preflight'
|
||||
import { assertPosixOrcadHost } from './orcad-remote-host-support'
|
||||
import {
|
||||
orcadWindowsBaseDir,
|
||||
orcadWindowsHostOpCommand,
|
||||
orcadWindowsNodeCommandLine,
|
||||
readOrcadWindowsEncodedAnswer
|
||||
} from './orcad-remote-windows-node'
|
||||
import { ORCAD_WINDOWS_RUNTIME_MARKER } from './orcad-windows-host-script'
|
||||
import { orcadNodeSlotRuntimeCommand } from './orcad-remote-runtime'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
|
||||
export function orcadProfilePreflightCommand(
|
||||
@@ -40,10 +47,47 @@ export async function preflightInstalledOrcad(options: {
|
||||
signal?: AbortSignal
|
||||
}): Promise<void> {
|
||||
const nonce = randomUUID()
|
||||
const output = await execCommand(
|
||||
options.conn,
|
||||
orcadProfilePreflightCommand(options.host, options.remoteInstallDir, nonce),
|
||||
{ signal: options.signal, timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS }
|
||||
)
|
||||
const command = isWindowsRemoteHost(options.host)
|
||||
? await windowsOrcadProfilePreflightCommand(options, nonce)
|
||||
: orcadProfilePreflightCommand(options.host, options.remoteInstallDir, nonce)
|
||||
const output = await execCommand(options.conn, command, {
|
||||
signal: options.signal,
|
||||
timeoutMs: ORCAD_PROFILE_PREFLIGHT_TIMEOUT_MS,
|
||||
wrapCommand: !isWindowsRemoteHost(options.host)
|
||||
})
|
||||
parseOrcadProfilePreflight(output, nonce, ORCAD_NODE_RUNTIME_IDENTITY, options.fullVersion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows: resolve the slot's node.exe, then run its `orcad.js` with plain argv. No
|
||||
* ORCA_BACKGROUND_LAUNCH here: orcad opens no window, and argv cannot set environment.
|
||||
*/
|
||||
async function windowsOrcadProfilePreflightCommand(
|
||||
options: {
|
||||
conn: SshConnection
|
||||
host: RemoteHostPlatform
|
||||
remoteInstallDir: string
|
||||
signal?: AbortSignal
|
||||
},
|
||||
nonce: string
|
||||
): Promise<string> {
|
||||
const { host, remoteInstallDir } = options
|
||||
const runtime = readOrcadWindowsEncodedAnswer(
|
||||
await execCommand(
|
||||
options.conn,
|
||||
orcadWindowsHostOpCommand(host, orcadWindowsBaseDir(host, remoteInstallDir), 'slot-runtime', [
|
||||
remoteInstallDir
|
||||
]),
|
||||
{ signal: options.signal, wrapCommand: false }
|
||||
),
|
||||
ORCAD_WINDOWS_RUNTIME_MARKER
|
||||
)
|
||||
if (!runtime) {
|
||||
throw new Error('The Windows host did not name the runtime this orcad slot needs.')
|
||||
}
|
||||
return orcadWindowsNodeCommandLine(runtime, [
|
||||
joinRemotePath(host, remoteInstallDir, 'orcad.js'),
|
||||
ORCAD_PROFILE_PREFLIGHT_FLAG,
|
||||
nonce
|
||||
])
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./ssh-remote-platform-detection', () => ({ detectRemoteHostPlatform: vi.fn() }))
|
||||
vi.mock('./orcad-windows-host-preparation', () => ({ prepareWindowsOrcadHost: vi.fn() }))
|
||||
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { detectRemoteHostPlatform } from './ssh-remote-platform-detection'
|
||||
import { prepareWindowsOrcadHost } from './orcad-windows-host-preparation'
|
||||
import { resolveOrcadRemoteContext } from './orcad-remote-context'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
@@ -234,10 +236,26 @@ describe('readiness parsing bounds', () => {
|
||||
})
|
||||
|
||||
describe('orcad remote context', () => {
|
||||
it('refuses a Windows host before probing anything else', async () => {
|
||||
it('prepares a Windows host (runtime, host script) before reading the activation record', async () => {
|
||||
vi.mocked(detectRemoteHostPlatform).mockResolvedValueOnce(windows)
|
||||
const order: string[] = []
|
||||
vi.mocked(prepareWindowsOrcadHost).mockImplementationOnce(async () => {
|
||||
order.push('prepare')
|
||||
})
|
||||
mockExec.mockImplementation(async (_conn, command: string) => {
|
||||
if (command.includes('record-read')) {
|
||||
order.push('record')
|
||||
return '__ORCAD_RECORD_ABSENT__\r\n'
|
||||
}
|
||||
return 'C:\\Users\\u\r\n'
|
||||
})
|
||||
const sshTarget = { id: 't', label: 't', host: 'h', port: 22, username: 'u' }
|
||||
await expect(resolveOrcadRemoteContext(sshTarget, conn)).rejects.toThrow()
|
||||
expect(mockExec).not.toHaveBeenCalled()
|
||||
const context = await resolveOrcadRemoteContext(sshTarget, conn)
|
||||
expect(context).toMatchObject({ serverTarget: 'win32-x64', remoteHome: 'C:/Users/u' })
|
||||
expect(vi.mocked(prepareWindowsOrcadHost).mock.calls[0]?.[0]).toMatchObject({
|
||||
remoteHome: 'C:/Users/u',
|
||||
serverTarget: 'win32-x64'
|
||||
})
|
||||
expect(order).toEqual(['prepare', 'record'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ import { readOrcadActivationRecord } from './orcad-activation-record-store'
|
||||
import { sameOrcadActivationRecord } from './orcad-activation-transaction'
|
||||
import { readOrcadActivationTransaction } from './orcad-activation-transaction-store'
|
||||
import type { RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { assertPosixOrcadHost } from './orcad-remote-host-support'
|
||||
import {
|
||||
resolveOrcadActivationReadinessTimeout,
|
||||
withOrcadActivationLock
|
||||
@@ -58,7 +57,6 @@ export type OrcadRollbackResult =
|
||||
| { outcome: 'failed'; code: string; reason: string }
|
||||
|
||||
export async function rollbackOrcad(input: OrcadRollbackOptions): Promise<OrcadRollbackResult> {
|
||||
assertPosixOrcadHost(input.host)
|
||||
const options = {
|
||||
...input,
|
||||
readinessTimeoutMs: resolveOrcadActivationReadinessTimeout(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../../shared/orcad-artifacts'
|
||||
import { pinnedNodeRuntimeAsset, type NodeRuntimeTarget } from '../../shared/node-runtime-pin'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { RELAY_REMOTE_DIR } from './relay-protocol'
|
||||
import { joinRemotePath, remoteDirname, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { powerShellLiteral } from './ssh-remote-powershell'
|
||||
import {
|
||||
@@ -73,6 +74,11 @@ export function orcadWindowsNodeCommandLine(executable: string, args: readonly s
|
||||
return `powershell.exe -NoProfile -NonInteractive -Command "${inner}"`
|
||||
}
|
||||
|
||||
/** `~/.orca-remote` from the remote home. */
|
||||
export function orcadRemoteBaseDir(host: RemoteHostPlatform, remoteHome: string): string {
|
||||
return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)
|
||||
}
|
||||
|
||||
/** `~/.orca-remote`, the parent of every slot and of the runtime store. */
|
||||
export function orcadWindowsBaseDir(host: RemoteHostPlatform, slotDir: string): string {
|
||||
return remoteDirname(slotDir.replace(/\/+$/u, ''), host)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** The locked, journaled half of `rollbackOrcad`. */
|
||||
import { orcadRemoteBaseDir } from './orcad-remote-windows-node'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { OrcadRollbackOptions, OrcadRollbackResult } from './orcad-remote-rollback'
|
||||
import { isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers'
|
||||
@@ -58,7 +59,14 @@ async function stateWritesSinceActivation(options: OrcadRollbackOptions): Promis
|
||||
return null
|
||||
}
|
||||
const newest = parseNewestStateMtimeSeconds(
|
||||
await execOrEmpty(options, newestStateMtimeCommand(options.host, options.userDataDir))
|
||||
await execOrEmpty(
|
||||
options,
|
||||
newestStateMtimeCommand(
|
||||
options.host,
|
||||
options.userDataDir,
|
||||
orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
)
|
||||
)
|
||||
)
|
||||
return newest === null ? null : newest >= activatedAtSeconds
|
||||
}
|
||||
@@ -73,7 +81,11 @@ export async function rollbackOrcadLocked(
|
||||
? parseOrcadSnapshotPresence(
|
||||
await execOrEmpty(
|
||||
options,
|
||||
probeOrcadStateSnapshotCommand(options.host, orcadSnapshotPath(options, snapshot.dirName))
|
||||
probeOrcadStateSnapshotCommand(
|
||||
options.host,
|
||||
orcadSnapshotPath(options, snapshot.dirName),
|
||||
orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
)
|
||||
)
|
||||
)
|
||||
: 'absent'
|
||||
@@ -145,7 +157,8 @@ export async function rollbackOrcadLocked(
|
||||
captureOrcadStateSnapshotCommand(
|
||||
options.host,
|
||||
options.userDataDir,
|
||||
orcadSnapshotPath(options, transaction.rescue.dirName)
|
||||
orcadSnapshotPath(options, transaction.rescue.dirName),
|
||||
orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -169,7 +182,8 @@ export async function rollbackOrcadLocked(
|
||||
restoreOrcadStateSnapshotCommand(
|
||||
options.host,
|
||||
options.userDataDir,
|
||||
orcadSnapshotPath(options, snapshot.dirName)
|
||||
orcadSnapshotPath(options, snapshot.dirName),
|
||||
orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** What the pre-activation snapshot holds; shared by the POSIX commands and the Windows host script. */
|
||||
|
||||
/**
|
||||
* Root-relative paths a rollback needs restored. Everything else under the data root is
|
||||
* either regenerable, or owned by a process that survives the rollback.
|
||||
*/
|
||||
export const ORCAD_SNAPSHOT_MEMBERS = [
|
||||
'orca-profile-index.json',
|
||||
// Pre-profiles layout; still read as a migration source.
|
||||
'orca-data.json',
|
||||
'profiles',
|
||||
// Cross-profile SQLite moves must survive an orcad rollback too.
|
||||
'profile-move-intents'
|
||||
] as const
|
||||
|
||||
/** Never captured and never restored — see `orcad-state-snapshot.ts`. */
|
||||
export const ORCAD_SNAPSHOT_EXCLUDED = ['daemon', 'logs'] as const
|
||||
|
||||
export const ORCAD_STATE_RESTORE_STAGE_DIRNAME = '.orcad-state-restore-stage'
|
||||
|
||||
/** Windows keeps the snapshot as a directory copy, where POSIX keeps `state.tar`. */
|
||||
export const ORCAD_WINDOWS_SNAPSHOT_STATE_DIRNAME = 'state'
|
||||
@@ -105,14 +105,39 @@ describe('detecting writes since activation', () => {
|
||||
})
|
||||
|
||||
describe('Windows hosts', () => {
|
||||
const BASE = 'C:/Users/u/.orca-remote'
|
||||
it.each([
|
||||
['capture', () => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP)],
|
||||
['restore', () => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP)],
|
||||
['clear', () => clearOrcadStateSnapshotMembersCommand(windows, ROOT)],
|
||||
['presence', () => probeOrcadStateSnapshotCommand(windows, SNAP)],
|
||||
['compare', () => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP)],
|
||||
['mtime', () => newestStateMtimeCommand(windows, ROOT)]
|
||||
])('refuses %s rather than emitting a POSIX command', (_label, build) => {
|
||||
expect(build).toThrow('orcad to a Windows host is not implemented')
|
||||
[
|
||||
'capture',
|
||||
'snapshot-capture',
|
||||
(base?: string) => captureOrcadStateSnapshotCommand(windows, ROOT, SNAP, base)
|
||||
],
|
||||
[
|
||||
'restore',
|
||||
'snapshot-restore',
|
||||
(base?: string) => restoreOrcadStateSnapshotCommand(windows, ROOT, SNAP, base)
|
||||
],
|
||||
[
|
||||
'clear',
|
||||
'snapshot-clear',
|
||||
(base?: string) => clearOrcadStateSnapshotMembersCommand(windows, ROOT, base)
|
||||
],
|
||||
[
|
||||
'presence',
|
||||
'snapshot-probe',
|
||||
(base?: string) => probeOrcadStateSnapshotCommand(windows, SNAP, base)
|
||||
],
|
||||
[
|
||||
'compare',
|
||||
'snapshot-compare',
|
||||
(base?: string) => compareOrcadStateSnapshotCommand(windows, ROOT, SNAP, base)
|
||||
],
|
||||
['mtime', 'state-newest-mtime', (base?: string) => newestStateMtimeCommand(windows, ROOT, base)]
|
||||
])('%s runs the host script op %s with node.exe, never a POSIX command', (_label, op, build) => {
|
||||
const command = build(BASE)
|
||||
expect(command).toContain(` ${op} `)
|
||||
expect(command).toMatch(/^C:\\Users\\u\\\.orca-remote\\runtimes\\node-[0-9a-f]+\\node\.exe /u)
|
||||
expect(command).not.toMatch(/tar |find |diff |EncodedCommand|powershell/u)
|
||||
expect(() => build()).toThrow('~/.orca-remote')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,24 +16,15 @@
|
||||
* massacre the daemon exists to prevent.
|
||||
*/
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { assertPosixOrcadHost as assertPosixHost } from './orcad-remote-host-support'
|
||||
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { OrcadWindowsHostStateOp } from './orcad-windows-host-state-ops'
|
||||
import {
|
||||
ORCAD_SNAPSHOT_MEMBERS,
|
||||
ORCAD_STATE_RESTORE_STAGE_DIRNAME
|
||||
} from './orcad-state-snapshot-members'
|
||||
import { orcadWindowsHostOpCommand } from './orcad-remote-windows-node'
|
||||
|
||||
/**
|
||||
* Root-relative paths a rollback needs restored. Everything else under the data root is
|
||||
* either regenerable, or owned by a process that survives the rollback.
|
||||
*/
|
||||
export const ORCAD_SNAPSHOT_MEMBERS = [
|
||||
'orca-profile-index.json',
|
||||
// Pre-profiles layout; still read as a migration source.
|
||||
'orca-data.json',
|
||||
'profiles',
|
||||
// Cross-profile SQLite moves must survive an orcad rollback too.
|
||||
'profile-move-intents'
|
||||
] as const
|
||||
|
||||
/** Never captured and never restored — see the module comment. */
|
||||
export const ORCAD_SNAPSHOT_EXCLUDED = ['daemon', 'logs'] as const
|
||||
export { ORCAD_SNAPSHOT_EXCLUDED, ORCAD_SNAPSHOT_MEMBERS } from './orcad-state-snapshot-members'
|
||||
|
||||
/**
|
||||
* The member names go into the command unquoted (see `captureOrcadStateSnapshotCommand`), so
|
||||
@@ -47,7 +38,23 @@ function assertPlainMemberName(member: string): string {
|
||||
return member
|
||||
}
|
||||
|
||||
const RESTORE_STAGE_DIRNAME = '.orcad-state-restore-stage'
|
||||
const RESTORE_STAGE_DIRNAME = ORCAD_STATE_RESTORE_STAGE_DIRNAME
|
||||
|
||||
/** The Windows host-script op, or null on POSIX; `baseDir` is `~/.orca-remote`. */
|
||||
function windowsStateCommand(
|
||||
host: RemoteHostPlatform,
|
||||
baseDir: string | undefined,
|
||||
op: OrcadWindowsHostStateOp,
|
||||
args: string[]
|
||||
): string | null {
|
||||
if (!isWindowsRemoteHost(host)) {
|
||||
return null
|
||||
}
|
||||
if (!baseDir) {
|
||||
throw new Error('Windows orcad state commands need the ~/.orca-remote directory')
|
||||
}
|
||||
return orcadWindowsHostOpCommand(host, baseDir, op, args)
|
||||
}
|
||||
|
||||
function noSymlinkedStateCommand(path: string): string {
|
||||
return `links=$(find ${path} -type l -print) && [ -z "$links" ]`
|
||||
@@ -75,9 +82,13 @@ export function orcadRollbackRescueDirName(fullVersion: string, takenAtMs: numbe
|
||||
export function captureOrcadStateSnapshotCommand(
|
||||
host: RemoteHostPlatform,
|
||||
userDataDir: string,
|
||||
snapshotDir: string
|
||||
snapshotDir: string,
|
||||
baseDir?: string
|
||||
): string {
|
||||
assertPosixHost(host)
|
||||
const windows = windowsStateCommand(host, baseDir, 'snapshot-capture', [userDataDir, snapshotDir])
|
||||
if (windows) {
|
||||
return windows
|
||||
}
|
||||
const root = shellEscape(userDataDir)
|
||||
const dir = shellEscape(snapshotDir)
|
||||
const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar'))
|
||||
@@ -118,9 +129,13 @@ export function parseOrcadSnapshotCapture(output: string): OrcadSnapshotCapture
|
||||
|
||||
export function probeOrcadStateSnapshotCommand(
|
||||
host: RemoteHostPlatform,
|
||||
snapshotDir: string
|
||||
snapshotDir: string,
|
||||
baseDir?: string
|
||||
): string {
|
||||
assertPosixHost(host)
|
||||
const windows = windowsStateCommand(host, baseDir, 'snapshot-probe', [snapshotDir])
|
||||
if (windows) {
|
||||
return windows
|
||||
}
|
||||
const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar'))
|
||||
return `test -f ${archive} && echo PRESENT || echo ABSENT`
|
||||
}
|
||||
@@ -151,9 +166,13 @@ export function parseOrcadSnapshotPresence(output: string): OrcadSnapshotPresenc
|
||||
export function restoreOrcadStateSnapshotCommand(
|
||||
host: RemoteHostPlatform,
|
||||
userDataDir: string,
|
||||
snapshotDir: string
|
||||
snapshotDir: string,
|
||||
baseDir?: string
|
||||
): string {
|
||||
assertPosixHost(host)
|
||||
const windows = windowsStateCommand(host, baseDir, 'snapshot-restore', [userDataDir, snapshotDir])
|
||||
if (windows) {
|
||||
return windows
|
||||
}
|
||||
const root = shellEscape(userDataDir)
|
||||
const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar'))
|
||||
const stage = shellEscape(joinRemotePath(host, userDataDir, RESTORE_STAGE_DIRNAME))
|
||||
@@ -183,9 +202,13 @@ export function restoreOrcadStateSnapshotCommand(
|
||||
/** Restore an originally empty state root after a candidate populated it. */
|
||||
export function clearOrcadStateSnapshotMembersCommand(
|
||||
host: RemoteHostPlatform,
|
||||
userDataDir: string
|
||||
userDataDir: string,
|
||||
baseDir?: string
|
||||
): string {
|
||||
assertPosixHost(host)
|
||||
const windows = windowsStateCommand(host, baseDir, 'snapshot-clear', [userDataDir])
|
||||
if (windows) {
|
||||
return windows
|
||||
}
|
||||
const root = shellEscape(userDataDir)
|
||||
const removals = ORCAD_SNAPSHOT_MEMBERS.map(
|
||||
(member) => `rm -rf ${root}/${shellEscape(member)}`
|
||||
@@ -207,9 +230,13 @@ export function parseOrcadSnapshotRestore(output: string): OrcadSnapshotRestore
|
||||
export function compareOrcadStateSnapshotCommand(
|
||||
host: RemoteHostPlatform,
|
||||
userDataDir: string,
|
||||
snapshotDir: string
|
||||
snapshotDir: string,
|
||||
baseDir?: string
|
||||
): string {
|
||||
assertPosixHost(host)
|
||||
const windows = windowsStateCommand(host, baseDir, 'snapshot-compare', [userDataDir, snapshotDir])
|
||||
if (windows) {
|
||||
return windows
|
||||
}
|
||||
const root = shellEscape(userDataDir)
|
||||
const dir = shellEscape(snapshotDir)
|
||||
const archive = shellEscape(joinRemotePath(host, snapshotDir, 'state.tar'))
|
||||
@@ -246,8 +273,15 @@ export function orcadSnapshotIsUnchanged(output: string): boolean {
|
||||
* caller compares; an `UNKNOWN` becomes `null`, which `assessOrcadRollback` treats as "yes,
|
||||
* assume writes".
|
||||
*/
|
||||
export function newestStateMtimeCommand(host: RemoteHostPlatform, userDataDir: string): string {
|
||||
assertPosixHost(host)
|
||||
export function newestStateMtimeCommand(
|
||||
host: RemoteHostPlatform,
|
||||
userDataDir: string,
|
||||
baseDir?: string
|
||||
): string {
|
||||
const windows = windowsStateCommand(host, baseDir, 'state-newest-mtime', [userDataDir])
|
||||
if (windows) {
|
||||
return windows
|
||||
}
|
||||
const root = shellEscape(userDataDir)
|
||||
const paths = ORCAD_SNAPSHOT_MEMBERS.map((member) => `${root}/${shellEscape(member)}`).join(' ')
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// Managed orcad on a real Win32-OpenSSH host, one cell per DefaultShell: resolve the context
|
||||
// (pinned node.exe, host script), deploy and activate, prove readiness and liveness, stop through
|
||||
// the slot's request file, and prove exit. config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1
|
||||
// provisions the account and runs this file for `orcad-*` cells; ssh-windows-hosts.yml runs that.
|
||||
//
|
||||
// Run: ORCA_RUN_SSH_WINDOWS_HOST=1 ORCA_SSH_WINDOWS_HOST_CELL=<descriptor.json> pnpm test <this file>
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } }))
|
||||
|
||||
import { SshConnection } from './ssh-connection'
|
||||
import {
|
||||
connectHostileHost,
|
||||
installHostileHostAppEnvironment
|
||||
} from './ssh-hostile-host-test-harness'
|
||||
import { resolveOrcadRemoteContext } from './orcad-remote-context'
|
||||
import { deployOrcad } from './orcad-remote-deploy'
|
||||
import { orcadLivenessProbeCommand, parseOrcadLiveness } from './orcad-remote-launch'
|
||||
import { orcadSlotDir, stopOrcadSlot, type OrcadSlotOptions } from './orcad-recovery-slot'
|
||||
import { execOrcadRemote } from './orcad-remote-runtime-control'
|
||||
import {
|
||||
isWindowsOrcadCellId,
|
||||
readWindowsHostCellDescriptor,
|
||||
windowsHostSshTarget
|
||||
} from './ssh-windows-host-cells'
|
||||
|
||||
const RUN = process.env.ORCA_RUN_SSH_WINDOWS_HOST === '1'
|
||||
const CELL_TIMEOUT_MS = 20 * 60_000
|
||||
|
||||
/** Orcad's own host ops and launches: these must never take a PowerShell hop. */
|
||||
function isOrcadHostCommand(command: string): boolean {
|
||||
return /orcad-host-script-[0-9a-f]{16}\.js|[\\/]orcad\.js /u.test(command)
|
||||
}
|
||||
|
||||
describe.runIf(RUN)('managed orcad on a Windows OpenSSH host', () => {
|
||||
let cleanupAppEnvironment: (() => void) | null = null
|
||||
|
||||
beforeAll(() => {
|
||||
cleanupAppEnvironment = installHostileHostAppEnvironment()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
cleanupAppEnvironment?.()
|
||||
})
|
||||
|
||||
it(
|
||||
'deploys, serves, stops by request and exits',
|
||||
async () => {
|
||||
const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '')
|
||||
if (!isWindowsOrcadCellId(descriptor.cell)) {
|
||||
throw new Error(`${descriptor.cell} runs in ssh-relay-windows-host-lane.test.ts`)
|
||||
}
|
||||
const sshTarget = windowsHostSshTarget(
|
||||
descriptor,
|
||||
{ id: descriptor.cell, remoteRuntime: 'pinned-node' },
|
||||
randomUUID()
|
||||
)
|
||||
const exec = vi.spyOn(SshConnection.prototype, 'exec')
|
||||
const receipt: Record<string, unknown> = { cell: descriptor.cell, target: descriptor.target }
|
||||
let conn: SshConnection | null = null
|
||||
try {
|
||||
conn = await connectHostileHost(sshTarget)
|
||||
const context = await resolveOrcadRemoteContext(sshTarget, conn)
|
||||
expect(context.host.os).toBe('win32')
|
||||
const options: OrcadSlotOptions = {
|
||||
conn,
|
||||
host: context.host,
|
||||
remoteHome: context.remoteHome,
|
||||
nodePath: 'node',
|
||||
userDataDir: context.userDataDir,
|
||||
bindHost: '127.0.0.1',
|
||||
port: 0
|
||||
}
|
||||
const deployed = await deployOrcad({
|
||||
...options,
|
||||
target: context.serverTarget,
|
||||
census: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }
|
||||
})
|
||||
receipt.deploy = deployed
|
||||
expect(deployed.outcome).toBe('installed-and-activated')
|
||||
const slotDir = orcadSlotDir(options, deployed.fullVersion)
|
||||
const liveness = async () =>
|
||||
parseOrcadLiveness(
|
||||
await execOrcadRemote(options, orcadLivenessProbeCommand(options.host, slotDir))
|
||||
)
|
||||
expect(await liveness()).toBe('LIVE')
|
||||
receipt.stop = await stopOrcadSlot(options, slotDir, false)
|
||||
expect(receipt.stop).toBe('stopped')
|
||||
expect(await liveness()).toBe('DEAD')
|
||||
|
||||
const commands = exec.mock.calls.map(([command]) => String(command))
|
||||
const orcadCommands = commands.filter(isOrcadHostCommand)
|
||||
receipt.orcadCommands = orcadCommands.length
|
||||
receipt.powershellCommands = commands.filter((command) =>
|
||||
/^powershell\.exe /iu.test(command)
|
||||
).length
|
||||
expect(orcadCommands.length).toBeGreaterThan(0)
|
||||
for (const command of orcadCommands) {
|
||||
expect(command).not.toMatch(/EncodedCommand/u)
|
||||
}
|
||||
receipt.passed = true
|
||||
} finally {
|
||||
exec.mockRestore()
|
||||
await conn?.disconnect().catch(() => {})
|
||||
writeFileSync(descriptor.receipt, `${JSON.stringify(receipt, null, 2)}\n`)
|
||||
}
|
||||
},
|
||||
CELL_TIMEOUT_MS
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* What every managed-orcad operation on a Windows host needs before its first host op: this
|
||||
* client's pinned node.exe in the runtime store, and the host script it runs.
|
||||
*
|
||||
* Both are idempotent: a present runtime costs one probe and nothing is uploaded, and the host
|
||||
* script is content-addressed, so rewriting it changes nothing a running orcad depends on.
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import type { ServerTarget } 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'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
|
||||
|
||||
export async function prepareWindowsOrcadHost(options: {
|
||||
conn: SshConnection
|
||||
host: RemoteHostPlatform
|
||||
remoteHome: string
|
||||
serverTarget: ServerTarget
|
||||
signal?: AbortSignal
|
||||
}): Promise<void> {
|
||||
const baseDir = orcadRemoteBaseDir(options.host, options.remoteHome)
|
||||
await ensureRemoteOrcadNodeRuntime({
|
||||
conn: options.conn,
|
||||
host: options.host,
|
||||
// Only its parent is read: the runtime store sits beside the slots.
|
||||
slotDir: joinRemotePath(options.host, baseDir, 'orcad-host'),
|
||||
target: options.serverTarget,
|
||||
archivePath: () =>
|
||||
materializeNodeRuntimeArchive(
|
||||
options.serverTarget,
|
||||
join(getAppEnvironment().getPath('userData'), 'orcad-artifacts'),
|
||||
{ signal: options.signal }
|
||||
),
|
||||
signal: options.signal
|
||||
})
|
||||
await installOrcadWindowsHostScript(options, baseDir)
|
||||
}
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
ORCAD_STOP_REQUEST_FILENAME,
|
||||
ORCAD_STOP_REQUESTS_CAPABILITY
|
||||
} from '../../shared/orcad-stop-request'
|
||||
import {
|
||||
ORCAD_WINDOWS_HOST_STATE_OPS,
|
||||
type OrcadWindowsHostStateOp
|
||||
} from './orcad-windows-host-state-ops'
|
||||
import {
|
||||
ORCAD_READINESS_FILENAME,
|
||||
ORCAD_READINESS_MAX_BYTES,
|
||||
@@ -48,6 +52,8 @@ export type OrcadWindowsHostOp =
|
||||
| 'record-publish'
|
||||
| 'slot-runtime'
|
||||
| 'remove-file'
|
||||
| 'remove-tree'
|
||||
| OrcadWindowsHostStateOp
|
||||
|
||||
const text = JSON.stringify
|
||||
|
||||
@@ -187,6 +193,11 @@ const ops = {
|
||||
answer('')
|
||||
},
|
||||
|
||||
'remove-tree'(target) {
|
||||
fs.rmSync(target, { recursive: true, force: true, maxRetries: 5 })
|
||||
answer('')
|
||||
},
|
||||
|
||||
// The node.exe a slot's marker names; with clear-stop-request, also drops a stale request.
|
||||
'slot-runtime'(slotDir, mode) {
|
||||
let sha
|
||||
@@ -202,6 +213,7 @@ const ops = {
|
||||
}
|
||||
}
|
||||
|
||||
${ORCAD_WINDOWS_HOST_STATE_OPS}
|
||||
const run = Object.hasOwn(ops, op) ? ops[op] : null
|
||||
if (!run) {
|
||||
process.stderr.write('unknown orcad host op: ' + String(op) + '\\n')
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Runs the Windows host script's state ops under this machine's node, against real files:
|
||||
* the snapshot a rollback depends on, its comparison and restore, and owner admission.
|
||||
*/
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
utimesSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { ORCAD_WINDOWS_HOST_SCRIPT, type OrcadWindowsHostOp } from './orcad-windows-host-script'
|
||||
|
||||
let dir = ''
|
||||
let root = ''
|
||||
let snapshot = ''
|
||||
let script = ''
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orcad-win-state-'))
|
||||
root = join(dir, '.orca')
|
||||
snapshot = join(dir, '.orca-remote', 'orcad-snapshots', 'pre-0.2.0+bb01-1')
|
||||
script = join(dir, 'orcad-host-script.js')
|
||||
writeFileSync(script, ORCAD_WINDOWS_HOST_SCRIPT)
|
||||
mkdirSync(join(root, 'profiles', 'p1'), { recursive: true })
|
||||
mkdirSync(join(root, 'daemon'), { recursive: true })
|
||||
writeFileSync(join(root, 'orca-profile-index.json'), '{"v":"before"}')
|
||||
writeFileSync(join(root, 'profiles', 'p1', 'orca-data.json'), '{"repos":"before"}')
|
||||
writeFileSync(join(root, 'daemon', 'daemon.sock.token'), 'live-daemon-token')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function op(name: OrcadWindowsHostOp, ...args: string[]): Promise<string> {
|
||||
const result = await runProcess({ program: process.execPath, args: [script, name, ...args] })
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
describe('Windows snapshot ops', () => {
|
||||
it('captures, proves unchanged, then restores the members and never the daemon', async () => {
|
||||
expect(await op('snapshot-probe', snapshot)).toBe('ABSENT')
|
||||
expect(await op('snapshot-capture', root, snapshot)).toBe('CAPTURED')
|
||||
expect(await op('snapshot-probe', snapshot)).toBe('PRESENT')
|
||||
expect(await op('snapshot-compare', root, snapshot)).toBe('UNCHANGED')
|
||||
|
||||
writeFileSync(join(root, 'profiles', 'p1', 'orca-data.json'), '{"repos":"after"}')
|
||||
writeFileSync(join(root, 'profiles', 'p1', 'added.json'), '{}')
|
||||
writeFileSync(join(root, 'daemon', 'daemon.sock.token'), 'rotated-token')
|
||||
expect(await op('snapshot-compare', root, snapshot)).toBe('CHANGED')
|
||||
|
||||
expect(await op('snapshot-restore', root, snapshot)).toBe('RESTORED')
|
||||
expect(readFileSync(join(root, 'profiles', 'p1', 'orca-data.json'), 'utf8')).toBe(
|
||||
'{"repos":"before"}'
|
||||
)
|
||||
expect(existsSync(join(root, 'profiles', 'p1', 'added.json'))).toBe(false)
|
||||
expect(readFileSync(join(root, 'daemon', 'daemon.sock.token'), 'utf8')).toBe('rotated-token')
|
||||
expect(existsSync(join(root, '.orcad-state-restore-stage'))).toBe(false)
|
||||
expect(await op('snapshot-compare', root, snapshot)).toBe('UNCHANGED')
|
||||
})
|
||||
|
||||
it('reports EMPTY rather than fabricating a snapshot of a root with no state', async () => {
|
||||
const empty = join(dir, 'empty-root')
|
||||
mkdirSync(empty)
|
||||
expect(await op('snapshot-capture', empty, snapshot)).toBe('EMPTY')
|
||||
expect(await op('snapshot-probe', snapshot)).toBe('ABSENT')
|
||||
})
|
||||
|
||||
it('fails closed on a link inside captured state', async () => {
|
||||
symlinkSync(join(dir), join(root, 'profiles', 'escape'), 'junction')
|
||||
expect(await op('snapshot-capture', root, snapshot)).toBe('FAILED')
|
||||
expect(await op('snapshot-probe', snapshot)).toBe('ABSENT')
|
||||
})
|
||||
|
||||
it('answers MISSING, UNKNOWN and FAILED rather than guessing', async () => {
|
||||
expect(await op('snapshot-restore', root, snapshot)).toBe('MISSING')
|
||||
expect(await op('snapshot-compare', root, snapshot)).toBe('UNKNOWN')
|
||||
expect(await op('snapshot-compare', join(dir, 'no-root'), snapshot)).toBe('UNKNOWN')
|
||||
})
|
||||
|
||||
it('clears the members of a root that started empty, leaving the daemon', async () => {
|
||||
expect(await op('snapshot-clear', root)).toBe('RESTORED')
|
||||
expect(existsSync(join(root, 'profiles'))).toBe(false)
|
||||
expect(existsSync(join(root, 'orca-profile-index.json'))).toBe(false)
|
||||
expect(existsSync(join(root, 'daemon', 'daemon.sock.token'))).toBe(true)
|
||||
})
|
||||
|
||||
it('reports the newest member write in epoch seconds, or UNKNOWN', async () => {
|
||||
const at = new Date('2026-09-30T12:00:00.000Z')
|
||||
utimesSync(join(root, 'orca-profile-index.json'), at, at)
|
||||
utimesSync(join(root, 'profiles', 'p1', 'orca-data.json'), at, at)
|
||||
expect(await op('state-newest-mtime', root)).toBe(String(Math.floor(at.getTime() / 1000)))
|
||||
const empty = join(dir, 'empty-root')
|
||||
mkdirSync(empty)
|
||||
expect(await op('state-newest-mtime', empty)).toBe('UNKNOWN')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Windows owner admission', () => {
|
||||
it('is CLEAR with no owners, LIVE for a running owner, and silent for a dead one', async () => {
|
||||
expect(await op('owner-admission', root)).toBe('CLEAR')
|
||||
writeFileSync(join(root, 'orcad.lock'), JSON.stringify({ pid: process.pid }))
|
||||
expect(await op('owner-admission', root)).toBe(`LIVE orcad.lock ${process.pid}`)
|
||||
writeFileSync(join(root, 'orcad.lock'), JSON.stringify({ pid: 4_194_303 }))
|
||||
expect(await op('owner-admission', root)).toBe('CLEAR')
|
||||
})
|
||||
|
||||
it('refuses a record it cannot interpret', async () => {
|
||||
writeFileSync(join(root, 'orcad.lock'), 'not json')
|
||||
expect(await op('owner-admission', root)).toBe('UNVERIFIABLE orcad.lock')
|
||||
writeFileSync(join(root, 'orcad.lock'), JSON.stringify({ pid: -1 }))
|
||||
expect(await op('owner-admission', root)).toBe('UNVERIFIABLE orcad.lock')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* The host script's state ops on Windows: the pre-activation snapshot, its comparison and
|
||||
* restore, the newest-write probe, and the first-activation owner admission.
|
||||
*
|
||||
* Same contract and tokens as the POSIX commands in `orcad-state-snapshot.ts` and
|
||||
* `orcad-initial-activation-admission.ts`. The snapshot is a directory copy (`state/`) rather
|
||||
* than a tar: there is no tar in Node, and spawning `tar.exe` would be a second process. It is
|
||||
* built under a partial name and renamed into place, so a half-written snapshot is never
|
||||
* `PRESENT`. Symlinks and junctions anywhere in captured state fail closed, as on POSIX.
|
||||
*/
|
||||
import { PRIMARY_RUNTIME_METADATA_FILE } from '../../shared/runtime-bootstrap'
|
||||
import { ORCAD_LOCK_FILE_NAME } from '../orcad/orcad-instance-lock'
|
||||
import {
|
||||
ORCAD_SNAPSHOT_MEMBERS,
|
||||
ORCAD_STATE_RESTORE_STAGE_DIRNAME,
|
||||
ORCAD_WINDOWS_SNAPSHOT_STATE_DIRNAME
|
||||
} from './orcad-state-snapshot-members'
|
||||
|
||||
export type OrcadWindowsHostStateOp =
|
||||
| 'snapshot-capture'
|
||||
| 'snapshot-probe'
|
||||
| 'snapshot-restore'
|
||||
| 'snapshot-clear'
|
||||
| 'snapshot-compare'
|
||||
| 'state-newest-mtime'
|
||||
| 'owner-admission'
|
||||
|
||||
const OWNER_RECORD_MAX_BYTES = 64 * 1024
|
||||
const text = JSON.stringify
|
||||
|
||||
/** Evaluated inside the host script, after `fs`, `path`, `answer` and `ops` exist. */
|
||||
export const ORCAD_WINDOWS_HOST_STATE_OPS = `
|
||||
const MEMBERS = ${text(ORCAD_SNAPSHOT_MEMBERS)}
|
||||
const STATE_DIR = ${text(ORCAD_WINDOWS_SNAPSHOT_STATE_DIRNAME)}
|
||||
const RESTORE_STAGE = ${text(ORCAD_STATE_RESTORE_STAGE_DIRNAME)}
|
||||
|
||||
function lstatOrNull(target) {
|
||||
try { return fs.lstatSync(target) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
|
||||
}
|
||||
|
||||
// Node reports junctions as symbolic links too, so one check covers both.
|
||||
function treeHasLink(target) {
|
||||
const stats = fs.lstatSync(target)
|
||||
if (stats.isSymbolicLink()) return true
|
||||
if (!stats.isDirectory()) return false
|
||||
return fs.readdirSync(target).some((name) => treeHasLink(path.join(target, name)))
|
||||
}
|
||||
|
||||
function treesEqual(left, right) {
|
||||
const a = lstatOrNull(left)
|
||||
const b = lstatOrNull(right)
|
||||
if (!a || !b) return !a && !b
|
||||
if (a.isSymbolicLink() || b.isSymbolicLink()) throw new Error('link in state')
|
||||
if (a.isDirectory() !== b.isDirectory()) return false
|
||||
if (!a.isDirectory()) return a.size === b.size && fs.readFileSync(left).equals(fs.readFileSync(right))
|
||||
const names = fs.readdirSync(left).sort()
|
||||
const other = fs.readdirSync(right).sort()
|
||||
return names.length === other.length && names.every((name, index) => name === other[index] && treesEqual(path.join(left, name), path.join(right, name)))
|
||||
}
|
||||
|
||||
function newestMtime(target) {
|
||||
const stats = lstatOrNull(target)
|
||||
if (!stats || stats.isSymbolicLink()) return null
|
||||
if (!stats.isDirectory()) return stats.mtimeMs
|
||||
return fs.readdirSync(target).reduce((newest, name) => {
|
||||
const value = newestMtime(path.join(target, name))
|
||||
return value === null || (newest !== null && newest >= value) ? newest : value
|
||||
}, null)
|
||||
}
|
||||
|
||||
function renameWithRetry(from, to) {
|
||||
for (const delay of [0, 50, 100, 150, 200, 250]) {
|
||||
if (delay) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
|
||||
try { fs.renameSync(from, to); return } catch (error) {
|
||||
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || delay === 250) throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeTree = (target) => fs.rmSync(target, { recursive: true, force: true, maxRetries: 5 })
|
||||
|
||||
Object.assign(ops, {
|
||||
'snapshot-capture'(root, snapshotDir) {
|
||||
let present
|
||||
try {
|
||||
present = MEMBERS.filter((member) => lstatOrNull(path.join(root, member)))
|
||||
if (present.some((member) => treeHasLink(path.join(root, member)))) return answer('FAILED')
|
||||
} catch { return answer('FAILED') }
|
||||
if (present.length === 0) return answer('EMPTY')
|
||||
const partial = path.join(snapshotDir, STATE_DIR + '.partial-' + process.pid)
|
||||
try {
|
||||
removeTree(partial)
|
||||
fs.mkdirSync(partial, { recursive: true })
|
||||
for (const member of present) {
|
||||
fs.cpSync(path.join(root, member), path.join(partial, member), { recursive: true, errorOnExist: true })
|
||||
}
|
||||
removeTree(path.join(snapshotDir, STATE_DIR))
|
||||
renameWithRetry(partial, path.join(snapshotDir, STATE_DIR))
|
||||
} catch {
|
||||
try { removeTree(partial) } catch {}
|
||||
return answer('FAILED')
|
||||
}
|
||||
answer('CAPTURED')
|
||||
},
|
||||
|
||||
'snapshot-probe'(snapshotDir) {
|
||||
let stats
|
||||
try { stats = lstatOrNull(path.join(snapshotDir, STATE_DIR)) } catch { return answer('UNKNOWN') }
|
||||
answer(stats && stats.isDirectory() ? 'PRESENT' : 'ABSENT')
|
||||
},
|
||||
|
||||
// Copy into a stage first, so an unreadable snapshot fails before live state is touched.
|
||||
'snapshot-restore'(root, snapshotDir) {
|
||||
const state = path.join(snapshotDir, STATE_DIR)
|
||||
const stats = lstatOrNull(state)
|
||||
if (!stats || !stats.isDirectory()) return answer('MISSING')
|
||||
const stage = path.join(root, RESTORE_STAGE)
|
||||
try {
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
removeTree(stage)
|
||||
fs.cpSync(state, stage, { recursive: true })
|
||||
if (!MEMBERS.some((member) => lstatOrNull(path.join(stage, member)))) {
|
||||
removeTree(stage)
|
||||
return answer('FAILED')
|
||||
}
|
||||
} catch {
|
||||
try { removeTree(stage) } catch {}
|
||||
return answer('FAILED')
|
||||
}
|
||||
try {
|
||||
for (const member of MEMBERS) removeTree(path.join(root, member))
|
||||
for (const member of MEMBERS) {
|
||||
if (lstatOrNull(path.join(stage, member))) renameWithRetry(path.join(stage, member), path.join(root, member))
|
||||
}
|
||||
removeTree(stage)
|
||||
} catch { return answer('FAILED') }
|
||||
answer('RESTORED')
|
||||
},
|
||||
|
||||
'snapshot-clear'(root) {
|
||||
try {
|
||||
fs.mkdirSync(root, { recursive: true })
|
||||
for (const member of MEMBERS) removeTree(path.join(root, member))
|
||||
} catch { return answer('FAILED') }
|
||||
answer('RESTORED')
|
||||
},
|
||||
|
||||
'snapshot-compare'(root, snapshotDir) {
|
||||
try {
|
||||
const rootStats = lstatOrNull(root)
|
||||
const state = path.join(snapshotDir, STATE_DIR)
|
||||
const stateStats = lstatOrNull(state)
|
||||
if (!rootStats || !rootStats.isDirectory() || !stateStats || !stateStats.isDirectory()) return answer('UNKNOWN')
|
||||
if (treeHasLink(state)) return answer('UNKNOWN')
|
||||
for (const member of MEMBERS) {
|
||||
const live = path.join(root, member)
|
||||
if (lstatOrNull(live) && treeHasLink(live)) return answer('UNKNOWN')
|
||||
if (!treesEqual(live, path.join(state, member))) return answer('CHANGED')
|
||||
}
|
||||
} catch { return answer('UNKNOWN') }
|
||||
answer('UNCHANGED')
|
||||
},
|
||||
|
||||
'state-newest-mtime'(root) {
|
||||
let newest = null
|
||||
try {
|
||||
for (const member of MEMBERS) {
|
||||
const value = newestMtime(path.join(root, member))
|
||||
if (value !== null && (newest === null || value > newest)) newest = value
|
||||
}
|
||||
} catch { return answer('UNKNOWN') }
|
||||
answer(newest === null ? 'UNKNOWN' : String(Math.floor(newest / 1000)))
|
||||
},
|
||||
|
||||
// A live, unreadable or unexpected owner record defers the first activation.
|
||||
'owner-admission'(userDataDir) {
|
||||
const owners = ${text([ORCAD_LOCK_FILE_NAME, PRIMARY_RUNTIME_METADATA_FILE])}
|
||||
for (const name of owners) {
|
||||
const file = path.join(userDataDir, name)
|
||||
let pid
|
||||
try {
|
||||
const stats = lstatOrNull(file)
|
||||
if (!stats) continue
|
||||
if (!stats.isFile() || stats.size > ${OWNER_RECORD_MAX_BYTES}) return answer('UNVERIFIABLE ' + name)
|
||||
pid = JSON.parse(fs.readFileSync(file, 'utf8')).pid
|
||||
} catch { return answer('UNVERIFIABLE ' + name) }
|
||||
if (!Number.isSafeInteger(pid) || pid <= 0) return answer('UNVERIFIABLE ' + name)
|
||||
try { process.kill(pid, 0); return answer('LIVE ' + name + ' ' + pid) } catch (error) {
|
||||
if (error.code === 'EPERM') return answer('LIVE ' + name + ' ' + pid)
|
||||
if (error.code !== 'ESRCH') return answer('UNVERIFIABLE ' + name)
|
||||
}
|
||||
}
|
||||
answer('CLEAR')
|
||||
}
|
||||
})
|
||||
`
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type WindowsSessionCommandAudit
|
||||
} from './ssh-session-command-audit'
|
||||
import {
|
||||
isWindowsOrcadCellId,
|
||||
readWindowsHostCellDescriptor,
|
||||
windowsHostCell,
|
||||
windowsHostSshTarget
|
||||
@@ -54,6 +55,9 @@ describe.runIf(RUN)('SSH relay on a Windows OpenSSH host', () => {
|
||||
'lands the cell the descriptor names',
|
||||
async () => {
|
||||
const descriptor = readWindowsHostCellDescriptor(process.env.ORCA_SSH_WINDOWS_HOST_CELL ?? '')
|
||||
if (isWindowsOrcadCellId(descriptor.cell)) {
|
||||
throw new Error(`${descriptor.cell} runs in orcad-windows-host-lane.test.ts`)
|
||||
}
|
||||
const cell = windowsHostCell(descriptor.cell, descriptor.target)
|
||||
const observer = localHostObserver(descriptor.forbiddenToolLog)
|
||||
const sshTarget = windowsHostSshTarget(descriptor, cell, randomUUID())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isWindowsOrcadCellId,
|
||||
parseWindowsHostCellDescriptor,
|
||||
WINDOWS_FORBIDDEN_TOOLS,
|
||||
WINDOWS_HOST_CELL_IDS,
|
||||
@@ -44,6 +45,9 @@ describe('Windows SSH-host cells', () => {
|
||||
it('reads the descriptor PowerShell writes, BOM included', () => {
|
||||
const parsed = parseWindowsHostCellDescriptor(`\uFEFF${JSON.stringify(DESCRIPTOR)}`)
|
||||
expect(parsed).toEqual(DESCRIPTOR)
|
||||
if (isWindowsOrcadCellId(parsed.cell)) {
|
||||
throw new Error(`expected a relay cell, got ${parsed.cell}`)
|
||||
}
|
||||
const target = windowsHostSshTarget(parsed, windowsHostCell(parsed.cell, parsed.target), 'r1')
|
||||
expect(target).toMatchObject({
|
||||
id: 'windows-host-pinned-powershell-r1',
|
||||
@@ -60,6 +64,9 @@ describe('Windows SSH-host cells', () => {
|
||||
const parse = (patch: Record<string, unknown>): unknown =>
|
||||
parseWindowsHostCellDescriptor(JSON.stringify({ ...DESCRIPTOR, ...patch }))
|
||||
expect(() => parse({ cell: 'pinned-bash' })).toThrow('Unknown Windows host cell')
|
||||
expect(
|
||||
parseWindowsHostCellDescriptor(JSON.stringify({ ...DESCRIPTOR, cell: 'orcad-cmd' })).cell
|
||||
).toBe('orcad-cmd')
|
||||
expect(() => parse({ target: 'linux-x64-glibc' })).toThrow('win32-x64 or win32-arm64')
|
||||
expect(() => parse({ port: '22' })).toThrow('port is invalid')
|
||||
expect(() => parse({ port: 70_000 })).toThrow('port is invalid')
|
||||
|
||||
@@ -50,9 +50,21 @@ export function windowsHostCell(
|
||||
}
|
||||
}
|
||||
|
||||
/** Managed orcad on the pinned node.exe, per DefaultShell: deploy, readiness, stop request, exit. */
|
||||
export const WINDOWS_ORCAD_CELL_IDS = ['orcad-cmd', 'orcad-powershell'] as const
|
||||
export type WindowsOrcadCellId = (typeof WINDOWS_ORCAD_CELL_IDS)[number]
|
||||
|
||||
export function isWindowsOrcadCellId(id: string): id is WindowsOrcadCellId {
|
||||
return WINDOWS_ORCAD_CELL_IDS.some((candidate) => candidate === id)
|
||||
}
|
||||
|
||||
export function windowsOrcadCellShell(id: WindowsOrcadCellId): WindowsSshDefaultShell {
|
||||
return id === 'orcad-cmd' ? 'cmd' : 'powershell'
|
||||
}
|
||||
|
||||
/** Written by config/ci/windows-ssh-provider/invoke-pinned-relay-cells.ps1, one per run. */
|
||||
export type WindowsHostCellDescriptor = {
|
||||
cell: WindowsHostCellId
|
||||
cell: WindowsHostCellId | WindowsOrcadCellId
|
||||
target: WindowsServerTarget
|
||||
host: string
|
||||
port: number
|
||||
@@ -78,7 +90,9 @@ export function parseWindowsHostCellDescriptor(text: string): WindowsHostCellDes
|
||||
throw new Error('Windows host cell descriptor must be a JSON object')
|
||||
}
|
||||
const record = Object.fromEntries(Object.entries(parsed))
|
||||
const cell = WINDOWS_HOST_CELL_IDS.find((id) => id === record.cell)
|
||||
const cell =
|
||||
WINDOWS_HOST_CELL_IDS.find((id) => id === record.cell) ??
|
||||
WINDOWS_ORCAD_CELL_IDS.find((id) => id === record.cell)
|
||||
if (!cell) {
|
||||
throw new Error(`Unknown Windows host cell: ${String(record.cell)}`)
|
||||
}
|
||||
@@ -109,7 +123,7 @@ export function readWindowsHostCellDescriptor(path: string): WindowsHostCellDesc
|
||||
|
||||
export function windowsHostSshTarget(
|
||||
descriptor: WindowsHostCellDescriptor,
|
||||
cell: WindowsHostCell,
|
||||
cell: Pick<WindowsHostCell, 'id' | 'remoteRuntime'>,
|
||||
runId: string
|
||||
): SshTarget {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user