From 6ec32ff6067d8e60d78d82adf1dd6e7a9bea7aae Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 14 Aug 2026 02:21:06 -0400 Subject: [PATCH] fix(vm): publish rollback lifecycle authority first --- .github/workflows/pr.yml | 6 +++-- config/scripts/pr-e2e-gate-contract.test.mjs | 10 +++++++ src/main/ephemeral-vm-failed-start-cleanup.ts | 5 ++-- ...ral-vm-runtime-provisioning-persistence.ts | 17 ++++++++++-- src/main/ephemeral-vm-runtime-service.test.ts | 26 +++++++++++++++++++ ...phemeral-vm-runtime-store-rollback.test.ts | 15 +++++++---- src/shared/ephemeral-vm-runtime-store.ts | 21 ++++++++------- 7 files changed, 79 insertions(+), 21 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 50292de11b5..293ff008020 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -55,11 +55,13 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - if git diff --quiet "$BASE_SHA" "$HEAD_SHA" -- \ + if git diff --quiet --merge-base "$BASE_SHA" "$HEAD_SHA" -- \ src/shared/ephemeral-vm-runtime-store.ts \ src/shared/ephemeral-vm-runtime-feature-store.ts \ src/shared/ephemeral-vm-runtime-rollback-projection.ts \ src/shared/ephemeral-vm-runtimes.ts \ + src/shared/ephemeral-vm-recipes.ts \ + src/shared/orca-yaml-hook-types.ts \ src/main/ephemeral-vm-runtime-service.ts \ src/main/ephemeral-vm-runtime-provisioning-persistence.ts \ src/main/ephemeral-vm-failed-start-cleanup.ts; then @@ -498,7 +500,7 @@ jobs: HEAD="${{ github.event.pull_request.head.sha }}" CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")" TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)" - if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes))\.ts$'; then + if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)" fi TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')" diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index c600d233437..e8b7c19c5d8 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -14,6 +14,9 @@ const sshDockerRunner = readFileSync( const filterStep = prWorkflow.jobs['e2e-paths'].steps.find( (step) => step.name === 'Filter changed E2E specs' ) +const rollbackStep = prWorkflow.jobs.static_analysis.steps.find( + (step) => step.name === 'Check VM runtime rollback compatibility' +) const verifyStep = prWorkflow.jobs.verify.steps.find( (step) => step.name === 'Require successful checks' ) @@ -121,4 +124,11 @@ describe('PR E2E gate contract', () => { expect(filterStep.run).toContain('--merge-base "$BASE" "$HEAD"') expect(filterStep.run).toContain('set -euo pipefail') }) + + it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => { + expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"') + expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts') + expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts') + expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types') + }) }) diff --git a/src/main/ephemeral-vm-failed-start-cleanup.ts b/src/main/ephemeral-vm-failed-start-cleanup.ts index 702a1a6af1e..4b56a43e52b 100644 --- a/src/main/ephemeral-vm-failed-start-cleanup.ts +++ b/src/main/ephemeral-vm-failed-start-cleanup.ts @@ -21,10 +21,10 @@ type FailedStart = { export async function cleanupFailedEphemeralVmStart( args: ProvisionEphemeralVmRuntimeArgs, start: FailedStart -): Promise { +): Promise { const cleanupError = await getCleanupError(args, start) if (cleanupError === null) { - return + return true } const now = args.now ?? Date.now() @@ -56,6 +56,7 @@ export async function cleanupFailedEphemeralVmStart( // Why: cleanup retry metadata must survive even when its feature companion is unreadable. upsertEphemeralVmRuntimeRollbackRecovery(args.userDataPath, recovery) } + return false } async function getCleanupError( diff --git a/src/main/ephemeral-vm-runtime-provisioning-persistence.ts b/src/main/ephemeral-vm-runtime-provisioning-persistence.ts index a9d017f28d3..ee1d6e5418b 100644 --- a/src/main/ephemeral-vm-runtime-provisioning-persistence.ts +++ b/src/main/ephemeral-vm-runtime-provisioning-persistence.ts @@ -1,7 +1,11 @@ import { randomUUID } from 'node:crypto' import { assertEphemeralVmRuntimeCheckoutModeCanPersist } from '../shared/ephemeral-vm-runtime-feature-store' import { getEphemeralVmRecipeResultConnection } from '../shared/ephemeral-vm-recipes' -import { upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store' +import { + listEphemeralVmRuntimes, + removeEphemeralVmRuntime, + upsertEphemeralVmRuntime +} from '../shared/ephemeral-vm-runtime-store' import type { EphemeralVmRuntimeRecord } from '../shared/ephemeral-vm-runtimes' import type { ProvisionEphemeralVmRuntimeArgs, @@ -20,6 +24,7 @@ export function prepareEphemeralVmCompatibilityPersistence( if (!args.recipe.checkoutMode) { return null } + listEphemeralVmRuntimes(args.userDataPath) const compatibility = { instanceId: `orca-${randomUUID()}`, createdAt: args.now ?? Date.now() @@ -59,10 +64,18 @@ export async function persistProvisionedEphemeralVmRuntime( }) } catch (error) { if (compatibility) { - await cleanupFailedEphemeralVmStart(args, { + const cleaned = await cleanupFailedEphemeralVmStart(args, { context: start.context, recipeResult: start.result }) + if ( + cleaned && + listEphemeralVmRuntimes(args.userDataPath).some( + (runtime) => runtime.id === compatibility.instanceId + ) + ) { + removeEphemeralVmRuntime(args.userDataPath, compatibility.instanceId) + } } throw error } diff --git a/src/main/ephemeral-vm-runtime-service.test.ts b/src/main/ephemeral-vm-runtime-service.test.ts index 9d4e6d509a5..9280be2ba90 100644 --- a/src/main/ephemeral-vm-runtime-service.test.ts +++ b/src/main/ephemeral-vm-runtime-service.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing' import { + getEphemeralVmRuntimeStorePath, listEphemeralVmRuntimes, upsertEphemeralVmRuntime } from '../shared/ephemeral-vm-runtime-store' @@ -209,6 +210,30 @@ describe('ephemeral VM runtime service', () => { expect(existsSync(markerPath)).toBe(false) }) + it('rejects an unreadable lifecycle store before a checkout-mode recipe creates resources', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-service-repo-') + const startPath = join(repoPath, 'start.js') + const markerPath = join(repoPath, 'create-ran.txt') + writeFileSync(startPath, `require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'yes')`) + writeFileSync(getEphemeralVmRuntimeStorePath(userDataPath), '{') + + await expect( + provisionEphemeralVmRuntime({ + userDataPath, + repoPath, + recipe: { + id: 'cloud-sandbox', + name: 'Cloud Sandbox', + checkoutMode: 'provisioned-root', + create: nodeCommand(startPath), + destroyDisabled: true + } + }) + ).rejects.toThrow('file is invalid') + expect(existsSync(markerPath)).toBe(false) + }) + it('destroys a checkout-mode resource when compatibility persistence fails after create', async () => { const userDataPath = makeDir('orca-ephemeral-vm-service-user-data-') const repoPath = makeDir('orca-ephemeral-vm-service-repo-') @@ -250,6 +275,7 @@ describe('ephemeral VM runtime service', () => { }) ).rejects.toThrow('Could not preserve ephemeral VM runtime compatibility metadata') expect(readFileSync(cleanupMarkerPath, 'utf8')).toBe('yes') + expect(listEphemeralVmRuntimes(userDataPath)).toEqual([]) }) it('keeps rollback-readable cleanup recovery when post-create destroy also fails', async () => { diff --git a/src/shared/ephemeral-vm-runtime-store-rollback.test.ts b/src/shared/ephemeral-vm-runtime-store-rollback.test.ts index 1c53338ef1b..725d7bd066e 100644 --- a/src/shared/ephemeral-vm-runtime-store-rollback.test.ts +++ b/src/shared/ephemeral-vm-runtime-store-rollback.test.ts @@ -269,18 +269,23 @@ describe('ephemeral VM runtime store rollback projection', () => { } ) - it('does not overwrite an oversized feature sidecar or poison the v1 store', () => { + it('publishes lifecycle authority before an unreadable feature companion', () => { const userDataPath = makeUserDataPath() upsertEphemeralVmRuntime(userDataPath, runtimeRecord()) const featurePath = getEphemeralVmRuntimeFeatureStorePath(userDataPath) writeFileSync(featurePath, '{}') truncateSync(featurePath, MAX_EPHEMERAL_VM_RUNTIME_FEATURE_STORE_FILE_BYTES + 1) - const before = readFileSync(getEphemeralVmRuntimeStorePath(userDataPath), 'utf8') - expect(() => upsertEphemeralVmRuntime(userDataPath, provisionedRootRecord())).toThrow( EphemeralVmRuntimeStoreError ) - expect(readFileSync(getEphemeralVmRuntimeStorePath(userDataPath), 'utf8')).toBe(before) - expect(listEphemeralVmRuntimes(userDataPath)).toEqual([runtimeRecord()]) + const persisted = JSON.parse(readFileSync(getEphemeralVmRuntimeStorePath(userDataPath), 'utf8')) + expect(RollbackEphemeralVmRuntimeStoreSchema.parse(persisted).runtimes).toHaveLength(2) + expect(readFileSync(featurePath, 'utf8')).toHaveLength( + MAX_EPHEMERAL_VM_RUNTIME_FEATURE_STORE_FILE_BYTES + 1 + ) + expect(listEphemeralVmRuntimes(userDataPath).map((runtime) => runtime.id)).toEqual([ + 'provisioned-runtime', + 'ordinary-runtime' + ]) }) }) diff --git a/src/shared/ephemeral-vm-runtime-store.ts b/src/shared/ephemeral-vm-runtime-store.ts index b56b54e1eb9..722e45caf3c 100644 --- a/src/shared/ephemeral-vm-runtime-store.ts +++ b/src/shared/ephemeral-vm-runtime-store.ts @@ -260,24 +260,25 @@ function writeEphemeralVmRuntimeStore( return feature ? [feature] : [] }) ) - if (!features.writable && requiredFeatures.length > 0) { - throw new EphemeralVmRuntimeStoreError( - 'runtime_error', - 'Could not preserve ephemeral VM runtime compatibility metadata.' - ) - } const preparedFeatures = mergeRuntimeFeatures(features.features, requiredFeatures) - if (features.writable && !runtimeFeatureListsEqual(features.features, preparedFeatures)) { - writeEphemeralVmRuntimeFeatureStore(userDataPath, features, preparedFeatures) - } writeSecureJsonFileWithinLimit( path, RollbackEphemeralVmRuntimeStoreSchema.parse({ version: 1, runtimes: parsed.runtimes.map(projectRuntimeForRollback) }), - MAX_EPHEMERAL_VM_RUNTIME_STORE_FILE_BYTES + MAX_EPHEMERAL_VM_RUNTIME_STORE_FILE_BYTES, + { durable: preparedFeatures.length > 0 || features.features.length > 0 } ) + if (!features.writable && requiredFeatures.length > 0) { + throw new EphemeralVmRuntimeStoreError( + 'runtime_error', + 'Could not preserve ephemeral VM runtime compatibility metadata.' + ) + } + if (features.writable && !runtimeFeatureListsEqual(features.features, preparedFeatures)) { + writeEphemeralVmRuntimeFeatureStore(userDataPath, features, preparedFeatures) + } if (features.writable && !runtimeFeatureListsEqual(preparedFeatures, requiredFeatures)) { try { writeEphemeralVmRuntimeFeatureStore(userDataPath, features, requiredFeatures)