mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
Refresh SSH host status after a verified manual managed-server update (#26665)
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ send: vi.fn(), notify: vi.fn(), getState: vi.fn() }))
|
||||
|
||||
vi.mock('./ssh-ipc-context', () => ({
|
||||
connectionManager: { getState: mocks.getState },
|
||||
currentRuntime: { notifySshStateChanged: mocks.notify },
|
||||
getCurrentMainWindow: () => ({ isDestroyed: () => false, webContents: { send: mocks.send } }),
|
||||
persistedStore: null,
|
||||
portForwardManager: null
|
||||
}))
|
||||
vi.mock('../ssh/ssh-target-registry', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getSshTargetRegistryStore: () => null
|
||||
}))
|
||||
|
||||
const { clearManagedServerNotes, publishHostServerStatus } =
|
||||
await import('./runtime-environment-managed-tunnel')
|
||||
const { getPublicSshState, relayStateOverrides } = await import('./ssh-renderer-broadcast')
|
||||
const { clearSshHostServerStatus, getSshHostServerStatus, setSshHostServerStatus } =
|
||||
await import('../ssh/ssh-host-server-status')
|
||||
|
||||
const ready = { kind: 'managed', environmentId: 'env-1' } as const
|
||||
const deferred = {
|
||||
...ready,
|
||||
update: { state: 'deferred', detail: 'A terminal is still running.' }
|
||||
} as const
|
||||
|
||||
function connection(status: 'connected' | 'disconnected') {
|
||||
return { targetId: 'ssh-1', status, error: null, reconnectAttempt: 0, managedServer: deferred }
|
||||
}
|
||||
|
||||
describe('publishing a verified managed host status', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
relayStateOverrides.clear()
|
||||
clearSshHostServerStatus('ssh-1')
|
||||
clearSshHostServerStatus('ssh-2')
|
||||
})
|
||||
|
||||
it('replaces the deferred diagnostic in the host store and both subscribers', () => {
|
||||
setSshHostServerStatus('ssh-1', deferred)
|
||||
mocks.getState.mockReturnValue(connection('connected'))
|
||||
publishHostServerStatus('ssh-1', ready)
|
||||
expect(getSshHostServerStatus('ssh-1')).toEqual(ready)
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
expect.objectContaining({ managedServer: ready })
|
||||
)
|
||||
expect(mocks.send).toHaveBeenCalledWith(
|
||||
'ssh:state-changed',
|
||||
expect.objectContaining({ state: expect.objectContaining({ managedServer: ready }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('refreshes a retained override so later reads also forget the deferral', () => {
|
||||
relayStateOverrides.set('ssh-1', connection('connected'))
|
||||
publishHostServerStatus('ssh-1', ready)
|
||||
expect(relayStateOverrides.get('ssh-1')?.managedServer).toEqual(ready)
|
||||
expect(getPublicSshState('ssh-1')?.managedServer).toEqual(ready)
|
||||
})
|
||||
|
||||
it('preserves disconnected contact instead of inventing a connection', () => {
|
||||
mocks.getState.mockReturnValue(connection('disconnected'))
|
||||
publishHostServerStatus('ssh-1', ready)
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
expect.objectContaining({ status: 'disconnected', managedServer: ready })
|
||||
)
|
||||
})
|
||||
|
||||
it('retains the current unverifiable host evidence when publishing it', () => {
|
||||
mocks.getState.mockReturnValue(connection('connected'))
|
||||
const unverifiable = {
|
||||
...ready,
|
||||
serving: { state: 'unverifiable', detail: 'The process is live but not answering.' }
|
||||
} as const
|
||||
publishHostServerStatus('ssh-1', unverifiable)
|
||||
expect(getSshHostServerStatus('ssh-1')).toEqual(unverifiable)
|
||||
expect(mocks.notify.mock.calls[0]?.[1].managedServer).toEqual(unverifiable)
|
||||
})
|
||||
|
||||
it('records a host without a connection and leaves another host untouched', () => {
|
||||
mocks.getState.mockReturnValue(undefined)
|
||||
setSshHostServerStatus('ssh-2', deferred)
|
||||
publishHostServerStatus('ssh-1', ready)
|
||||
expect(getSshHostServerStatus('ssh-1')).toEqual(ready)
|
||||
expect(getSshHostServerStatus('ssh-2')).toEqual(deferred)
|
||||
expect(mocks.notify).not.toHaveBeenCalled()
|
||||
expect(mocks.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("clears a verified host's deferral and unverifiable notes", () => {
|
||||
mocks.getState.mockReturnValue(connection('connected'))
|
||||
setSshHostServerStatus('ssh-1', {
|
||||
...deferred,
|
||||
serving: { state: 'unverifiable', detail: 'No reply.' }
|
||||
})
|
||||
clearManagedServerNotes('ssh-1', 'env-1')
|
||||
expect(getSshHostServerStatus('ssh-1')).toEqual(ready)
|
||||
expect(mocks.notify).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
expect.objectContaining({ managedServer: ready })
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a relay route, another server, or an unknown host as it was', () => {
|
||||
mocks.getState.mockReturnValue(connection('connected'))
|
||||
const relay = { kind: 'relay', reason: 'source_changed' } as const
|
||||
setSshHostServerStatus('ssh-1', relay)
|
||||
clearManagedServerNotes('ssh-1', 'env-1')
|
||||
expect(getSshHostServerStatus('ssh-1')).toEqual(relay)
|
||||
setSshHostServerStatus('ssh-1', { ...deferred, environmentId: 'env-2' })
|
||||
clearManagedServerNotes('ssh-1', 'env-1')
|
||||
expect(getSshHostServerStatus('ssh-1')).toMatchObject({ environmentId: 'env-2' })
|
||||
clearManagedServerNotes('ssh-2', 'env-1')
|
||||
expect(getSshHostServerStatus('ssh-2')).toBeUndefined()
|
||||
expect(mocks.notify).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,8 @@ export type ManagedOrcadActionOptions = {
|
||||
getUserDataPath: () => string
|
||||
getActiveEnvironmentId: () => string | null | undefined
|
||||
invalidateTransport: (environmentId: string) => Promise<void> | void
|
||||
/** Drops the SSH host's stale update/serving notes once an update verified its server. */
|
||||
clearHostServerNotes: (sshTargetId: string, environmentId: string) => void
|
||||
/** Drops the SSH host's stale managed-server state once its server is unlinked. */
|
||||
clearHostServerStatus: (sshTargetId: string) => void
|
||||
/** Drops the unlinked server's workspace session partition. */
|
||||
@@ -33,6 +35,12 @@ export function createManagedOrcadActions(
|
||||
if (result.outcome === 'updated') {
|
||||
await options.invalidateTransport(result.environment.id)
|
||||
}
|
||||
if (result.outcome !== 'deferred') {
|
||||
const targetId = result.environment.orcadDeployment?.sshTargetId
|
||||
if (targetId) {
|
||||
options.clearHostServerNotes(targetId, result.environment.id)
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
rollback: async (selector) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ const { registerOrcadRuntimeMaintenanceHandlers } =
|
||||
|
||||
const invalidateTransport = vi.fn()
|
||||
const clearHostServerStatus = vi.fn()
|
||||
const clearHostServerNotes = vi.fn()
|
||||
const forgetHostSession = vi.fn()
|
||||
|
||||
function handler(channel: string): (_event: unknown, args: unknown) => Promise<unknown> {
|
||||
@@ -45,6 +46,7 @@ describe('managed orcad maintenance IPC', () => {
|
||||
getActiveEnvironmentId: () => 'active-environment',
|
||||
invalidateTransport,
|
||||
clearHostServerStatus,
|
||||
clearHostServerNotes,
|
||||
forgetHostSession
|
||||
})
|
||||
})
|
||||
@@ -54,11 +56,54 @@ describe('managed orcad maintenance IPC', () => {
|
||||
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: 'yes' })
|
||||
expect(mocks.update).toHaveBeenCalledWith('/profile', { selector: 'Managed', force: false })
|
||||
expect(invalidateTransport).not.toHaveBeenCalled()
|
||||
expect(clearHostServerNotes).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.each(['updated', 'already-current'])(
|
||||
'clears the host notes after a verified %s update',
|
||||
async (outcome) => {
|
||||
mocks.update.mockResolvedValueOnce({
|
||||
outcome,
|
||||
environment: { id: 'e-1', orcadDeployment: { sshTargetId: 'ssh-1' } },
|
||||
activeVersion: 'candidate'
|
||||
})
|
||||
await handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed', force: true })
|
||||
expect(clearHostServerNotes).toHaveBeenCalledExactlyOnceWith('ssh-1', 'e-1')
|
||||
expect(invalidateTransport).toHaveBeenCalledTimes(outcome === 'updated' ? 1 : 0)
|
||||
}
|
||||
)
|
||||
|
||||
it('clears the notes only after the old transport has been retired', async () => {
|
||||
let finishRetirement: (() => void) | undefined
|
||||
invalidateTransport.mockReturnValueOnce(
|
||||
new Promise<void>((resolve) => {
|
||||
finishRetirement = resolve
|
||||
})
|
||||
)
|
||||
mocks.update.mockResolvedValueOnce({
|
||||
outcome: 'updated',
|
||||
environment: { id: 'e-1', orcadDeployment: { sshTargetId: 'ssh-1' } }
|
||||
})
|
||||
const updating = handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed' })
|
||||
await vi.waitFor(() => expect(invalidateTransport).toHaveBeenCalledOnce())
|
||||
expect(clearHostServerNotes).not.toHaveBeenCalled()
|
||||
finishRetirement?.()
|
||||
await updating
|
||||
expect(clearHostServerNotes).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the previous diagnostic when an update rejects', async () => {
|
||||
mocks.update.mockRejectedValueOnce(new Error('SSH contact lost'))
|
||||
await expect(
|
||||
handler('runtimeEnvironments:updateOrcad')(null, { selector: 'Managed' })
|
||||
).rejects.toThrow('SSH contact lost')
|
||||
expect(clearHostServerNotes).not.toHaveBeenCalled()
|
||||
expect(invalidateTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
@@ -9,10 +9,10 @@ import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel'
|
||||
import { verifyOrcadManagedServing } from '../ssh/orcad-managed-serving-verify'
|
||||
import { setManagedOrcadStartListener } from '../ssh/orcad-managed-serving'
|
||||
import { updateManagedOrcadOnRestore } from '../ssh/orcad-managed-update-on-restore'
|
||||
import { setSshHostServerStatus } from '../ssh/ssh-host-server-status'
|
||||
import { getSshHostServerStatus, setSshHostServerStatus } from '../ssh/ssh-host-server-status'
|
||||
import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry'
|
||||
import { connectionManager, getCurrentMainWindow } from './ssh-ipc-context'
|
||||
import { broadcastSshState } from './ssh-renderer-broadcast'
|
||||
import { broadcastSshState, relayStateOverrides } from './ssh-renderer-broadcast'
|
||||
|
||||
export async function resolveManagedRuntimeEnvironment(
|
||||
userDataPath: string,
|
||||
@@ -41,7 +41,7 @@ function publishRestoreUpdate(
|
||||
note?: SshManagedServerUpdateNote
|
||||
): void {
|
||||
publishHostServerStatus(
|
||||
target,
|
||||
target.id,
|
||||
phase === 'updating'
|
||||
? { kind: 'setting-up', phase: 'updating' }
|
||||
: { kind: 'managed', environmentId, ...(note ? { update: note } : {}) }
|
||||
@@ -52,9 +52,9 @@ function publishRestoreUpdate(
|
||||
export function installManagedOrcadStartStatus(): void {
|
||||
setManagedOrcadStartListener({
|
||||
starting: (target) =>
|
||||
publishHostServerStatus(target, { kind: 'setting-up', phase: 'starting' }),
|
||||
publishHostServerStatus(target.id, { kind: 'setting-up', phase: 'starting' }),
|
||||
settled: (target, environmentId, serving) =>
|
||||
publishHostServerStatus(target, {
|
||||
publishHostServerStatus(target.id, {
|
||||
kind: 'managed',
|
||||
environmentId,
|
||||
...(serving.state === 'unverifiable' ? { serving } : {})
|
||||
@@ -78,11 +78,26 @@ export function publishResolvedChangedHostStatus(target: SshTarget, environmentI
|
||||
)
|
||||
}
|
||||
|
||||
function publishHostServerStatus(target: SshTarget, status: SshManagedServerStatus): void {
|
||||
setSshHostServerStatus(target.id, status)
|
||||
export function publishHostServerStatus(targetId: string, status: SshManagedServerStatus): void {
|
||||
setSshHostServerStatus(targetId, status)
|
||||
const override = relayStateOverrides.get(targetId)
|
||||
if (override) {
|
||||
relayStateOverrides.set(targetId, { ...override, managedServer: status })
|
||||
}
|
||||
// Only a host with a connection state has a status line to refresh.
|
||||
const state = connectionManager?.getState(target.id)
|
||||
const state = relayStateOverrides.get(targetId) ?? connectionManager?.getState(targetId)
|
||||
if (state) {
|
||||
broadcastSshState(getCurrentMainWindow, target.id, state)
|
||||
broadcastSshState(getCurrentMainWindow, targetId, { ...state, managedServer: status })
|
||||
}
|
||||
}
|
||||
|
||||
/** A verified update supersedes the host's update and serving notes; any other host route is kept. */
|
||||
export function clearManagedServerNotes(targetId: string, environmentId: string): void {
|
||||
const current = getSshHostServerStatus(targetId)
|
||||
if (current?.kind !== 'managed' || current.environmentId !== environmentId) {
|
||||
return
|
||||
}
|
||||
if (current.update || current.serving) {
|
||||
publishHostServerStatus(targetId, { kind: 'managed', environmentId })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { registerOrcadRuntimeConversionHandlers } from './orcad-runtime-conversi
|
||||
import { registerOrcadDeltaMoveHandlers } from './orcad-delta-move-handlers'
|
||||
import { registerOrcadRuntimeMaintenanceHandlers } from './orcad-runtime-maintenance-handlers'
|
||||
import { clearPublishedManagedServer } from './ssh-renderer-broadcast'
|
||||
import { clearManagedServerNotes } from './runtime-environment-managed-tunnel'
|
||||
import { reconcileOrphanedRuntimeSessions } from './runtime-environment-session-reconcile'
|
||||
import { registerRuntimeSshAccessHandlers } from './runtime-ssh-access-handlers'
|
||||
import { retirePairedRuntimeBrowserClientHostEnvironment } from '../browser/paired-runtime-browser-client-host-runtime'
|
||||
@@ -129,6 +130,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void {
|
||||
getActiveEnvironmentId: () => store.getSettings().activeRuntimeEnvironmentId,
|
||||
invalidateTransport: invalidateRuntimeEnvironmentTransport,
|
||||
clearHostServerStatus: clearPublishedManagedServer,
|
||||
clearHostServerNotes: clearManagedServerNotes,
|
||||
forgetHostSession: (hostId) => store.removeWorkspaceSessionHost(hostId)
|
||||
})
|
||||
registerRuntimeEnvironmentSubscriptionHandlers({
|
||||
|
||||
Reference in New Issue
Block a user