Skip to content

Commit 1da94d5

Browse files
committed
Persist managed service cleanup evidence
1 parent e6a2fd2 commit 1da94d5

4 files changed

Lines changed: 69 additions & 31 deletions

File tree

packages/cli/src/commands/recipe-run-finalizer.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export interface RunResourceCleanupEvidence {
1717
error?: RunOutput["error"]
1818
}
1919

20+
export class RunResourceCleanupError extends Error {
21+
constructor(readonly evidence: RunResourceCleanupEvidence, options: ErrorOptions) {
22+
super("Recipe resource cleanup failed", options)
23+
this.name = "RunResourceCleanupError"
24+
}
25+
}
26+
2027
interface RunResourceEvidenceOptions {
2128
startedAtMs: number
2229
status: RuntimeRunRecord["status"]
@@ -227,8 +234,10 @@ export async function runRecipeCleanup(runRegistry: RuntimeRunRegistry, runRecor
227234
} catch (error) {
228235
const updatedRunRecord = await runRegistry.update(runRecord.runId, { cleanup: { status: "failed", error: serializeError(error) } })
229236
const cleanupError = serializeRecipeRunError(error)
230-
cleanupEvidenceFromRunRecord(updatedRunRecord, Date.now() - startedAtMs, cleanupError)
231-
throw error
237+
throw new RunResourceCleanupError(
238+
cleanupEvidenceFromRunRecord(updatedRunRecord, Date.now() - startedAtMs, cleanupError),
239+
{ cause: error },
240+
)
232241
}
233242
}
234243

packages/cli/src/commands/recipe-run.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { previewSpec, releaseRuntime, runtimeMetadata, type RunOutput } from "..
2020
import { artifactManifestFilesByPath, parseBenchResults, writeBenchmarkArtifactEvidence } from "./recipe-run-benchmark-artifacts.js"
2121
import { createRecipeRunContext } from "./recipe-run-context.js"
2222
import { collectRecipeDeclaredArtifacts, materializeTypedRecipeDeclaredArtifacts, recipeDeclaredArtifactFailure, recipeProbeFailure, recipeRuntimeEvidenceFiles } from "./recipe-declared-artifacts.js"
23-
import { completedRecipeOutputFields, finalizeCompletedRecipeRun, finalizeRecipeValidationFailure, finalizeRecoveredRecipeFailure, runRecipeCleanup, type RunResourceCleanupEvidence } from "./recipe-run-finalizer.js"
23+
import { completedRecipeOutputFields, finalizeCompletedRecipeRun, finalizeRecipeValidationFailure, finalizeRecoveredRecipeFailure, runRecipeCleanup, RunResourceCleanupError, type RunResourceCleanupEvidence } from "./recipe-run-finalizer.js"
2424
import { RecipeRunPhaseExecutor } from "./recipe-run-phase-executor.js"
2525
import { RecipeArtifactsMountConflictError, recipeArtifactsMountConflict } from "./recipe-run-artifacts-mount-guard.js"
2626
import { createRecipeInterruptionController, interruptedRecipeOutput, markRecipeArtifactsFinalized, recipeInterruptionSerializedError } from "./recipe-run-interruption.js"
@@ -353,7 +353,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
353353
await markPreviewLeaseAvailable(options.previewLeaseFile, { runId: runRecord.runId, preview: artifacts.preview, holdSeconds: options.previewHoldSeconds })
354354
}
355355
const activeRuntime = runtime
356-
cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, async () => {
356+
cleanupEvidence = await runManagedServiceCleanup(runRegistry, runRecord, serviceEvidence, false, async () => {
357357
await awaitRecipe("runtime.release", async () => {
358358
try {
359359
await releaseRuntime(activeRuntime, successfulRecipe && options.previewHoldBlocking ? options.previewHoldSeconds : 0, async () => {
@@ -371,7 +371,6 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
371371
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
372372
await cleanupInputMountBaselines(inputMountBaselinePaths)
373373
})
374-
runRecord = await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: serviceEvidence } })
375374
runRecord = await runRegistry.read(runRecord.runId)
376375
interruption?.throwIfInterrupted()
377376

@@ -506,12 +505,11 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
506505
])
507506
}
508507

509-
cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, async () => {
508+
cleanupEvidence = await runManagedServiceCleanup(runRegistry, runRecord, serviceEvidence, true, async () => {
510509
await managedServices?.release()
511510
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
512511
await cleanupInputMountBaselines(inputMountBaselinePaths)
513512
})
514-
runRecord = await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: serviceEvidence } })
515513
runRecord = await runRegistry.read(runRecord.runId)
516514
const fuzzRunResult = recipeFuzzRunResult(recipe, executions)
517515
return await finalizeRecoveredRecipeFailure({
@@ -553,6 +551,27 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
553551
}
554552
}
555553

554+
export async function runManagedServiceCleanup(
555+
runRegistry: RuntimeRunRegistry,
556+
runRecord: Awaited<ReturnType<RuntimeRunRegistry["read"]>>,
557+
serviceEvidence: RuntimeServiceEvidence[],
558+
preservePrimaryFailure: boolean,
559+
cleanup: () => Promise<void>,
560+
): Promise<RunResourceCleanupEvidence> {
561+
try {
562+
return await runRecipeCleanup(runRegistry, runRecord, cleanup)
563+
} catch (error) {
564+
if (preservePrimaryFailure && error instanceof RunResourceCleanupError) {
565+
return error.evidence
566+
}
567+
throw error
568+
} finally {
569+
await runRegistry.update(runRecord.runId, {
570+
metadata: { managedRuntimeServices: serviceEvidence },
571+
})
572+
}
573+
}
574+
556575
async function recipeArtifactsMountConflictFailure(options: RecipeRunOptions): Promise<RecipeRunOutput | undefined> {
557576
const recipePath = resolve(options.recipePath)
558577
const recipeDirectory = dirname(recipePath)

packages/cli/src/runtime-services.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ async function releaseServices(services: ManagedRuntimeService[]): Promise<void>
129129
}
130130

