mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
fix(vm): publish rollback lifecycle authority first
This commit is contained in:
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user