mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +00:00
feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)
* feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) Builds managed-server maintenance on T6-2's deploy, rollback and recovery and T6-4's journaled decommission. Each step reads a terminal census through the server's tunnel; orcad answers it through a new capability-gated orcad.terminalCensus RPC, and an older host or lost answer is unverifiable. Update defers over live or uncounted terminals and status reports the last deferral. A stop unlinks the server (deployment record, tunnel, SSH claim) only after a proven exit. Inert until the T6-6 settings UI. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix(rpc): load the orcad terminal census lazily so the dispatcher does not pull in the xterm window polyfill Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
m4air
parent
eefc49f7e3
commit
38c2d1dcb9
@@ -7,6 +7,7 @@ import { createAdapter } from './daemon-pty-router-test-fixture'
|
||||
import {
|
||||
disconnectDaemon,
|
||||
listLiveDaemonSessions,
|
||||
listLiveDaemonSessionsWithProtocol,
|
||||
replaceDaemonProvider,
|
||||
requestIdleDaemonRetirement
|
||||
} from './daemon-provider-state'
|
||||
@@ -30,6 +31,17 @@ it('reads a census with an unanswered generation as unverifiable', async () => {
|
||||
await expect(listLiveDaemonSessions()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('labels each live session with the protocol of the generation that owns it', async () => {
|
||||
const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION)
|
||||
const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION - 1)
|
||||
replaceDaemonProvider(new DaemonPtyRouter({ current, legacy: [legacy] }))
|
||||
|
||||
await expect(listLiveDaemonSessionsWithProtocol()).resolves.toEqual([
|
||||
{ sessionId: 'live-1', isAlive: true, protocolVersion: PROTOCOL_VERSION },
|
||||
{ sessionId: 'live-2', isAlive: true, protocolVersion: PROTOCOL_VERSION - 1 }
|
||||
])
|
||||
})
|
||||
|
||||
it('lists every generation when each one answers', async () => {
|
||||
const current = createAdapter('current', ['live-1'], undefined, PROTOCOL_VERSION)
|
||||
const legacy = createAdapter('legacy', ['live-2'], undefined, PROTOCOL_VERSION)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getMacDaemonTccAttributionHealth,
|
||||
type MacDaemonTccAttributionHealth
|
||||
} from './daemon-tcc-attribution'
|
||||
import { PROTOCOL_VERSION, type SessionInfo } from './types'
|
||||
import { PROTOCOL_VERSION, type DaemonSessionInfo, type SessionInfo } from './types'
|
||||
import type { DaemonIdleRetirementResult } from './daemon-pty-runtime-state'
|
||||
|
||||
let spawner: DaemonSpawner | null = null
|
||||
@@ -147,6 +147,12 @@ export async function listLiveDaemonPtyIds(): Promise<string[] | null> {
|
||||
|
||||
/** Returns null unless every daemon generation supplied an authoritative session inventory. */
|
||||
export async function listLiveDaemonSessions(): Promise<SessionInfo[] | null> {
|
||||
const sessions = await listLiveDaemonSessionsWithProtocol()
|
||||
return sessions?.map(({ protocolVersion: _protocolVersion, ...session }) => session) ?? null
|
||||
}
|
||||
|
||||
/** Like listLiveDaemonSessions, with the protocol of the daemon generation owning each session. */
|
||||
export async function listLiveDaemonSessionsWithProtocol(): Promise<DaemonSessionInfo[] | null> {
|
||||
if (!adapter) {
|
||||
return null
|
||||
}
|
||||
@@ -155,7 +161,12 @@ export async function listLiveDaemonSessions(): Promise<SessionInfo[] | null> {
|
||||
? adapter.getAllAdapters()
|
||||
: [adapter]
|
||||
const inventories = await Promise.allSettled(
|
||||
adapters.map((daemonAdapter) => daemonAdapter.listSessions())
|
||||
adapters.map(async (daemonAdapter) =>
|
||||
(await daemonAdapter.listSessions()).map((session) => ({
|
||||
...session,
|
||||
protocolVersion: daemonAdapter.protocolVersion
|
||||
}))
|
||||
)
|
||||
)
|
||||
if (inventories.some((inventory) => inventory.status === 'rejected')) {
|
||||
return null
|
||||
|
||||
@@ -29,7 +29,7 @@ export function registerOrcadRuntimeLifecycleHandlers(options: {
|
||||
)
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
export function requiredString(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(`${label} is required.`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handle: vi.fn(),
|
||||
update: vi.fn(),
|
||||
rollback: vi.fn(),
|
||||
recover: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
retire: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
|
||||
vi.mock('../ssh/orcad-runtime-lifecycle', () => ({
|
||||
updateManagedOrcadEnvironment: mocks.update,
|
||||
rollbackManagedOrcadEnvironment: mocks.rollback,
|
||||
recoverManagedOrcadEnvironment: mocks.recover,
|
||||
stopManagedOrcadEnvironment: mocks.stop,
|
||||
cancelManagedOrcadStop: mocks.cancel
|
||||
}))
|
||||
vi.mock('./runtime-environment-removal-cleanup', () => ({
|
||||
retireRemovedRuntimeEnvironment: mocks.retire
|
||||
}))
|
||||
|
||||
const { registerOrcadRuntimeMaintenanceHandlers } =
|
||||
await import('./orcad-runtime-maintenance-handlers')
|
||||
|
||||
const invalidateTransport = vi.fn()
|
||||
|
||||
function handler(channel: string): (_event: unknown, args: unknown) => Promise<unknown> {
|
||||
const registration = mocks.handle.mock.calls.find(([name]) => name === channel)
|
||||
if (!registration) {
|
||||
throw new Error(`${channel} handler was not registered`)
|
||||
}
|
||||
return registration[1]
|
||||
}
|
||||
|
||||
describe('managed orcad maintenance IPC', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
registerOrcadRuntimeMaintenanceHandlers({
|
||||
getUserDataPath: () => '/profile',
|
||||
getActiveEnvironmentId: () => 'active-environment',
|
||||
invalidateTransport
|
||||
})
|
||||
})
|
||||
|
||||
it('reconnects after an update restarts orcad, but not after a deferral', async () => {
|
||||
mocks.update.mockResolvedValueOnce({ outcome: 'deferred', code: 'busy' })
|
||||
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: 'yes' })
|
||||
expect(mocks.update).toHaveBeenCalledWith('/profile', { selector: 'Managed', force: false })
|
||||
expect(invalidateTransport).not.toHaveBeenCalled()
|
||||
mocks.update.mockResolvedValueOnce({ outcome: 'updated', environment: { id: 'e-1' } })
|
||||
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true })
|
||||
expect(invalidateTransport).toHaveBeenCalledWith('e-1')
|
||||
})
|
||||
|
||||
it('reconnects after rollback and after recovery restores a serving slot', async () => {
|
||||
mocks.rollback.mockResolvedValueOnce({ outcome: 'rolled-back', environment: { id: 'e-1' } })
|
||||
await handler('runtimeEnvironments:rollbackOrcad')(null, { selector: 'Managed' })
|
||||
mocks.recover.mockResolvedValueOnce({
|
||||
outcome: 'recovered',
|
||||
activeVersion: null,
|
||||
environment: { id: 'e-1' }
|
||||
})
|
||||
await handler('runtimeEnvironments:recoverOrcad')(null, { selector: 'Managed' })
|
||||
expect(invalidateTransport).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops with the Active Server guard and the shared removal cleanup', async () => {
|
||||
mocks.stop.mockResolvedValueOnce({ outcome: 'unlinked' })
|
||||
await handler('runtimeEnvironments:stopOrcad')(null, { selector: ' Managed ' })
|
||||
const [, args, policy] = mocks.stop.mock.calls[0] ?? []
|
||||
expect(args).toEqual({ selector: 'Managed' })
|
||||
expect(policy.isActiveEnvironment('active-environment')).toBe(true)
|
||||
expect(policy.isActiveEnvironment('e-1')).toBe(false)
|
||||
policy.retireLocalState('e-1')
|
||||
expect(mocks.retire).toHaveBeenCalledWith('e-1', invalidateTransport)
|
||||
})
|
||||
|
||||
it('rejects a missing selector before touching SSH', async () => {
|
||||
await expect(handler('runtimeEnvironments:cancelOrcadStop')(null, {})).rejects.toThrow(
|
||||
'Server is required'
|
||||
)
|
||||
expect(mocks.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import type {
|
||||
OrcadManagedCancelStopResult,
|
||||
OrcadManagedDeployResult,
|
||||
OrcadManagedRecoveryResult,
|
||||
OrcadManagedRollbackResult,
|
||||
OrcadManagedStopResult
|
||||
} from '../../shared/orcad-managed-runtime'
|
||||
import {
|
||||
cancelManagedOrcadStop,
|
||||
recoverManagedOrcadEnvironment,
|
||||
rollbackManagedOrcadEnvironment,
|
||||
stopManagedOrcadEnvironment,
|
||||
updateManagedOrcadEnvironment
|
||||
} from '../ssh/orcad-runtime-lifecycle'
|
||||
import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup'
|
||||
import { requiredString } from './orcad-runtime-lifecycle-handlers'
|
||||
|
||||
export function registerOrcadRuntimeMaintenanceHandlers(options: {
|
||||
getUserDataPath: () => string
|
||||
getActiveEnvironmentId: () => string | null | undefined
|
||||
invalidateTransport: (environmentId: string) => Promise<void> | void
|
||||
}): void {
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:updateOrcad',
|
||||
async (_event, args: { selector: string; force?: boolean }): Promise<OrcadManagedDeployResult> => {
|
||||
const result = await updateManagedOrcadEnvironment(options.getUserDataPath(), {
|
||||
selector: requiredString(args?.selector, 'Server'),
|
||||
force: args?.force === true
|
||||
})
|
||||
// Why: a restarted orcad drops the old connection; reconnect on the new one.
|
||||
if (result.outcome === 'updated') {
|
||||
await options.invalidateTransport(result.environment.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:rollbackOrcad',
|
||||
async (_event, args: { selector: string }): Promise<OrcadManagedRollbackResult> => {
|
||||
const result = await rollbackManagedOrcadEnvironment(options.getUserDataPath(), {
|
||||
selector: requiredString(args?.selector, 'Server')
|
||||
})
|
||||
if (result.outcome === 'rolled-back') {
|
||||
await options.invalidateTransport(result.environment.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:recoverOrcad',
|
||||
async (_event, args: { selector: string }): Promise<OrcadManagedRecoveryResult> => {
|
||||
const result = await recoverManagedOrcadEnvironment(options.getUserDataPath(), {
|
||||
selector: requiredString(args?.selector, 'Server')
|
||||
})
|
||||
if (result.outcome === 'recovered' && result.activeVersion) {
|
||||
await options.invalidateTransport(result.environment.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:stopOrcad',
|
||||
async (_event, args: { selector: string }): Promise<OrcadManagedStopResult> =>
|
||||
stopManagedOrcadEnvironment(
|
||||
options.getUserDataPath(),
|
||||
{ selector: requiredString(args?.selector, 'Server') },
|
||||
{
|
||||
isActiveEnvironment: (environmentId) =>
|
||||
options.getActiveEnvironmentId() === environmentId,
|
||||
retireLocalState: (environmentId) =>
|
||||
retireRemovedRuntimeEnvironment(environmentId, options.invalidateTransport)
|
||||
}
|
||||
)
|
||||
)
|
||||
ipcMain.handle(
|
||||
'runtimeEnvironments:cancelOrcadStop',
|
||||
async (_event, args: { selector: string }): Promise<OrcadManagedCancelStopResult> =>
|
||||
cancelManagedOrcadStop(options.getUserDataPath(), {
|
||||
selector: requiredString(args?.selector, 'Server')
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -14,10 +14,8 @@ import { RuntimeRpcCallQueueOverloadError } from '../../shared/runtime-rpc-call-
|
||||
import type { RuntimeRpcFailure, RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime'
|
||||
import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement'
|
||||
import { retireRemovedRuntimeEnvironment } from './runtime-environment-removal-cleanup'
|
||||
import { verifyAndAddRuntimeEnvironmentFromPairingCode } from './runtime-environment-pairing-verification'
|
||||
import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence'
|
||||
import {
|
||||
closeRemoteRuntimeRequestConnection,
|
||||
getRuntimeEnvironmentStatusOwner,
|
||||
@@ -106,22 +104,8 @@ export function registerRuntimeEnvironmentConnectivityHandlers({
|
||||
throw new Error('Choose another Active Server in Advanced before removing this server.')
|
||||
}
|
||||
const removed = removeEnvironment(getUserDataPath(), args.selector)
|
||||
clearRuntimeEnvironmentCapabilityEvidence(removed.id)
|
||||
clearRuntimeEnvironmentManualDisconnect(removed.id)
|
||||
const retiring = Promise.resolve(invalidateTransport(removed.id))
|
||||
void retireRemovedRuntimeEnvironment(removed.id, invalidateTransport)
|
||||
closeLegacySelectorTransport(args.selector, removed.id)
|
||||
// Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes
|
||||
// too -- but only once the client host releases its partitions, or every one refuses as live.
|
||||
void retireBrowserRoutePartitionStorageForEnvironment({
|
||||
environmentId: removed.id,
|
||||
whenClientHostClosed: retiring,
|
||||
clearStorage: clearBrowserRoutePartitionStorageForEnvironment,
|
||||
onError: (error) => {
|
||||
console.warn('[runtime-environments] browser partition storage clear failed:', error)
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.warn('[runtime-environments] browser partition storage clear failed:', error)
|
||||
})
|
||||
return { removed: redactRuntimeEnvironment(removed) }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -17,6 +17,11 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
|
||||
'runtimeEnvironments:unlinkSshAccess',
|
||||
'runtimeEnvironments:deployOrcad',
|
||||
'runtimeEnvironments:getOrcadStatus',
|
||||
'runtimeEnvironments:updateOrcad',
|
||||
'runtimeEnvironments:rollbackOrcad',
|
||||
'runtimeEnvironments:recoverOrcad',
|
||||
'runtimeEnvironments:stopOrcad',
|
||||
'runtimeEnvironments:cancelOrcadStop',
|
||||
'runtimeEnvironments:createOrcadSshHost',
|
||||
'runtimeEnvironments:resumeOrcadSshHost',
|
||||
'runtimeEnvironments:listPendingOrcadSshProvisioning'
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { clearBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-runtime'
|
||||
import { retireBrowserRoutePartitionStorageForEnvironment } from '../browser/browser-route-partition-storage-retirement'
|
||||
import { clearRuntimeEnvironmentCapabilityEvidence } from './runtime-environment-capability-evidence'
|
||||
import { clearRuntimeEnvironmentManualDisconnect } from './runtime-environment-manual-disconnect'
|
||||
|
||||
/** Retires a removed server's client-side state; resolves once its transport is invalidated. */
|
||||
export function retireRemovedRuntimeEnvironment(
|
||||
environmentId: string,
|
||||
invalidateTransport: (environmentId: string) => Promise<void> | void
|
||||
): Promise<void> {
|
||||
clearRuntimeEnvironmentCapabilityEvidence(environmentId)
|
||||
clearRuntimeEnvironmentManualDisconnect(environmentId)
|
||||
const retiring = Promise.resolve(invalidateTransport(environmentId))
|
||||
// Why: removal is an explicit lifecycle decision, so its client-hosted browser storage goes
|
||||
// too -- but only once the client host releases its partitions, or every one refuses as live.
|
||||
void retireBrowserRoutePartitionStorageForEnvironment({
|
||||
environmentId,
|
||||
whenClientHostClosed: retiring,
|
||||
clearStorage: clearBrowserRoutePartitionStorageForEnvironment,
|
||||
onError: (error) => {
|
||||
console.warn('[runtime-environments] browser partition storage clear failed:', error)
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.warn('[runtime-environments] browser partition storage clear failed:', error)
|
||||
})
|
||||
return retiring
|
||||
}
|
||||
@@ -161,6 +161,11 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'runtimeEnvironments:listPendingOrcadSshProvisioning',
|
||||
'runtimeEnvironments:deployOrcad',
|
||||
'runtimeEnvironments:getOrcadStatus',
|
||||
'runtimeEnvironments:updateOrcad',
|
||||
'runtimeEnvironments:rollbackOrcad',
|
||||
'runtimeEnvironments:recoverOrcad',
|
||||
'runtimeEnvironments:stopOrcad',
|
||||
'runtimeEnvironments:cancelOrcadStop',
|
||||
'runtimeEnvironments:subscribe',
|
||||
'runtimeEnvironments:unsubscribe'
|
||||
])
|
||||
@@ -191,6 +196,11 @@ describe('registerRuntimeEnvironmentHandlers', () => {
|
||||
'runtimeEnvironments:unlinkSshAccess',
|
||||
'runtimeEnvironments:deployOrcad',
|
||||
'runtimeEnvironments:getOrcadStatus',
|
||||
'runtimeEnvironments:updateOrcad',
|
||||
'runtimeEnvironments:rollbackOrcad',
|
||||
'runtimeEnvironments:recoverOrcad',
|
||||
'runtimeEnvironments:stopOrcad',
|
||||
'runtimeEnvironments:cancelOrcadStop',
|
||||
'runtimeEnvironments:createOrcadSshHost',
|
||||
'runtimeEnvironments:resumeOrcadSshHost',
|
||||
'runtimeEnvironments:listPendingOrcadSshProvisioning',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './runtime-environment-subscriptions'
|
||||
import { RUNTIME_ENVIRONMENT_HANDLER_CHANNELS } from './runtime-environment-handler-channels'
|
||||
import { registerOrcadRuntimeLifecycleHandlers } from './orcad-runtime-lifecycle-handlers'
|
||||
import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers'
|
||||
import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers'
|
||||
import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime'
|
||||
import { registerRuntimeEnvironmentBrowserClientHostHandler } from './runtime-environment-browser-client-host-handler'
|
||||
@@ -74,5 +75,10 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
invalidateTransport: invalidateRuntimeEnvironmentTransport
|
||||
})
|
||||
registerOrcadRuntimeLifecycleHandlers({ getUserDataPath })
|
||||
registerOrcadRuntimeMaintenanceHandlers({
|
||||
getUserDataPath,
|
||||
getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId,
|
||||
invalidateTransport: invalidateRuntimeEnvironmentTransport
|
||||
})
|
||||
registerRuntimeEnvironmentSubscriptions(getUserDataPath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DaemonSessionInfo } from '../daemon/types'
|
||||
import { collectOrcadTerminalCensus } from './orcad-terminal-census'
|
||||
|
||||
function session(createdAt: number, protocolVersion = 7): DaemonSessionInfo {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only createdAt and protocolVersion.
|
||||
return { sessionId: `s-${createdAt}`, createdAt, protocolVersion } as DaemonSessionInfo
|
||||
}
|
||||
|
||||
describe('orcad terminal census', () => {
|
||||
it('counts live sessions, those since activation, and their single owning protocol', async () => {
|
||||
await expect(
|
||||
collectOrcadTerminalCensus(100, async () => [session(50), session(100), session(150)])
|
||||
).resolves.toEqual({ liveSessions: 3, startedSinceActivation: 2, daemonProtocolVersion: 7 })
|
||||
})
|
||||
|
||||
it('reports zero, not unknown, for an answered empty daemon', async () => {
|
||||
await expect(collectOrcadTerminalCensus(100, async () => [])).resolves.toEqual({
|
||||
liveSessions: 0,
|
||||
startedSinceActivation: 0,
|
||||
daemonProtocolVersion: null
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the protocol unknown when sessions span daemon generations', async () => {
|
||||
const census = await collectOrcadTerminalCensus(0, async () => [session(1, 7), session(2, 6)])
|
||||
expect(census.daemonProtocolVersion).toBeNull()
|
||||
expect(census.liveSessions).toBe(2)
|
||||
})
|
||||
|
||||
it('leaves the since-activation count unknown when a session has no creation time', async () => {
|
||||
const census = await collectOrcadTerminalCensus(0, async () => [session(0)])
|
||||
expect(census).toMatchObject({ liveSessions: 1, startedSinceActivation: null })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no inventory', async () => null],
|
||||
[
|
||||
'a failed inventory',
|
||||
async () => {
|
||||
throw new Error('daemon unreachable')
|
||||
}
|
||||
]
|
||||
])('reads %s as unverifiable, never as zero', async (_label, list) => {
|
||||
await expect(collectOrcadTerminalCensus(0, list)).resolves.toEqual({
|
||||
liveSessions: null,
|
||||
startedSinceActivation: null,
|
||||
daemonProtocolVersion: null
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
/** The terminal census a managed orcad reports to the client planning an update or stop. */
|
||||
import { listLiveDaemonSessionsWithProtocol } from '../daemon/daemon-provider-state'
|
||||
import type { DaemonSessionInfo } from '../daemon/types'
|
||||
import type { OrcadTerminalCensus } from '../../shared/orcad-terminal-census'
|
||||
|
||||
const UNVERIFIABLE: OrcadTerminalCensus = {
|
||||
liveSessions: null,
|
||||
startedSinceActivation: null,
|
||||
daemonProtocolVersion: null
|
||||
}
|
||||
|
||||
export async function collectOrcadTerminalCensus(
|
||||
activatedAt: number,
|
||||
listSessions: () => Promise<DaemonSessionInfo[] | null> = listLiveDaemonSessionsWithProtocol
|
||||
): Promise<OrcadTerminalCensus> {
|
||||
let sessions: DaemonSessionInfo[] | null
|
||||
try {
|
||||
sessions = await listSessions()
|
||||
} catch {
|
||||
sessions = null
|
||||
}
|
||||
if (!sessions) {
|
||||
return UNVERIFIABLE
|
||||
}
|
||||
const timestampsKnown = sessions.every(
|
||||
(session) => Number.isFinite(session.createdAt) && session.createdAt > 0
|
||||
)
|
||||
const protocols = new Set(sessions.map((session) => session.protocolVersion))
|
||||
const [protocol] = protocols
|
||||
return {
|
||||
liveSessions: sessions.length,
|
||||
startedSinceActivation: timestampsKnown
|
||||
? sessions.filter((session) => session.createdAt >= activatedAt).length
|
||||
: null,
|
||||
// Why one protocol only: sessions split across daemon generations have no single owner to
|
||||
// check an incoming build against, so that reads as unverifiable.
|
||||
daemonProtocolVersion: protocols.size === 1 && protocol !== undefined ? protocol : null
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STATUS_METHODS } from './status'
|
||||
import { ORCAD_TERMINAL_CENSUS_METHODS } from './orcad-terminal-census'
|
||||
import { AI_VAULT_METHODS } from './ai-vault'
|
||||
import { AUTOMATION_METHODS } from './automations'
|
||||
import { REPO_METHODS } from './repo'
|
||||
@@ -54,6 +55,7 @@ import { AGENT_LAUNCH_METHODS } from './agent-launch'
|
||||
// auditing the security boundary or wiring new CLI commands.
|
||||
export const ALL_RPC_METHODS = [
|
||||
...STATUS_METHODS,
|
||||
...ORCAD_TERMINAL_CENSUS_METHODS,
|
||||
...AGENT_HOOK_METHODS,
|
||||
...AI_VAULT_METHODS,
|
||||
...ARTIFACT_METHODS,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineMethod } from '../core'
|
||||
import {
|
||||
ORCAD_TERMINAL_CENSUS_METHOD,
|
||||
OrcadTerminalCensusParamsSchema
|
||||
} from '../../../../shared/orcad-terminal-census'
|
||||
|
||||
export const ORCAD_TERMINAL_CENSUS_METHODS = [
|
||||
defineMethod({
|
||||
name: ORCAD_TERMINAL_CENSUS_METHOD,
|
||||
params: OrcadTerminalCensusParamsSchema,
|
||||
handler: async (params) => {
|
||||
// Why lazy: the census reaches the daemon modules, whose xterm polyfill defines a global
|
||||
// `window`; importing them statically would load it into every process with the dispatcher.
|
||||
const { collectOrcadTerminalCensus } = await import('../../../orcad/orcad-terminal-census')
|
||||
return collectOrcadTerminalCensus(params.activatedAt)
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createManagedOrcadSshOwner } from '../../shared/managed-orcad-ssh-owner'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { ServeReadiness } from '../server/serve-readiness'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import { SshTargetOrcadClaims } from './ssh-target-orcad-claims'
|
||||
import { emptyDependentStateStore } from './ssh-target-orcad-dependents-fixture'
|
||||
|
||||
export const MANAGED_VERSION = '0.2.0+abc'
|
||||
export const MANAGED_PREVIOUS_VERSION = '0.1.0+def'
|
||||
export const MANAGED_LOCAL_PORT = 46_768
|
||||
|
||||
/** A managed server registered in a temp profile, with its claimed SSH target. */
|
||||
export function createManagedLifecycleHarness() {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-managed-lifecycle-'))
|
||||
let target: SshTarget = {
|
||||
id: 'ssh-1',
|
||||
label: 'Builder',
|
||||
host: 'builder',
|
||||
port: 22,
|
||||
username: 'dev',
|
||||
generation: 4,
|
||||
owner: createManagedOrcadSshOwner('environment-1')
|
||||
}
|
||||
const environment = addManagedOrcadEnvironment(userDataPath, {
|
||||
id: 'environment-1',
|
||||
name: 'Managed',
|
||||
pairingCode: encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: `ws://127.0.0.1:${MANAGED_LOCAL_PORT}/`,
|
||||
deviceToken: 'device-token',
|
||||
publicKeyB64: 'public-key'
|
||||
}),
|
||||
orcadDeployment: {
|
||||
sshTargetId: 'ssh-1',
|
||||
sshTargetGeneration: 4,
|
||||
localPort: MANAGED_LOCAL_PORT,
|
||||
remotePort: 6_768
|
||||
}
|
||||
})
|
||||
const flushes: number[] = []
|
||||
const claims = new SshTargetOrcadClaims({
|
||||
...emptyDependentStateStore(),
|
||||
allocateSshTargetGeneration: () => 5,
|
||||
flushPendingOrThrowAsync: async () => {
|
||||
flushes.push(flushes.length)
|
||||
},
|
||||
getFolderWorkspaces: () => [],
|
||||
getRepos: () => [],
|
||||
getSshTarget: (id) => (id === target.id ? target : undefined),
|
||||
getSshTargets: () => [target],
|
||||
updateSshTarget: (_id, updates) => (target = { ...target, ...updates })
|
||||
})
|
||||
const targetStore = {
|
||||
getTarget: (id: string) => (id === target.id ? target : undefined),
|
||||
getOrcadRuntimeClaims: () => claims
|
||||
}
|
||||
const context = (record: Record<string, unknown> = {}) => ({
|
||||
activationRecord: {
|
||||
active: MANAGED_VERSION,
|
||||
previous: MANAGED_PREVIOUS_VERSION,
|
||||
activatedAt: '2026-01-01T00:00:00.000Z',
|
||||
snapshot: null,
|
||||
...record
|
||||
},
|
||||
serverTarget: 'linux-x64-glibc',
|
||||
connection: {},
|
||||
host: getRemoteHostPlatform('linux-x64'),
|
||||
remoteHome: '/home/dev',
|
||||
target,
|
||||
userDataDir: '/home/dev/.orca'
|
||||
})
|
||||
return { userDataPath, environment, targetStore, context, flushes, current: () => target }
|
||||
}
|
||||
|
||||
/** Readiness whose pairing offer, once tunneled, matches the harness server's saved one. */
|
||||
export function managedReadiness(deviceToken = 'device-token'): ServeReadiness {
|
||||
const endpoint = 'ws://127.0.0.1:6768'
|
||||
return {
|
||||
runtimeId: 'runtime-1',
|
||||
boundEndpoint: endpoint,
|
||||
advertisedEndpoint: null,
|
||||
managedWslCliReconciliation: 'settled',
|
||||
pairing: {
|
||||
available: true,
|
||||
url: encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint,
|
||||
deviceToken,
|
||||
publicKeyB64: 'public-key'
|
||||
}),
|
||||
endpoint,
|
||||
deviceId: 'device-1',
|
||||
webClientUrl: null,
|
||||
scope: 'runtime',
|
||||
qr: null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,20 @@ export async function resolveLinkedOrcadContext(
|
||||
return resolveOrcadRemoteContext(target, connection, signal)
|
||||
}
|
||||
|
||||
/** The slot options every remote lifecycle step takes for this managed server. */
|
||||
export function managedOrcadSlot(context: OrcadRemoteContext, port: number, signal?: AbortSignal) {
|
||||
return {
|
||||
conn: context.connection,
|
||||
host: context.host,
|
||||
remoteHome: context.remoteHome,
|
||||
nodePath: MANAGED_ORCAD_LEGACY_NODE_PATH,
|
||||
userDataDir: context.userDataDir,
|
||||
bindHost: ORCAD_BIND_HOST,
|
||||
port,
|
||||
signal
|
||||
}
|
||||
}
|
||||
|
||||
export function managedOrcadInstallDir(context: OrcadRemoteContext, version: string): string {
|
||||
return computeRemoteInstallDir(
|
||||
ORCAD_INSTALL_MODEL,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** The last update each managed server deferred in this session, so status can report it. */
|
||||
import type { OrcadManagedDeferral } from '../../shared/orcad-managed-runtime'
|
||||
|
||||
type RecordedDeferral = OrcadManagedDeferral & { deferredAt: string }
|
||||
|
||||
const deferrals = new Map<string, RecordedDeferral>()
|
||||
|
||||
export function recordManagedOrcadUpdateDeferral(
|
||||
environmentId: string,
|
||||
deferral: OrcadManagedDeferral,
|
||||
now = new Date()
|
||||
): void {
|
||||
deferrals.set(environmentId, { ...deferral, deferredAt: now.toISOString() })
|
||||
}
|
||||
|
||||
export function clearManagedOrcadUpdateDeferral(environmentId: string): void {
|
||||
deferrals.delete(environmentId)
|
||||
}
|
||||
|
||||
export function readManagedOrcadUpdateDeferral(environmentId: string): RecordedDeferral | null {
|
||||
return deferrals.get(environmentId) ?? null
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner'
|
||||
import { getRuntimeEnvironmentSidecarPath } from '../../shared/runtime-environment-sidecar'
|
||||
import { listEnvironments } from '../../shared/runtime-environment-store'
|
||||
import {
|
||||
createManagedLifecycleHarness,
|
||||
MANAGED_VERSION
|
||||
} from './orcad-managed-lifecycle-test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: { store: unknown; transaction: unknown } = { store: null, transaction: null }
|
||||
return {
|
||||
state,
|
||||
resolveContext: vi.fn(),
|
||||
census: vi.fn(),
|
||||
decommission: vi.fn(),
|
||||
recover: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
keepServing: vi.fn(),
|
||||
closeTunnel: vi.fn(),
|
||||
ensureTunnel: vi.fn(),
|
||||
retire: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./ssh-target-registry', () => ({
|
||||
getSshConnectionManager: () => ({ connect: async () => ({}) }),
|
||||
getSshTargetRegistryStore: () => mocks.state.store
|
||||
}))
|
||||
vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext }))
|
||||
vi.mock('./orcad-terminal-census-client', () => ({ collectManagedTerminalCensus: mocks.census }))
|
||||
vi.mock('./orcad-remote-stop', () => ({ decommissionRemoteOrcad: mocks.decommission }))
|
||||
vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover }))
|
||||
vi.mock('./orcad-activation-transaction-store', () => ({
|
||||
readOrcadActivationTransaction: async () => mocks.state.transaction
|
||||
}))
|
||||
vi.mock('./orcad-activation-lock', () => ({
|
||||
withStaleOrcadActivationRecoveryLock: async (
|
||||
_options: unknown,
|
||||
run: (lock: { retain: () => void }) => Promise<unknown>
|
||||
) => run({ retain: () => {} })
|
||||
}))
|
||||
vi.mock('./orcad-managed-remote-stop', () => ({ cancelRemoteOrcadManagedStop: mocks.cancel }))
|
||||
vi.mock('./orcad-decommission-recovery', () => ({ reconcileOrcadDecommission: mocks.keepServing }))
|
||||
vi.mock('./orcad-managed-tunnel', () => ({
|
||||
closeOrcadManagedTunnel: mocks.closeTunnel,
|
||||
ensureOrcadManagedTunnel: mocks.ensureTunnel
|
||||
}))
|
||||
|
||||
const { cancelManagedOrcadStop, stopManagedOrcadEnvironment } =
|
||||
await import('./orcad-runtime-lifecycle')
|
||||
|
||||
const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 }
|
||||
let harness: ReturnType<typeof createManagedLifecycleHarness>
|
||||
|
||||
const stop = (isActive = false) =>
|
||||
stopManagedOrcadEnvironment(
|
||||
harness.userDataPath,
|
||||
{ selector: 'Managed' },
|
||||
{ isActiveEnvironment: () => isActive, retireLocalState: mocks.retire }
|
||||
)
|
||||
|
||||
function expectStillLinked(): void {
|
||||
expect(listEnvironments(harness.userDataPath)[0]?.orcadDeployment).toBeDefined()
|
||||
expect(getManagedOrcadOwnerEnvironmentId(harness.current().owner)).toBe('environment-1')
|
||||
expect(mocks.retire).not.toHaveBeenCalled()
|
||||
expect(mocks.closeTunnel).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
harness = createManagedLifecycleHarness()
|
||||
mocks.state.store = harness.targetStore
|
||||
mocks.state.transaction = null
|
||||
mocks.resolveContext.mockImplementation(async () => harness.context())
|
||||
mocks.census.mockResolvedValue(idle)
|
||||
})
|
||||
|
||||
afterEach(() => rmSync(harness.userDataPath, { recursive: true, force: true }))
|
||||
|
||||
describe('stopManagedOrcadEnvironment', () => {
|
||||
it('unlinks the server only after the host proves orcad exited', async () => {
|
||||
mocks.decommission.mockResolvedValueOnce({
|
||||
outcome: 'decommissioned',
|
||||
version: MANAGED_VERSION,
|
||||
retirement: 'retired'
|
||||
})
|
||||
await expect(stop()).resolves.toEqual({
|
||||
outcome: 'unlinked',
|
||||
verdict: 'exited',
|
||||
environmentId: 'environment-1',
|
||||
sshTargetId: 'ssh-1',
|
||||
stoppedVersion: MANAGED_VERSION,
|
||||
retirement: 'retired'
|
||||
})
|
||||
expect(mocks.decommission.mock.calls[0]?.[0]).toMatchObject({ census: idle, port: 6_768 })
|
||||
expect(listEnvironments(harness.userDataPath)).toEqual([])
|
||||
expect(
|
||||
readFileSync(getRuntimeEnvironmentSidecarPath(harness.userDataPath), 'utf8')
|
||||
).not.toContain('orcadDeployment')
|
||||
expect(harness.current().owner).toBeUndefined()
|
||||
expect(harness.flushes).toHaveLength(1)
|
||||
expect(mocks.retire).toHaveBeenCalledWith('environment-1')
|
||||
expect(mocks.closeTunnel).toHaveBeenCalledWith('environment-1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['live', 'orcad_decommission_terminals_running'],
|
||||
['unverifiable', 'orcad_decommission_census_unavailable'],
|
||||
['unverifiable', 'orcad_decommission_stop_unsettled']
|
||||
] as const)('keeps every link when the verdict is %s (%s)', async (verdict, code) => {
|
||||
mocks.decommission.mockResolvedValueOnce({ outcome: 'refused', verdict, code, reason: 'no' })
|
||||
await expect(stop()).resolves.toMatchObject({ outcome: 'refused', verdict, code })
|
||||
expectStillLinked()
|
||||
expect(existsSync(getRuntimeEnvironmentSidecarPath(harness.userDataPath))).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses the Active Server before contacting the host', async () => {
|
||||
await expect(stop(true)).resolves.toMatchObject({ code: 'orcad_stop_active_environment' })
|
||||
expect(mocks.resolveContext).not.toHaveBeenCalled()
|
||||
expectStillLinked()
|
||||
})
|
||||
|
||||
it('finishes an interrupted stop from its journal and unlinks only on proven exit', async () => {
|
||||
mocks.state.transaction = { operation: 'decommission', activeVersion: MANAGED_VERSION }
|
||||
mocks.recover.mockResolvedValueOnce({
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
code: 'orcad_recovery_decommission_unsettled',
|
||||
reason: 'no answer'
|
||||
})
|
||||
await expect(stop()).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' })
|
||||
expectStillLinked()
|
||||
mocks.recover.mockResolvedValueOnce({
|
||||
outcome: 'recovered',
|
||||
resolution: 'committed',
|
||||
activeVersion: null,
|
||||
readiness: null
|
||||
})
|
||||
await expect(stop()).resolves.toMatchObject({ outcome: 'unlinked', verdict: 'exited' })
|
||||
expect(mocks.decommission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses while another interrupted operation holds the journal', async () => {
|
||||
mocks.state.transaction = { operation: 'activate' }
|
||||
await expect(stop()).resolves.toMatchObject({ code: 'orcad_activation_recovery_required' })
|
||||
expectStillLinked()
|
||||
})
|
||||
|
||||
it('unlinks a server whose record already shows nothing active', async () => {
|
||||
mocks.resolveContext.mockImplementation(async () => harness.context({ active: null }))
|
||||
await expect(stop()).resolves.toMatchObject({ outcome: 'unlinked', stoppedVersion: null })
|
||||
expect(mocks.decommission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelManagedOrcadStop', () => {
|
||||
const request = { transactionId: 't-1', version: MANAGED_VERSION }
|
||||
const cancel = () => cancelManagedOrcadStop(harness.userDataPath, { selector: 'Managed' })
|
||||
|
||||
it('has nothing to cancel without a decommission journal', async () => {
|
||||
await expect(cancel()).resolves.toEqual({ outcome: 'none' })
|
||||
expect(mocks.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('withdraws a stop orcad never acted on and keeps the server serving', async () => {
|
||||
mocks.state.transaction = {
|
||||
operation: 'decommission',
|
||||
phase: 'stop-dispatched',
|
||||
activeVersion: MANAGED_VERSION,
|
||||
request
|
||||
}
|
||||
mocks.cancel.mockResolvedValueOnce({ outcome: 'canceled' })
|
||||
mocks.keepServing.mockResolvedValueOnce({
|
||||
outcome: 'recovered',
|
||||
resolution: 'restored-incumbent',
|
||||
activeVersion: MANAGED_VERSION,
|
||||
readiness: null
|
||||
})
|
||||
await expect(cancel()).resolves.toEqual({ outcome: 'canceled', activeVersion: MANAGED_VERSION })
|
||||
expect(mocks.keepServing.mock.calls[0]?.[1]).toEqual({
|
||||
action: 'keep-serving',
|
||||
version: MANAGED_VERSION
|
||||
})
|
||||
expect(mocks.ensureTunnel).toHaveBeenCalledOnce()
|
||||
expectStillLinked()
|
||||
})
|
||||
|
||||
it('refuses once orcad already acted on the stop', async () => {
|
||||
mocks.state.transaction = {
|
||||
operation: 'decommission',
|
||||
phase: 'stop-dispatched',
|
||||
activeVersion: MANAGED_VERSION,
|
||||
request
|
||||
}
|
||||
mocks.cancel.mockResolvedValueOnce({ outcome: 'dispatched' })
|
||||
await expect(cancel()).resolves.toMatchObject({
|
||||
outcome: 'refused',
|
||||
verdict: 'live',
|
||||
code: 'orcad_stop_already_dispatched'
|
||||
})
|
||||
expect(mocks.keepServing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports an already-exited stop for stop to finish', async () => {
|
||||
mocks.state.transaction = {
|
||||
operation: 'decommission',
|
||||
phase: 'process-exited',
|
||||
activeVersion: MANAGED_VERSION,
|
||||
request
|
||||
}
|
||||
await expect(cancel()).resolves.toEqual({ outcome: 'already-stopped' })
|
||||
expectStillLinked()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Stopping a managed orcad and unlinking it, on T6-4's journaled decommission. The server stays
|
||||
* linked — its SSH claim, tunnel and deployment record — unless the host proves orcad exited.
|
||||
*/
|
||||
import type {
|
||||
OrcadManagedCancelStopResult,
|
||||
OrcadManagedStopResult
|
||||
} from '../../shared/orcad-managed-runtime'
|
||||
import { removeManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store'
|
||||
import type { KnownRuntimeEnvironment, OrcadDeploymentLink } from '../../shared/runtime-environments'
|
||||
import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery'
|
||||
import { withStaleOrcadActivationRecoveryLock } from './orcad-activation-lock'
|
||||
import { readOrcadActivationTransaction } from './orcad-activation-transaction-store'
|
||||
import { reconcileOrcadDecommission } from './orcad-decommission-recovery'
|
||||
import { cancelRemoteOrcadManagedStop } from './orcad-managed-remote-stop'
|
||||
import {
|
||||
managedOrcadSlot,
|
||||
requireManagedOrcadInfrastructure,
|
||||
resolveLinkedOrcadContext
|
||||
} from './orcad-managed-runtime-context'
|
||||
import { closeOrcadManagedTunnel, ensureOrcadManagedTunnel } from './orcad-managed-tunnel'
|
||||
import { clearManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals'
|
||||
import { decommissionRemoteOrcad } from './orcad-remote-stop'
|
||||
import { withManagedOrcadLifecycle } from './orcad-runtime-maintenance'
|
||||
import { collectManagedTerminalCensus } from './orcad-terminal-census-client'
|
||||
import { RemoteInstallLockBusyError } from './ssh-relay-install-lock'
|
||||
|
||||
export type ManagedOrcadStopPolicy = {
|
||||
isActiveEnvironment: (environmentId: string) => boolean
|
||||
/** Invalidates the server's transport and retires its client-side state once it is unlinked. */
|
||||
retireLocalState: (environmentId: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
type Refusal = Extract<OrcadManagedStopResult, { outcome: 'refused' }>
|
||||
type Stopped = { version: string | null; retirement: 'retired' | 'live' | 'unverifiable' | null }
|
||||
|
||||
function refuse(verdict: Refusal['verdict'], code: string, reason: string): Refusal {
|
||||
return { outcome: 'refused', verdict, code, reason }
|
||||
}
|
||||
|
||||
export function stopManagedOrcadEnvironment(
|
||||
userDataPath: string,
|
||||
args: { selector: string; signal?: AbortSignal },
|
||||
policy: ManagedOrcadStopPolicy
|
||||
): Promise<OrcadManagedStopResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => {
|
||||
const { environment, deployment } = managed
|
||||
if (policy.isActiveEnvironment(environment.id)) {
|
||||
return refuse(
|
||||
'live',
|
||||
'orcad_stop_active_environment',
|
||||
'Choose another Active Server in Advanced before stopping this server.'
|
||||
)
|
||||
}
|
||||
const stopped = await stopRemote(userDataPath, environment, deployment, args.signal)
|
||||
if ('outcome' in stopped) {
|
||||
return stopped
|
||||
}
|
||||
await unlinkStoppedEnvironment(userDataPath, environment, deployment, policy)
|
||||
return {
|
||||
outcome: 'unlinked',
|
||||
verdict: 'exited',
|
||||
environmentId: environment.id,
|
||||
sshTargetId: deployment.sshTargetId,
|
||||
stoppedVersion: stopped.version,
|
||||
retirement: stopped.retirement
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Proven exit, or the refusal that keeps the server linked. */
|
||||
async function stopRemote(
|
||||
userDataPath: string,
|
||||
environment: KnownRuntimeEnvironment,
|
||||
deployment: OrcadDeploymentLink,
|
||||
signal?: AbortSignal
|
||||
): Promise<Stopped | Refusal> {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, signal)
|
||||
const slot = managedOrcadSlot(context, deployment.remotePort, signal)
|
||||
const transaction = await readOrcadActivationTransaction(slot)
|
||||
if (transaction?.operation === 'decommission') {
|
||||
// An earlier stop was interrupted: its journal decides, so finish exactly that one.
|
||||
const recovered = await recoverInterruptedOrcadActivation(slot)
|
||||
if (recovered.outcome === 'recovered' && recovered.activeVersion === null) {
|
||||
return { version: transaction.activeVersion, retirement: null }
|
||||
}
|
||||
if (recovered.outcome === 'refused') {
|
||||
return refuse(recovered.verdict, recovered.code, recovered.reason)
|
||||
}
|
||||
if (recovered.outcome === 'pending') {
|
||||
return refuse('unverifiable', recovered.code, recovered.reason)
|
||||
}
|
||||
return refuse(
|
||||
'live',
|
||||
'orcad_stop_withdrawn',
|
||||
'The interrupted stop was withdrawn and the server keeps serving. Stop it again.'
|
||||
)
|
||||
}
|
||||
if (transaction) {
|
||||
return refuse(
|
||||
'unverifiable',
|
||||
'orcad_activation_recovery_required',
|
||||
'An earlier update on this server was interrupted. Recover it before stopping.'
|
||||
)
|
||||
}
|
||||
const record = context.activationRecord
|
||||
if (!record.active) {
|
||||
// Only a proven exit (or a deploy that never activated) leaves the record without a version.
|
||||
return { version: null, retirement: null }
|
||||
}
|
||||
const census = await collectManagedTerminalCensus(userDataPath, environment, record)
|
||||
const result = await decommissionRemoteOrcad({ ...slot, record, census })
|
||||
if (result.outcome === 'refused') {
|
||||
return refuse(result.verdict, result.code, result.reason)
|
||||
}
|
||||
return { version: result.version, retirement: result.retirement }
|
||||
}
|
||||
|
||||
async function unlinkStoppedEnvironment(
|
||||
userDataPath: string,
|
||||
environment: KnownRuntimeEnvironment,
|
||||
deployment: OrcadDeploymentLink,
|
||||
policy: ManagedOrcadStopPolicy
|
||||
): Promise<void> {
|
||||
// Environment first: a crash before the claim is released leaves a hidden target to resume,
|
||||
// never a linked server whose SSH target was already handed back.
|
||||
removeManagedOrcadEnvironment(userDataPath, environment.id)
|
||||
await policy.retireLocalState(environment.id)
|
||||
await closeOrcadManagedTunnel(environment.id)
|
||||
const { claims } = requireManagedOrcadInfrastructure()
|
||||
claims.release(deployment.sshTargetId, environment.id)
|
||||
clearManagedOrcadUpdateDeferral(environment.id)
|
||||
await claims.flush()
|
||||
}
|
||||
|
||||
/** Withdraws a stop orcad has not acted on yet; the server then keeps serving. */
|
||||
export function cancelManagedOrcadStop(
|
||||
userDataPath: string,
|
||||
args: { selector: string; signal?: AbortSignal }
|
||||
): Promise<OrcadManagedCancelStopResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, args.selector, async (managed) => {
|
||||
const { environment, deployment } = managed
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const slot = managedOrcadSlot(context, deployment.remotePort, args.signal)
|
||||
let result: OrcadManagedCancelStopResult
|
||||
try {
|
||||
result = await withStaleOrcadActivationRecoveryLock(slot, async (lock) => {
|
||||
const transaction = await readOrcadActivationTransaction(slot)
|
||||
if (transaction?.operation !== 'decommission') {
|
||||
if (transaction) {
|
||||
lock.retain()
|
||||
}
|
||||
return { outcome: 'none' }
|
||||
}
|
||||
if (transaction.phase === 'process-exited') {
|
||||
lock.retain()
|
||||
return { outcome: 'already-stopped' }
|
||||
}
|
||||
if (transaction.request) {
|
||||
const cancellation = await cancelRemoteOrcadManagedStop(slot, transaction.request)
|
||||
if (cancellation.outcome !== 'canceled') {
|
||||
lock.retain()
|
||||
return {
|
||||
outcome: 'refused',
|
||||
verdict: 'live',
|
||||
code: 'orcad_stop_already_dispatched',
|
||||
reason: 'orcad already acted on the stop and is shutting down; it cannot be canceled.'
|
||||
}
|
||||
}
|
||||
}
|
||||
const kept = await reconcileOrcadDecommission(slot, {
|
||||
action: 'keep-serving',
|
||||
version: transaction.activeVersion
|
||||
})
|
||||
if (kept.outcome !== 'recovered' || !kept.activeVersion) {
|
||||
lock.retain()
|
||||
return {
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
code: 'orcad_stop_cancel_unverifiable',
|
||||
reason: 'The stop was withdrawn but the server could not be shown to be serving.'
|
||||
}
|
||||
}
|
||||
return { outcome: 'canceled', activeVersion: kept.activeVersion }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteInstallLockBusyError) {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
code: 'orcad_stop_still_running',
|
||||
reason: 'A stop on this server may still be running. Retry after it settles.'
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (result.outcome === 'canceled') {
|
||||
await ensureOrcadManagedTunnel(userDataPath, environment.id)
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
@@ -46,6 +46,13 @@ vi.mock('./orcad-artifact-materializer', () => ({
|
||||
}))
|
||||
vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'build-hash' }))
|
||||
vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe }))
|
||||
vi.mock('./orcad-terminal-census-client', () => ({
|
||||
collectManagedTerminalCensus: async () => ({
|
||||
liveSessions: 0,
|
||||
startedSinceActivation: 0,
|
||||
daemonProtocolVersion: 39
|
||||
})
|
||||
}))
|
||||
vi.mock('./orcad-managed-tunnel', () => ({
|
||||
startOrcadManagedTunnel: mocks.startTunnel,
|
||||
ensureOrcadManagedTunnel: mocks.ensureTunnel,
|
||||
@@ -330,7 +337,9 @@ describe('getManagedOrcadRuntimeStatus', () => {
|
||||
phase: 'candidate-ready',
|
||||
version: '0.2.0+def',
|
||||
startedAt: 'then'
|
||||
}
|
||||
},
|
||||
terminals: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 39 },
|
||||
deferredUpdate: null
|
||||
})
|
||||
expect(mocks.recover).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -28,8 +28,7 @@ import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing'
|
||||
import { hasRegisteredDirectSshAuthority } from './ssh-target-registry'
|
||||
import {
|
||||
isForceableOrcadDeferral,
|
||||
MANAGED_ORCAD_LEGACY_NODE_PATH,
|
||||
ORCAD_BIND_HOST,
|
||||
managedOrcadSlot,
|
||||
probeManagedOrcadReadiness,
|
||||
requireManagedOrcadInfrastructure
|
||||
} from './orcad-managed-runtime-context'
|
||||
@@ -70,16 +69,7 @@ export async function createManagedOrcadEnvironment(
|
||||
try {
|
||||
const connection = await connectionManager.connect(claimed)
|
||||
let context = await resolveOrcadRemoteContext(claimed, connection, args.signal)
|
||||
const slot = {
|
||||
conn: context.connection,
|
||||
host: context.host,
|
||||
remoteHome: context.remoteHome,
|
||||
nodePath: MANAGED_ORCAD_LEGACY_NODE_PATH,
|
||||
userDataDir: context.userDataDir,
|
||||
bindHost: ORCAD_BIND_HOST,
|
||||
port: ORCAD_MANAGED_REMOTE_PORT,
|
||||
signal: args.signal
|
||||
}
|
||||
const slot = managedOrcadSlot(context, ORCAD_MANAGED_REMOTE_PORT, args.signal)
|
||||
const recovery = await recoverInterruptedOrcadActivation(slot)
|
||||
if (recovery.outcome === 'pending' || recovery.outcome === 'refused') {
|
||||
throw new Error(recovery.reason)
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
export { createManagedOrcadEnvironment } from './orcad-runtime-deployment'
|
||||
export { getManagedOrcadRuntimeStatus } from './orcad-runtime-status'
|
||||
export {
|
||||
recoverManagedOrcadEnvironment,
|
||||
rollbackManagedOrcadEnvironment,
|
||||
updateManagedOrcadEnvironment
|
||||
} from './orcad-runtime-maintenance'
|
||||
export { cancelManagedOrcadStop, stopManagedOrcadEnvironment } from './orcad-runtime-decommission'
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { rmSync } from 'node:fs'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listEnvironments } from '../../shared/runtime-environment-store'
|
||||
import {
|
||||
createManagedLifecycleHarness,
|
||||
MANAGED_PREVIOUS_VERSION,
|
||||
MANAGED_VERSION,
|
||||
managedReadiness
|
||||
} from './orcad-managed-lifecycle-test-fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state: { store: unknown } = { store: null }
|
||||
return {
|
||||
state,
|
||||
resolveContext: vi.fn(),
|
||||
census: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
rollback: vi.fn(),
|
||||
recover: vi.fn(),
|
||||
probe: vi.fn(),
|
||||
buildHash: vi.fn(),
|
||||
ensureTunnel: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./ssh-target-registry', () => ({
|
||||
getSshConnectionManager: () => ({ connect: async () => ({}) }),
|
||||
getSshTargetRegistryStore: () => mocks.state.store
|
||||
}))
|
||||
vi.mock('./orcad-remote-context', () => ({ resolveOrcadRemoteContext: mocks.resolveContext }))
|
||||
vi.mock('./orcad-terminal-census-client', () => ({ collectManagedTerminalCensus: mocks.census }))
|
||||
vi.mock('./orcad-remote-deploy', () => ({ deployOrcad: mocks.deploy }))
|
||||
vi.mock('./orcad-remote-rollback', () => ({ rollbackOrcad: mocks.rollback }))
|
||||
vi.mock('./orcad-activation-recovery', () => ({ recoverInterruptedOrcadActivation: mocks.recover }))
|
||||
vi.mock('./orcad-active-readiness', () => ({ probeActiveOrcadReadiness: mocks.probe }))
|
||||
vi.mock('./orcad-remote-build-hash', () => ({ readRemoteOrcadBuildHash: mocks.buildHash }))
|
||||
vi.mock('./orcad-artifact-materializer', () => ({
|
||||
materializeOrcadArtifact: async () => '/local/orcad'
|
||||
}))
|
||||
vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'local-hash' }))
|
||||
vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel }))
|
||||
vi.mock('./orcad-activation-transaction-store', () => ({
|
||||
readOrcadActivationTransaction: async () => null
|
||||
}))
|
||||
|
||||
const {
|
||||
getManagedOrcadRuntimeStatus,
|
||||
recoverManagedOrcadEnvironment,
|
||||
rollbackManagedOrcadEnvironment,
|
||||
updateManagedOrcadEnvironment
|
||||
} = await import('./orcad-runtime-lifecycle')
|
||||
|
||||
const idle = { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 7 }
|
||||
let harness: ReturnType<typeof createManagedLifecycleHarness>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
harness = createManagedLifecycleHarness()
|
||||
mocks.state.store = harness.targetStore
|
||||
mocks.resolveContext.mockImplementation(async () => harness.context())
|
||||
mocks.census.mockResolvedValue(idle)
|
||||
mocks.probe.mockResolvedValue(managedReadiness())
|
||||
mocks.buildHash.mockResolvedValue('previous-hash')
|
||||
mocks.recover.mockResolvedValue({ outcome: 'none' })
|
||||
})
|
||||
|
||||
afterEach(() => rmSync(harness.userDataPath, { recursive: true, force: true }))
|
||||
|
||||
describe('updateManagedOrcadEnvironment', () => {
|
||||
it('defers over live or unverifiable terminals and reports the deferral in status', async () => {
|
||||
mocks.census.mockResolvedValue({ ...idle, liveSessions: null })
|
||||
mocks.deploy.mockResolvedValueOnce({
|
||||
outcome: 'installed-not-activated',
|
||||
fullVersion: '0.3.0+new',
|
||||
code: 'orcad_update_terminal_census_unavailable',
|
||||
reason: 'The terminal daemon did not answer a session count.'
|
||||
})
|
||||
const result = await updateManagedOrcadEnvironment(harness.userDataPath, {
|
||||
selector: 'Managed'
|
||||
})
|
||||
expect(result).toMatchObject({ outcome: 'deferred', forceable: false })
|
||||
expect(mocks.deploy.mock.calls[0]?.[0]).toMatchObject({
|
||||
census: { liveSessions: null },
|
||||
port: 6_768,
|
||||
nodePath: ''
|
||||
})
|
||||
const status = await getManagedOrcadRuntimeStatus(harness.userDataPath, 'Managed')
|
||||
expect(status.deferredUpdate).toMatchObject({
|
||||
candidateVersion: '0.3.0+new',
|
||||
code: 'orcad_update_terminal_census_unavailable'
|
||||
})
|
||||
expect(status.terminals.liveSessions).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the deferral once an update goes through and keeps an unchanged pairing as is', async () => {
|
||||
mocks.deploy.mockResolvedValueOnce({
|
||||
outcome: 'installed-not-activated',
|
||||
fullVersion: '0.3.0+new',
|
||||
code: 'orcad_update_terminals_running',
|
||||
reason: 'busy'
|
||||
})
|
||||
await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
|
||||
mocks.deploy.mockResolvedValueOnce({
|
||||
outcome: 'installed-and-activated',
|
||||
fullVersion: '0.3.0+new'
|
||||
})
|
||||
const result = await updateManagedOrcadEnvironment(harness.userDataPath, {
|
||||
selector: 'Managed',
|
||||
force: true
|
||||
})
|
||||
expect(result).toMatchObject({ outcome: 'updated', activeVersion: '0.3.0+new' })
|
||||
expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), {
|
||||
buildHash: 'local-hash',
|
||||
fullVersion: '0.3.0+new'
|
||||
})
|
||||
const [environment] = listEnvironments(harness.userDataPath)
|
||||
expect(environment?.pairingRevision).toBe(harness.environment.pairingRevision)
|
||||
expect(environment?.orcadDeployment).toEqual(harness.environment.orcadDeployment)
|
||||
const status = await getManagedOrcadRuntimeStatus(harness.userDataPath, 'Managed')
|
||||
expect(status.deferredUpdate).toBeNull()
|
||||
})
|
||||
|
||||
it('re-pairs through the same tunnel, keeping the deployment link, when the offer changed', async () => {
|
||||
mocks.deploy.mockResolvedValueOnce({ outcome: 'installed-and-activated', fullVersion: 'v3' })
|
||||
mocks.probe.mockResolvedValueOnce(managedReadiness('rotated-token'))
|
||||
await updateManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
|
||||
const [environment] = listEnvironments(harness.userDataPath)
|
||||
expect(environment?.endpoints[0]?.deviceToken).toBe('rotated-token')
|
||||
expect(environment?.orcadDeployment).toEqual(harness.environment.orcadDeployment)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rollbackManagedOrcadEnvironment', () => {
|
||||
it.each([
|
||||
[{ ...idle, liveSessions: 2 }, 'orcad_rollback_terminals_running'],
|
||||
[{ ...idle, liveSessions: null }, 'orcad_rollback_census_unavailable']
|
||||
])('refuses while terminals run or cannot be counted: %j', async (census, code) => {
|
||||
mocks.census.mockResolvedValue(census)
|
||||
await expect(
|
||||
rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
|
||||
).resolves.toMatchObject({ outcome: 'refused', code })
|
||||
expect(mocks.rollback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses with no previous version without contacting the slot', async () => {
|
||||
mocks.resolveContext.mockImplementation(async () => harness.context({ previous: null }))
|
||||
await expect(
|
||||
rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
|
||||
).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_rollback_no_target' })
|
||||
expect(mocks.census).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back against the installed bytes of the previous slot', async () => {
|
||||
mocks.rollback.mockResolvedValueOnce({
|
||||
outcome: 'rolled-back',
|
||||
target: MANAGED_PREVIOUS_VERSION,
|
||||
discarded: [MANAGED_VERSION],
|
||||
verdict: { decision: 'activate', coverage: 'pty-spawn', warnings: [] }
|
||||
})
|
||||
await expect(
|
||||
rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
|
||||
).resolves.toMatchObject({ outcome: 'rolled-back', activeVersion: MANAGED_PREVIOUS_VERSION })
|
||||
expect(mocks.rollback.mock.calls[0]?.[0]).toMatchObject({ targetBuildHash: 'previous-hash' })
|
||||
expect(mocks.probe).toHaveBeenCalledWith(expect.anything(), {
|
||||
buildHash: 'previous-hash',
|
||||
fullVersion: MANAGED_PREVIOUS_VERSION
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('recoverManagedOrcadEnvironment', () => {
|
||||
it('passes a refusal through and leaves the server linked', async () => {
|
||||
mocks.recover.mockResolvedValueOnce({
|
||||
outcome: 'refused',
|
||||
verdict: 'unverifiable',
|
||||
code: 'orcad_recovery_unverifiable',
|
||||
reason: 'host fenced'
|
||||
})
|
||||
await expect(
|
||||
recoverManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
|
||||
).resolves.toMatchObject({ outcome: 'refused', verdict: 'unverifiable' })
|
||||
expect(listEnvironments(harness.userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-ensures the tunnel once a serving slot is restored', async () => {
|
||||
mocks.recover.mockResolvedValueOnce({
|
||||
outcome: 'recovered',
|
||||
resolution: 'restored-incumbent',
|
||||
activeVersion: MANAGED_VERSION,
|
||||
readiness: managedReadiness()
|
||||
})
|
||||
await expect(
|
||||
recoverManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' })
|
||||
).resolves.toMatchObject({ outcome: 'recovered', activeVersion: MANAGED_VERSION })
|
||||
expect(mocks.ensureTunnel).toHaveBeenCalledWith(harness.userDataPath, 'environment-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Updating, rolling back and recovering a managed orcad, on T6-2's deploy, rollback and recovery.
|
||||
* Every step reads the terminal census through the server's tunnel first; an unanswered census
|
||||
* is unverifiable, never zero, so an update over live or uncounted terminals defers (D7).
|
||||
*/
|
||||
import { refreshManagedOrcadPairing } from '../../shared/runtime-environment-managed-orcad-store'
|
||||
import { assertRuntimeEnvironmentNotReconciling } from '../../shared/runtime-environment-reconciliation-record'
|
||||
import {
|
||||
redactRuntimeEnvironment,
|
||||
type KnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import type {
|
||||
OrcadManagedDeployResult,
|
||||
OrcadManagedRecoveryResult,
|
||||
OrcadManagedRollbackResult
|
||||
} from '../../shared/orcad-managed-runtime'
|
||||
import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue'
|
||||
import type { ServeReadiness } from '../server/serve-readiness'
|
||||
import { recoverInterruptedOrcadActivation } from './orcad-activation-recovery'
|
||||
import { probeActiveOrcadReadiness } from './orcad-active-readiness'
|
||||
import { materializeOrcadArtifact } from './orcad-artifact-materializer'
|
||||
import { CURRENT_ORCAD_DAEMON_PROTOCOL } from './orcad-daemon-protocol-crossing'
|
||||
import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel'
|
||||
import {
|
||||
clearManagedOrcadUpdateDeferral,
|
||||
recordManagedOrcadUpdateDeferral
|
||||
} from './orcad-managed-update-deferrals'
|
||||
import {
|
||||
isForceableOrcadDeferral,
|
||||
managedOrcadInstallDir,
|
||||
managedOrcadSlot,
|
||||
probeManagedOrcadReadiness,
|
||||
requireManagedOrcadEnvironment,
|
||||
resolveLinkedOrcadContext
|
||||
} from './orcad-managed-runtime-context'
|
||||
import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash'
|
||||
import { deployOrcad } from './orcad-remote-deploy'
|
||||
import { rollbackOrcad } from './orcad-remote-rollback'
|
||||
import { collectManagedTerminalCensus } from './orcad-terminal-census-client'
|
||||
import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing'
|
||||
|
||||
type LifecycleArgs = { selector: string; signal?: AbortSignal }
|
||||
|
||||
export function withManagedOrcadLifecycle<T>(
|
||||
userDataPath: string,
|
||||
selector: string,
|
||||
run: (managed: ReturnType<typeof requireManagedOrcadEnvironment>) => Promise<T>
|
||||
): Promise<T> {
|
||||
const { environment, deployment } = requireManagedOrcadEnvironment(userDataPath, selector)
|
||||
return runTargetLifecycle(deployment.sshTargetId, async () => {
|
||||
// Re-read under the queue: a reconciliation or stop may have won the race for it.
|
||||
const current = requireManagedOrcadEnvironment(userDataPath, environment.id)
|
||||
assertRuntimeEnvironmentNotReconciling(current.environment)
|
||||
return run(current)
|
||||
})
|
||||
}
|
||||
|
||||
function refreshPairing(
|
||||
userDataPath: string,
|
||||
environment: KnownRuntimeEnvironment,
|
||||
readiness: ServeReadiness,
|
||||
localPort: number
|
||||
): KnownRuntimeEnvironment {
|
||||
return refreshManagedOrcadPairing(
|
||||
userDataPath,
|
||||
environment.id,
|
||||
tunneledOrcadPairingCode(readiness, localPort)
|
||||
)
|
||||
}
|
||||
|
||||
export function updateManagedOrcadEnvironment(
|
||||
userDataPath: string,
|
||||
args: LifecycleArgs & { force?: boolean }
|
||||
): Promise<OrcadManagedDeployResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const census = await collectManagedTerminalCensus(
|
||||
userDataPath,
|
||||
environment,
|
||||
context.activationRecord
|
||||
)
|
||||
const localOrcadDir = await materializeOrcadArtifact(context.serverTarget, {
|
||||
signal: args.signal
|
||||
})
|
||||
const result = await deployOrcad({
|
||||
...managedOrcadSlot(context, deployment.remotePort, args.signal),
|
||||
localOrcadDir,
|
||||
target: context.serverTarget,
|
||||
census,
|
||||
force: args.force
|
||||
})
|
||||
if (result.outcome === 'installed-not-activated') {
|
||||
const deferral = {
|
||||
outcome: 'deferred' as const,
|
||||
candidateVersion: result.fullVersion,
|
||||
code: result.code,
|
||||
reason: result.reason,
|
||||
forceable: isForceableOrcadDeferral(result.code)
|
||||
}
|
||||
recordManagedOrcadUpdateDeferral(environment.id, deferral)
|
||||
return deferral
|
||||
}
|
||||
clearManagedOrcadUpdateDeferral(environment.id)
|
||||
const readiness = await probeManagedOrcadReadiness(
|
||||
context,
|
||||
localOrcadDir,
|
||||
result.fullVersion,
|
||||
args.signal
|
||||
)
|
||||
const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort)
|
||||
return {
|
||||
outcome: result.outcome === 'already-active' ? 'already-current' : 'updated',
|
||||
environment: redactRuntimeEnvironment(updated),
|
||||
activeVersion: result.fullVersion
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function rollbackManagedOrcadEnvironment(
|
||||
userDataPath: string,
|
||||
args: LifecycleArgs
|
||||
): Promise<OrcadManagedRollbackResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const record = context.activationRecord
|
||||
const target = record.previous
|
||||
if (!target) {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
code: 'orcad_rollback_no_target',
|
||||
reason: 'This server has no previous version to roll back to.'
|
||||
}
|
||||
}
|
||||
const census = await collectManagedTerminalCensus(userDataPath, environment, record)
|
||||
// Why idle only: this client cannot read the older build's daemon protocol, so it cannot show
|
||||
// that build would reach terminals that are still running.
|
||||
if (census.liveSessions !== 0) {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
code:
|
||||
census.liveSessions === null
|
||||
? 'orcad_rollback_census_unavailable'
|
||||
: 'orcad_rollback_terminals_running',
|
||||
reason:
|
||||
census.liveSessions === null
|
||||
? 'The server did not answer how many terminals it runs. Retry when it answers.'
|
||||
: 'Close the terminals running on this server before rolling it back.'
|
||||
}
|
||||
}
|
||||
const slot = managedOrcadSlot(context, deployment.remotePort, args.signal)
|
||||
const targetDir = managedOrcadInstallDir(context, target)
|
||||
const targetBuildHash = await readRemoteOrcadBuildHash(slot, targetDir)
|
||||
const result = await rollbackOrcad({
|
||||
...slot,
|
||||
record,
|
||||
census,
|
||||
targetBuildHash,
|
||||
targetDaemonProtocol: CURRENT_ORCAD_DAEMON_PROTOCOL
|
||||
})
|
||||
if (result.outcome !== 'rolled-back') {
|
||||
return result
|
||||
}
|
||||
const readiness = await probeActiveOrcadReadiness(
|
||||
{ ...slot, remoteInstallDir: targetDir },
|
||||
{ buildHash: targetBuildHash, fullVersion: result.target }
|
||||
)
|
||||
const updated = refreshPairing(userDataPath, environment, readiness, deployment.localPort)
|
||||
return {
|
||||
outcome: 'rolled-back',
|
||||
environment: redactRuntimeEnvironment(updated),
|
||||
activeVersion: result.target,
|
||||
discarded: result.discarded
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Finishes or undoes an interrupted activation, rollback or decommission on the host. */
|
||||
export function recoverManagedOrcadEnvironment(
|
||||
userDataPath: string,
|
||||
args: LifecycleArgs
|
||||
): Promise<OrcadManagedRecoveryResult> {
|
||||
return withManagedOrcadLifecycle(userDataPath, args.selector, async ({ environment, deployment }) => {
|
||||
const context = await resolveLinkedOrcadContext(environment, deployment, args.signal)
|
||||
const result = await recoverInterruptedOrcadActivation(
|
||||
managedOrcadSlot(context, deployment.remotePort, args.signal)
|
||||
)
|
||||
if (result.outcome !== 'recovered') {
|
||||
return result
|
||||
}
|
||||
const updated = result.readiness
|
||||
? refreshPairing(userDataPath, environment, result.readiness, deployment.localPort)
|
||||
: environment
|
||||
if (result.activeVersion) {
|
||||
await ensureOrcadManagedTunnel(userDataPath, environment.id)
|
||||
}
|
||||
return {
|
||||
outcome: 'recovered',
|
||||
resolution: result.resolution,
|
||||
activeVersion: result.activeVersion,
|
||||
environment: redactRuntimeEnvironment(updated)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
requireManagedOrcadEnvironment,
|
||||
resolveLinkedOrcadContext
|
||||
} from './orcad-managed-runtime-context'
|
||||
import { readManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals'
|
||||
import { collectManagedTerminalCensus } from './orcad-terminal-census-client'
|
||||
|
||||
/** Read-only: what the host's activation record and journal say, without repairing either. */
|
||||
export async function getManagedOrcadRuntimeStatus(
|
||||
@@ -30,7 +32,9 @@ export async function getManagedOrcadRuntimeStatus(
|
||||
previousVersion: record.previous,
|
||||
activatedAt: record.activatedAt,
|
||||
rollbackAvailable: Boolean(record.previous && record.snapshot),
|
||||
recovery: transaction ? managedRecoveryStatus(transaction) : null
|
||||
recovery: transaction ? managedRecoveryStatus(transaction) : null,
|
||||
terminals: await collectManagedTerminalCensus(userDataPath, environment, record),
|
||||
deferredUpdate: readManagedOrcadUpdateDeferral(environment.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import {
|
||||
createEnvironmentFromPairingOffer,
|
||||
type KnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import { emptyOrcadActivationRecord } from './orcad-activation-record'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ send: vi.fn(), ensure: vi.fn() }))
|
||||
vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
sendRemoteRuntimeRequestWithStatusPreflight: mocks.send
|
||||
}))
|
||||
vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensure }))
|
||||
|
||||
const { collectManagedTerminalCensus, collectRemoteOrcadTerminalCensus } =
|
||||
await import('./orcad-terminal-census-client')
|
||||
|
||||
const environment: KnownRuntimeEnvironment = createEnvironmentFromPairingOffer({
|
||||
id: 'environment-1',
|
||||
name: 'Managed',
|
||||
now: 1,
|
||||
offer: { v: 2, endpoint: 'ws://127.0.0.1:46768', deviceToken: 't', publicKeyB64: 'k' },
|
||||
connectionDependency: 'ssh-tunnel'
|
||||
})
|
||||
const active = {
|
||||
...emptyOrcadActivationRecord(),
|
||||
active: '1.0.0',
|
||||
activatedAt: '2026-01-01T00:00:00.000Z'
|
||||
}
|
||||
const unverifiable = {
|
||||
liveSessions: null,
|
||||
startedSinceActivation: null,
|
||||
daemonProtocolVersion: null
|
||||
}
|
||||
const census = { liveSessions: 2, startedSinceActivation: 1, daemonProtocolVersion: 7 }
|
||||
|
||||
function answerWith(capabilities: string[], response: unknown) {
|
||||
mocks.send.mockImplementation(async (_pairing, _method, _params, _timeout, validate) => {
|
||||
validate({ ok: true, result: { capabilities } })
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
describe('managed orcad terminal census client', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mocks.ensure.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('reads an idle census without contacting a host that has nothing active', async () => {
|
||||
await expect(
|
||||
collectRemoteOrcadTerminalCensus(environment, { ...active, active: null })
|
||||
).resolves.toEqual({ liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null })
|
||||
expect(mocks.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks an advertising host with the activation time', async () => {
|
||||
answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: census })
|
||||
await expect(collectRemoteOrcadTerminalCensus(environment, active)).resolves.toEqual(census)
|
||||
expect(mocks.send.mock.calls[0]?.slice(1, 3)).toEqual([
|
||||
'orcad.terminalCensus',
|
||||
{ activatedAt: Date.parse(active.activatedAt) }
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an older host without the capability', () => answerWith([], { ok: true, result: census })],
|
||||
[
|
||||
'method not found',
|
||||
() =>
|
||||
answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], {
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'no' }
|
||||
})
|
||||
],
|
||||
[
|
||||
'a malformed answer',
|
||||
() => answerWith([ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY], { ok: true, result: {} })
|
||||
],
|
||||
['loss of contact', () => mocks.send.mockRejectedValue(new Error('socket closed'))]
|
||||
])('reads %s as unverifiable, never as zero', async (_label, arrange) => {
|
||||
arrange()
|
||||
await expect(collectRemoteOrcadTerminalCensus(environment, active)).resolves.toEqual(
|
||||
unverifiable
|
||||
)
|
||||
})
|
||||
|
||||
it('reads an unparseable activation time as unverifiable', async () => {
|
||||
await expect(
|
||||
collectRemoteOrcadTerminalCensus(environment, { ...active, activatedAt: 'later' })
|
||||
).resolves.toEqual(unverifiable)
|
||||
expect(mocks.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads a tunnel that cannot open as unverifiable', async () => {
|
||||
mocks.ensure.mockRejectedValue(new Error('SSH unavailable'))
|
||||
await expect(collectManagedTerminalCensus('/profile', environment, active)).resolves.toEqual(
|
||||
unverifiable
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Asks a managed orcad, through its tunnel, how many terminals its daemon runs. Every failure,
|
||||
* including an older host without the method, reads as an unverifiable census, never as zero.
|
||||
*/
|
||||
import {
|
||||
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES,
|
||||
ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY
|
||||
} from '../../shared/protocol-version'
|
||||
import {
|
||||
getPreferredPairingOffer,
|
||||
type KnownRuntimeEnvironment
|
||||
} from '../../shared/runtime-environments'
|
||||
import {
|
||||
ORCAD_TERMINAL_CENSUS_METHOD,
|
||||
OrcadTerminalCensusSchema,
|
||||
type OrcadTerminalCensus
|
||||
} from '../../shared/orcad-terminal-census'
|
||||
import { sendRemoteRuntimeRequestWithStatusPreflight } from '../../shared/remote-runtime-client'
|
||||
import type { OrcadActivationRecord } from './orcad-activation-record'
|
||||
import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel'
|
||||
|
||||
export const UNVERIFIABLE_ORCAD_TERMINAL_CENSUS: OrcadTerminalCensus = {
|
||||
liveSessions: null,
|
||||
startedSinceActivation: null,
|
||||
daemonProtocolVersion: null
|
||||
}
|
||||
|
||||
export async function collectRemoteOrcadTerminalCensus(
|
||||
environment: KnownRuntimeEnvironment,
|
||||
record: OrcadActivationRecord,
|
||||
timeoutMs = 15_000
|
||||
): Promise<OrcadTerminalCensus> {
|
||||
if (!record.active) {
|
||||
return collectIdle()
|
||||
}
|
||||
const activatedAt = record.activatedAt ? Date.parse(record.activatedAt) : Number.NaN
|
||||
if (!Number.isFinite(activatedAt) || activatedAt < 0) {
|
||||
return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS
|
||||
}
|
||||
try {
|
||||
const response = await sendRemoteRuntimeRequestWithStatusPreflight<unknown>(
|
||||
getPreferredPairingOffer(environment),
|
||||
ORCAD_TERMINAL_CENSUS_METHOD,
|
||||
{ activatedAt },
|
||||
timeoutMs,
|
||||
(status) => {
|
||||
if (
|
||||
!status.ok ||
|
||||
!status.result.capabilities?.includes(ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY)
|
||||
) {
|
||||
throw new Error('The managed Orca server does not report a terminal census.')
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES
|
||||
)
|
||||
return response.ok
|
||||
? OrcadTerminalCensusSchema.parse(response.result)
|
||||
: UNVERIFIABLE_ORCAD_TERMINAL_CENSUS
|
||||
} catch {
|
||||
return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS
|
||||
}
|
||||
}
|
||||
|
||||
/** The census through the server's ensured tunnel; a tunnel that cannot open is unverifiable. */
|
||||
export async function collectManagedTerminalCensus(
|
||||
userDataPath: string,
|
||||
environment: KnownRuntimeEnvironment,
|
||||
record: OrcadActivationRecord
|
||||
): Promise<OrcadTerminalCensus> {
|
||||
if (!record.active) {
|
||||
return collectIdle()
|
||||
}
|
||||
try {
|
||||
await ensureOrcadManagedTunnel(userDataPath, environment.id)
|
||||
} catch {
|
||||
return UNVERIFIABLE_ORCAD_TERMINAL_CENSUS
|
||||
}
|
||||
return collectRemoteOrcadTerminalCensus(environment, record)
|
||||
}
|
||||
|
||||
function collectIdle(): OrcadTerminalCensus {
|
||||
return { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: null }
|
||||
}
|
||||
@@ -1,21 +1,64 @@
|
||||
import type { PublicKnownRuntimeEnvironment } from './runtime-environments'
|
||||
import type { OrcadTerminalCensus } from './orcad-terminal-census'
|
||||
|
||||
export const ORCAD_MANAGED_REMOTE_PORT = 6_768
|
||||
|
||||
export type OrcadManagedDeferral = {
|
||||
outcome: 'deferred'
|
||||
candidateVersion: string
|
||||
code: string
|
||||
reason: string
|
||||
/** False when force cannot make the rejected lifecycle transition safe. */
|
||||
forceable?: boolean
|
||||
}
|
||||
|
||||
export type OrcadManagedDeployResult =
|
||||
| {
|
||||
outcome: 'created' | 'already-current'
|
||||
outcome: 'created' | 'updated' | 'already-current'
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
activeVersion: string
|
||||
}
|
||||
| OrcadManagedDeferral
|
||||
|
||||
export type OrcadManagedRollbackResult =
|
||||
| {
|
||||
outcome: 'deferred'
|
||||
candidateVersion: string
|
||||
code: string
|
||||
reason: string
|
||||
/** False when force cannot make the rejected lifecycle transition safe. */
|
||||
forceable?: boolean
|
||||
outcome: 'rolled-back'
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
activeVersion: string
|
||||
discarded: string[]
|
||||
}
|
||||
| { outcome: 'refused' | 'failed'; code: string; reason: string }
|
||||
|
||||
export type OrcadManagedRecoveryResult =
|
||||
| { outcome: 'none' }
|
||||
| { outcome: 'pending'; code: string; reason: string }
|
||||
| {
|
||||
outcome: 'recovered'
|
||||
resolution: 'committed' | 'restored-incumbent'
|
||||
activeVersion: string | null
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
}
|
||||
| { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string }
|
||||
|
||||
/** Only a proven `exited` unlinks the server locally; anything less keeps it linked. */
|
||||
export type OrcadManagedStopResult =
|
||||
| {
|
||||
outcome: 'unlinked'
|
||||
verdict: 'exited'
|
||||
environmentId: string
|
||||
sshTargetId: string
|
||||
stoppedVersion: string | null
|
||||
retirement: 'retired' | 'live' | 'unverifiable' | null
|
||||
}
|
||||
| { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string }
|
||||
|
||||
export type OrcadManagedCancelStopResult =
|
||||
| { outcome: 'none' }
|
||||
/** The stop was withdrawn before orcad acted on it; the server keeps serving. */
|
||||
| { outcome: 'canceled'; activeVersion: string }
|
||||
/** orcad had already exited; finish with stop to unlink the server. */
|
||||
| { outcome: 'already-stopped' }
|
||||
| { outcome: 'refused'; verdict: 'live' | 'unverifiable'; code: string; reason: string }
|
||||
|
||||
export type OrcadManagedRuntimeStatus = {
|
||||
environmentId: string
|
||||
@@ -49,4 +92,8 @@ export type OrcadManagedRuntimeStatus = {
|
||||
startedAt: string
|
||||
}
|
||||
| null
|
||||
/** Terminals the daemon runs; `null` counts are unverifiable and block updates and stops. */
|
||||
terminals: OrcadTerminalCensus
|
||||
/** The last update this client deferred for this server, cleared once one goes through. */
|
||||
deferredUpdate: (OrcadManagedDeferral & { deferredAt: string }) | null
|
||||
}
|
||||
|
||||
@@ -17,3 +17,11 @@ export const OrcadTerminalCensusSchema = z.object({
|
||||
})
|
||||
|
||||
export type OrcadTerminalCensus = z.infer<typeof OrcadTerminalCensusSchema>
|
||||
|
||||
/** RPC a managed orcad answers with its census; clients call it only when advertised. */
|
||||
export const ORCAD_TERMINAL_CENSUS_METHOD = 'orcad.terminalCensus'
|
||||
|
||||
export const OrcadTerminalCensusParamsSchema = z.object({
|
||||
/** The active version's activation time, epoch ms; sessions created at or after it count. */
|
||||
activatedAt: z.number().finite().nonnegative()
|
||||
})
|
||||
|
||||
@@ -345,7 +345,12 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY =
|
||||
export const AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY =
|
||||
'agentSession.create.tab-id.v1' as const
|
||||
|
||||
// Why: older hosts answer orcad.terminalCensus with method-not-found, so a client asks only when
|
||||
// this is advertised and otherwise treats the census as unverifiable, never as zero.
|
||||
export const ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY = 'orcad.terminal-census.v1' as const
|
||||
|
||||
export const RUNTIME_CAPABILITIES = [
|
||||
ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY,
|
||||
...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES,
|
||||
AGENT_SESSION_CREATE_TAB_ID_RUNTIME_CAPABILITY,
|
||||
ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
PairingProvisionRelayParamsSchema
|
||||
} from '../mobile-relay-credential-contract'
|
||||
import { MobileWebBundleChunkParamsSchema } from '../mobile-web-bundle/bundle-rpc-contract'
|
||||
import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census'
|
||||
import { pluginConsentRequestSchema } from '../plugins/plugin-consent-request'
|
||||
import {
|
||||
AccountsUnsubscribeParams,
|
||||
@@ -986,6 +987,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'notifications.testPush': null,
|
||||
'notifications.unregisterPush': null,
|
||||
'notifications.unsubscribe': NotificationUnsubscribeParams,
|
||||
'orcad.terminalCensus': OrcadTerminalCensusParamsSchema,
|
||||
'orchestration.ask': AskParams,
|
||||
'orchestration.check': CheckParams,
|
||||
'orchestration.dispatch': DispatchParams,
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
removeEnvironment,
|
||||
updateEnvironmentFromPairingCode
|
||||
} from './runtime-environment-store'
|
||||
import { addManagedOrcadEnvironment } from './runtime-environment-managed-orcad-store'
|
||||
import {
|
||||
addManagedOrcadEnvironment,
|
||||
refreshManagedOrcadPairing,
|
||||
removeManagedOrcadEnvironment
|
||||
} from './runtime-environment-managed-orcad-store'
|
||||
import { getRuntimeEnvironmentSidecarPath } from './runtime-environment-sidecar'
|
||||
import { getRuntimeSshAccess } from './runtime-environments'
|
||||
import { shippedBuildRewrite } from './runtime-environment-shipped-store-fixture'
|
||||
@@ -97,4 +101,33 @@ describe('managed orcad environment store', () => {
|
||||
).toThrow('managed by Orca over SSH')
|
||||
expect(listEnvironments(userDataPath)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('removes the server and its deployment record together once it is unlinked', () => {
|
||||
add()
|
||||
removeManagedOrcadEnvironment(userDataPath, 'environment-1')
|
||||
expect(listEnvironments(userDataPath)).toEqual([])
|
||||
expect(readFileSync(getRuntimeEnvironmentSidecarPath(userDataPath), 'utf8')).not.toContain(
|
||||
'environment-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an unchanged pairing alone and re-binds the link when the offer rotates', () => {
|
||||
const environment = add()
|
||||
expect(refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode())).toEqual(
|
||||
environment
|
||||
)
|
||||
const rotated = encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: 'ws://127.0.0.1:46768',
|
||||
deviceToken: 'rotated-token',
|
||||
publicKeyB64: Buffer.alloc(32, 1).toString('base64')
|
||||
})
|
||||
const refreshed = refreshManagedOrcadPairing(userDataPath, 'environment-1', rotated, 500)
|
||||
expect(refreshed.endpoints[0]?.deviceToken).toBe('rotated-token')
|
||||
expect(refreshed.pairingRevision).toBe(500)
|
||||
expect(refreshed.orcadDeployment).toEqual(deployment)
|
||||
expect(() =>
|
||||
refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode('ws://127.0.0.1:1'))
|
||||
).toThrow('does not point at its SSH tunnel')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { parsePairingCode } from './pairing'
|
||||
import { parsePairingCode, type PairingOffer } from './pairing'
|
||||
import {
|
||||
createEnvironmentFromPairingOffer,
|
||||
getPreferredLoopbackRuntimePort,
|
||||
getPreferredPairingOffer,
|
||||
PersistedRuntimeEnvironmentSchema,
|
||||
type KnownRuntimeEnvironment,
|
||||
type OrcadDeploymentLink
|
||||
@@ -12,7 +13,11 @@ import {
|
||||
RuntimeEnvironmentStoreError,
|
||||
writeEnvironmentStore
|
||||
} from './runtime-environment-store-file'
|
||||
import { writeRuntimeEnvironmentSidecarEntry } from './runtime-environment-sidecar'
|
||||
import {
|
||||
readCurrentRuntimeEnvironmentSidecarEntry,
|
||||
writeRuntimeEnvironmentSidecarEntry
|
||||
} from './runtime-environment-sidecar'
|
||||
import { resolveEnvironmentFromStore } from './runtime-environment-store'
|
||||
|
||||
/** Registers a server Orca deployed over SSH, paired through its own loopback tunnel. */
|
||||
export function addManagedOrcadEnvironment(
|
||||
@@ -78,3 +83,79 @@ export function addManagedOrcadEnvironment(
|
||||
}
|
||||
return registered
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlinks a managed server once its orcad is proven to have exited: drops the persisted entry
|
||||
* and the sidecar record that held its deployment link.
|
||||
*/
|
||||
export function removeManagedOrcadEnvironment(userDataPath: string, environmentId: string): void {
|
||||
const store = readPersistedEnvironmentStore(userDataPath)
|
||||
const persisted = resolveEnvironmentFromStore(store, environmentId)
|
||||
const remaining = store.environments.filter((entry) => entry.id !== persisted.id)
|
||||
writeEnvironmentStore(userDataPath, { version: 1, environments: remaining })
|
||||
writeRuntimeEnvironmentSidecarEntry(userDataPath, remaining, persisted, null)
|
||||
}
|
||||
|
||||
/** Keeps a managed server's pairing current after its orcad changed versions. */
|
||||
export function refreshManagedOrcadPairing(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
pairingCode: string,
|
||||
now = Date.now()
|
||||
): KnownRuntimeEnvironment {
|
||||
const offer = parsePairingCode(pairingCode)
|
||||
if (!offer) {
|
||||
throw new RuntimeEnvironmentStoreError('invalid_argument', 'Invalid managed pairing code.')
|
||||
}
|
||||
const current = resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId)
|
||||
const deployment = current.orcadDeployment
|
||||
if (!deployment) {
|
||||
throw new RuntimeEnvironmentStoreError('invalid_argument', 'This server is not managed.')
|
||||
}
|
||||
// Why skip: orcad keeps its pairing state across versions, so an unchanged offer needs no
|
||||
// rewrite, and every rewrite re-binds the sidecar entry.
|
||||
if (samePairing(getPreferredPairingOffer(current), offer)) {
|
||||
return current
|
||||
}
|
||||
const store = readPersistedEnvironmentStore(userDataPath)
|
||||
const existing = resolveEnvironmentFromStore(store, environmentId)
|
||||
const entry = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, existing)
|
||||
const next = PersistedRuntimeEnvironmentSchema.parse({
|
||||
...createEnvironmentFromPairingOffer({
|
||||
id: existing.id,
|
||||
name: existing.name,
|
||||
now: existing.createdAt,
|
||||
offer,
|
||||
runtimeId: existing.runtimeId,
|
||||
connectionDependency: 'ssh-tunnel'
|
||||
}),
|
||||
updatedAt: now,
|
||||
pairingRevision: Math.max(now, (existing.pairingRevision ?? existing.createdAt) + 1),
|
||||
lastUsedAt: existing.lastUsedAt
|
||||
})
|
||||
if (getPreferredLoopbackRuntimePort(next) !== deployment.localPort) {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'invalid_argument',
|
||||
'The managed pairing does not point at its SSH tunnel.'
|
||||
)
|
||||
}
|
||||
const environments = store.environments.map((candidate) =>
|
||||
candidate.id === existing.id ? next : candidate
|
||||
)
|
||||
const { binding: _binding, ...state } = entry ?? {}
|
||||
writeRuntimeEnvironmentSidecarEntry(userDataPath, environments, next, {
|
||||
...state,
|
||||
orcadDeployment: deployment
|
||||
})
|
||||
writeEnvironmentStore(userDataPath, { version: 1, environments })
|
||||
return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId)
|
||||
}
|
||||
|
||||
function samePairing(left: PairingOffer, right: PairingOffer): boolean {
|
||||
return (
|
||||
left.endpoint === right.endpoint &&
|
||||
left.deviceToken === right.deviceToken &&
|
||||
left.publicKeyB64 === right.publicKeyB64 &&
|
||||
left.pairedDeviceId === right.pairedDeviceId
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user