diff --git a/src/main/ssh/orcad-migration-cutover-journal.test.ts b/src/main/ssh/orcad-migration-cutover-journal.test.ts new file mode 100644 index 000000000000..fb00375f5571 --- /dev/null +++ b/src/main/ssh/orcad-migration-cutover-journal.test.ts @@ -0,0 +1,103 @@ +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../shared/orcad-migration-manifest' +import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' +import { computeOrcadMigrationManifestSha256 } from '../orcad/orcad-migration-manifest-digest' +import { + findOrcadMigrationSourceCutoverForTarget, + listOrcadMigrationSourceCutovers, + orcadMigrationCutoverJournalDirectory, + removeOrcadMigrationSourceCutover, + writeOrcadMigrationSourceCutover +} from './orcad-migration-cutover-journal' + +let userDataPath: string +beforeEach(() => { + userDataPath = mkdtempSync(join(tmpdir(), 'orcad-cutover-journal-')) +}) +afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) + +function cutover(migrationId = 'migration-1', sshTargetId = 'ssh-1'): OrcadMigrationSourceCutover { + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId, + createdAt: '2026-10-01T00:00:00.000Z', + source: { sshTargetId, sshTargetGeneration: 2, targetLabel: 'Prod' }, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, + destinationEnvironmentId: 'env-1' + } + const manifest = { ...unsigned, manifestSha256: computeOrcadMigrationManifestSha256(unsigned) } + return { + version: 1, + migrationId, + phase: 'source-fenced', + startedAt: '2026-10-01T00:00:00.000Z', + updatedAt: '2026-10-01T00:00:00.000Z', + destinationEnvironmentId: 'env-1', + destinationName: 'Managed', + sshTargetId, + sshTargetGeneration: 2, + manifestSha256: manifest.manifestSha256, + manifest + } +} + +const journalPath = (id: string) => + join(orcadMigrationCutoverJournalDirectory(userDataPath), `${id}.json`) + +describe('migration cutover journal sidecar', () => { + it('round-trips a cutover in an owner-only file', () => { + writeOrcadMigrationSourceCutover(userDataPath, cutover()) + expect(findOrcadMigrationSourceCutoverForTarget(userDataPath, 'ssh-1')).toEqual(cutover()) + if (process.platform !== 'win32') { + expect(statSync(journalPath('migration-1')).mode & 0o077).toBe(0) + } + removeOrcadMigrationSourceCutover(userDataPath, 'migration-1') + expect(listOrcadMigrationSourceCutovers(userDataPath)).toEqual([]) + }) + + it.each([ + ['corrupt JSON', '{not json'], + ['a manifest bound to another target', JSON.stringify({ ...cutover(), sshTargetId: 'ssh-2' })], + ['a tampered manifest', JSON.stringify({ ...cutover(), manifestSha256: 'f'.repeat(64) })], + ['an unknown version', JSON.stringify({ ...cutover(), version: 2 })] + ])('fails closed on %s', (_label, contents) => { + mkdirSync(orcadMigrationCutoverJournalDirectory(userDataPath), { recursive: true }) + writeFileSync(journalPath('migration-1'), contents) + expect(() => listOrcadMigrationSourceCutovers(userDataPath)).toThrow('stays fenced') + }) + + it('fails closed on a file whose name disagrees with its migration', () => { + mkdirSync(orcadMigrationCutoverJournalDirectory(userDataPath), { recursive: true }) + writeFileSync(journalPath('other'), JSON.stringify(cutover())) + expect(() => listOrcadMigrationSourceCutovers(userDataPath)).toThrow('stays fenced') + }) + + it('ignores durable-write temporaries but refuses two journals for one target', () => { + writeOrcadMigrationSourceCutover(userDataPath, cutover()) + writeFileSync(join(orcadMigrationCutoverJournalDirectory(userDataPath), 'x.json.tmp'), '?') + expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1) + writeOrcadMigrationSourceCutover(userDataPath, cutover('migration-2')) + expect(() => findOrcadMigrationSourceCutoverForTarget(userDataPath, 'ssh-1')).toThrow( + 'journals name SSH target' + ) + }) + + it('bounds concurrent migrations but rewrites an existing one', () => { + for (const index of [1, 2, 3, 4]) { + writeOrcadMigrationSourceCutover(userDataPath, cutover(`m-${index}`, `ssh-${index}`)) + } + expect(() => writeOrcadMigrationSourceCutover(userDataPath, cutover('m-5', 'ssh-5'))).toThrow( + 'capacity' + ) + writeOrcadMigrationSourceCutover(userDataPath, { + ...cutover('m-1', 'ssh-1'), + phase: 'destination-staged' + }) + expect(findOrcadMigrationSourceCutoverForTarget(userDataPath, 'ssh-1')?.phase).toBe( + 'destination-staged' + ) + }) +}) diff --git a/src/main/ssh/orcad-migration-cutover-journal.ts b/src/main/ssh/orcad-migration-cutover-journal.ts new file mode 100644 index 000000000000..d6f5782761fd --- /dev/null +++ b/src/main/ssh/orcad-migration-cutover-journal.ts @@ -0,0 +1,128 @@ +/** + * The cutover journal sidecar: one durable, owner-only file per migration beside the profile. + * + * Why not the profile store: shipped builds rewrite orca-data.json and the profile database with + * schemas that drop unknown fields, so a journal kept there would vanish across a downgrade while + * the target fence (the SSH owner, which shipped builds keep) survived it. A missing journal must + * never look like "no migration"; an unreadable one fails closed. + */ +import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { writeSecureJsonFileWithinLimit } from '../../shared/bounded-secure-json-file' +import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader' +import { + MAX_ORCAD_MIGRATION_SOURCE_CUTOVERS, + parseOrcadMigrationSourceCutover, + type OrcadMigrationSourceCutover +} from '../../shared/orcad-migration-source-cutover' +import { syncDirectoryDurablySync } from '../durable-file-write' + +const JOURNAL_DIRECTORY = 'orcad-migration-cutovers' +const JOURNAL_FILE = /^[A-Za-z0-9_-]{1,128}\.json$/ +// A manifest carries catalog rows and dormant session state, never scrollback bytes. +const MAX_JOURNAL_FILE_BYTES = 16 * 1024 * 1024 + +export class OrcadMigrationCutoverJournalUnreadableError extends Error { + constructor(detail: string) { + super(`The migration journal cannot be read, so the SSH host stays fenced: ${detail}`) + this.name = 'OrcadMigrationCutoverJournalUnreadableError' + } +} + +export function orcadMigrationCutoverJournalDirectory(userDataPath: string): string { + return join(userDataPath, JOURNAL_DIRECTORY) +} + +/** Every journaled cutover; throws rather than skipping a file it cannot trust. */ +export function listOrcadMigrationSourceCutovers( + userDataPath: string +): OrcadMigrationSourceCutover[] { + const directory = orcadMigrationCutoverJournalDirectory(userDataPath) + if (!existsSync(directory)) { + return [] + } + let names: string[] + try { + names = readdirSync(directory) + } catch (error) { + throw new OrcadMigrationCutoverJournalUnreadableError(errorMessage(error)) + } + const cutovers: OrcadMigrationSourceCutover[] = [] + for (const name of names) { + if (!name.endsWith('.json')) { + continue // Durable-write temporaries and foreign files carry no journal state. + } + if (!JOURNAL_FILE.test(name)) { + throw new OrcadMigrationCutoverJournalUnreadableError(`unexpected entry ${name}`) + } + cutovers.push(readJournalFile(join(directory, name), name.slice(0, -'.json'.length))) + } + return cutovers +} + +export function findOrcadMigrationSourceCutoverForTarget( + userDataPath: string, + sshTargetId: string +): OrcadMigrationSourceCutover | null { + const matches = listOrcadMigrationSourceCutovers(userDataPath).filter( + (cutover) => cutover.sshTargetId === sshTargetId + ) + if (matches.length > 1) { + throw new OrcadMigrationCutoverJournalUnreadableError( + `${matches.length} journals name SSH target ${sshTargetId}` + ) + } + return matches[0] ?? null +} + +/** Durable before it returns: a fence written after this always has its journal on disk. */ +export function writeOrcadMigrationSourceCutover( + userDataPath: string, + cutover: OrcadMigrationSourceCutover +): void { + const parsed = parseOrcadMigrationSourceCutover(cutover) + const existing = listOrcadMigrationSourceCutovers(userDataPath) + if ( + !existing.some((entry) => entry.migrationId === parsed.migrationId) && + existing.length >= MAX_ORCAD_MIGRATION_SOURCE_CUTOVERS + ) { + throw new Error('orcad_migration_cutover_capacity_exceeded') + } + const directory = orcadMigrationCutoverJournalDirectory(userDataPath) + mkdirSync(directory, { recursive: true, mode: 0o700 }) + writeSecureJsonFileWithinLimit( + join(directory, `${parsed.migrationId}.json`), + parsed, + MAX_JOURNAL_FILE_BYTES, + { durable: true } + ) + syncDirectoryDurablySync(directory) +} + +export function removeOrcadMigrationSourceCutover(userDataPath: string, migrationId: string): void { + if (!JOURNAL_FILE.test(`${migrationId}.json`)) { + throw new Error('orcad_migration_cutover_id_invalid') + } + const directory = orcadMigrationCutoverJournalDirectory(userDataPath) + rmSync(join(directory, `${migrationId}.json`), { force: true }) + if (existsSync(directory)) { + syncDirectoryDurablySync(directory) + } +} + +function readJournalFile(path: string, migrationId: string): OrcadMigrationSourceCutover { + try { + const raw = readNodeFileSyncWithinLimit(path, MAX_JOURNAL_FILE_BYTES).buffer.toString('utf8') + const cutover = parseOrcadMigrationSourceCutover(JSON.parse(raw)) + if (cutover.migrationId !== migrationId) { + throw new Error('file name does not match its migration id') + } + return cutover + } catch (error) { + throw new OrcadMigrationCutoverJournalUnreadableError(errorMessage(error)) + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/ssh/orcad-migration-source-fence.test.ts b/src/main/ssh/orcad-migration-source-fence.test.ts new file mode 100644 index 000000000000..750717afb578 --- /dev/null +++ b/src/main/ssh/orcad-migration-source-fence.test.ts @@ -0,0 +1,207 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import type { SshTarget } from '../../shared/ssh-types' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { Store } from '../persistence/loading-store/store' +import { + listOrcadMigrationSourceCutovers, + orcadMigrationCutoverJournalDirectory +} from './orcad-migration-cutover-journal' +import { + fenceOrcadMigrationSource, + resolveOrcadMigrationFence +} from './orcad-migration-source-fence' +import type { OrcadMigrationTerminalVerdict } from './orcad-migration-terminal-gate' +import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 2 +} +const exited: OrcadMigrationTerminalVerdict = { verdict: 'exited', provenPtyIds: [] } + +const directories: string[] = [] +afterEach(async () => { + await closeTestStores() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function setup() { + const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-migration-fence-')) + directories.push(userDataPath) + const dataFile = join(userDataPath, 'orca-data.json') + const store = createSqliteTestStore(Store, { dataFile }) + store.addSshTarget(TARGET) + store.addRepo({ + id: 'repo-1', + path: '/srv/app', + displayName: 'App', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId: TARGET.id + }) + const claims = new SshTargetOrcadClaims(store) + const fence = (overrides: Partial[0]> = {}) => + fenceOrcadMigrationSource({ + userDataPath, + store, + claims, + targetId: TARGET.id, + destinationEnvironmentId: 'env-1', + destinationName: 'Managed', + terminalProof: exited, + hasDirectSshAuthority: () => false, + ...overrides + }) + const target = () => store.getSshTarget(TARGET.id)! + return { userDataPath, dataFile, store, claims, fence, target } +} + +describe('migration source fence', () => { + it('journals, then fences, then flushes, all before returning', async () => { + const harness = setup() + const order: string[] = [] + const journalWrite = vi + .spyOn(harness.claims, 'fenceForMigration') + .mockImplementation((...args) => { + order.push( + listOrcadMigrationSourceCutovers(harness.userDataPath).length ? 'journal' : 'none' + ) + order.push('fence') + return SshTargetOrcadClaims.prototype.fenceForMigration.apply(harness.claims, args) + }) + const flush = vi.spyOn(harness.claims, 'flush').mockImplementation(async () => { + order.push(harness.target().owner ? 'flush-after-fence' : 'flush-before-fence') + }) + const result = await harness.fence() + expect(result).toMatchObject({ outcome: 'fenced', resumed: false }) + expect(order).toEqual(['journal', 'fence', 'flush-after-fence']) + expect(getManagedOrcadOwnerEnvironmentId(harness.target().owner)).toBe('env-1') + journalWrite.mockRestore() + flush.mockRestore() + }) + + it('keeps the journal out of the profile, where an older build would strip it', async () => { + const harness = setup() + const result = await harness.fence() + if (result.outcome !== 'fenced') { + throw new Error('expected a fence') + } + await harness.store.flushPendingOrThrowAsync() + const journalDirectory = orcadMigrationCutoverJournalDirectory(harness.userDataPath) + const profileFiles = readdirSync(harness.userDataPath, { recursive: true, encoding: 'utf8' }) + .map((name) => join(harness.userDataPath, name)) + .filter((path) => !path.startsWith(journalDirectory) && statSync(path).isFile()) + expect(profileFiles.length).toBeGreaterThan(0) + for (const path of profileFiles) { + expect(readFileSync(path).includes(result.cutover.migrationId)).toBe(false) + } + const [onDisk] = listOrcadMigrationSourceCutovers(harness.userDataPath) + expect(onDisk).toMatchObject({ phase: 'source-fenced', sshTargetGeneration: 2 }) + expect(onDisk?.manifest.payload.repositories.map((repo) => repo.id)).toEqual(['repo-1']) + }) + + it('resumes the same migration instead of starting a second one', async () => { + const harness = setup() + const first = await harness.fence() + const second = await harness.fence({ + terminalProof: { verdict: 'live', ptyIds: ['p'], reason: 'x' } + }) + expect(second).toMatchObject({ outcome: 'fenced', resumed: true }) + expect( + first.outcome === 'fenced' && second.outcome === 'fenced' && second.cutover.migrationId + ).toBe(first.outcome === 'fenced' && first.cutover.migrationId) + expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toHaveLength(1) + }) + + it('treats a fence whose journal was lost as unverifiable and never releases it', async () => { + const harness = setup() + await harness.fence() + rmSync(orcadMigrationCutoverJournalDirectory(harness.userDataPath), { recursive: true }) + expect(resolveOrcadMigrationFence(harness.userDataPath, harness.target())).toEqual({ + state: 'fenced-unverifiable', + environmentId: 'env-1' + }) + await expect(harness.fence()).resolves.toMatchObject({ + outcome: 'refused', + verdict: 'unverifiable', + code: 'orcad_migration_fenced_unverifiable' + }) + expect(getManagedOrcadOwnerEnvironmentId(harness.target().owner)).toBe('env-1') + }) + + it('treats a journal whose fence an older build removed as stale, granting nothing', async () => { + const harness = setup() + await harness.fence() + harness.store.updateSshTarget(TARGET.id, { owner: undefined }) + expect(resolveOrcadMigrationFence(harness.userDataPath, harness.target()).state).toBe( + 'stale-journal' + ) + await expect(harness.fence()).resolves.toMatchObject({ code: 'orcad_migration_stale_journal' }) + }) + + it('fails closed on an unreadable journal', async () => { + const harness = setup() + await harness.fence() + const [cutover] = listOrcadMigrationSourceCutovers(harness.userDataPath) + writeFileSync( + join( + orcadMigrationCutoverJournalDirectory(harness.userDataPath), + `${cutover!.migrationId}.json` + ), + '{not json' + ) + expect(() => resolveOrcadMigrationFence(harness.userDataPath, harness.target())).toThrow( + 'stays fenced' + ) + await expect(harness.fence()).rejects.toThrow('stays fenced') + }) + + it.each<[OrcadMigrationTerminalVerdict, string]>([ + [{ verdict: 'live', ptyIds: ['p'], reason: 'terminals run' }, 'live'], + [{ verdict: 'unverifiable', ptyIds: ['p'], reason: 'relay silent' }, 'unverifiable'] + ])('refuses before fencing when terminals are %j', async (terminalProof, verdict) => { + const harness = setup() + await expect(harness.fence({ terminalProof })).resolves.toMatchObject({ + outcome: 'refused', + verdict + }) + expect(harness.target().owner).toBeUndefined() + expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toEqual([]) + }) + + it('refuses while the host is still connected directly', async () => { + const harness = setup() + await expect(harness.fence({ hasDirectSshAuthority: () => true })).resolves.toMatchObject({ + code: 'orcad_migration_direct_ssh_connected' + }) + expect(harness.target().owner).toBeUndefined() + }) + + it('releases its own fence when a terminal appeared before the fence took hold', async () => { + const harness = setup() + const flush = vi.spyOn(harness.claims, 'flush').mockImplementation(async () => { + if (harness.target().owner) { + harness.store.upsertSshRemotePtyLease({ + targetId: TARGET.id, + ptyId: 'late-pty', + state: 'detached' + }) + } + }) + await expect(harness.fence()).resolves.toMatchObject({ outcome: 'refused', verdict: 'live' }) + expect(harness.target().owner).toBeUndefined() + expect(listOrcadMigrationSourceCutovers(harness.userDataPath)).toEqual([]) + flush.mockRestore() + }) +}) diff --git a/src/main/ssh/orcad-migration-source-fence.ts b/src/main/ssh/orcad-migration-source-fence.ts new file mode 100644 index 000000000000..2851293df50d --- /dev/null +++ b/src/main/ssh/orcad-migration-source-fence.ts @@ -0,0 +1,194 @@ +/** + * Fencing a relay-hosted SSH target for a dormant migration into a managed orcad. + * + * Two durable records describe a fence: the journal sidecar and the target's managed owner. + * Write order is journal, then owner, then the profile flush, all before any remote call, so a + * crash leaves either nothing, a journal without a fence (stale, never authority), or both. + * An owner without a journal is never released here: only the destination can say what happened. + */ +import { randomUUID } from 'node:crypto' +import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import type { OrcadMigrationBlocker } from '../../shared/orcad-migration-preflight' +import { + ORCAD_MIGRATION_SOURCE_CUTOVER_VERSION, + type OrcadMigrationSourceCutover +} from '../../shared/orcad-migration-source-cutover' +import type { SshTarget } from '../../shared/ssh-types' +import { createOrcadMigrationManifest } from './orcad-migration-manifest-export' +import { + findOrcadMigrationSourceCutoverForTarget, + removeOrcadMigrationSourceCutover, + writeOrcadMigrationSourceCutover +} from './orcad-migration-cutover-journal' +import { + confirmOrcadMigrationTerminalsUnderFence, + type OrcadMigrationTerminalVerdict +} from './orcad-migration-terminal-gate' +import type { SshTargetOrcadClaims } from './ssh-target-orcad-claims' +import { + preflightOrcadMigrationExport, + type OrcadMigrationPreflightStore +} from './ssh-target-orcad-preflight' + +export type OrcadMigrationFenceState = + | { state: 'none' } + /** Owner and journal agree: the migration can resume. */ + | { state: 'fenced'; cutover: OrcadMigrationSourceCutover } + /** Owned by an empty-host deploy, which records itself as a provisioning intent instead. */ + | { state: 'owned-by-deploy'; environmentId: string } + /** Owner with no journal (a downgrade dropped it, or it never landed): recover, never release. */ + | { state: 'fenced-unverifiable'; environmentId: string } + /** Journal whose fence is gone or changed: it can never authorize staging or retirement. */ + | { state: 'stale-journal'; cutover: OrcadMigrationSourceCutover } + +/** Throws when the journal cannot be read: an unreadable record keeps the host fenced. */ +export function resolveOrcadMigrationFence( + userDataPath: string, + target: SshTarget +): OrcadMigrationFenceState { + const cutover = findOrcadMigrationSourceCutoverForTarget(userDataPath, target.id) + const ownerEnvironmentId = getManagedOrcadOwnerEnvironmentId(target.owner) + if (cutover) { + return ownerEnvironmentId === cutover.destinationEnvironmentId && + target.generation === cutover.sshTargetGeneration + ? { state: 'fenced', cutover } + : { state: 'stale-journal', cutover } + } + if (!ownerEnvironmentId) { + return { state: 'none' } + } + return target.orcadProvisioning + ? { state: 'owned-by-deploy', environmentId: ownerEnvironmentId } + : { state: 'fenced-unverifiable', environmentId: ownerEnvironmentId } +} + +export type OrcadMigrationFenceResult = + | { outcome: 'fenced'; cutover: OrcadMigrationSourceCutover; resumed: boolean } + | { + outcome: 'refused' + verdict: 'live' | 'unverifiable' + code: string + reason: string + blockers?: OrcadMigrationBlocker[] + } + +export async function fenceOrcadMigrationSource(args: { + userDataPath: string + store: OrcadMigrationPreflightStore + claims: SshTargetOrcadClaims + targetId: string + destinationEnvironmentId: string + destinationName: string + /** Taken by the caller while the relay could still answer; see orcad-migration-terminal-gate. */ + terminalProof: OrcadMigrationTerminalVerdict + hasDirectSshAuthority: (targetId: string) => boolean + now?: () => Date + signal?: AbortSignal +}): Promise { + const target = args.store.getSshTarget(args.targetId) + if (!target) { + return refuse('unverifiable', 'orcad_migration_target_not_found', 'The SSH host is gone.') + } + const existing = resolveOrcadMigrationFence(args.userDataPath, target) + if (existing.state === 'fenced') { + return existing.cutover.destinationEnvironmentId === args.destinationEnvironmentId + ? { outcome: 'fenced', cutover: existing.cutover, resumed: true } + : refuse('live', 'orcad_migration_in_progress', 'Another migration holds this SSH host.') + } + if (existing.state !== 'none') { + return refuse( + 'unverifiable', + `orcad_migration_${existing.state.replaceAll('-', '_')}`, + fenceStateReason(existing) + ) + } + if (args.terminalProof.verdict !== 'exited') { + return refuse( + args.terminalProof.verdict, + 'orcad_migration_terminals', + args.terminalProof.reason + ) + } + if (args.hasDirectSshAuthority(target.id)) { + return refuse('live', 'orcad_migration_direct_ssh_connected', 'Disconnect this SSH host first.') + } + const preflight = preflightOrcadMigrationExport(args.store, target.id) + if (!preflight.claimable) { + return { + ...refuse('live', 'orcad_migration_preflight_blocked', 'This SSH host cannot move yet.'), + blockers: preflight.blockers + } + } + const generation = args.claims.ensureGeneration(target.id) + const now = (args.now ?? (() => new Date()))().toISOString() + const manifest = createOrcadMigrationManifest( + args.store, + { ...target, generation }, + { migrationId: randomUUID(), destinationEnvironmentId: args.destinationEnvironmentId } + ) + const cutover: OrcadMigrationSourceCutover = { + version: ORCAD_MIGRATION_SOURCE_CUTOVER_VERSION, + migrationId: manifest.migrationId, + phase: 'source-fenced', + startedAt: now, + updatedAt: now, + destinationEnvironmentId: args.destinationEnvironmentId, + destinationName: args.destinationName, + sshTargetId: target.id, + sshTargetGeneration: generation, + manifestSha256: manifest.manifestSha256, + manifest + } + writeOrcadMigrationSourceCutover(args.userDataPath, cutover) + args.claims.fenceForMigration(target.id, args.destinationEnvironmentId, generation) + await args.claims.flush(args.signal) + // Why again: a terminal may have started between the proof and the fence. + const underFence = confirmOrcadMigrationTerminalsUnderFence( + args.store, + target.id, + args.terminalProof + ) + if (underFence.verdict !== 'exited') { + await releaseUnstagedFence(args, cutover) + return refuse(underFence.verdict, 'orcad_migration_terminals', underFence.reason) + } + return { outcome: 'fenced', cutover, resumed: false } +} + +/** + * Undoes a fence nothing remote has seen. Owner first, then the journal: a crash between them + * leaves a stale journal, which grants nothing, rather than an owner nothing explains. + */ +export async function releaseUnstagedFence( + args: { userDataPath: string; claims: SshTargetOrcadClaims; signal?: AbortSignal }, + cutover: OrcadMigrationSourceCutover +): Promise { + if (cutover.phase !== 'source-fenced') { + throw new Error('orcad_migration_fence_release_after_stage') + } + args.claims.release(cutover.sshTargetId, cutover.destinationEnvironmentId) + await args.claims.flush(args.signal) + removeOrcadMigrationSourceCutover(args.userDataPath, cutover.migrationId) +} + +function fenceStateReason(state: OrcadMigrationFenceState): string { + switch (state.state) { + case 'owned-by-deploy': + return 'This SSH host already belongs to a managed server.' + case 'fenced-unverifiable': + return 'This SSH host is held for a migration whose record is missing. Recover it from the managed server first.' + case 'stale-journal': + return 'A migration record names this SSH host but its fence is gone. Resolve that migration first.' + case 'fenced': + case 'none': + return 'This SSH host is not in a state a migration can start from.' + } +} + +function refuse( + verdict: 'live' | 'unverifiable', + code: string, + reason: string +): Extract { + return { outcome: 'refused', verdict, code, reason } +} diff --git a/src/main/ssh/orcad-migration-terminal-gate.test.ts b/src/main/ssh/orcad-migration-terminal-gate.test.ts new file mode 100644 index 000000000000..6be8455dcf9f --- /dev/null +++ b/src/main/ssh/orcad-migration-terminal-gate.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import { + assessOrcadMigrationTerminals, + confirmOrcadMigrationTerminalsUnderFence +} from './orcad-migration-terminal-gate' + +function store(leases: Pick[]) { + const full = leases.map((lease) => ({ ...lease, targetId: 'ssh-1', createdAt: 1, updatedAt: 1 })) + return { getSshRemotePtyLeases: () => full } +} + +describe('migration terminal gate', () => { + it('proves exit from terminated leases and an empty relay', async () => { + await expect( + assessOrcadMigrationTerminals( + store([{ ptyId: 'a', state: 'terminated' }]), + 'ssh-1', + async () => [] + ) + ).resolves.toEqual({ verdict: 'exited', provenPtyIds: ['a'] }) + }) + + it.each(['attached', 'detached'] as const)('blocks a %s lease as live', async (state) => { + await expect( + assessOrcadMigrationTerminals(store([{ ptyId: 'a', state }]), 'ssh-1', async () => []) + ).resolves.toMatchObject({ verdict: 'live', ptyIds: ['a'] }) + }) + + it('blocks terminals the relay still runs even with no lease for them', async () => { + await expect( + assessOrcadMigrationTerminals(store([]), 'ssh-1', async () => ['x']) + ).resolves.toMatchObject({ verdict: 'live', ptyIds: ['x'] }) + }) + + it.each([ + ['no relay to ask', null], + ['a relay that did not answer', async () => null], + [ + 'a relay that failed', + async () => { + throw new Error('channel closed') + } + ] + ])('reads an expired lease with %s as unverifiable, never as exited', async (_label, list) => { + await expect( + assessOrcadMigrationTerminals(store([{ ptyId: 'old', state: 'expired' }]), 'ssh-1', list) + ).resolves.toMatchObject({ verdict: 'unverifiable', ptyIds: ['old'] }) + }) + + it('accepts an expired lease once the relay answers that nothing runs', async () => { + await expect( + assessOrcadMigrationTerminals( + store([{ ptyId: 'old', state: 'expired' }]), + 'ssh-1', + async () => [] + ) + ).resolves.toEqual({ verdict: 'exited', provenPtyIds: ['old'] }) + }) + + it('confirms under the fence only when no unproven terminal appeared', () => { + const proof = { verdict: 'exited' as const, provenPtyIds: ['old'] } + expect( + confirmOrcadMigrationTerminalsUnderFence( + store([{ ptyId: 'old', state: 'expired' }]), + 'ssh-1', + proof + ) + ).toBe(proof) + expect( + confirmOrcadMigrationTerminalsUnderFence( + store([{ ptyId: 'new', state: 'expired' }]), + 'ssh-1', + proof + ) + ).toMatchObject({ verdict: 'unverifiable', ptyIds: ['new'] }) + expect( + confirmOrcadMigrationTerminalsUnderFence( + store([{ ptyId: 'old', state: 'attached' }]), + 'ssh-1', + proof + ) + ).toMatchObject({ verdict: 'live' }) + expect( + confirmOrcadMigrationTerminalsUnderFence( + store([{ ptyId: 'new', state: 'terminated' }]), + 'ssh-1', + proof + ) + ).toBe(proof) + }) +}) diff --git a/src/main/ssh/orcad-migration-terminal-gate.ts b/src/main/ssh/orcad-migration-terminal-gate.ts new file mode 100644 index 000000000000..d31c90ffe6ba --- /dev/null +++ b/src/main/ssh/orcad-migration-terminal-gate.ts @@ -0,0 +1,85 @@ +/** + * The dormant migration's terminal gate: a relay PTY cannot move into orcad, so the source must + * prove that every terminal it ever leased on the target has exited. Loss of contact is never + * exit: an unanswered relay, or a lease the relay cannot account for, blocks as `unverifiable`. + */ +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import type { Store } from '../persistence' + +export type OrcadMigrationTerminalVerdict = + | { verdict: 'exited'; provenPtyIds: string[] } + | { verdict: 'live' | 'unverifiable'; ptyIds: string[]; reason: string } + +/** The relay's own process list for the target; `null` when it did not answer. */ +export type ListRelayPtyIds = () => Promise + +type LeaseStore = Pick + +/** Taken before the fence, while the relay can still be asked. */ +export async function assessOrcadMigrationTerminals( + store: LeaseStore, + targetId: string, + listRelayPtyIds: ListRelayPtyIds | null +): Promise { + const leases = store.getSshRemotePtyLeases(targetId) + const live = leases.filter((lease) => lease.state === 'attached' || lease.state === 'detached') + if (live.length > 0) { + return refuse('live', live, 'terminals on this host are still running') + } + let relayPtyIds: string[] | null + try { + relayPtyIds = listRelayPtyIds ? await listRelayPtyIds() : null + } catch { + relayPtyIds = null + } + if (relayPtyIds && relayPtyIds.length > 0) { + return { + verdict: 'live', + ptyIds: [...relayPtyIds], + reason: 'the SSH relay still runs terminals on this host' + } + } + // An expired lease lost its owner without an exit record; only the relay can rule it out. + const expired = leases.filter((lease) => lease.state === 'expired') + if (expired.length > 0 && relayPtyIds === null) { + return refuse('unverifiable', expired, 'the SSH relay could not confirm expired terminals') + } + return { verdict: 'exited', provenPtyIds: leases.map((lease) => lease.ptyId) } +} + +/** + * Re-checked under the fence, after the relay was let go: the fence stops new leases, so any + * lease the earlier proof did not cover means a terminal started in between. + */ +export function confirmOrcadMigrationTerminalsUnderFence( + store: LeaseStore, + targetId: string, + proof: OrcadMigrationTerminalVerdict +): OrcadMigrationTerminalVerdict { + if (proof.verdict !== 'exited') { + return proof + } + const proven = new Set(proof.provenPtyIds) + const leases = store.getSshRemotePtyLeases(targetId) + const live = leases.filter((lease) => lease.state === 'attached' || lease.state === 'detached') + if (live.length > 0) { + return refuse('live', live, 'a terminal started on this host before the fence took hold') + } + const unproven = leases.filter((lease) => !proven.has(lease.ptyId)) + if (unproven.some((lease) => lease.state !== 'terminated')) { + return refuse( + 'unverifiable', + unproven, + 'a terminal lease appeared on this host that the relay was not asked about' + ) + } + return proof +} + +function refuse( + verdict: 'live' | 'unverifiable', + leases: SshRemotePtyLease[], + reason: string +): OrcadMigrationTerminalVerdict { + return { verdict, ptyIds: leases.map((lease) => lease.ptyId), reason } +} diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts index fe4ee7b79a6c..b4d4cffe82c8 100644 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ b/src/main/ssh/orcad-runtime-deployment.ts @@ -56,7 +56,12 @@ export async function createManagedOrcadEnvironment( if (hasRegisteredDirectSshAuthority(args.sshTargetId)) { throw new Error('Disconnect this SSH host before converting it to a managed Orca server.') } - const claimed = claims.claim(args.sshTargetId, environmentId, args.name) + const current = targetStore.getTarget(args.sshTargetId) + const claimed = claims.claim(args.sshTargetId, environmentId, { + deployName: args.name, + // Why: this deploy's own claim left a provisioning intent, or the server it registered. + ownerRecorded: Boolean(current?.orcadProvisioning) || Boolean(registered) + }) await claims.flush(args.signal) const targetGeneration = claimed.generation if (targetGeneration === undefined) { diff --git a/src/main/ssh/runtime-ssh-access.ts b/src/main/ssh/runtime-ssh-access.ts index 6092a501052a..e7ae6bbbc69e 100644 --- a/src/main/ssh/runtime-ssh-access.ts +++ b/src/main/ssh/runtime-ssh-access.ts @@ -154,7 +154,8 @@ export function linkRuntimeSshAccess( await claims.flush() requireAccessOnlyTarget(claims, target.id, environmentId) requireFence(claims, environmentId, fence, true) - claims.claim(target.id, environmentId) + // Why recorded: the durable link intent above names this target and environment. + claims.claim(target.id, environmentId, { ownerRecorded: true }) await claims.flush() let tunnelStarted = false let linkCommitted = false diff --git a/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts b/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts index 261c4288aa14..aba19e0a820a 100644 --- a/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts +++ b/src/main/ssh/ssh-connection-store-orcad-ownership.test.ts @@ -36,7 +36,7 @@ describe('managed orcad ownership of SSH targets', () => { orcadProvisioning: { requestId: 'r', name: 'n' } }) const claimed = sshStore.addTarget({ ...base, label: 'claimed' }) - sshStore.getOrcadRuntimeClaims().claim(claimed.id, 'environment-1') + sshStore.getOrcadRuntimeClaims().claim(claimed.id, 'environment-1', { ownerRecorded: false }) expect(sshStore.listTargets()).toEqual([visible]) expect(sshStore.getOrcadRuntimeClaims().listTargets()).toHaveLength(3) diff --git a/src/main/ssh/ssh-target-orcad-claims.test.ts b/src/main/ssh/ssh-target-orcad-claims.test.ts index c2849eb1685f..e6381d9a7cb6 100644 --- a/src/main/ssh/ssh-target-orcad-claims.test.ts +++ b/src/main/ssh/ssh-target-orcad-claims.test.ts @@ -48,7 +48,10 @@ function setup(options: SetupOptions = {}) { describe('managed orcad SSH target claims', () => { it('claims an empty target with a generation and a resumable provisioning intent', () => { const { claims, current } = setup() - const claimed = claims.claim('ssh-1', 'environment-1', 'Managed') + const claimed = claims.claim('ssh-1', 'environment-1', { + deployName: 'Managed', + ownerRecorded: false + }) expect(getManagedOrcadOwnerEnvironmentId(claimed.owner)).toBe('environment-1') expect(claimed.generation).toBe(4) expect(current().orcadProvisioning).toEqual({ requestId: 'environment-1', name: 'Managed' }) @@ -62,14 +65,29 @@ describe('managed orcad SSH target claims', () => { orcadProvisioning: { requestId: 'request-1', name: 'From dialog' } } }) - const claimed = claims.claim('ssh-1', 'environment-1', 'Other name') + const claimed = claims.claim('ssh-1', 'environment-1', { + deployName: 'Other name', + ownerRecorded: true + }) expect(claimed.generation).toBe(2) expect(claimed.orcadProvisioning).toEqual({ requestId: 'request-1', name: 'From dialog' }) }) + it('will not reuse its own owner without the durable record that explains it', () => { + const { claims, current } = setup({ + target: { generation: 2, owner: createManagedOrcadSshOwner('environment-1') } + }) + expect(() => + claims.claim('ssh-1', 'environment-1', { deployName: 'Managed', ownerRecorded: false }) + ).toThrow('no record explains why') + expect(current().orcadProvisioning).toBeUndefined() + }) + it('claims for SSH access without recording a provisioning intent', () => { const { claims } = setup({ target: { generation: 1 } }) - expect(claims.claim('ssh-1', 'environment-1').orcadProvisioning).toBeUndefined() + expect( + claims.claim('ssh-1', 'environment-1', { ownerRecorded: false }).orcadProvisioning + ).toBeUndefined() }) it.each<[string, SetupOptions, string]>([ @@ -101,7 +119,9 @@ describe('managed orcad SSH target claims', () => { ])('refuses a target with %s and leaves it unclaimed', (_label, options, message) => { const { claims, current } = setup(options) expect(claims.preflight('ssh-1').claimable).toBe(false) - expect(() => claims.claim('ssh-1', 'environment-1', 'Managed')).toThrow(message) + expect(() => + claims.claim('ssh-1', 'environment-1', { deployName: 'Managed', ownerRecorded: false }) + ).toThrow(message) expect(current().orcadProvisioning).toBeUndefined() }) @@ -132,7 +152,7 @@ describe('managed orcad SSH target claims', () => { it('releases only its own claim and clears the provisioning intent', () => { const { claims, current } = setup() - claims.claim('ssh-1', 'environment-1', 'Managed') + claims.claim('ssh-1', 'environment-1', { deployName: 'Managed', ownerRecorded: false }) expect(claims.release('ssh-1', 'environment-2')).toBeNull() expect(claims.release('ssh-1', 'environment-1')).toMatchObject({ owner: undefined, diff --git a/src/main/ssh/ssh-target-orcad-claims.ts b/src/main/ssh/ssh-target-orcad-claims.ts index cfce055a9eec..f5260435e35d 100644 --- a/src/main/ssh/ssh-target-orcad-claims.ts +++ b/src/main/ssh/ssh-target-orcad-claims.ts @@ -39,8 +39,15 @@ export class SshTargetOrcadClaims { return this.store.getSshTargets() } - /** `environmentId` lets that environment's own claim pass; omit it to see every blocker. */ - preflight(targetId: string, environmentId?: string): OrcadMigrationPreflight { + /** + * `owner` lets that environment's own claim pass, but only when the caller holds the durable + * record that explains it (a provisioning intent, an SSH-access intent or a migration journal). + * An owner with no such record is unexplained state to recover, never something to reuse. + */ + preflight( + targetId: string, + owner?: { environmentId: string; recorded: boolean } + ): OrcadMigrationPreflight { const target = this.store.getSshTarget(targetId) if (!target) { return { @@ -50,8 +57,18 @@ export class SshTargetOrcadClaims { blockers: [{ code: 'orcad_migration_target_not_found', category: 'registration' }] } } - if (environmentId && getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId) { - return { targetId, targetLabel: target.label, claimable: true, blockers: [] } + const ownedBy = getManagedOrcadOwnerEnvironmentId(target.owner) + if (owner && ownedBy === owner.environmentId) { + return owner.recorded + ? { targetId, targetLabel: target.label, claimable: true, blockers: [] } + : { + targetId, + targetLabel: target.label, + claimable: false, + blockers: [ + { code: 'orcad_migration_owner_unrecorded', category: 'exclusive-ownership' } + ] + } } const blockers = collectEmptyTargetBlockers(this.store, target) return { targetId, targetLabel: target.label, claimable: blockers.length === 0, blockers } @@ -61,8 +78,16 @@ export class SshTargetOrcadClaims { * Idempotent for the same environment; the caller makes the claim durable before acting on it. * A deploy passes its server name so an interrupted claim reads as pending provisioning. */ - claim(targetId: string, environmentId: string, deployName?: string): SshTarget { - const blocker = this.preflight(targetId, environmentId).blockers[0] + claim( + targetId: string, + environmentId: string, + options: { deployName?: string; ownerRecorded: boolean } + ): SshTarget { + const { deployName } = options + const blocker = this.preflight(targetId, { + environmentId, + recorded: options.ownerRecorded + }).blockers[0] if (blocker) { throw new Error(orcadTargetBlockerMessage(targetId, blocker)) } @@ -84,6 +109,46 @@ export class SshTargetOrcadClaims { return claimed } + /** A durable registration generation, so a fence and its journal bind to one registration. */ + ensureGeneration(targetId: string): number { + const target = this.requireTarget(targetId) + if (target.generation !== undefined) { + return target.generation + } + const generation = this.store.allocateSshTargetGeneration() + if (!this.store.updateSshTarget(targetId, { generation })) { + throw new Error(`SSH target "${targetId}" disappeared while assigning its generation.`) + } + return generation + } + + /** + * The migration fence. Unlike `claim`, it skips the empty-target rule: the caller has passed + * the conversion preflight and written the journal that records this fence. + */ + fenceForMigration(targetId: string, environmentId: string, generation: number): SshTarget { + const target = this.requireTarget(targetId) + if (target.generation !== generation) { + throw new Error('orcad_migration_target_generation_changed') + } + if (target.owner && getManagedOrcadOwnerEnvironmentId(target.owner) !== environmentId) { + throw new Error( + orcadTargetBlockerMessage(targetId, { + code: 'orcad_migration_target_owned', + category: 'exclusive-ownership', + owner: { ...target.owner } + }) + ) + } + const fenced = this.store.updateSshTarget(targetId, { + owner: createManagedOrcadSshOwner(environmentId) + }) + if (!fenced) { + throw new Error(`SSH target "${targetId}" disappeared while it was being fenced.`) + } + return fenced + } + release(targetId: string, environmentId: string): SshTarget | null { const target = this.store.getSshTarget(targetId) if (!target || getManagedOrcadOwnerEnvironmentId(target.owner) !== environmentId) { @@ -167,6 +232,8 @@ export function orcadTargetBlockerMessage( return `SSH target "${targetId}" not found.` case 'orcad_migration_target_owned': return 'This SSH target is already owned by another managed runtime.' + case 'orcad_migration_owner_unrecorded': + return 'This SSH target is held for this server, but no record explains why. Recover the server before reusing the host.' case 'orcad_migration_direct_ssh_repositories': case 'orcad_migration_direct_ssh_folder_workspaces': return 'This SSH target owns repositories or folder workspaces. A managed server can only be created on a host with no direct SSH projects yet; keep this host in direct SSH mode.' diff --git a/src/main/ssh/ssh-target-orcad-preflight.test.ts b/src/main/ssh/ssh-target-orcad-preflight.test.ts index 65905d9a50c2..e282f8620797 100644 --- a/src/main/ssh/ssh-target-orcad-preflight.test.ts +++ b/src/main/ssh/ssh-target-orcad-preflight.test.ts @@ -137,12 +137,21 @@ describe('migration export preflight', () => { ) }) - it('refuses a target another runtime owns, and passes the owner its own claim', () => { + it('refuses a target another runtime owns, and passes the owner only with its journal', () => { const owned = preflightStore((store) => store.addSshTarget({ ...TARGET, owner: createManagedOrcadSshOwner('env-1') }) ) expect(preflightOrcadMigrationExport(owned, TARGET.id).claimable).toBe(false) - expect(preflightOrcadMigrationExport(owned, TARGET.id, 'env-1').claimable).toBe(true) + expect( + preflightOrcadMigrationExport(owned, TARGET.id, { environmentId: 'env-1', recorded: true }) + .claimable + ).toBe(true) + expect( + preflightOrcadMigrationExport(owned, TARGET.id, { environmentId: 'env-1', recorded: false }) + ).toMatchObject({ + claimable: false, + blockers: [{ code: 'orcad_migration_owner_unrecorded' }] + }) }) it('reports an unknown target', () => { diff --git a/src/main/ssh/ssh-target-orcad-preflight.ts b/src/main/ssh/ssh-target-orcad-preflight.ts index 14a2bce0270b..5ddbaf485a23 100644 --- a/src/main/ssh/ssh-target-orcad-preflight.ts +++ b/src/main/ssh/ssh-target-orcad-preflight.ts @@ -25,10 +25,11 @@ export type OrcadMigrationPreflightStore = OrcadMigrationExportStore & 'getSshTarget' | 'getSshRemotePtyLeases' | 'inspectOrcadMigrationUntransferredDependencies' > +/** `owner` passes the environment's own fence only when its migration journal is on disk. */ export function preflightOrcadMigrationExport( store: OrcadMigrationPreflightStore, targetId: string, - environmentId?: string + owner?: { environmentId: string; recorded: boolean } ): OrcadMigrationPreflight { const target = store.getSshTarget(targetId) if (!target) { @@ -39,8 +40,15 @@ export function preflightOrcadMigrationExport( blockers: [{ code: 'orcad_migration_target_not_found', category: 'registration' }] } } - if (environmentId && getManagedOrcadOwnerEnvironmentId(target.owner) === environmentId) { - return { targetId, targetLabel: target.label, claimable: true, blockers: [] } + if (owner && getManagedOrcadOwnerEnvironmentId(target.owner) === owner.environmentId) { + return owner.recorded + ? { targetId, targetLabel: target.label, claimable: true, blockers: [] } + : { + targetId, + targetLabel: target.label, + claimable: false, + blockers: [{ code: 'orcad_migration_owner_unrecorded', category: 'exclusive-ownership' }] + } } const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)] const terminalLeases = store diff --git a/src/shared/orcad-migration-preflight.ts b/src/shared/orcad-migration-preflight.ts index 31b1687329ca..fbfd3d63ff43 100644 --- a/src/shared/orcad-migration-preflight.ts +++ b/src/shared/orcad-migration-preflight.ts @@ -51,6 +51,11 @@ export type OrcadMigrationBlocker = category: 'exclusive-ownership' owner: NonNullable } + | { + /** Owned by the asking environment, but no durable record of why. */ + code: 'orcad_migration_owner_unrecorded' + category: 'exclusive-ownership' + } | { code: 'orcad_migration_direct_ssh_repositories' category: 'drainable-static-state' diff --git a/src/shared/orcad-migration-source-cutover.ts b/src/shared/orcad-migration-source-cutover.ts new file mode 100644 index 000000000000..fa05bd0c3992 --- /dev/null +++ b/src/shared/orcad-migration-source-cutover.ts @@ -0,0 +1,59 @@ +/** + * The client-side journal of a dormant migration from a relay-hosted SSH target into a managed + * orcad. Kept outside the profile store so an older build rewriting the profile cannot strip it. + */ +import { z } from 'zod' +import { + parseOrcadMigrationManifest, + type OrcadMigrationManifest +} from './orcad-migration-manifest' + +export const ORCAD_MIGRATION_SOURCE_CUTOVER_VERSION = 1 +export const MAX_ORCAD_MIGRATION_SOURCE_CUTOVERS = 4 + +export const ORCAD_MIGRATION_SOURCE_CUTOVER_PHASES = [ + 'source-fenced', + 'destination-staged', + 'destination-committed', + 'source-retired' +] as const + +export type OrcadMigrationSourceCutoverPhase = + (typeof ORCAD_MIGRATION_SOURCE_CUTOVER_PHASES)[number] + +const CutoverRecordSchema = z + .object({ + version: z.literal(ORCAD_MIGRATION_SOURCE_CUTOVER_VERSION), + migrationId: z.string().min(1).max(128), + phase: z.enum(ORCAD_MIGRATION_SOURCE_CUTOVER_PHASES), + startedAt: z.string().datetime(), + updatedAt: z.string().datetime(), + destinationEnvironmentId: z.string().min(1).max(256), + destinationName: z.string().min(1).max(1_024), + // What the fence binds to: the exact registration and the manifest the destination will see. + sshTargetId: z.string().min(1).max(1_024), + sshTargetGeneration: z.number().int().positive(), + manifestSha256: z.string().regex(/^[a-f0-9]{64}$/), + manifest: z.unknown() + }) + .strict() + +export type OrcadMigrationSourceCutover = Omit, 'manifest'> & { + manifest: OrcadMigrationManifest +} + +/** Throws on anything that is not exactly a cutover whose manifest matches its binding. */ +export function parseOrcadMigrationSourceCutover(value: unknown): OrcadMigrationSourceCutover { + const record = CutoverRecordSchema.parse(value) + const manifest = parseOrcadMigrationManifest(record.manifest) + if ( + manifest.migrationId !== record.migrationId || + manifest.manifestSha256 !== record.manifestSha256 || + manifest.source.sshTargetId !== record.sshTargetId || + manifest.source.sshTargetGeneration !== record.sshTargetGeneration || + manifest.destinationEnvironmentId !== record.destinationEnvironmentId + ) { + throw new Error('orcad_migration_cutover_binding_mismatch') + } + return { ...record, manifest } +}