131131
async function releaseService(container: string, evidence: RuntimeServiceEvidence, dependencies: RuntimeServiceDependencies, signal?: AbortSignal): Promise<void> {
132-
if (evidence.teardown) return
132+
if (evidence.teardown === "completed") return
133133
try {
134134
await dependencies.execute("docker", ["rm", "--force", container], { signal, timeout: 30_000 })
135135
evidence.lifecycle = "released"

tests/runtime-services-lifecycle.test.ts

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,42 @@ import { mkdtemp, rm } from "node:fs/promises"
33
import { tmpdir } from "node:os"
44
import { join } from "node:path"
55
import { RuntimeRunRegistry } from "../packages/runtime-core/src/run-registry.ts"
6-
import { runRecipeCleanup } from "../packages/cli/src/commands/recipe-run-finalizer.ts"
6+
import { runManagedServiceCleanup } from "../packages/cli/src/commands/recipe-run.ts"
7+
import { RunResourceCleanupError } from "../packages/cli/src/commands/recipe-run-finalizer.ts"
78
import type { RuntimeServiceEvidence } from "../packages/cli/src/runtime-services.ts"
89

9-
async function assertTerminalCleanup(terminal: string): Promise<void> {
10-
const directory = await mkdtemp(join(tmpdir(), "wp-codebox-service-lifecycle-"))
11-
try {
12-
const registry = new RuntimeRunRegistry(directory)
13-
const run = await registry.create({ runId: `service-${terminal}`, status: "running", metadata: {} })
14-
const evidence: RuntimeServiceEvidence[] = [{ id: "mysql", kind: "mysql", provider: "test", version: "test", readiness: "ready", lifecycle: "provisioned" }]
15-
let releases = 0
16-
await runRecipeCleanup(registry, run, async () => {
17-
releases += 1
18-
evidence[0]!.lifecycle = "released"
19-
evidence[0]!.teardown = "completed"
20-
})
21-
await registry.update(run.runId, { metadata: { managedRuntimeServices: evidence, terminal } })
22-
const completed = await registry.read(run.runId)
23-
assert.equal(releases, 1, `${terminal} releases services once`)
24-
assert.deepEqual(completed.metadata.managedRuntimeServices, evidence, `${terminal} persists final service evidence`)
25-
} finally {
26-
await rm(directory, { recursive: true, force: true })
27-
}
28-
}
10+
const directory = await mkdtemp(join(tmpdir(), "wp-codebox-service-lifecycle-"))
11+
try {
12+
const registry = new RuntimeRunRegistry(directory)
13+
14+
const succeeded = await registry.create({ runId: "service-success", status: "running", metadata: {} })
15+
const succeededEvidence: RuntimeServiceEvidence[] = [{ id: "mysql", kind: "mysql", provider: "test", version: "test", readiness: "ready", lifecycle: "provisioned" }]
16+
const cleanup = await runManagedServiceCleanup(registry, succeeded, succeededEvidence, false, async () => {
17+
succeededEvidence[0]!.lifecycle = "released"
18+
succeededEvidence[0]!.teardown = "completed"
19+
})
20+
assert.equal(cleanup.state, "completed")
21+
assert.deepEqual((await registry.read(succeeded.runId)).metadata.managedRuntimeServices, succeededEvidence)
22+
23+
const failed = await registry.create({ runId: "service-failure", status: "running", metadata: {} })
24+
const failedEvidence: RuntimeServiceEvidence[] = [{ id: "mysql", kind: "mysql", provider: "test", version: "test", readiness: "ready", lifecycle: "provisioned" }]
25+
const preserved = await runManagedServiceCleanup(registry, failed, failedEvidence, true, async () => {
26+
failedEvidence[0]!.lifecycle = "failed"
27+
failedEvidence[0]!.teardown = "failed"
28+
failedEvidence[0]!.diagnostic = { code: "teardown-failed" }
29+
throw new Error("fixture teardown failure")
30+
})
31+
assert.equal(preserved.state, "failed", "a primary recipe failure keeps structured cleanup evidence")
32+
assert.deepEqual((await registry.read(failed.runId)).metadata.managedRuntimeServices, failedEvidence)
2933

30-
for (const terminal of ["runtime-creation-failure", "workflow-failure", "timeout", "cancellation", "interruption", "success"]) {
31-
await assertTerminalCleanup(terminal)
34+
const terminal = await registry.create({ runId: "service-terminal-cleanup-failure", status: "running", metadata: {} })
35+
await assert.rejects(
36+
runManagedServiceCleanup(registry, terminal, [], false, async () => { throw new Error("fixture teardown failure") }),
37+
(error: unknown) => error instanceof RunResourceCleanupError && error.evidence.state === "failed",
38+
"cleanup failure becomes the terminal error when there is no earlier recipe failure",
39+
)
40+
} finally {
41+
await rm(directory, { recursive: true, force: true })
3242
}
3343

3444
console.log("runtime service lifecycle cleanup tests passed")

0 commit comments

Comments
 (0)