Skip to content

Commit 03c3aff

Browse files
committed
Harden disposable MySQL service lifecycle
1 parent fab7acd commit 03c3aff

9 files changed

Lines changed: 244 additions & 33 deletions

docs/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,7 @@ unless this index says otherwise.
9090
- JSON Schema factory: `packages/runtime-core/src/recipe-schema.ts`.
9191
- Default check coverage: `npm run check` includes
9292
`npm run test:generic-primitives` through the smoke manifest `core` group.
93+
- Disposable MySQL integration coverage runs through
94+
`npm run test:disposable-mysql-mysqli-e2e`. The test detects Docker with
95+
`docker info`; Docker-capable CI/Lab runs the public recipe path and PHP-WASM
96+
`mysqli` assertion, while hosts without a Docker daemon report an explicit skip.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@
202202
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",
203203
"test:runtime-requirements-readiness": "tsx tests/runtime-requirements-readiness.test.ts",
204204
"test:runtime-services": "tsx tests/runtime-services.test.ts",
205+
"test:disposable-mysql-mysqli-e2e": "tsx tests/disposable-mysql-mysqli.integration.test.ts",
205206
"test:runtime-contract-manifest": "tsx tests/runtime-contract-manifest.test.ts",
206207
"test:runtime-contract-package-exports": "tsx tests/runtime-contract-package-exports.test.ts",
207208
"test:runtime-command-result-envelope": "tsx tests/runtime-command-result-envelope.test.ts",

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ export function createRecipeInterruptionController(): RecipeInterruptionControll
1818
let parentWatcher: NodeJS.Timeout | undefined
1919
let stdinWatcherInstalled = false
2020
let stdioErrorWatcherInstalled = false
21+
const abortController = new AbortController()
2122
const initialParentPid = process.ppid
2223
const signals: RecipeInterruptionSignal[] = ["SIGINT", "SIGTERM", "SIGHUP"]
2324
const interrupt = (signal: RecipeInterruptionSignal, reason: RecipeInterruptionReason): void => {
2425
if (!metadata) {
2526
metadata = { signal, reason, receivedAt: new Date().toISOString(), artifactsFinalized: false }
27+
abortController.abort()
2628
}
2729
rejectInterrupted?.(new RecipeInterruptedError(metadata.signal, metadata.reason, metadata.receivedAt))
2830
}
@@ -45,6 +47,9 @@ export function createRecipeInterruptionController(): RecipeInterruptionControll
4547
get metadata() {
4648
return metadata
4749
},
50+
get signal() {
51+
return abortController.signal
52+
},
4853
install() {
4954
if (installed) {
5055
return

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ export interface RecipeInterruptionMetadata {
439439

440440
export interface RecipeInterruptionController {
441441
readonly metadata: RecipeInterruptionMetadata | undefined
442+
readonly signal: AbortSignal
442443
install(): void
443444
dispose(): void
444445
interruptible<T>(promise: Promise<T>): Promise<T>

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { RecipePhaseError } from "./recipe-run-phases.js"
2929
import { markPreviewLeaseAvailable, markPreviewLeaseFailed, markPreviewLeaseReleased, startPreviewLeaseRecipeRun } from "./preview-lease.js"
3030
import { importRecipeSiteSeeds } from "./recipe-site-seeds.js"
3131
import { applyRecipeRuntimeSetup, cleanupInputMountBaselines, prepareRecipeRuntimeSetup, recipeRunDependencyOverlay, recipeRunExtraPlugin, recipeRunStagedFile, rewriteInputMountPathArgs } from "./recipe-runtime-setup.js"
32-
import { provisionRuntimeServices, type RuntimeServiceEvidence } from "../runtime-services.js"
32+
import { provisionRuntimeServices, RuntimeServiceProvisionError, type RuntimeServiceEvidence } from "../runtime-services.js"
3333
import { distributionStartupProbeFailure, executeRecipeCollectWorkloadResult, executeRecipeWorkflowStep, recipeAdvisoryFailure, recipeBrowserEvidence, recipeStepFailure, recipeWorkflowArgsEvidence, recipeWorkflowStepIsAdvisory, runDistributionSetupArtifacts, runDistributionStartupProbes, runRecipeProbes, withRecipeExecutionPhase } from "./recipe-run-workflow-evidence.js"
3434
import type { RecipeAdvisoryFailure, RecipeBrowserEvidence, RecipeDiagnosticArtifactRef, RecipeEffectiveRecipeArtifact, RecipeExecutionResult, RecipeFuzzCaseCommandRef, RecipeFuzzCaseResult, RecipeFuzzCaseStatus, RecipeFuzzRunResult, RecipeInterruptionController, RecipePhaseEvidence, RecipePhaseName, RecipePhpWasmRuntimeDiagnostic, RecipeRunCommandOutput, RecipeRunComponentContract, RecipeRunDeclaredArtifact, RecipeRunDistributionSetupArtifact, RecipeRunDistributionStartupProbe, RecipeRunFixtureDatabase, RecipeRunOptions, RecipeRunOutput, RecipeRunProbe, RecipeRunProvenance, RecipeRunStagedFile, RecipeRuntimeDiagnostic, RecipeStepFailure, RecipeValidateOptions, RecipeValidateOutput } from "./recipe-run-types.js"
3535

@@ -183,7 +183,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
183183
interruption?.throwIfInterrupted()
184184

185185
runRecord = await runRegistry.update(runRecord.runId, { status: "booting" })
186-
managedServices = await phaseTracker.run("provision_runtime_services", { services: recipe.inputs?.services?.map(({ id, kind }) => ({ id, kind })) ?? [] }, async () => await awaitRecipe("runtime-services.provision", () => provisionRuntimeServices(recipe.inputs?.services ?? [])))
186+
managedServices = await phaseTracker.run("provision_runtime_services", { services: recipe.inputs?.services?.map(({ id, kind }) => ({ id, kind })) ?? [] }, async () => await awaitRecipe("runtime-services.provision", () => provisionRuntimeServices(recipe.inputs?.services ?? [], { signal: interruption?.signal })))
187187
serviceEvidence = managedServices.evidence
188188
Object.assign(runtimeEnv, managedServices.env)
189189
const runtimeEnvironment = {
@@ -422,6 +422,10 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
422422
output: { ...completedRecipeOutputFields({ executions, componentContracts: componentContractResults(recipe, extraPlugins, phaseTracker.list(), executions), stagedFiles: stagedFiles.map(recipeRunStagedFile), fixtureDatabases, siteSeeds, distributionSetupArtifacts, distributionStartupProbes, probes, declaredArtifacts, stepFailures, phaseEvidence: phaseTracker.list(), advisoryFailures, browserEvidence, benchResultsList, fuzzRun: fuzzRunResult, evidence }), provenance: recipeRunProvenance(recipe, recipePath) },
423423
})
424424
} catch (error) {
425+
if (error instanceof RuntimeServiceProvisionError) {
426+
serviceEvidence = error.evidence
427+
await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: serviceEvidence } })
428+
}
425429
await markPreviewLeaseFailed(options.previewLeaseFile, error)
426430
const serializedError = interruption?.metadata ? recipeInterruptionSerializedError(interruption.metadata) : serializeRecipeRunError(error)
427431
const failureDiagnostics = recipeFailureRuntimeEvidenceFile({
Lines changed: 141 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,177 @@
1-
import { randomBytes } from "node:crypto"
21
import { execFile } from "node:child_process"
2+
import { randomBytes } from "node:crypto"
33
import { createConnection } from "node:net"
44
import { promisify } from "node:util"
55
import type { WorkspaceRecipeRuntimeService } from "@automattic/wp-codebox-core"
66

77
const execFileAsync = promisify(execFile)
88
const MYSQL_IMAGE = "mysql:8.4"
99

10-
export interface RuntimeServiceEvidence { id: string; kind: string; provider: string; version: string; readiness: "ready" | "failed"; lifecycle: "provisioned" | "released"; teardown?: "completed" | "failed" }
11-
interface ManagedRuntimeService { env: Record<string, string>; evidence: RuntimeServiceEvidence; release(): Promise<void> }
10+
export interface RuntimeServiceEvidence {
11+
id: string
12+
kind: string
13+
provider: string
14+
version: string
15+
readiness: "pending" | "ready" | "failed"
16+
lifecycle: "provisioning" | "provisioned" | "released" | "failed"
17+
teardown?: "completed" | "failed"
18+
diagnostic?: { code: "readiness-failed" | "provision-failed" | "teardown-failed" | "interrupted" }
19+
}
20+
21+
export class RuntimeServiceProvisionError extends Error {
22+
constructor(message: string, readonly evidence: RuntimeServiceEvidence[]) {
23+
super(message)
24+
this.name = "RuntimeServiceProvisionError"
25+
}
26+
}
27+
28+
interface ManagedRuntimeService {
29+
env: Record<string, string>
30+
evidence: RuntimeServiceEvidence
31+
release(): Promise<void>
32+
}
33+
34+
export interface RuntimeServiceDependencies {
35+
execute(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout: number }): Promise<{ stdout: string }>
36+
waitForReady(host: string, port: number, timeoutMs: number, signal?: AbortSignal): Promise<void>
37+
randomBytes(size: number): Buffer
38+
}
39+
40+
const defaultDependencies: RuntimeServiceDependencies = {
41+
execute: async (command, args, options) => await execFileAsync(command, args, options),
42+
waitForReady: waitForMysqlProtocol,
43+
randomBytes,
44+
}
1245

1346
export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback"; port: "ephemeral"; persistentVolume: false; outputs: Record<string, string> }> {
1447
return services.map((service) => ({ id: service.id, kind: service.kind, provider: "docker", version: MYSQL_IMAGE, bind: "loopback", port: "ephemeral", persistentVolume: false, outputs: service.outputs }))
1548
}
1649

