From 5f0ad50a55ae21783cb34cfda0a00100164190e0 Mon Sep 17 00:00:00 2001 From: m4air Date: Thu, 1 Oct 2026 22:06:58 -0700 Subject: [PATCH] feat(ssh): journal and fence an SSH host for dormant migration, gated on proven terminal exit (#16741 T8-c1+c2) A migration from a relay-hosted SSH target into a managed orcad now starts with a journal in its own sidecar directory, then the target's managed-owner fence, then a profile flush, before any remote call. A fence with no journal is unverifiable and never released; a journal whose fence is gone is stale and grants nothing; an unreadable journal fails closed. The fence requires every terminal the target ever leased to be proven exited, checked before the fence (with the relay's process list) and again under it. Same-owner claims now need the durable record that explains them. Inert until T8-c4/T6-10. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../orcad-migration-cutover-journal.test.ts | 103 +++++++++ .../ssh/orcad-migration-cutover-journal.ts | 128 +++++++++++ .../ssh/orcad-migration-source-fence.test.ts | 207 ++++++++++++++++++ src/main/ssh/orcad-migration-source-fence.ts | 194 ++++++++++++++++ .../ssh/orcad-migration-terminal-gate.test.ts | 92 ++++++++ src/main/ssh/orcad-migration-terminal-gate.ts | 85 +++++++ src/main/ssh/orcad-runtime-deployment.ts | 7 +- src/main/ssh/runtime-ssh-access.ts | 3 +- ...h-connection-store-orcad-ownership.test.ts | 2 +- src/main/ssh/ssh-target-orcad-claims.test.ts | 30 ++- src/main/ssh/ssh-target-orcad-claims.ts | 79 ++++++- .../ssh/ssh-target-orcad-preflight.test.ts | 13 +- src/main/ssh/ssh-target-orcad-preflight.ts | 14 +- src/shared/orcad-migration-preflight.ts | 5 + src/shared/orcad-migration-source-cutover.ts | 59 +++++ 15 files changed, 1002 insertions(+), 19 deletions(-) create mode 100644 src/main/ssh/orcad-migration-cutover-journal.test.ts create mode 100644 src/main/ssh/orcad-migration-cutover-journal.ts create mode 100644 src/main/ssh/orcad-migration-source-fence.test.ts create mode 100644 src/main/ssh/orcad-migration-source-fence.ts create mode 100644 src/main/ssh/orcad-migration-terminal-gate.test.ts create mode 100644 src/main/ssh/orcad-migration-terminal-gate.ts create mode 100644 src/shared/orcad-migration-source-cutover.ts 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 00000000000..fb00375f557 --- /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 00000000000..d6f5782761f --- /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 00000000000..750717afb57 --- /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 00000000000..2851293df50 --- /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 00000000000..6be8455dcf9 --- /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 00000000000..d31c90ffe6b --- /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 fe4ee7b79a6..b4d4cffe82c 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 6092a501052..e7ae6bbbc69 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 261c4288aa1..aba19e0a820 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 c2849eb1685..e6381d9a7cb 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 cfce055a9ee..f5260435e35 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 65905d9a50c..e282f862079 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 14a2bce0270..5ddbaf485a2 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 31b1687329c..fbfd3d63ff4 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 00000000000..fa05bd0c399 --- /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 } +}