mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(orcad-migration): a host fenced on a manifest no request can carry can be cancelled by its id (#30864)
* fix(orcad-migration): a source fenced on a manifest no request can carry can be aborted by its identity A desktop that fenced a manifest just under the export cap before #30836 could never send its abort: every catalog request carries the whole manifest and refuses it before sending. The abort now asks the server by migration id and digest instead (orcad.migration.abortCatalogByReference, behind a new capability), and releases only on the server's durable absent answer. A committed server still refuses the release. Co-authored-by: Claude <noreply@anthropic.com> * test(orcad-migration): cover the durable abort by reference and its RPC method Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
stageRemoteOrcadMigrationCatalog,
|
||||
stageRemoteOrcadMigrationSnapshotChunk
|
||||
} from '../../ssh/orcad-migration-catalog-client'
|
||||
import { abortRemoteOrcadMigrationCatalogByReference } from '../../ssh/orcad-migration-catalog-reference-client'
|
||||
import { censusHostRelayTerminalsFor } from '../../ssh/legacy-relay/ssh-host-relay-census-for-target'
|
||||
import type { OrcadManagedConversionArgs } from './orcad-runtime-conversion'
|
||||
import type { OrcadServerRepository } from '../../ssh/orcad-migration-repository-link'
|
||||
@@ -33,6 +34,8 @@ export function orcadMigrationDestinationFor(
|
||||
stage: (manifest) => stageRemoteOrcadMigrationCatalog(pairingCode, manifest, options),
|
||||
commit: (manifest) => commitRemoteOrcadMigrationCatalog(pairingCode, manifest, options),
|
||||
abort: (manifest) => abortRemoteOrcadMigrationCatalog(pairingCode, manifest, options),
|
||||
abortByReference: (manifest) =>
|
||||
abortRemoteOrcadMigrationCatalogByReference(pairingCode, manifest, options),
|
||||
stageChunk: (request) => stageRemoteOrcadMigrationSnapshotChunk(pairingCode, request, options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +384,39 @@ describe('orcad migration catalog persistence', () => {
|
||||
expect(store.getRepos()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('aborts by reference from its own staged copy, never a different manifest or a commit', () => {
|
||||
const store = createStore()
|
||||
const input = manifest()
|
||||
const reference = { migrationId: input.migrationId, manifestSha256: input.manifestSha256 }
|
||||
|
||||
expect(store.abortStagedOrcadMigrationCatalogByReference(reference)).toEqual({
|
||||
...reference,
|
||||
state: 'absent',
|
||||
aborted: false
|
||||
})
|
||||
store.stageOrcadMigrationCatalog(input)
|
||||
expect(() =>
|
||||
store.abortStagedOrcadMigrationCatalogByReference({
|
||||
...reference,
|
||||
manifestSha256: 'f'.repeat(64)
|
||||
})
|
||||
).toThrow('orcad_migration_id_reused_with_different_manifest')
|
||||
expect(store.getOrcadMigrationCatalogState(input).state).toBe('staged')
|
||||
expect(store.abortStagedOrcadMigrationCatalogByReference(reference)).toEqual({
|
||||
...reference,
|
||||
state: 'absent',
|
||||
aborted: true
|
||||
})
|
||||
expect(store.getOrcadMigrationCatalogState(input).state).toBe('absent')
|
||||
commitCatalog(store, input)
|
||||
expect(store.abortStagedOrcadMigrationCatalogByReference(reference)).toEqual({
|
||||
...reference,
|
||||
state: 'committed',
|
||||
aborted: false
|
||||
})
|
||||
expect(store.getRepos()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('requires a durable stage before commit and bounds concurrent dormant catalogs', () => {
|
||||
const store = createStore()
|
||||
const input = manifest()
|
||||
|
||||
@@ -2,6 +2,8 @@ import { MAX_ORCAD_MIGRATION_STAGED_CATALOGS } from '../../../shared/orcad-migra
|
||||
import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../../shared/orcad-migration/orcad-migration-manifest-validation'
|
||||
import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogReference,
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationImportReceipt,
|
||||
OrcadMigrationManifest
|
||||
@@ -52,6 +54,11 @@ import {
|
||||
stageOrcadMigrationBlobChunk
|
||||
} from './orcad-migration-blob-staging'
|
||||
import { assertOrcadMigrationBlobContentsValid } from './orcad-migration-blob-installers'
|
||||
import {
|
||||
abortOrcadMigrationCatalogByReference,
|
||||
committedMigrations,
|
||||
stagedManifests
|
||||
} from './orcad-catalog-reference-abort'
|
||||
|
||||
type OrcadCatalogImportRuntime = OrcadMigrationCommitGenerationRuntime &
|
||||
Pick<StoreRuntimeState, 'state' | 'terminalScrollbackSnapshotStorage' | 'orcadMigrationBlobRoot'>
|
||||
@@ -194,6 +201,17 @@ export class OrcadCatalogImportPersistence {
|
||||
return { ...absentCatalogState(manifest), aborted: true }
|
||||
}
|
||||
|
||||
/** For a source whose manifest no request can carry: this server's own copy stands in for it. */
|
||||
abortStagedOrcadMigrationCatalogByReference(
|
||||
reference: OrcadMigrationCatalogReference
|
||||
): OrcadMigrationCatalogReferenceAbortResult {
|
||||
return abortOrcadMigrationCatalogByReference(
|
||||
this[orcadCatalogImportContext].runtime.state,
|
||||
reference,
|
||||
(manifest) => this.abortStagedOrcadMigrationCatalog(manifest)
|
||||
)
|
||||
}
|
||||
|
||||
getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState {
|
||||
const context = this[orcadCatalogImportContext]
|
||||
return migrationCatalogState(context.runtime, manifest)
|
||||
@@ -251,19 +269,6 @@ function migrationCatalogState(
|
||||
return stagedCatalogState(manifest, staged.stagedAt, runtime)
|
||||
}
|
||||
|
||||
function stagedManifests(state: StoreRuntimeState['state']): OrcadMigrationManifest[] {
|
||||
return (state.orcadMigrationStagedCatalogs ?? []).map((entry) => entry.manifest)
|
||||
}
|
||||
|
||||
function committedMigrations(
|
||||
state: StoreRuntimeState['state']
|
||||
): { migrationId: string; manifestSha256: string }[] {
|
||||
return [
|
||||
...(state.orcadMigrationImportReceipts ?? []),
|
||||
...(state.orcadMigrationEvictedReceipts ?? [])
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt alone proves the commit: the server is live afterwards, so its rows and snapshot
|
||||
* files may legitimately change, and a later read must still say "committed".
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogReference,
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationManifest
|
||||
} from '../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import type { StoreRuntimeState } from '../loading-store/store-runtime-state'
|
||||
|
||||
export function stagedManifests(state: StoreRuntimeState['state']): OrcadMigrationManifest[] {
|
||||
return (state.orcadMigrationStagedCatalogs ?? []).map((entry) => entry.manifest)
|
||||
}
|
||||
|
||||
export function committedMigrations(
|
||||
state: StoreRuntimeState['state']
|
||||
): { migrationId: string; manifestSha256: string }[] {
|
||||
return [
|
||||
...(state.orcadMigrationImportReceipts ?? []),
|
||||
...(state.orcadMigrationEvictedReceipts ?? [])
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts by identity, with the stored manifest standing in for the one the source cannot send.
|
||||
* A commit wins over a stage, as it does for an abort that carries the manifest.
|
||||
*/
|
||||
export function abortOrcadMigrationCatalogByReference(
|
||||
state: StoreRuntimeState['state'],
|
||||
reference: OrcadMigrationCatalogReference,
|
||||
abortStaged: (manifest: OrcadMigrationManifest) => OrcadMigrationCatalogAbortResult
|
||||
): OrcadMigrationCatalogReferenceAbortResult {
|
||||
const identity = { migrationId: reference.migrationId, manifestSha256: reference.manifestSha256 }
|
||||
const committed = committedMigrations(state).find(
|
||||
(entry) => entry.migrationId === reference.migrationId
|
||||
)
|
||||
const staged = stagedManifests(state).find(
|
||||
(manifest) => manifest.migrationId === reference.migrationId
|
||||
)
|
||||
const held = committed ?? staged
|
||||
if (held && held.manifestSha256 !== reference.manifestSha256) {
|
||||
throw new Error('orcad_migration_id_reused_with_different_manifest')
|
||||
}
|
||||
if (committed) {
|
||||
return { ...identity, state: 'committed', aborted: false }
|
||||
}
|
||||
if (!staged) {
|
||||
return { ...identity, state: 'absent', aborted: false }
|
||||
}
|
||||
const result = abortStaged(staged)
|
||||
return {
|
||||
...identity,
|
||||
state: result.state === 'committed' ? 'committed' : 'absent',
|
||||
aborted: result.aborted
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogReference,
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
} from '../../shared/orcad-migration/orcad-migration-manifest'
|
||||
@@ -13,6 +15,7 @@ import type {
|
||||
OrcadMigrationBlobDescriptor
|
||||
} from '../../shared/orcad-migration/orcad-migration-blob-transfer'
|
||||
import {
|
||||
abortStagedOrcadMigrationCatalogByReferenceDurably,
|
||||
abortStagedOrcadMigrationCatalogDurably,
|
||||
commitStagedOrcadMigrationCatalogDurably,
|
||||
getOrcadMigrationCatalogState,
|
||||
@@ -27,6 +30,7 @@ type MigrationStoreKey =
|
||||
| 'stageOrcadMigrationSnapshotChunk'
|
||||
| 'stageOrcadMigrationBlobChunk'
|
||||
| 'abortStagedOrcadMigrationCatalog'
|
||||
| 'abortStagedOrcadMigrationCatalogByReference'
|
||||
| 'getOrcadMigrationCatalogState'
|
||||
| 'flushPendingOrThrowAsync'
|
||||
|
||||
@@ -94,6 +98,20 @@ export abstract class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithRes
|
||||
})
|
||||
}
|
||||
|
||||
async abortStagedOrcadMigrationCatalogByReference(
|
||||
reference: OrcadMigrationCatalogReference,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<OrcadMigrationCatalogReferenceAbortResult> {
|
||||
return abortStagedOrcadMigrationCatalogByReferenceDurably({
|
||||
store: this.requireMigrationStore(
|
||||
'abortStagedOrcadMigrationCatalogByReference',
|
||||
'flushPendingOrThrowAsync'
|
||||
),
|
||||
reference,
|
||||
signal: options.signal
|
||||
})
|
||||
}
|
||||
|
||||
getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState {
|
||||
return getOrcadMigrationCatalogState({
|
||||
store: this.requireMigrationStore('getOrcadMigrationCatalogState'),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
} from '../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import {
|
||||
abortStagedOrcadMigrationCatalogByReferenceDurably,
|
||||
abortStagedOrcadMigrationCatalogDurably,
|
||||
commitStagedOrcadMigrationCatalogDurably,
|
||||
stageOrcadMigrationCatalogDurably
|
||||
@@ -78,4 +80,32 @@ describe('durable orcad migration catalog import', () => {
|
||||
})
|
||||
expect(flush).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('answers an abort by reference absent only once its flush succeeds', async () => {
|
||||
const reference = { migrationId: 'migration-1', manifestSha256: 'a'.repeat(64) }
|
||||
const aborted: OrcadMigrationCatalogReferenceAbortResult = {
|
||||
...reference,
|
||||
state: 'absent',
|
||||
aborted: true
|
||||
}
|
||||
const unchanged: OrcadMigrationCatalogReferenceAbortResult = { ...aborted, aborted: false }
|
||||
const abort = vi.fn().mockReturnValueOnce(aborted).mockReturnValueOnce(unchanged)
|
||||
const flush = vi.fn().mockRejectedValueOnce(new Error('disk full')).mockResolvedValue(undefined)
|
||||
const args = {
|
||||
store: {
|
||||
abortStagedOrcadMigrationCatalogByReference: abort,
|
||||
flushPendingOrThrowAsync: flush
|
||||
},
|
||||
reference
|
||||
}
|
||||
|
||||
await expect(abortStagedOrcadMigrationCatalogByReferenceDurably(args)).rejects.toThrow(
|
||||
'disk full'
|
||||
)
|
||||
await expect(abortStagedOrcadMigrationCatalogByReferenceDurably(args)).resolves.toEqual({
|
||||
...unchanged,
|
||||
durableAbsent: true
|
||||
})
|
||||
expect(flush).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogReference,
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
} from '../../shared/orcad-migration/orcad-migration-manifest'
|
||||
@@ -16,6 +18,8 @@ type OrcadMigrationCatalogCommitStore = OrcadMigrationCatalogFlushStore &
|
||||
Partial<Pick<Store, 'commitStagedOrcadMigrationCatalog' | 'installOrcadMigrationReceivedBlobs'>>
|
||||
type OrcadMigrationCatalogAbortStore = OrcadMigrationCatalogFlushStore &
|
||||
Partial<Pick<Store, 'abortStagedOrcadMigrationCatalog'>>
|
||||
type OrcadMigrationCatalogAbortByReferenceStore = OrcadMigrationCatalogFlushStore &
|
||||
Partial<Pick<Store, 'abortStagedOrcadMigrationCatalogByReference'>>
|
||||
|
||||
export async function stageOrcadMigrationCatalogDurably(args: {
|
||||
store: OrcadMigrationCatalogStageStore
|
||||
@@ -76,6 +80,20 @@ export async function abortStagedOrcadMigrationCatalogDurably(args: {
|
||||
return result
|
||||
}
|
||||
|
||||
export async function abortStagedOrcadMigrationCatalogByReferenceDurably(args: {
|
||||
store: OrcadMigrationCatalogAbortByReferenceStore
|
||||
reference: OrcadMigrationCatalogReference
|
||||
signal?: AbortSignal
|
||||
}): Promise<OrcadMigrationCatalogReferenceAbortResult> {
|
||||
// SAFETY: Returned or mutable Store slots retain their original native missing-method failure.
|
||||
const result = args.store.abortStagedOrcadMigrationCatalogByReference!(args.reference)
|
||||
if (result.state === 'absent') {
|
||||
await flushMigrationState(args.store, args.signal)
|
||||
return { ...result, durableAbsent: true }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function getOrcadMigrationCatalogState(args: {
|
||||
store: Partial<
|
||||
Pick<Store, 'getOrcadMigrationCatalogState' | 'installOrcadMigrationReceivedBlobs'>
|
||||
|
||||
@@ -31,6 +31,7 @@ const MIGRATION_METHOD_NAMES = [
|
||||
'orcad.migration.stageSnapshotChunk',
|
||||
'orcad.migration.stageBlobChunk',
|
||||
'orcad.migration.abortCatalog',
|
||||
'orcad.migration.abortCatalogByReference',
|
||||
'orcad.migration.catalogState'
|
||||
] as const
|
||||
|
||||
@@ -126,7 +127,9 @@ describe('orcad migration RPC', () => {
|
||||
? 'stageOrcadMigrationBlobChunk'
|
||||
: name === 'orcad.migration.abortCatalog'
|
||||
? 'abortStagedOrcadMigrationCatalog'
|
||||
: 'getOrcadMigrationCatalogState'
|
||||
: name === 'orcad.migration.abortCatalogByReference'
|
||||
? 'abortStagedOrcadMigrationCatalogByReference'
|
||||
: 'getOrcadMigrationCatalogState'
|
||||
|
||||
await expect(
|
||||
method.handler(
|
||||
@@ -264,6 +267,9 @@ function migrationMethodInput(name: (typeof MIGRATION_METHOD_NAMES)[number]) {
|
||||
if (name === 'orcad.migration.stageBlobChunk') {
|
||||
return migrationBlobRequest(input)
|
||||
}
|
||||
if (name === 'orcad.migration.abortCatalogByReference') {
|
||||
return { migrationId: input.migrationId, manifestSha256: input.manifestSha256 }
|
||||
}
|
||||
return name === 'orcad.migration.stageSnapshotChunk'
|
||||
? migrationSnapshotRequest(input)
|
||||
: { manifest: input }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { z } from 'zod'
|
||||
import {
|
||||
OrcadMigrationCatalogParams,
|
||||
OrcadMigrationCatalogReferenceParams,
|
||||
OrcadMigrationCommitCatalogParams
|
||||
} from '../../../../shared/rpc-contract/orcad-migration-params'
|
||||
import { parseOrcadMigrationManifest } from '../../../../shared/orcad-migration/orcad-migration-manifest-validation'
|
||||
@@ -65,6 +66,18 @@ export const ORCAD_MIGRATION_METHODS = [
|
||||
})
|
||||
}
|
||||
}),
|
||||
// Why a reference: a source fenced on a manifest no request can carry must still be releasable.
|
||||
defineMethod({
|
||||
name: 'orcad.migration.abortCatalogByReference',
|
||||
permission: 'host-admin',
|
||||
params: OrcadMigrationCatalogReferenceParams,
|
||||
handler: async (params, context) => {
|
||||
requireMigrationRuntimeClient(context)
|
||||
return context.runtime.abortStagedOrcadMigrationCatalogByReference(params, {
|
||||
signal: context.signal
|
||||
})
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'orcad.migration.catalogState',
|
||||
permission: 'host-admin',
|
||||
|
||||
@@ -61,6 +61,7 @@ export type RuntimeStore = {
|
||||
stageOrcadMigrationSnapshotChunk?: Store['stageOrcadMigrationSnapshotChunk']
|
||||
stageOrcadMigrationBlobChunk?: Store['stageOrcadMigrationBlobChunk']
|
||||
abortStagedOrcadMigrationCatalog?: Store['abortStagedOrcadMigrationCatalog']
|
||||
abortStagedOrcadMigrationCatalogByReference?: Store['abortStagedOrcadMigrationCatalogByReference']
|
||||
getOrcadMigrationCatalogState?: Store['getOrcadMigrationCatalogState']
|
||||
installOrcadMigrationReceivedBlobs?: Store['installOrcadMigrationReceivedBlobs']
|
||||
installPendingOrcadMigrationReceivedBlobs?: Store['installPendingOrcadMigrationReceivedBlobs']
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
} from '../../shared/orcad-migration/orcad-migration-manifest-validation'
|
||||
import {
|
||||
ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY
|
||||
} from '../../shared/orcad-runtime-capabilities'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
|
||||
const sendRequest = vi.hoisted(() => vi.fn())
|
||||
const extraCapabilities = vi.hoisted((): string[] => [])
|
||||
vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
sendRemoteRuntimeRequestWithStatusPreflight: async (
|
||||
pairing: unknown,
|
||||
@@ -27,7 +29,8 @@ vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
capabilities: [
|
||||
ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY,
|
||||
'orcad.migration-blob.agent-session-records.v1'
|
||||
'orcad.migration-blob.agent-session-records.v1',
|
||||
...extraCapabilities
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -37,6 +40,10 @@ vi.mock('../../shared/remote-runtime-client', () => ({
|
||||
|
||||
const { commitRemoteOrcadMigrationCatalog, stageRemoteOrcadMigrationCatalog } =
|
||||
await import('./orcad-migration-catalog-client')
|
||||
const {
|
||||
abortRemoteOrcadMigrationCatalogByReference,
|
||||
ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED
|
||||
} = await import('./orcad-migration-catalog-reference-client')
|
||||
|
||||
const pairingCode = encodePairingOffer({
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
@@ -68,6 +75,7 @@ const manyBlobs: OrcadMigrationBlobDescriptor[] = Array.from({ length: 200 }, (_
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
extraCapabilities.length = 0
|
||||
sendRequest.mockResolvedValue({
|
||||
ok: true,
|
||||
_meta: { runtimeId: 'runtime' },
|
||||
@@ -103,4 +111,40 @@ describe("orcad migration requests fit the host's sealed inbound frame", () => {
|
||||
).rejects.toThrow(/too large to move to its Orca server/)
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('aborts a manifest no request can carry by its identity alone, durably', async () => {
|
||||
extraCapabilities.push(ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY)
|
||||
const manifest = manifestOfBytes(MAX_ORCAD_MIGRATION_MANIFEST_BYTES)
|
||||
const identity = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 }
|
||||
sendRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { ...identity, state: 'absent', aborted: true, durableAbsent: true }
|
||||
})
|
||||
await expect(
|
||||
abortRemoteOrcadMigrationCatalogByReference(pairingCode, manifest)
|
||||
).resolves.toEqual({ ...identity, state: 'absent', aborted: true, durableAbsent: true })
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'orcad.migration.abortCatalogByReference',
|
||||
identity
|
||||
)
|
||||
|
||||
sendRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { ...identity, state: 'absent', aborted: true }
|
||||
})
|
||||
await expect(
|
||||
abortRemoteOrcadMigrationCatalogByReference(pairingCode, manifest)
|
||||
).rejects.toThrow('orcad_migration_catalog_abort_durability_invalid')
|
||||
})
|
||||
|
||||
it('refuses, unsent, an abort by reference to a host that predates it', async () => {
|
||||
await expect(
|
||||
abortRemoteOrcadMigrationCatalogByReference(
|
||||
pairingCode,
|
||||
manifestOfBytes(MAX_ORCAD_MIGRATION_MANIFEST_BYTES)
|
||||
)
|
||||
).rejects.toThrow(ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED)
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -255,7 +255,7 @@ async function request(
|
||||
return response.result
|
||||
}
|
||||
|
||||
async function sendSupportedMigrationRequest(
|
||||
export async function sendSupportedMigrationRequest(
|
||||
pairing: NonNullable<ReturnType<typeof parsePairingCode>>,
|
||||
method: string,
|
||||
params: unknown,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { parseOrcadMigrationCatalogReferenceAbortResult } from '../../shared/orcad-migration/orcad-migration-catalog-state'
|
||||
import type {
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationManifest
|
||||
} from '../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import { ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
ORCAD_MIGRATION_DESTINATION_UNSUPPORTED,
|
||||
sendSupportedMigrationRequest,
|
||||
type CatalogRequestOptions
|
||||
} from './orcad-migration-catalog-client'
|
||||
|
||||
/** Not the release evidence `ORCAD_MIGRATION_DESTINATION_UNSUPPORTED` is: the host may hold a stage. */
|
||||
export const ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED =
|
||||
'orcad_migration_destination_abort_by_reference_unsupported:Update Orca on this host to cancel this move.'
|
||||
|
||||
/** Sends only the manifest's identity, for a manifest whose whole request would overflow the frame. */
|
||||
export async function abortRemoteOrcadMigrationCatalogByReference(
|
||||
pairingCode: string,
|
||||
manifest: OrcadMigrationManifest,
|
||||
options: CatalogRequestOptions = {}
|
||||
): Promise<OrcadMigrationCatalogReferenceAbortResult> {
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
if (!pairing) {
|
||||
throw new Error('orcad_migration_pairing_code_invalid')
|
||||
}
|
||||
let supportsReference = false
|
||||
let response: RuntimeRpcResponse<unknown>
|
||||
try {
|
||||
response = await sendSupportedMigrationRequest(
|
||||
pairing,
|
||||
'orcad.migration.abortCatalogByReference',
|
||||
{ migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 },
|
||||
options,
|
||||
false,
|
||||
null,
|
||||
(capabilities) => {
|
||||
supportsReference = capabilities.includes(
|
||||
ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY
|
||||
)
|
||||
if (!supportsReference) {
|
||||
throw new Error(ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
// A missing method on a host that advertised it proves nothing about what it staged.
|
||||
if (
|
||||
supportsReference &&
|
||||
error instanceof Error &&
|
||||
error.message === ORCAD_MIGRATION_DESTINATION_UNSUPPORTED
|
||||
) {
|
||||
throw new Error(ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`orcad_migration_abort_failed:${response.error.message}`)
|
||||
}
|
||||
return parseOrcadMigrationCatalogReferenceAbortResult(response.result, manifest)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner'
|
||||
import type { SshTarget } from '../../shared/ssh-types'
|
||||
import type { OrcadMigrationManifest } from '../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness'
|
||||
import { Store } from '../persistence/loading-store/store'
|
||||
import {
|
||||
@@ -13,7 +14,15 @@ import {
|
||||
type OrcadMigrationCutoverContext
|
||||
} from './orcad-migration-cutover-coordinator'
|
||||
import { ORCAD_MIGRATION_DESTINATION_UNSUPPORTED } from './orcad-migration-catalog-client'
|
||||
import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal'
|
||||
import {
|
||||
listOrcadMigrationSourceCutovers,
|
||||
writeOrcadMigrationSourceCutover
|
||||
} from './orcad-migration-cutover-journal'
|
||||
import {
|
||||
assertOrcadMigrationRequestFits,
|
||||
createOrcadMigrationManifest
|
||||
} from './orcad-migration-manifest-export'
|
||||
import { MAX_ORCAD_MIGRATION_MANIFEST_BYTES } from '../../shared/orcad-migration/orcad-migration-manifest-validation'
|
||||
import { fenceOrcadMigrationSource } from './orcad-migration-source-fence'
|
||||
import { SshTargetOrcadClaims } from './targets/ssh-target-orcad-claims'
|
||||
import { fakeOrcadMigrationDestination as fakeDestination } from './orcad-migration-destination-fake'
|
||||
@@ -257,4 +266,99 @@ describe('migration cutover coordinator', () => {
|
||||
)
|
||||
expect(h.journal()?.phase).toBe('source-fenced')
|
||||
})
|
||||
|
||||
describe('a journal fenced before #30836 on a manifest no request can carry', () => {
|
||||
async function stuck(stagedByOlderBuild = false) {
|
||||
const h = await setup()
|
||||
const cutover = h.journal()!
|
||||
const target = {
|
||||
...h.store.getSshTarget(TARGET.id)!,
|
||||
generation: cutover.sshTargetGeneration
|
||||
}
|
||||
const exportFor = () =>
|
||||
createOrcadMigrationManifest(h.store, target, {
|
||||
migrationId: cutover.migrationId,
|
||||
destinationEnvironmentId: cutover.destinationEnvironmentId
|
||||
})
|
||||
const baseline = JSON.stringify(exportFor()).length
|
||||
// Under the export cap, over the sealed-frame request budget.
|
||||
h.store.updateRepo(h.store.getRepo('repo-1')!, {
|
||||
displayName: 'x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES - 200 - baseline)
|
||||
})
|
||||
const manifest = exportFor()
|
||||
expect(() => assertOrcadMigrationRequestFits({ manifest })).toThrow(
|
||||
/orcad_migration_manifest_too_large/
|
||||
)
|
||||
writeOrcadMigrationSourceCutover(h.context.userDataPath, {
|
||||
...cutover,
|
||||
manifestSha256: manifest.manifestSha256,
|
||||
manifest
|
||||
})
|
||||
if (stagedByOlderBuild) {
|
||||
// A build before the client-side size check sent it whole, and the frame fit.
|
||||
await h.destination.stage(manifest)
|
||||
}
|
||||
// As the real client does: every request that carries the manifest refuses unsent.
|
||||
const refuseUnsent =
|
||||
<T>(real: (sent: OrcadMigrationManifest) => Promise<T>) =>
|
||||
async (sent: OrcadMigrationManifest): Promise<T> => {
|
||||
assertOrcadMigrationRequestFits({ manifest: sent })
|
||||
return real(sent)
|
||||
}
|
||||
const d = h.destination
|
||||
d.readState.mockImplementation(refuseUnsent(d.readState.getMockImplementation()!))
|
||||
d.stage.mockImplementation(refuseUnsent(d.stage.getMockImplementation()!))
|
||||
d.commit.mockImplementation(refuseUnsent(d.commit.getMockImplementation()!))
|
||||
d.abort.mockImplementation(refuseUnsent(d.abort.getMockImplementation()!))
|
||||
return { ...h, manifest }
|
||||
}
|
||||
|
||||
it('releases the fence once the server, asked by reference, proves it holds nothing', async () => {
|
||||
const h = await stuck()
|
||||
await expect(abortOrcadMigrationCutover(h.context, h.migrationId)).resolves.toEqual({
|
||||
outcome: 'released',
|
||||
evidence: 'catalog-absent'
|
||||
})
|
||||
expect(h.destination.abortByReference).toHaveBeenCalledWith(h.manifest)
|
||||
expect(h.owner()).toBeNull()
|
||||
expect(h.journal()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('aborts, by reference, a stage an older build delivered whole', async () => {
|
||||
const h = await stuck(true)
|
||||
await expect(abortOrcadMigrationCutover(h.context, h.migrationId)).resolves.toMatchObject({
|
||||
outcome: 'released'
|
||||
})
|
||||
await expect(h.destination.abortByReference.mock.results[0]?.value).resolves.toMatchObject({
|
||||
state: 'absent',
|
||||
aborted: true
|
||||
})
|
||||
expect(h.owner()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the fence when the server cannot answer by reference', async () => {
|
||||
const h = await stuck()
|
||||
h.destination.abortByReference.mockRejectedValueOnce(new Error('update required'))
|
||||
await expect(abortOrcadMigrationCutover(h.context, h.migrationId)).rejects.toThrow(
|
||||
'update required'
|
||||
)
|
||||
expect(h.owner()).toBe('env-1')
|
||||
expect(h.journal()?.migrationId).toBe(h.migrationId)
|
||||
})
|
||||
|
||||
it('never releases a server that already committed it', async () => {
|
||||
const h = await stuck()
|
||||
h.destination.abortByReference.mockResolvedValueOnce({
|
||||
migrationId: h.migrationId,
|
||||
manifestSha256: h.manifest.manifestSha256,
|
||||
state: 'committed',
|
||||
aborted: false
|
||||
})
|
||||
await expect(abortOrcadMigrationCutover(h.context, h.migrationId)).resolves.toMatchObject({
|
||||
outcome: 'refused',
|
||||
code: 'orcad_migration_committed_source_cannot_be_released'
|
||||
})
|
||||
expect(h.owner()).toBe('env-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
} from '../../shared/orcad-migration/orcad-migration-manifest'
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
} from '../../shared/orcad-migration/orcad-migration-source-cutover'
|
||||
import { resolveDurableOrcadCatalogMutation } from './orcad-catalog-durable-mutation'
|
||||
import { ORCAD_MIGRATION_DESTINATION_UNSUPPORTED } from './orcad-migration-catalog-client'
|
||||
import { orcadMigrationRequestFits } from './orcad-migration-manifest-export'
|
||||
import {
|
||||
listOrcadMigrationSourceCutovers,
|
||||
writeOrcadMigrationSourceCutover
|
||||
@@ -41,6 +43,10 @@ export type OrcadMigrationDestinationCatalog = {
|
||||
stage: (manifest: OrcadMigrationManifest) => Promise<OrcadMigrationCatalogState>
|
||||
commit: (manifest: OrcadMigrationManifest) => Promise<OrcadMigrationCatalogState>
|
||||
abort: (manifest: OrcadMigrationManifest) => Promise<OrcadMigrationCatalogAbortResult>
|
||||
/** Sends only the manifest's identity; for a journal whose manifest no request can carry. */
|
||||
abortByReference: (
|
||||
manifest: OrcadMigrationManifest
|
||||
) => Promise<OrcadMigrationCatalogReferenceAbortResult>
|
||||
stageChunk: (
|
||||
request: OrcadMigrationSnapshotChunkRequest
|
||||
) => Promise<OrcadMigrationSnapshotChunkResult>
|
||||
@@ -134,6 +140,15 @@ export async function abortOrcadMigrationCutover(
|
||||
return committedRefusal()
|
||||
}
|
||||
const { destination } = context
|
||||
// A journal from before #30836 may hold a manifest no request can carry; only abort could not.
|
||||
if (!orcadMigrationRequestFits({ manifest: cutover.manifest })) {
|
||||
const aborted = await destination.abortByReference(cutover.manifest)
|
||||
if (aborted.state === 'committed') {
|
||||
return committedRefusal()
|
||||
}
|
||||
await release(cutover)
|
||||
return { outcome: 'released', evidence: 'catalog-absent' }
|
||||
}
|
||||
let state: OrcadMigrationCatalogState
|
||||
try {
|
||||
state = await destination.readState(cutover.manifest)
|
||||
|
||||
@@ -66,6 +66,18 @@ export function fakeOrcadMigrationDestination(): FakeOrcadMigrationDestination {
|
||||
}
|
||||
return { ...view(manifest), aborted, ...(aborted ? {} : { durableAbsent: true as const }) }
|
||||
}),
|
||||
abortByReference: vi.fn<Catalog['abortByReference']>(async (manifest) => {
|
||||
const identity = {
|
||||
migrationId: manifest.migrationId,
|
||||
manifestSha256: manifest.manifestSha256
|
||||
}
|
||||
if (stateOf(manifest) === 'committed') {
|
||||
return { ...identity, state: 'committed', aborted: false }
|
||||
}
|
||||
const aborted = stateOf(manifest) === 'staged'
|
||||
states.delete(manifest.migrationId)
|
||||
return { ...identity, state: 'absent', aborted, durableAbsent: true }
|
||||
}),
|
||||
stageChunk: vi.fn<Catalog['stageChunk']>()
|
||||
}
|
||||
return destination
|
||||
|
||||
@@ -119,9 +119,7 @@ export const ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES =
|
||||
export function assertOrcadMigrationRequestFits(params: {
|
||||
manifest: OrcadMigrationManifest
|
||||
}): void {
|
||||
if (
|
||||
Buffer.byteLength(JSON.stringify(params), 'utf8') > ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES
|
||||
) {
|
||||
if (!orcadMigrationRequestFits(params)) {
|
||||
throw new Error(
|
||||
describeOversizedOrcadMigrationPayload(
|
||||
params.manifest.payload,
|
||||
@@ -131,6 +129,12 @@ export function assertOrcadMigrationRequestFits(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export function orcadMigrationRequestFits(params: { manifest: OrcadMigrationManifest }): boolean {
|
||||
return (
|
||||
Buffer.byteLength(JSON.stringify(params), 'utf8') <= ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES
|
||||
)
|
||||
}
|
||||
|
||||
/** The bare code told users nothing (#30126): name the largest parts and what to trim. */
|
||||
export function describeOversizedOrcadMigrationPayload(
|
||||
payload: OrcadMigrationCatalogPayload,
|
||||
|
||||
@@ -100,6 +100,7 @@ function serverDestination(): typeof destination {
|
||||
const result = server.abortStagedOrcadMigrationCatalog(manifest)
|
||||
return result.aborted ? result : { ...result, durableAbsent: true as const }
|
||||
},
|
||||
abortByReference: vi.fn(),
|
||||
stageChunk: async (request) => server.stageOrcadMigrationSnapshotChunk(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { normalizeOrcadMigrationImportReceipts } from './orcad-migration-manifest-validation'
|
||||
import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
} from './orcad-migration-manifest'
|
||||
@@ -77,6 +78,35 @@ export function parseOrcadMigrationCatalogAbortResult(
|
||||
}
|
||||
}
|
||||
|
||||
/** Absent only with the host's flush behind it: an unflushed abort could come back on restart. */
|
||||
export function parseOrcadMigrationCatalogReferenceAbortResult(
|
||||
value: unknown,
|
||||
manifest: OrcadMigrationManifest
|
||||
): OrcadMigrationCatalogReferenceAbortResult {
|
||||
const record = requireRecord(value)
|
||||
if (
|
||||
record.migrationId !== manifest.migrationId ||
|
||||
record.manifestSha256 !== manifest.manifestSha256
|
||||
) {
|
||||
throw new Error('orcad_migration_catalog_state_identity_mismatch')
|
||||
}
|
||||
if (typeof record.aborted !== 'boolean') {
|
||||
throw new Error('orcad_migration_catalog_abort_result_invalid')
|
||||
}
|
||||
if (record.state === 'committed') {
|
||||
return { state: 'committed', ...migrationIdentity(manifest), aborted: false }
|
||||
}
|
||||
if (record.state === 'absent' && record.durableAbsent === true) {
|
||||
return {
|
||||
state: 'absent',
|
||||
...migrationIdentity(manifest),
|
||||
aborted: record.aborted,
|
||||
durableAbsent: true
|
||||
}
|
||||
}
|
||||
throw new Error('orcad_migration_catalog_abort_durability_invalid')
|
||||
}
|
||||
|
||||
function migrationIdentity(manifest: OrcadMigrationManifest) {
|
||||
return { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 }
|
||||
}
|
||||
|
||||
@@ -149,6 +149,18 @@ export type OrcadMigrationCatalogAbortResult = OrcadMigrationCatalogState & {
|
||||
durableAbsent?: true
|
||||
}
|
||||
|
||||
export type OrcadMigrationCatalogReference = Pick<
|
||||
OrcadMigrationManifest,
|
||||
'migrationId' | 'manifestSha256'
|
||||
>
|
||||
|
||||
/** An abort by reference: no receipt, since an evicted one is rebuilt only from the manifest. */
|
||||
export type OrcadMigrationCatalogReferenceAbortResult = OrcadMigrationCatalogReference & {
|
||||
state: 'absent' | 'committed'
|
||||
aborted: boolean
|
||||
durableAbsent?: true
|
||||
}
|
||||
|
||||
export function orcadMigrationManifestHashInput(
|
||||
manifest: Omit<OrcadMigrationManifest, 'manifestSha256'>
|
||||
): string {
|
||||
|
||||
@@ -6,6 +6,11 @@ export const ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY = 'orcad.terminal-census.v
|
||||
// reads a catalog only on a host advertising this, and treats its absence as a refusal.
|
||||
export const ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY = 'orcad.migration-catalog.v1' as const
|
||||
|
||||
// Why: older hosts have no orcad.migration.abortCatalogByReference. A source fenced on a manifest
|
||||
// no request can carry (before #30836 refused one) can be released only through it.
|
||||
export const ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY =
|
||||
'orcad.migration-catalog-abort-by-reference.v1' as const
|
||||
|
||||
// Why per kind: an older destination drops `source.kind` and cannot tell a WSL source from an SSH
|
||||
// one, so a client sends a non-SSH manifest only to a host advertising that kind. Not advertised
|
||||
// until a destination parses `source.kind: 'local-wsl'`.
|
||||
@@ -23,5 +28,6 @@ export const HOST_ACCOUNT_LAUNCH_ENV_RUNTIME_CAPABILITY = 'accounts.host-launch-
|
||||
export const ORCAD_RUNTIME_CAPABILITIES = [
|
||||
ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY,
|
||||
HOST_ACCOUNT_LAUNCH_ENV_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
@@ -15,3 +15,9 @@ export const OrcadMigrationCommitCatalogParams = OrcadMigrationCatalogParams.ext
|
||||
.max(MAX_ORCAD_MIGRATION_BLOBS)
|
||||
.optional()
|
||||
})
|
||||
|
||||
// The manifest's identity alone, for a request the whole manifest would overflow.
|
||||
export const OrcadMigrationCatalogReferenceParams = z.object({
|
||||
migrationId: z.string().min(1).max(256),
|
||||
manifestSha256: z.string().regex(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
@@ -405,6 +405,7 @@ import {
|
||||
} from './notifications-params'
|
||||
import {
|
||||
OrcadMigrationCatalogParams,
|
||||
OrcadMigrationCatalogReferenceParams,
|
||||
OrcadMigrationCommitCatalogParams
|
||||
} from './orcad-migration-params'
|
||||
import {
|
||||
@@ -1140,6 +1141,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'notifications.unregisterPush': null,
|
||||
'notifications.unsubscribe': NotificationUnsubscribeParams,
|
||||
'orcad.migration.abortCatalog': OrcadMigrationCatalogParams,
|
||||
'orcad.migration.abortCatalogByReference': OrcadMigrationCatalogReferenceParams,
|
||||
'orcad.migration.catalogState': OrcadMigrationCatalogParams,
|
||||
'orcad.migration.commitCatalog': OrcadMigrationCommitCatalogParams,
|
||||
'orcad.migration.stageBlobChunk': OrcadMigrationBlobChunkRequestSchema,
|
||||
|
||||
Reference in New Issue
Block a user