17-
export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[]): Promise<{ env: Record<string, string>; evidence: RuntimeServiceEvidence[]; release(): Promise<void> }> {
50+
export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: { signal?: AbortSignal; dependencies?: RuntimeServiceDependencies } = {}): Promise<{ env: Record<string, string>; evidence: RuntimeServiceEvidence[]; release(): Promise<void> }> {
51+
const dependencies = options.dependencies ?? defaultDependencies
1852
const provisioned: ManagedRuntimeService[] = []
19-
try { for (const service of services) provisioned.push(await provisionRuntimeService(service)) } catch (error) { await Promise.allSettled(provisioned.map((service) => service.release())); throw error }
20-
return { env: Object.assign({}, ...provisioned.map((service) => service.env)), evidence: provisioned.map((service) => service.evidence), async release() { await Promise.all(provisioned.reverse().map((service) => service.release())) } }
53+
const evidence: RuntimeServiceEvidence[] = []
54+
try {
55+
for (const service of services) {
56+
const managed = await provisionRuntimeService(service, dependencies, options.signal, evidence)
57+
provisioned.push(managed)
58+
}
59+
} catch (error) {
60+
await releaseServices(provisioned)
61+
if (error instanceof RuntimeServiceProvisionError) throw error
62+
throw new RuntimeServiceProvisionError("Managed runtime service provisioning failed", evidence)
63+
}
64+
65+
return {
66+
env: Object.assign({}, ...provisioned.map((service) => service.env)),
67+
evidence,
68+
async release() {
69+
await releaseServices(provisioned)
70+
},
71+
}
2172
}
2273

