Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions src/main/ssh/orcad-managed-migration-status.ts
Original file line number Diff line number Diff line change
@@ -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
)
}
60 changes: 2 additions & 58 deletions src/main/ssh/orcad-migration-cutover-coordinator.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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',
Expand All @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions src/main/ssh/orcad-migration-cutover-fixture.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
30 changes: 1 addition & 29 deletions src/main/ssh/orcad-migration-cutover-journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`)

Expand Down
67 changes: 67 additions & 0 deletions src/main/ssh/orcad-migration-destination-fake.ts
Original file line number Diff line number Diff line change
@@ -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<Catalog[Method]>
}

/** 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<Catalog['readState']>(async (manifest: OrcadMigrationManifest) =>
view(manifest)
),
stage: vi.fn<Catalog['stage']>(async (manifest: OrcadMigrationManifest) => {
if (state === 'absent') {
state = 'staged'
}
return view(manifest)
}),
commit: vi.fn<Catalog['commit']>(async (manifest: OrcadMigrationManifest) => {
if (state === 'staged') {
state = 'committed'
destination.commits += 1
}
return view(manifest)
}),
abort: vi.fn<Catalog['abort']>(async (manifest: OrcadMigrationManifest) => {
const aborted = state === 'staged'
if (aborted) {
state = 'absent'
}
return { ...view(manifest), aborted, ...(aborted ? {} : { durableAbsent: true as const }) }
}),
stageChunk: vi.fn<Catalog['stageChunk']>()
}
return destination
}
Loading
Loading