diff --git a/src/main/ssh/orcad-managed-migration-status.ts b/src/main/ssh/orcad-managed-migration-status.ts new file mode 100644 index 000000000000..a05675ef8017 --- /dev/null +++ b/src/main/ssh/orcad-managed-migration-status.ts @@ -0,0 +1,53 @@ +/** Which dormant migrations into managed servers have not finished, for status and resume. */ +import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' +import type { KnownRuntimeEnvironment } from '../../shared/runtime-environments' +import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' + +export type OrcadManagedPendingMigration = { + migrationId: string + environmentId: string + name: string + sshTargetId: string + phase: OrcadMigrationSourceCutover['phase'] + startedAt: string +} + +/** Throws on an unreadable journal: a migration we cannot read is not "none". */ +export function listPendingManagedOrcadMigrations( + userDataPath: string +): OrcadManagedPendingMigration[] { + return listOrcadMigrationSourceCutovers(userDataPath) + .filter((cutover) => cutover.phase !== 'source-retired') + .map((cutover) => ({ + migrationId: cutover.migrationId, + environmentId: cutover.destinationEnvironmentId, + name: cutover.destinationName, + sshTargetId: cutover.sshTargetId, + phase: cutover.phase, + startedAt: cutover.startedAt + })) +} + +export function findIncompleteManagedOrcadMigration( + userDataPath: string, + environmentId: string +): OrcadManagedPendingMigration | null { + return ( + listPendingManagedOrcadMigrations(userDataPath).find( + (migration) => migration.environmentId === environmentId + ) ?? null + ) +} + +/** The registered server is the one the journal deploys into, on the journaled registration. */ +export function environmentMatchesManagedOrcadCutover( + environment: KnownRuntimeEnvironment, + cutover: OrcadMigrationSourceCutover +): boolean { + return ( + environment.id === cutover.destinationEnvironmentId && + environment.name === cutover.destinationName && + environment.orcadDeployment?.sshTargetId === cutover.sshTargetId && + environment.orcadDeployment.sshTargetGeneration === cutover.sshTargetGeneration + ) +} diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts index 11a767be7ad6..95dba4a4c430 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts @@ -1,12 +1,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' -import type { - OrcadMigrationCatalogState, - OrcadMigrationManifest -} from '../../shared/orcad-migration-manifest' import type { SshTarget } from '../../shared/ssh-types' import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' import { Store } from '../persistence/loading-store/store' @@ -20,6 +16,7 @@ import { ORCAD_MIGRATION_DESTINATION_UNSUPPORTED } from './orcad-migration-catal import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' import { fenceOrcadMigrationSource } from './orcad-migration-source-fence' import { SshTargetOrcadClaims } from './ssh-target-orcad-claims' +import { fakeOrcadMigrationDestination as fakeDestination } from './orcad-migration-destination-fake' const TARGET: SshTarget = { id: 'ssh-prod', @@ -38,59 +35,6 @@ afterEach(async () => { } }) -/** An in-memory destination with the T6-9 semantics: idempotent stage, receipt-keyed commit. */ -function fakeDestination() { - let state: 'absent' | 'staged' | 'committed' = 'absent' - const view = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { - const base = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } - if (state === 'committed') { - return { - ...base, - state, - receipt: { - version: 1, - migrationId: manifest.migrationId, - manifestSha256: manifest.manifestSha256, - source: manifest.source, - importedAt: '2026-10-01T00:00:00.000Z', - repositoryIds: [], - projectGroupIds: [], - folderWorkspaceIds: [] - } - } - } - return state === 'staged' - ? { ...base, state, stagedAt: '2026-10-01T00:00:00.000Z', snapshotUploads: [] } - : { ...base, state } - } - const destination = { - commits: 0, - readState: vi.fn(async (manifest: OrcadMigrationManifest) => view(manifest)), - stage: vi.fn(async (manifest: OrcadMigrationManifest) => { - if (state === 'absent') { - state = 'staged' - } - return view(manifest) - }), - commit: vi.fn(async (manifest: OrcadMigrationManifest) => { - if (state === 'staged') { - state = 'committed' - destination.commits += 1 - } - return view(manifest) - }), - abort: vi.fn(async (manifest: OrcadMigrationManifest) => { - const aborted = state === 'staged' - if (aborted) { - state = 'absent' - } - return { ...view(manifest), aborted, ...(aborted ? {} : { durableAbsent: true as const }) } - }), - stageChunk: vi.fn() - } - return destination -} - async function setup() { const userDataPath = mkdtempSync(join(tmpdir(), 'orcad-cutover-coordinator-')) directories.push(userDataPath) diff --git a/src/main/ssh/orcad-migration-cutover-fixture.ts b/src/main/ssh/orcad-migration-cutover-fixture.ts new file mode 100644 index 000000000000..df56e7a5814b --- /dev/null +++ b/src/main/ssh/orcad-migration-cutover-fixture.ts @@ -0,0 +1,36 @@ +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' + +/** A valid journal entry whose manifest is bound to its target, generation and destination. */ +export function orcadMigrationCutoverFixture( + migrationId = 'migration-1', + sshTargetId = 'ssh-1', + binding: { generation?: number; environmentId?: string; name?: string } = {} +): OrcadMigrationSourceCutover { + const generation = binding.generation ?? 2 + const environmentId = binding.environmentId ?? 'env-1' + const unsigned = { + version: ORCAD_MIGRATION_MANIFEST_VERSION, + migrationId, + createdAt: '2026-10-01T00:00:00.000Z', + source: { sshTargetId, sshTargetGeneration: generation, targetLabel: 'Prod' }, + payload: { repositories: [], projectGroups: [], folderWorkspaces: [] }, + destinationEnvironmentId: environmentId + } + 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: environmentId, + destinationName: binding.name ?? 'Managed', + sshTargetId, + sshTargetGeneration: generation, + manifestSha256: manifest.manifestSha256, + provenPtyIds: [], + manifest + } +} diff --git a/src/main/ssh/orcad-migration-cutover-journal.test.ts b/src/main/ssh/orcad-migration-cutover-journal.test.ts index ef8656b2c87f..b8abb53a73b3 100644 --- a/src/main/ssh/orcad-migration-cutover-journal.test.ts +++ b/src/main/ssh/orcad-migration-cutover-journal.test.ts @@ -2,9 +2,7 @@ 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 { orcadMigrationCutoverFixture as cutover } from './orcad-migration-cutover-fixture' import { findOrcadMigrationSourceCutoverForTarget, listOrcadMigrationSourceCutovers, @@ -19,32 +17,6 @@ beforeEach(() => { }) 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, - provenPtyIds: [], - manifest - } -} - const journalPath = (id: string) => join(orcadMigrationCutoverJournalDirectory(userDataPath), `${id}.json`) diff --git a/src/main/ssh/orcad-migration-destination-fake.ts b/src/main/ssh/orcad-migration-destination-fake.ts new file mode 100644 index 000000000000..13794f5ff499 --- /dev/null +++ b/src/main/ssh/orcad-migration-destination-fake.ts @@ -0,0 +1,67 @@ +import { vi, type Mock } from 'vitest' +import type { + OrcadMigrationCatalogState, + OrcadMigrationManifest +} from '../../shared/orcad-migration-manifest' +import type { OrcadMigrationDestinationCatalog } from './orcad-migration-cutover-coordinator' + +type Catalog = OrcadMigrationDestinationCatalog + +export type FakeOrcadMigrationDestination = { commits: number } & { + [Method in keyof Catalog]: Mock +} + +/** An in-memory destination with the T6-9 semantics: idempotent stage, receipt-keyed commit. */ +export function fakeOrcadMigrationDestination(): FakeOrcadMigrationDestination { + let state: 'absent' | 'staged' | 'committed' = 'absent' + const view = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { + const base = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } + if (state === 'committed') { + return { + ...base, + state, + receipt: { + version: 1, + migrationId: manifest.migrationId, + manifestSha256: manifest.manifestSha256, + source: manifest.source, + importedAt: '2026-10-01T00:00:00.000Z', + repositoryIds: [], + projectGroupIds: [], + folderWorkspaceIds: [] + } + } + } + return state === 'staged' + ? { ...base, state, stagedAt: '2026-10-01T00:00:00.000Z', snapshotUploads: [] } + : { ...base, state } + } + const destination: FakeOrcadMigrationDestination = { + commits: 0, + readState: vi.fn(async (manifest: OrcadMigrationManifest) => + view(manifest) + ), + stage: vi.fn(async (manifest: OrcadMigrationManifest) => { + if (state === 'absent') { + state = 'staged' + } + return view(manifest) + }), + commit: vi.fn(async (manifest: OrcadMigrationManifest) => { + if (state === 'staged') { + state = 'committed' + destination.commits += 1 + } + return view(manifest) + }), + abort: vi.fn(async (manifest: OrcadMigrationManifest) => { + const aborted = state === 'staged' + if (aborted) { + state = 'absent' + } + return { ...view(manifest), aborted, ...(aborted ? {} : { durableAbsent: true as const }) } + }), + stageChunk: vi.fn() + } + return destination +} diff --git a/src/main/ssh/orcad-runtime-conversion.test.ts b/src/main/ssh/orcad-runtime-conversion.test.ts new file mode 100644 index 000000000000..bbe2abbecb48 --- /dev/null +++ b/src/main/ssh/orcad-runtime-conversion.test.ts @@ -0,0 +1,174 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' +import { addManagedOrcadEnvironment } from '../../shared/runtime-environment-managed-orcad-store' +import { listEnvironments } from '../../shared/runtime-environment-store' +import type { SshTarget } from '../../shared/ssh-types' +import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' +import { Store } from '../persistence/loading-store/store' +import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' +import { fakeOrcadMigrationDestination } from './orcad-migration-destination-fake' +import { SshConnectionStore } from './ssh-connection-store' + +const mocks = vi.hoisted(() => { + const state: { targetStore: unknown } = { targetStore: null } + return { state, deploy: vi.fn(), ensureTunnel: vi.fn(), directAuthority: vi.fn() } +}) +vi.mock('./ssh-target-registry', () => ({ + getSshConnectionManager: () => ({}), + getSshTargetRegistryStore: () => mocks.state.targetStore, + hasRegisteredDirectSshAuthority: mocks.directAuthority +})) +vi.mock('./orcad-runtime-deployment', () => ({ createManagedOrcadEnvironment: mocks.deploy })) +vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) + +const { convertSshTargetToManagedOrcad } = await import('./orcad-runtime-conversion') + +const TARGET: SshTarget = { + id: 'ssh-prod', + label: 'Production', + host: 'prod.example.com', + port: 22, + username: 'deploy', + generation: 2 +} + +let userDataPath: string +let store: Store +let destination: ReturnType + +beforeEach(() => { + vi.resetAllMocks() + userDataPath = mkdtempSync(join(tmpdir(), 'orcad-conversion-')) + store = createSqliteTestStore(Store, { dataFile: join(userDataPath, 'orca-data.json') }) + store.addSshTarget(TARGET) + store.addRepo({ + id: 'repo-1', + path: '/srv/app', + displayName: 'App', + badgeColor: '#737373', + addedAt: 1, + kind: 'git', + connectionId: TARGET.id + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SshConnectionStore wraps the real test store it is given. + mocks.state.targetStore = new SshConnectionStore(store as never) + destination = fakeOrcadMigrationDestination() + mocks.directAuthority.mockReturnValue(false) + mocks.ensureTunnel.mockResolvedValue(undefined) + // Registers the server the fence named, as the real deploy would after pairing. + mocks.deploy.mockImplementation(async (path: string, args: { name: string }) => { + const owner = getManagedOrcadOwnerEnvironmentId(store.getSshTarget(TARGET.id)?.owner) + if (!owner) { + throw new Error('deploy without a fence') + } + if (!listEnvironments(path).some((entry) => entry.id === owner)) { + addManagedOrcadEnvironment(path, { + id: owner, + name: args.name, + pairingCode: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: 'ws://127.0.0.1:46768/', + deviceToken: 'device-token', + publicKeyB64: 'public-key' + }), + orcadDeployment: { + sshTargetId: TARGET.id, + sshTargetGeneration: 2, + localPort: 46_768, + remotePort: 6_768 + } + }) + } + return { outcome: 'created', environment: {}, activeVersion: '1.0.0' } + }) +}) + +afterEach(async () => { + await closeTestStores() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +const releaseDirectSession = vi.fn(async () => {}) +const convert = (listRelayPtyIds: (() => Promise) | null = async () => []) => + convertSshTargetToManagedOrcad(userDataPath, { + sshTargetId: TARGET.id, + name: 'Managed', + listRelayPtyIds, + destinationFor: () => destination, + releaseDirectSession, + now: () => new Date('2026-10-02T00:00:00.000Z') + }) + +describe('converting an SSH host into a managed server', () => { + it('fences, deploys, marks the server, then commits the catalog exactly once', async () => { + const result = await convert() + expect(result).toMatchObject({ outcome: 'committed' }) + expect(releaseDirectSession).toHaveBeenCalledWith(TARGET.id) + expect(mocks.deploy).toHaveBeenCalledWith( + userDataPath, + expect.objectContaining({ migration: true }) + ) + expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-committed') + const [environment] = listEnvironments(userDataPath) + expect(environment?.orcadMigratedAt).toBe('2026-10-02T00:00:00.000Z') + expect(destination.commits).toBe(1) + // Retiring the source is the next step; until then it keeps every row. + expect(store.getRepos().map((repo) => repo.id)).toEqual(['repo-1']) + }) + + it('refuses before touching the host while terminals run or cannot be counted', async () => { + store.upsertSshRemotePtyLease({ targetId: TARGET.id, ptyId: 'p', state: 'expired' }) + await expect(convert(null)).resolves.toMatchObject({ + outcome: 'refused', + verdict: 'unverifiable' + }) + expect(releaseDirectSession).not.toHaveBeenCalled() + expect(store.getSshTarget(TARGET.id)?.owner).toBeUndefined() + expect(listOrcadMigrationSourceCutovers(userDataPath)).toEqual([]) + }) + + it('keeps the fence across a deferred deploy and resumes the same migration', async () => { + mocks.deploy.mockResolvedValueOnce({ + outcome: 'deferred', + candidateVersion: '1.0.0', + code: 'orcad_update_terminals_running', + reason: 'busy' + }) + await expect(convert()).resolves.toMatchObject({ outcome: 'deferred' }) + const [fenced] = listOrcadMigrationSourceCutovers(userDataPath) + expect(fenced?.phase).toBe('source-fenced') + await expect(convert(null)).resolves.toMatchObject({ + outcome: 'committed', + migrationId: fenced?.migrationId + }) + expect(listOrcadMigrationSourceCutovers(userDataPath)).toHaveLength(1) + }) + + it('resumes after a lost commit reply without committing twice', async () => { + const read = destination.readState.getMockImplementation() + const commit = destination.commit.getMockImplementation() + let reachable = true + destination.readState.mockImplementation(async (manifest) => { + if (!reachable) { + throw new Error('socket closed') + } + return read!(manifest) + }) + // The commit lands, then contact is lost before either the reply or a re-read arrives. + destination.commit.mockImplementationOnce(async (manifest) => { + await commit!(manifest) + reachable = false + throw new Error('socket closed') + }) + await expect(convert()).rejects.toThrow('socket closed') + expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-staged') + reachable = true + await expect(convert(null)).resolves.toMatchObject({ outcome: 'committed' }) + expect(destination.commits).toBe(1) + expect(listOrcadMigrationSourceCutovers(userDataPath)[0]?.phase).toBe('destination-committed') + }) +}) diff --git a/src/main/ssh/orcad-runtime-conversion.ts b/src/main/ssh/orcad-runtime-conversion.ts new file mode 100644 index 000000000000..a9e81a74950a --- /dev/null +++ b/src/main/ssh/orcad-runtime-conversion.ts @@ -0,0 +1,150 @@ +/** + * Converting a relay-hosted SSH target that holds Orca state into a managed orcad server: + * fence the source, deploy and pair the server, then stage and commit the dormant catalog. + * + * Every step is keyed by the journal, so calling this again after a crash or lost contact resumes + * the same migration instead of starting another. The source stays authoritative until the + * destination proves its commit; retiring the source is a separate, later step. + */ +import { randomUUID } from 'node:crypto' +import type { OrcadManagedConversionResult } from '../../shared/orcad-managed-runtime' +import type { OrcadMigrationSourceCutover } from '../../shared/orcad-migration-source-cutover' +import { recordManagedOrcadMigration } from '../../shared/runtime-environment-managed-orcad-store' +import { listEnvironments } from '../../shared/runtime-environment-store' +import { + redactRuntimeEnvironment, + type KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import { runTargetLifecycle } from '../ipc/ssh-target-lifecycle-queue' +import { environmentMatchesManagedOrcadCutover } from './orcad-managed-migration-status' +import { requireManagedOrcadInfrastructure } from './orcad-managed-runtime-context' +import { ensureOrcadManagedTunnel } from './orcad-managed-tunnel' +import { + commitOrcadMigrationDestination, + type OrcadMigrationDestinationCatalog +} from './orcad-migration-cutover-coordinator' +import { + fenceOrcadMigrationSource, + resolveOrcadMigrationFence +} from './orcad-migration-source-fence' +import { + assessOrcadMigrationTerminals, + type ListRelayPtyIds +} from './orcad-migration-terminal-gate' +import { createManagedOrcadEnvironment } from './orcad-runtime-deployment' +import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' + +export type OrcadManagedConversionArgs = { + sshTargetId: string + name: string + /** The relay's process list, asked while the host is still connected directly. */ + listRelayPtyIds: ListRelayPtyIds | null + /** The destination's T6-9 catalog client, reached through the server's tunnel. */ + destinationFor: (environment: KnownRuntimeEnvironment) => OrcadMigrationDestinationCatalog + /** Releases the direct SSH session after the terminal check, before the fence. */ + releaseDirectSession: (sshTargetId: string) => Promise + now?: () => Date + signal?: AbortSignal +} + +export async function convertSshTargetToManagedOrcad( + userDataPath: string, + args: OrcadManagedConversionArgs +): Promise { + const fenced = await fenceOrResume(userDataPath, args) + if ('outcome' in fenced) { + return fenced + } + const deployed = await createManagedOrcadEnvironment(userDataPath, { + name: fenced.destinationName, + sshTargetId: fenced.sshTargetId, + migration: true, + signal: args.signal + }) + if (deployed.outcome === 'deferred') { + return deployed // The fence and journal stay; a later call resumes here. + } + const environment = listEnvironments(userDataPath).find( + (entry) => entry.id === fenced.destinationEnvironmentId + ) + if (!environment || !environmentMatchesManagedOrcadCutover(environment, fenced)) { + throw new Error('orcad_migration_destination_environment_mismatch') + } + // Before any commit can land, so a crash after it still blocks rollbacks across it. + const marked = recordManagedOrcadMigration( + userDataPath, + environment.id, + (args.now ?? (() => new Date()))().toISOString() + ) + await ensureOrcadManagedTunnel(userDataPath, environment.id) + const { claims, targetStore } = requireManagedOrcadInfrastructure() + const committed = await runTargetLifecycle(fenced.sshTargetId, () => + commitOrcadMigrationDestination( + { + userDataPath, + store: targetStore.getOrcadMigrationSource(), + claims, + destination: args.destinationFor(marked), + now: args.now + }, + fenced.migrationId + ) + ) + if (committed.phase !== 'destination-committed' && committed.phase !== 'source-retired') { + throw new Error(`orcad_migration_commit_not_proven:${committed.phase}`) + } + return { + outcome: 'committed', + environment: redactRuntimeEnvironment(marked), + migrationId: committed.migrationId + } +} + +async function fenceOrResume( + userDataPath: string, + args: OrcadManagedConversionArgs +): Promise< + OrcadMigrationSourceCutover | Extract +> { + const { claims, targetStore } = requireManagedOrcadInfrastructure() + const target = targetStore.getTarget(args.sshTargetId) + if (!target) { + return refuse('unverifiable', 'orcad_migration_target_not_found', 'The SSH host is gone.') + } + const existing = resolveOrcadMigrationFence(userDataPath, target) + if (existing.state === 'fenced') { + return existing.cutover + } + const store = targetStore.getOrcadMigrationSource() + // Asked while the relay still answers; the fence re-checks leases once it holds. + const terminalProof = await assessOrcadMigrationTerminals(store, target.id, args.listRelayPtyIds) + if (terminalProof.verdict !== 'exited') { + return refuse(terminalProof.verdict, 'orcad_migration_terminals', terminalProof.reason) + } + await args.releaseDirectSession(target.id) + const result = await runTargetLifecycle(target.id, () => + fenceOrcadMigrationSource({ + userDataPath, + store, + claims, + targetId: target.id, + destinationEnvironmentId: randomUUID(), + destinationName: args.name, + terminalProof, + hasDirectSshAuthority: hasRegisteredDirectSshAuthority, + now: args.now, + signal: args.signal + }) + ) + return result.outcome === 'fenced' + ? result.cutover + : refuse(result.verdict, result.code, result.reason) +} + +function refuse( + verdict: 'live' | 'unverifiable', + code: string, + reason: string +): Extract { + return { outcome: 'refused', verdict, code, reason } +} diff --git a/src/main/ssh/orcad-runtime-deployment.test.ts b/src/main/ssh/orcad-runtime-deployment.test.ts index f0f66ec5e5c4..0efc18f53c4e 100644 --- a/src/main/ssh/orcad-runtime-deployment.test.ts +++ b/src/main/ssh/orcad-runtime-deployment.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { getManagedOrcadOwnerEnvironmentId } from '../../shared/managed-orcad-ssh-owner' +import { + createManagedOrcadSshOwner, + getManagedOrcadOwnerEnvironmentId +} from '../../shared/managed-orcad-ssh-owner' +import { writeOrcadMigrationSourceCutover } from './orcad-migration-cutover-journal' +import { orcadMigrationCutoverFixture } from './orcad-migration-cutover-fixture' import { listEnvironments } from '../../shared/runtime-environment-store' import type { SshTarget } from '../../shared/ssh-types' import { getRemoteHostPlatform } from './ssh-remote-platform' @@ -308,6 +313,37 @@ describe('createManagedOrcadEnvironment', () => { }) }) +describe('createManagedOrcadEnvironment for a migration', () => { + it('deploys into its own journaled fence without leaving a provisioning intent', async () => { + target = { ...target, owner: createManagedOrcadSshOwner('env-m'), generation: 9 } + writeOrcadMigrationSourceCutover( + userDataPath, + orcadMigrationCutoverFixture('m-1', 'ssh-1', { generation: 9, environmentId: 'env-m' }) + ) + await expect( + createManagedOrcadEnvironment(userDataPath, { + name: 'Managed', + sshTargetId: 'ssh-1', + migration: true + }) + ).resolves.toMatchObject({ outcome: 'created' }) + expect(listEnvironments(userDataPath).map((entry) => entry.id)).toEqual(['env-m']) + expect(target.orcadProvisioning).toBeUndefined() + }) + + it('refuses a migration deploy whose fence has no journal, before connecting', async () => { + target = { ...target, owner: createManagedOrcadSshOwner('env-m'), generation: 9 } + await expect( + createManagedOrcadEnvironment(userDataPath, { + name: 'Managed', + sshTargetId: 'ssh-1', + migration: true + }) + ).rejects.toThrow('orcad_migration_fence_required') + expect(mocks.connect).not.toHaveBeenCalled() + }) +}) + describe('getManagedOrcadRuntimeStatus', () => { it('reports the activation record and an interrupted transaction without repairing it', async () => { await deploy() @@ -339,6 +375,7 @@ describe('getManagedOrcadRuntimeStatus', () => { startedAt: 'then' }, terminals: { liveSessions: 0, startedSinceActivation: 0, daemonProtocolVersion: 39 }, + migration: null, deferredUpdate: null }) expect(mocks.recover).toHaveBeenCalledTimes(1) @@ -357,6 +394,28 @@ describe('getManagedOrcadRuntimeStatus', () => { }) }) + it('reports an unfinished migration into the server', async () => { + await deploy() + const [environment] = listEnvironments(userDataPath) + writeOrcadMigrationSourceCutover(userDataPath, { + ...orcadMigrationCutoverFixture('m-2', 'ssh-1', { + generation: 9, + environmentId: environment!.id + }), + phase: 'destination-staged' + }) + mocks.resolveContext.mockImplementation(async (claimed: SshTarget) => ({ + activationRecord: { ...emptyRecord, active: VERSION }, + connection: {}, + host: getRemoteHostPlatform('linux-x64'), + remoteHome: '/home/dev', + target: claimed + })) + await expect(getManagedOrcadRuntimeStatus(userDataPath, 'Managed')).resolves.toMatchObject({ + migration: { migrationId: 'm-2', phase: 'destination-staged' } + }) + }) + it('refuses a server whose SSH registration was re-created', async () => { await deploy() target = { ...target, generation: 10 } diff --git a/src/main/ssh/orcad-runtime-deployment.ts b/src/main/ssh/orcad-runtime-deployment.ts index b4d4cffe82c8..086525e13b81 100644 --- a/src/main/ssh/orcad-runtime-deployment.ts +++ b/src/main/ssh/orcad-runtime-deployment.ts @@ -26,6 +26,8 @@ import { resolveOrcadRemoteContext } from './orcad-remote-context' import { deployOrcad } from './orcad-remote-deploy' import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' import { hasRegisteredDirectSshAuthority } from './ssh-target-registry' +import { resolveOrcadMigrationFence } from './orcad-migration-source-fence' +import type { SshTarget } from '../../shared/ssh-types' import { isForceableOrcadDeferral, managedOrcadSlot, @@ -35,7 +37,14 @@ import { export async function createManagedOrcadEnvironment( userDataPath: string, - args: { name: string; sshTargetId: string; force?: boolean; signal?: AbortSignal } + args: { + name: string + sshTargetId: string + force?: boolean + signal?: AbortSignal + /** Deploying into a target fenced by a migration journal rather than claiming an empty one. */ + migration?: boolean + } ): Promise { return runTargetLifecycle(args.sshTargetId, async () => { const { connectionManager, targetStore, claims } = requireManagedOrcadInfrastructure() @@ -57,10 +66,15 @@ export async function createManagedOrcadEnvironment( throw new Error('Disconnect this SSH host before converting it to a managed Orca server.') } const current = targetStore.getTarget(args.sshTargetId) + if (args.migration) { + assertMigrationFence(userDataPath, current, environmentId, args.name) + } 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) + // A migration records itself in its journal, not as a provisioning intent. + ...(args.migration ? {} : { deployName: args.name }), + // Why: this deploy's own claim left a provisioning intent or a journal, or registered a server. + ownerRecorded: + Boolean(args.migration) || Boolean(current?.orcadProvisioning) || Boolean(registered) }) await claims.flush(args.signal) const targetGeneration = claimed.generation @@ -155,3 +169,19 @@ export async function createManagedOrcadEnvironment( } }) } + +function assertMigrationFence( + userDataPath: string, + target: SshTarget | undefined, + environmentId: string, + name: string +): void { + const fence = target ? resolveOrcadMigrationFence(userDataPath, target) : null + if ( + fence?.state !== 'fenced' || + fence.cutover.destinationEnvironmentId !== environmentId || + fence.cutover.destinationName !== name + ) { + throw new Error('orcad_migration_fence_required') + } +} diff --git a/src/main/ssh/orcad-runtime-maintenance.test.ts b/src/main/ssh/orcad-runtime-maintenance.test.ts index c150dc7b59dd..b9d89adec054 100644 --- a/src/main/ssh/orcad-runtime-maintenance.test.ts +++ b/src/main/ssh/orcad-runtime-maintenance.test.ts @@ -1,6 +1,7 @@ import { rmSync } from 'node:fs' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { listEnvironments } from '../../shared/runtime-environment-store' +import { recordManagedOrcadMigration } from '../../shared/runtime-environment-managed-orcad-store' import { createManagedLifecycleHarness, MANAGED_PREVIOUS_VERSION, @@ -19,7 +20,8 @@ const mocks = vi.hoisted(() => { recover: vi.fn(), probe: vi.fn(), buildHash: vi.fn(), - ensureTunnel: vi.fn() + ensureTunnel: vi.fn(), + pendingMigration: vi.fn() } }) @@ -39,6 +41,9 @@ vi.mock('./orcad-artifact-materializer', () => ({ })) vi.mock('./orcad-local-build-hash', () => ({ computeLocalOrcadBuildHash: () => 'local-hash' })) vi.mock('./orcad-managed-tunnel', () => ({ ensureOrcadManagedTunnel: mocks.ensureTunnel })) +vi.mock('./orcad-managed-migration-status', () => ({ + findIncompleteManagedOrcadMigration: mocks.pendingMigration +})) vi.mock('./orcad-activation-transaction-store', () => ({ readOrcadActivationTransaction: async () => null })) @@ -150,6 +155,29 @@ describe('rollbackManagedOrcadEnvironment', () => { expect(mocks.census).not.toHaveBeenCalled() }) + it('refuses while a migration into the server is unfinished', async () => { + mocks.pendingMigration.mockReturnValueOnce({ migrationId: 'm-1', phase: 'destination-staged' }) + await expect( + rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) + ).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_rollback_migration_in_progress' }) + expect(mocks.rollback).not.toHaveBeenCalled() + }) + + it('refuses a rollback to a snapshot older than the migrated catalog, not a newer one', async () => { + // The harness's active version activated 2026-01-01. + recordManagedOrcadMigration(harness.userDataPath, 'environment-1', '2026-02-01T00:00:00.000Z') + await expect( + rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) + ).resolves.toMatchObject({ outcome: 'refused', code: 'orcad_rollback_crosses_migration' }) + expect(mocks.rollback).not.toHaveBeenCalled() + mocks.resolveContext.mockImplementation(async () => + harness.context({ activatedAt: '2026-03-01T00:00:00.000Z' }) + ) + mocks.rollback.mockResolvedValueOnce({ outcome: 'refused', code: 'x', reason: 'y' }) + await rollbackManagedOrcadEnvironment(harness.userDataPath, { selector: 'Managed' }) + expect(mocks.rollback).toHaveBeenCalledOnce() + }) + it('rolls back against the installed bytes of the previous slot', async () => { mocks.rollback.mockResolvedValueOnce({ outcome: 'rolled-back', diff --git a/src/main/ssh/orcad-runtime-maintenance.ts b/src/main/ssh/orcad-runtime-maintenance.ts index 712d0d67ae00..94d685f89ab4 100644 --- a/src/main/ssh/orcad-runtime-maintenance.ts +++ b/src/main/ssh/orcad-runtime-maintenance.ts @@ -37,6 +37,7 @@ import { readRemoteOrcadBuildHash } from './orcad-remote-build-hash' import { deployOrcad } from './orcad-remote-deploy' import { rollbackOrcad } from './orcad-remote-rollback' import { collectManagedTerminalCensus } from './orcad-terminal-census-client' +import { findIncompleteManagedOrcadMigration } from './orcad-managed-migration-status' import { tunneledOrcadPairingCode } from './orcad-tunneled-pairing' type LifecycleArgs = { selector: string; signal?: AbortSignal } @@ -138,6 +139,10 @@ export function rollbackManagedOrcadEnvironment( reason: 'This server has no previous version to roll back to.' } } + const crossing = migrationRollbackRefusal(userDataPath, environment, record.activatedAt) + if (crossing) { + return crossing + } const census = await collectManagedTerminalCensus(userDataPath, environment, record) // Why idle only: this client cannot read the older build's daemon protocol, so it cannot show // that build would reach terminals that are still running. @@ -213,3 +218,32 @@ export function recoverManagedOrcadEnvironment( } ) } + +/** + * A rollback restores the snapshot taken when the current version activated. If a migration + * began after that, the snapshot predates the imported catalog and restoring it would drop it. + */ +function migrationRollbackRefusal( + userDataPath: string, + environment: KnownRuntimeEnvironment, + activatedAt: string | null +): OrcadManagedRollbackResult | null { + if (findIncompleteManagedOrcadMigration(userDataPath, environment.id)) { + return { + outcome: 'refused', + code: 'orcad_rollback_migration_in_progress', + reason: 'A migration into this server is still running. Finish it before rolling back.' + } + } + const migratedAt = environment.orcadMigratedAt + if (migratedAt && (activatedAt === null || Date.parse(activatedAt) < Date.parse(migratedAt))) { + return { + outcome: 'refused', + code: 'orcad_rollback_crosses_migration', + reason: + "The previous version's state predates the projects migrated onto this server; " + + 'rolling back would lose them. Deploy forward instead.' + } + } + return null +} diff --git a/src/main/ssh/orcad-runtime-status.ts b/src/main/ssh/orcad-runtime-status.ts index 101eb6d81e58..18a5bfaf3988 100644 --- a/src/main/ssh/orcad-runtime-status.ts +++ b/src/main/ssh/orcad-runtime-status.ts @@ -7,6 +7,7 @@ import { resolveLinkedOrcadContext } from './orcad-managed-runtime-context' import { readManagedOrcadUpdateDeferral } from './orcad-managed-update-deferrals' +import { findIncompleteManagedOrcadMigration } from './orcad-managed-migration-status' import { collectManagedTerminalCensus } from './orcad-terminal-census-client' /** Read-only: what the host's activation record and journal say, without repairing either. */ @@ -34,6 +35,7 @@ export async function getManagedOrcadRuntimeStatus( rollbackAvailable: Boolean(record.previous && record.snapshot), recovery: transaction ? managedRecoveryStatus(transaction) : null, terminals: await collectManagedTerminalCensus(userDataPath, environment, record), + migration: migrationStatus(userDataPath, environment.id), deferredUpdate: readManagedOrcadUpdateDeferral(environment.id) } }) @@ -66,3 +68,13 @@ function managedRecoveryStatus( } } } + +function migrationStatus( + userDataPath: string, + environmentId: string +): OrcadManagedRuntimeStatus['migration'] { + const migration = findIncompleteManagedOrcadMigration(userDataPath, environmentId) + return migration + ? { migrationId: migration.migrationId, phase: migration.phase, startedAt: migration.startedAt } + : null +} diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index a329c935fee1..dcf8c934aa00 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -90,6 +90,11 @@ export class SshConnectionStore { return new SshTargetOrcadClaims(this.store) } + /** The profile reads and scrollback reservations a migration exports from. */ + getOrcadMigrationSource(): Store { + return this.store + } + updateTarget(id: string, updates: Partial>): SshTarget | null { const existing = this.store.getSshTarget(id) // Why: a new runtime choice or endpoint must re-run the ladder, not replay the old rung. diff --git a/src/shared/orcad-managed-runtime.ts b/src/shared/orcad-managed-runtime.ts index 85a13fb2ddee..11292d0a8a8a 100644 --- a/src/shared/orcad-managed-runtime.ts +++ b/src/shared/orcad-managed-runtime.ts @@ -94,6 +94,26 @@ export type OrcadManagedRuntimeStatus = { | null /** Terminals the daemon runs; `null` counts are unverifiable and block updates and stops. */ terminals: OrcadTerminalCensus + /** An unfinished dormant migration into this server; a rollback is refused while it runs. */ + migration: { + migrationId: string + phase: 'source-fenced' | 'destination-staged' | 'destination-committed' | 'source-retired' + startedAt: string + } | null /** The last update this client deferred for this server, cleared once one goes through. */ deferredUpdate: (OrcadManagedDeferral & { deferredAt: string }) | null } + +export type OrcadManagedConversionResult = + | { + outcome: 'committed' + environment: PublicKnownRuntimeEnvironment + migrationId: string + } + | OrcadManagedDeferral + | { + outcome: 'refused' + verdict: 'live' | 'unverifiable' + code: string + reason: string + } diff --git a/src/shared/runtime-environment-managed-orcad-store.test.ts b/src/shared/runtime-environment-managed-orcad-store.test.ts index 2458ad176fa2..6e2f8b491702 100644 --- a/src/shared/runtime-environment-managed-orcad-store.test.ts +++ b/src/shared/runtime-environment-managed-orcad-store.test.ts @@ -11,6 +11,7 @@ import { } from './runtime-environment-store' import { addManagedOrcadEnvironment, + recordManagedOrcadMigration, refreshManagedOrcadPairing, removeManagedOrcadEnvironment } from './runtime-environment-managed-orcad-store' @@ -130,4 +131,23 @@ describe('managed orcad environment store', () => { refreshManagedOrcadPairing(userDataPath, 'environment-1', pairingCode('ws://127.0.0.1:1')) ).toThrow('does not point at its SSH tunnel') }) + + it('keeps the earliest migration mark, across a downgrade rewrite and a re-pair', () => { + add() + recordManagedOrcadMigration(userDataPath, 'environment-1', '2026-02-01T00:00:00.000Z') + recordManagedOrcadMigration(userDataPath, 'environment-1', '2026-03-01T00:00:00.000Z') + shippedBuildRewrite(userDataPath, (environments) => { + environments[0]!.lastUsedAt = 900 + }) + expect(listEnvironments(userDataPath)[0]?.orcadMigratedAt).toBe('2026-02-01T00:00:00.000Z') + const rotated = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:46768', + deviceToken: 'rotated-token', + publicKeyB64: Buffer.alloc(32, 1).toString('base64') + }) + expect( + refreshManagedOrcadPairing(userDataPath, 'environment-1', rotated, 500).orcadMigratedAt + ).toBe('2026-02-01T00:00:00.000Z') + }) }) diff --git a/src/shared/runtime-environment-managed-orcad-store.ts b/src/shared/runtime-environment-managed-orcad-store.ts index 7ca7e1dd9b5c..f3463bf0628a 100644 --- a/src/shared/runtime-environment-managed-orcad-store.ts +++ b/src/shared/runtime-environment-managed-orcad-store.ts @@ -159,3 +159,31 @@ function samePairing(left: PairingOffer, right: PairingOffer): boolean { left.pairedDeviceId === right.pairedDeviceId ) } + +/** + * Marks a managed server as holding migrated state from `at` on. Written before a commit is + * attempted, so a crash after the commit can never leave a rollback across it unblocked. + */ +export function recordManagedOrcadMigration( + userDataPath: string, + environmentId: string, + at: string +): KnownRuntimeEnvironment { + const store = readPersistedEnvironmentStore(userDataPath) + const persisted = resolveEnvironmentFromStore(store, environmentId) + const entry = readCurrentRuntimeEnvironmentSidecarEntry(userDataPath, persisted) + if (!entry?.orcadDeployment) { + throw new RuntimeEnvironmentStoreError('invalid_argument', 'This server is not managed.') + } + const { binding: _binding, ...state } = entry + // The earliest mark wins: a snapshot older than any migration must stay out of reach. + const earliest = + state.orcadMigratedAt && Date.parse(state.orcadMigratedAt) <= Date.parse(at) + ? state.orcadMigratedAt + : at + writeRuntimeEnvironmentSidecarEntry(userDataPath, store.environments, persisted, { + ...state, + orcadMigratedAt: earliest + }) + return resolveEnvironmentFromStore(readEnvironmentStore(userDataPath), environmentId) +} diff --git a/src/shared/runtime-environment-sidecar.ts b/src/shared/runtime-environment-sidecar.ts index d421d0e87947..ae27fe411954 100644 --- a/src/shared/runtime-environment-sidecar.ts +++ b/src/shared/runtime-environment-sidecar.ts @@ -38,6 +38,8 @@ const SidecarEntrySchema = z.object({ pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), // The managed deployment's tunnel; its loopback endpoint is the persisted pairing itself. orcadDeployment: OrcadDeploymentLinkSchema.optional(), + // A rollback to a snapshot older than this would discard the migrated catalog. + orcadMigratedAt: z.string().datetime().optional(), // Keeps the overlaid pairing revision monotonic after the access that raised it is removed. pairingRevisionFloor: z.number().finite().optional(), reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() @@ -143,6 +145,7 @@ export function overlayRuntimeEnvironmentSidecar( ? { pendingSshAccessOperation: entry.pendingSshAccessOperation } : {}), ...(entry.orcadDeployment ? { orcadDeployment: entry.orcadDeployment } : {}), + ...(entry.orcadMigratedAt ? { orcadMigratedAt: entry.orcadMigratedAt } : {}), ...(entry.reconciliation ? { reconciliation: entry.reconciliation } : {}) }) return overlaid.success ? overlaid.data : base @@ -171,6 +174,7 @@ export function writeRuntimeEnvironmentSidecarEntry( entry.sshAccess !== undefined || entry.pendingSshAccessOperation !== undefined || entry.orcadDeployment !== undefined || + entry.orcadMigratedAt !== undefined || entry.reconciliation !== undefined || (entry.pairingRevisionFloor ?? basePairingRevision) > basePairingRevision) if (entry && hasState) { diff --git a/src/shared/runtime-environments.ts b/src/shared/runtime-environments.ts index a7f19dd9788b..fb677cc8172a 100644 --- a/src/shared/runtime-environments.ts +++ b/src/shared/runtime-environments.ts @@ -82,6 +82,8 @@ export const KnownRuntimeEnvironmentSchema = PersistedRuntimeEnvironmentSchema.e sshAccess: RuntimeSshAccessLinkSchema.optional(), pendingSshAccessOperation: RuntimeSshAccessOperationSchema.optional(), orcadDeployment: OrcadDeploymentLinkSchema.optional(), + /** When a migration into this managed server began committing; older snapshots predate it. */ + orcadMigratedAt: z.string().datetime().optional(), reconciliation: RuntimeEnvironmentReconciliationRecordSchema.optional() }) .refine(