23-
async function provisionRuntimeService(service: WorkspaceRecipeRuntimeService): Promise<ManagedRuntimeService> {
24-
if (service.kind !== "mysql") throw new Error(`Unsupported managed runtime service kind: ${service.kind}`)
25-
const container = `wp-codebox-${service.id}-${randomBytes(6).toString("hex")}`
26-
const password = randomBytes(24).toString("base64url")
27-
const args = ["run", "--detach", "--rm", "--name", container, "--publish", "127.0.0.1::3306", "--tmpfs", "/var/lib/mysql", "--env", "MYSQL_DATABASE=runtime", "--env", "MYSQL_USER=runtime", "--env", `MYSQL_PASSWORD=${password}`, "--env", `MYSQL_ROOT_PASSWORD=${password}`, MYSQL_IMAGE]
74+
async function provisionRuntimeService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise<ManagedRuntimeService> {
75+
const evidence: RuntimeServiceEvidence = { id: service.id, kind: service.kind, provider: "docker", version: MYSQL_IMAGE, readiness: "pending", lifecycle: "provisioning" }
76+
evidenceList.push(evidence)
77+
if (service.kind !== "mysql") {
78+
evidence.lifecycle = "failed"
79+
evidence.diagnostic = { code: "provision-failed" }
80+
throw new RuntimeServiceProvisionError(`Unsupported managed runtime service kind: ${service.kind}`, evidenceList)
81+
}
82+
83+
const container = `wp-codebox-${service.id}-${dependencies.randomBytes(6).toString("hex")}`
84+
const password = dependencies.randomBytes(24).toString("base64url")
85+
const childEnvironment = { PATH: process.env.PATH, MYSQL_DATABASE: "runtime", MYSQL_USER: "runtime", MYSQL_PASSWORD: password, MYSQL_ROOT_PASSWORD: password }
86+
const runArgs = ["run", "--detach", "--rm", "--name", container, "--publish", "127.0.0.1::3306", "--tmpfs", "/var/lib/mysql", "--env", "MYSQL_DATABASE", "--env", "MYSQL_USER", "--env", "MYSQL_PASSWORD", "--env", "MYSQL_ROOT_PASSWORD", MYSQL_IMAGE]
87+
let started = false
2888
try {
29-
await execFileAsync("docker", args, { timeout: 30_000 })
30-
const { stdout } = await execFileAsync("docker", ["port", container, "3306/tcp"], { timeout: 10_000 })
89+
throwIfAborted(signal)
90+
await dependencies.execute("docker", runArgs, { env: childEnvironment, signal, timeout: 30_000 })
91+
started = true
92+
const { stdout } = await dependencies.execute("docker", ["port", container, "3306/tcp"], { signal, timeout: 10_000 })
3193
const port = parseLoopbackPort(stdout)
32-
await waitForMysqlProtocol("127.0.0.1", port, 30_000)
94+
await dependencies.waitForReady("127.0.0.1", port, 30_000, signal)
95+
throwIfAborted(signal)
96+
evidence.readiness = "ready"
97+
evidence.lifecycle = "provisioned"
3398
const values: Record<string, string> = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" }
34-
const env = Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""]))
35-
let released = false
36-
const evidence: RuntimeServiceEvidence = { id: service.id, kind: service.kind, provider: "docker", version: MYSQL_IMAGE, readiness: "ready", lifecycle: "provisioned" }
37-
return { env, evidence, async release() { if (released) return; released = true; try { await execFileAsync("docker", ["rm", "--force", container], { timeout: 30_000 }); evidence.lifecycle = "released"; evidence.teardown = "completed" } catch { evidence.teardown = "failed"; throw new Error(`Managed runtime service teardown failed: ${service.id}`) } } }
99+
return { env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), evidence, async release() { await releaseService(container, evidence, dependencies, signal) } }
100+
} catch (error) {
101+
evidence.readiness = "failed"
102+
evidence.lifecycle = "failed"
103+
evidence.diagnostic = { code: signal?.aborted ? "interrupted" : started ? "readiness-failed" : "provision-failed" }
104+
if (started) await releaseService(container, evidence, dependencies, undefined).catch(() => undefined)
105+
throw new RuntimeServiceProvisionError(`Managed runtime service failed: ${service.id}`, evidenceList)
106+
}
107+
}
108+
109+
async function releaseServices(services: ManagedRuntimeService[]): Promise<void> {
110+
const results = await Promise.allSettled([...services].reverse().map(async (service) => await service.release()))
111+
const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected")
112+
if (failure) throw failure.reason
113+
}
114+
115+
async function releaseService(container: string, evidence: RuntimeServiceEvidence, dependencies: RuntimeServiceDependencies, signal?: AbortSignal): Promise<void> {
116+
if (evidence.teardown) return
117+
try {
118+
await dependencies.execute("docker", ["rm", "--force", container], { signal, timeout: 30_000 })
119+
evidence.lifecycle = "released"
120+
evidence.teardown = "completed"
38121
} catch {
39-
await execFileAsync("docker", ["rm", "--force", container], { timeout: 30_000 }).catch(() => undefined)
40-
throw new Error(`Managed runtime service failed readiness: ${service.id} (${MYSQL_IMAGE})`)
122+
evidence.lifecycle = "failed"
123+
evidence.teardown = "failed"
124+
evidence.diagnostic = { code: "teardown-failed" }
125+
throw new Error(`Managed runtime service teardown failed: ${evidence.id}`)
41126
}
42127
}
43128

