fix(vm): publish rollback lifecycle authority first

This commit is contained in:
Jinwoo-H
2026-08-14 17:53:11 -04:00
parent c531e9fa51
commit 6ec32ff606
7 changed files with 79 additions and 21 deletions
+4 -2
View File
@@ -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))')"
@@ -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')
})
})
@@ -21,10 +21,10 @@ type FailedStart = {
export async function cleanupFailedEphemeralVmStart(
args: ProvisionEphemeralVmRuntimeArgs,
start: FailedStart
): Promise<void> {
): Promise<boolean> {
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(
@@ -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
}
@@ -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 () => {
@@ -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'
])
})
})
+11 -10
View File
@@ -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)