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:
Neil
2026-10-19 17:25:47 -07:00
committed by GitHub
co-authored by Claude
parent c3d3df497f
commit 6f96efb662
23 changed files with 500 additions and 20 deletions
@@ -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
View File
@@ -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}$/)
})
+2
View File
@@ -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,