44129
export function parseLoopbackPort(output: string): number {
45-
const match = output.trim().match(/^127\.0\.0\.1:(\d+)$/m); const port = match ? Number(match[1]) : NaN
130+
const match = output.trim().match(/^127\.0\.0\.1:(\d+)$/m)
131+
const port = match ? Number(match[1]) : NaN
46132
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Managed runtime service did not publish a loopback port")
47133
return port
48134
}
49135

50-
export async function waitForMysqlProtocol(host: string, port: number, timeoutMs: number): Promise<void> {
136+
export async function waitForMysqlProtocol(host: string, port: number, timeoutMs: number, signal?: AbortSignal): Promise<void> {
51137
const deadline = Date.now() + timeoutMs
52-
while (Date.now() < deadline) { try { await mysqlHandshake(host, port); return } catch { await new Promise((resolve) => setTimeout(resolve, 100)) } }
138+
while (Date.now() < deadline) {
139+
throwIfAborted(signal)
140+
try {
141+
await mysqlHandshake(host, port, signal)
142+
return
143+
} catch (error) {
144+
if (signal?.aborted) throw error
145+
await abortableDelay(100, signal)
146+
}
147+
}
53148
throw new Error(`MySQL protocol readiness timed out after ${timeoutMs}ms`)
54149
}
55150

56-
function mysqlHandshake(host: string, port: number): Promise<void> {
151+
function mysqlHandshake(host: string, port: number, signal?: AbortSignal): Promise<void> {
57152
return new Promise((resolve, reject) => {
58-
const socket = createConnection({ host, port }); const timer = setTimeout(() => socket.destroy(new Error("connection timeout")), 1_000)
153+
const socket = createConnection({ host, port })
154+
const timer = setTimeout(() => socket.destroy(new Error("connection timeout")), 1_000)
155+
const abort = () => socket.destroy(new Error("aborted"))
156+
signal?.addEventListener("abort", abort, { once: true })
59157
socket.once("error", reject)
60-
socket.once("data", (chunk: Buffer) => { clearTimeout(timer); socket.destroy(); if (chunk.length < 5 || chunk[4] !== 10) reject(new Error("invalid MySQL protocol handshake")); else resolve() })
61-
socket.once("close", () => clearTimeout(timer))
158+
socket.once("data", (chunk: Buffer) => {
159+
clearTimeout(timer)
160+
socket.destroy()
161+
if (chunk.length < 5 || chunk[4] !== 10) reject(new Error("invalid MySQL protocol handshake"))
162+
else resolve()
163+
})
164+
socket.once("close", () => { clearTimeout(timer); signal?.removeEventListener("abort", abort) })
165+
})
166+
}
167+
168+
function throwIfAborted(signal: AbortSignal | undefined): void {
169+
if (signal?.aborted) throw new Error("Managed runtime service provisioning interrupted")
170+
}
171+
172+
function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise<void> {
173+
return new Promise((resolve, reject) => {
174+
const timer = setTimeout(resolve, milliseconds)
175+
signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("Managed runtime service provisioning interrupted")) }, { once: true })
62176
})
63177
}

scripts/smoke-manifest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ export const smokeGroups = {
3838
description: "Build and core command contract smoke checks.",
3939
commands: [
4040
npmScript("build"),
41-
npmScript("test:runtime-services"),
4241
npmScript("test:generic-primitives"),
4342
npmScript("test:php-json-codec"),
4443
npmScript("test:primitive-contract-parity"),
@@ -130,6 +129,8 @@ export const smokeGroups = {
130129
description: "Package build contract smoke checks.",
131130
commands: [
132131
npmScript("build"),
132+
npmScript("test:runtime-services"),
133+
npmScript("test:disposable-mysql-mysqli-e2e"),
133134
],
134135
},
135136
agent: {

0 commit comments

Comments
 (0)