mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(settings): let an unfinished SSH access link be retried or cancelled (#29441)
* fix(settings): let an unfinished SSH access link be retried or cancelled A failed link to a paired server keeps its durable pending operation so it can resume, but the control only offered a new link with a fresh request id, which main refuses, and no cancel. The pending state then blocked environment rm and re-pairing with no way out. Show the unfinished operation with Retry and Cancel, both sent under its saved request id, and make the removal refusal say where to resolve it. * test: match the pending SSH access refusal
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments'
|
||||
import type { ManagedOrcadPreloadApi } from '../../../../preload/api/managed-orcad-api'
|
||||
import { RuntimeSshAccessControl } from './RuntimeSshAccessControl'
|
||||
|
||||
function environment(
|
||||
pendingSshAccessOperation: PublicKnownRuntimeEnvironment['pendingSshAccessOperation']
|
||||
): PublicKnownRuntimeEnvironment {
|
||||
return {
|
||||
id: 'env-a',
|
||||
name: 'fight PC',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: 'runtime-a',
|
||||
endpoints: [{ id: 'ws', kind: 'websocket', label: 'Tailscale', endpoint: 'ws://a:6768' }],
|
||||
preferredEndpointId: 'ws',
|
||||
pendingSshAccessOperation
|
||||
}
|
||||
}
|
||||
|
||||
const pendingLink = {
|
||||
requestId: 'request-1',
|
||||
operation: 'link' as const,
|
||||
sshTargetId: 'ssh-weknora',
|
||||
sshTargetGeneration: 3,
|
||||
remotePort: 6768,
|
||||
targetFingerprint: 'fingerprint'
|
||||
}
|
||||
|
||||
function renderControl(env: PublicKnownRuntimeEnvironment) {
|
||||
const linkSshAccess = vi.fn(async () => env)
|
||||
const unlinkSshAccess = vi.fn(async () => env)
|
||||
const onChanged = vi.fn()
|
||||
const api: Pick<ManagedOrcadPreloadApi, 'linkSshAccess' | 'unlinkSshAccess'> = {
|
||||
linkSshAccess,
|
||||
unlinkSshAccess
|
||||
}
|
||||
render(<RuntimeSshAccessControl api={api} environment={env} targets={[]} onChanged={onChanged} />)
|
||||
return { linkSshAccess, unlinkSshAccess, onChanged }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// #27765: an unfinished link blocked removing the server and offered no way out.
|
||||
describe('an unfinished SSH access link', () => {
|
||||
it('cancels the recorded operation by its own request id', async () => {
|
||||
const { unlinkSshAccess, onChanged } = renderControl(environment(pendingLink))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled())
|
||||
expect(unlinkSshAccess).toHaveBeenCalledWith({ selector: 'env-a', requestId: 'request-1' })
|
||||
})
|
||||
|
||||
it('retries the recorded link rather than starting another', async () => {
|
||||
const { linkSshAccess, onChanged } = renderControl(environment(pendingLink))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled())
|
||||
expect(linkSshAccess).toHaveBeenCalledWith({
|
||||
selector: 'env-a',
|
||||
requestId: 'request-1',
|
||||
sshTargetId: 'ssh-weknora',
|
||||
remotePort: 6768
|
||||
})
|
||||
})
|
||||
|
||||
it('finishes an unfinished unlink by its own request id', async () => {
|
||||
const { unlinkSshAccess, onChanged } = renderControl(
|
||||
environment({ ...pendingLink, operation: 'unlink' })
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled())
|
||||
expect(unlinkSshAccess).toHaveBeenCalledWith({ selector: 'env-a', requestId: 'request-1' })
|
||||
})
|
||||
})
|
||||
@@ -14,7 +14,7 @@ import { SshTargetSelect } from './SshTargetSelect'
|
||||
const DEFAULT_REMOTE_PORT = '6768'
|
||||
|
||||
type RuntimeSshAccessControlProps = {
|
||||
api: ManagedOrcadPreloadApi
|
||||
api: Pick<ManagedOrcadPreloadApi, 'linkSshAccess' | 'unlinkSshAccess'>
|
||||
environment: PublicKnownRuntimeEnvironment
|
||||
targets: SshTarget[]
|
||||
onChanged: () => void
|
||||
@@ -33,19 +33,12 @@ export function RuntimeSshAccessControl({
|
||||
const targetFieldId = useId()
|
||||
const portFieldId = useId()
|
||||
const linked = environment.sshAccess
|
||||
const pending = environment.pendingSshAccessOperation
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
const run = async (call: () => Promise<unknown>): Promise<void> => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const requestId = createBrowserUuid()
|
||||
await (linked
|
||||
? api.unlinkSshAccess({ selector: environment.id, requestId })
|
||||
: api.linkSshAccess({
|
||||
selector: environment.id,
|
||||
requestId,
|
||||
sshTargetId: targetId,
|
||||
remotePort: Number(remotePort)
|
||||
}))
|
||||
await call()
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error))
|
||||
@@ -54,6 +47,75 @@ export function RuntimeSshAccessControl({
|
||||
}
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
// Why the saved request id: main resumes or cancels only the operation it recorded, and a
|
||||
// fresh id made an unfinished link a dead end that also blocked removing the server (#27765).
|
||||
const requestId = pending.requestId
|
||||
const retry = (): Promise<unknown> =>
|
||||
pending.operation === 'link'
|
||||
? api.linkSshAccess({
|
||||
selector: environment.id,
|
||||
requestId,
|
||||
sshTargetId: pending.sshTargetId,
|
||||
remotePort: pending.remotePort
|
||||
})
|
||||
: api.unlinkSshAccess({ selector: environment.id, requestId })
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">{environment.name}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pending.operation === 'link'
|
||||
? translate(
|
||||
'auto.components.settings.managedServers.access.pendingLink',
|
||||
'Connecting through SSH didn’t finish.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.settings.managedServers.access.pendingUnlink',
|
||||
'Stopping SSH access didn’t finish.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void run(retry)}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.access.retry', 'Retry')}
|
||||
</Button>
|
||||
{pending.operation === 'link' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(() => api.unlinkSshAccess({ selector: environment.id, requestId }))
|
||||
}
|
||||
>
|
||||
{translate('auto.components.settings.managedServers.access.cancelLink', 'Cancel')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const submit = (): Promise<void> => {
|
||||
const requestId = createBrowserUuid()
|
||||
return run(() =>
|
||||
linked
|
||||
? api.unlinkSshAccess({ selector: environment.id, requestId })
|
||||
: api.linkSshAccess({
|
||||
selector: environment.id,
|
||||
requestId,
|
||||
sshTargetId: targetId,
|
||||
remotePort: Number(remotePort)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (linked) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
|
||||
@@ -12650,7 +12650,11 @@
|
||||
"target": "Reach through SSH host",
|
||||
"port": "Server port",
|
||||
"link": "Use SSH",
|
||||
"linking": "Connecting…"
|
||||
"linking": "Connecting…",
|
||||
"pendingLink": "Connecting through SSH didn’t finish.",
|
||||
"pendingUnlink": "Stopping SSH access didn’t finish.",
|
||||
"retry": "Retry",
|
||||
"cancelLink": "Cancel"
|
||||
},
|
||||
"search": {
|
||||
"title": "Managed servers",
|
||||
|
||||
@@ -12461,7 +12461,11 @@
|
||||
"target": "Conectar a través del host SSH",
|
||||
"port": "Puerto del servidor",
|
||||
"link": "Usar SSH",
|
||||
"linking": "Conectando…"
|
||||
"linking": "Conectando…",
|
||||
"pendingLink": "La conexión por SSH no terminó.",
|
||||
"pendingUnlink": "La desactivación del acceso SSH no terminó.",
|
||||
"retry": "Reintentar",
|
||||
"cancelLink": "Cancelar"
|
||||
},
|
||||
"search": {
|
||||
"title": "Servidores gestionados",
|
||||
|
||||
@@ -12573,7 +12573,11 @@
|
||||
"target": "Passer par un hôte SSH",
|
||||
"port": "Port du serveur",
|
||||
"link": "Utiliser SSH",
|
||||
"linking": "Connexion…"
|
||||
"linking": "Connexion…",
|
||||
"pendingLink": "La connexion via SSH n’a pas abouti.",
|
||||
"pendingUnlink": "L’arrêt de l’accès SSH n’a pas abouti.",
|
||||
"retry": "Réessayer",
|
||||
"cancelLink": "Annuler"
|
||||
},
|
||||
"search": {
|
||||
"title": "Serveurs gérés",
|
||||
|
||||
@@ -12465,7 +12465,11 @@
|
||||
"target": "SSH ホスト経由で接続",
|
||||
"port": "サーバーポート",
|
||||
"link": "SSH を使用",
|
||||
"linking": "接続中…"
|
||||
"linking": "接続中…",
|
||||
"pendingLink": "SSH 経由の接続が完了しませんでした。",
|
||||
"pendingUnlink": "SSH アクセスの停止が完了しませんでした。",
|
||||
"retry": "再試行",
|
||||
"cancelLink": "キャンセル"
|
||||
},
|
||||
"search": {
|
||||
"title": "管理対象サーバー",
|
||||
|
||||
@@ -12465,7 +12465,11 @@
|
||||
"target": "SSH 호스트를 통해 연결",
|
||||
"port": "서버 포트",
|
||||
"link": "SSH 사용",
|
||||
"linking": "연결 중…"
|
||||
"linking": "연결 중…",
|
||||
"pendingLink": "SSH를 통한 연결이 완료되지 않았습니다.",
|
||||
"pendingUnlink": "SSH 액세스 중지가 완료되지 않았습니다.",
|
||||
"retry": "다시 시도",
|
||||
"cancelLink": "취소"
|
||||
},
|
||||
"search": {
|
||||
"title": "관리형 서버",
|
||||
|
||||
@@ -12479,7 +12479,11 @@
|
||||
"target": "通过 SSH 主机访问",
|
||||
"port": "服务器端口",
|
||||
"link": "使用 SSH",
|
||||
"linking": "正在连接…"
|
||||
"linking": "正在连接…",
|
||||
"pendingLink": "通过 SSH 连接未完成。",
|
||||
"pendingUnlink": "停止 SSH 访问未完成。",
|
||||
"retry": "重试",
|
||||
"cancelLink": "取消"
|
||||
},
|
||||
"search": {
|
||||
"title": "受管服务器",
|
||||
|
||||
@@ -220,12 +220,12 @@ describe('independent paired runtime SSH access persistence', () => {
|
||||
expect(prepare(original)).toEqual(prepared)
|
||||
expect(prepare(prepared)).toEqual(prepared)
|
||||
expect(() => prepare(prepared, 'other-request')).toThrow('Another SSH access')
|
||||
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink')
|
||||
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Retry or cancel it')
|
||||
expect(() =>
|
||||
updateEnvironmentFromPairingCode(userDataPath, prepared.id, {
|
||||
pairingCode: encodePairingOffer(pairing)
|
||||
})
|
||||
).toThrow('Unlink')
|
||||
).toThrow('Retry or cancel it')
|
||||
expect(() =>
|
||||
markEnvironmentUsed(userDataPath, prepared.id, { runtimeId: 'different-runtime' })
|
||||
).toThrow('cannot change')
|
||||
@@ -288,7 +288,7 @@ describe('independent paired runtime SSH access persistence', () => {
|
||||
requestId: 'stale-request'
|
||||
})
|
||||
).toThrow('pending unlink intent')
|
||||
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Unlink')
|
||||
expect(() => removeEnvironment(userDataPath, prepared.id)).toThrow('Retry or cancel it')
|
||||
const finished = completeRuntimeEnvironmentSshAccessUnlink(userDataPath, {
|
||||
expectedEnvironment: prepared,
|
||||
requestId: 'release-request'
|
||||
|
||||
@@ -269,10 +269,16 @@ function assertNoIndependentSshAccess(environment: KnownRuntimeEnvironment): voi
|
||||
'This server is managed by Orca over SSH; its pairing cannot be replaced or removed here. To decommission it, run `orca environment stop --environment <name> --yes` or use Settings > Managed servers; for a host that is gone, run `orca environment forget --environment <name> --yes`.'
|
||||
)
|
||||
}
|
||||
if (environment.sshAccess || environment.pendingSshAccessOperation) {
|
||||
if (environment.pendingSshAccessOperation) {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'invalid_argument',
|
||||
"Unlink this server's SSH access before replacing its pairing or removing it."
|
||||
"This server's SSH access change didn't finish. Retry or cancel it under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it."
|
||||
)
|
||||
}
|
||||
if (environment.sshAccess) {
|
||||
throw new RuntimeEnvironmentStoreError(
|
||||
'invalid_argument',
|
||||
"Unlink this server's SSH access under Settings > Remote Orca Servers > SSH access for paired servers before replacing its pairing or removing it."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user