Skip to content

Commit 2a5f809

Browse files
authored
Add disposable MySQL runtime service primitive (#1816)
* Add disposable MySQL runtime service * Harden disposable MySQL service lifecycle * Ensure disposable service teardown survives interruption * Persist managed service cleanup evidence * Make MySQL provisioning pull-aware * Run MySQL integration through recipe lifecycle * Keep managed service leases alive * Settle failed MySQL readiness probes * Harden managed service timeout cleanup * Support explicit empty MySQL root auth * Pass managed services through PHPUnit recipes
1 parent 950a2ad commit 2a5f809

18 files changed

Lines changed: 697 additions & 9 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.

docs/recipe-contract.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,14 @@ runners:
723723
{
724724
"pluginSlug": "woocommerce",
725725
"pluginSource": "../woocommerce/plugins/woocommerce",
726+
"services": [
727+
{
728+
"id": "mysql",
729+
"kind": "mysql",
730+
"configuration": { "rootAuthentication": "empty-password" },
731+
"outputs": { "port": "TC_MYSQL_PORT" }
732+
}
733+
],
726734
"cwd": "/wordpress/wp-content/plugins/woocommerce",
727735
"autoloadFile": "/wp-codebox-vendor/autoload.php",
728736
"testsDir": "/wp-codebox-vendor/wp-phpunit/wp-phpunit",

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@
202202
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
203203
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",
204204
"test:runtime-requirements-readiness": "tsx tests/runtime-requirements-readiness.test.ts",
205+
"test:runtime-services": "tsx tests/runtime-services.test.ts",
206+
"test:runtime-services-lifecycle": "tsx tests/runtime-services-lifecycle.test.ts",
207+
"test:disposable-mysql-mysqli-e2e": "tsx tests/disposable-mysql-mysqli.integration.test.ts",
205208
"test:runtime-contract-manifest": "tsx tests/runtime-contract-manifest.test.ts",
206209
"test:runtime-contract-package-exports": "tsx tests/runtime-contract-package-exports.test.ts",
207210
"test:runtime-command-result-envelope": "tsx tests/runtime-command-result-envelope.test.ts",

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readFile, writeFile } from "node:fs/promises"
2-
import { buildGenericAbilityRuntimeRunRecipe, buildRuntimePackageRunRecipe, buildWordPressBenchRecipe, buildWordPressPhpunitRecipe, compileRecipeTemplate, type GenericAbilityRuntimeRunOptions, type RecipeTemplateInput, type RuntimePackageRunRecipeOptions, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core"
2+
import { buildGenericAbilityRuntimeRunRecipe, buildRuntimePackageRunRecipe, buildWordPressBenchRecipe, buildWordPressPhpunitRecipe, compileRecipeTemplate, type GenericAbilityRuntimeRunOptions, type RecipeTemplateInput, type RuntimePackageRunRecipeOptions, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipeRuntimeService, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core"
33

44
interface RecipeBuildOptions {
55
recipeType: "phpunit" | "bench" | "template" | "generic-ability-runtime-run" | "runtime-package-run"
@@ -11,6 +11,7 @@ interface WordPressPhpunitBuilderOptions {
1111
blueprint?: unknown
1212
wordpressVersion?: string
1313
mounts?: WorkspaceRecipeMount[]
14+
services?: WorkspaceRecipeRuntimeService[]
1415
extra_plugins?: WorkspaceRecipeExtraPlugin[]
1516
pluginSource?: string
1617
pluginSlug: string
@@ -74,6 +75,7 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word
7475
blueprint: phpunitOptions.blueprint,
7576
wordpressVersion: stringOrUndefined(phpunitOptions.wordpressVersion),
7677
mounts: Array.isArray(phpunitOptions.mounts) ? phpunitOptions.mounts : [],
78+
services: Array.isArray(phpunitOptions.services) ? phpunitOptions.services : [],
7779
extra_plugins: Array.isArray(phpunitOptions.extra_plugins) ? phpunitOptions.extra_plugins : [],
7880
pluginSource: stringOrUndefined(phpunitOptions.pluginSource),
7981
pluginSlug: requiredString(phpunitOptions.pluginSlug, "pluginSlug"),

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

Lines changed: 12 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

@@ -344,6 +353,7 @@ function classifyRunResourceFailure(status: RuntimeRunRecord["status"], failure:
344353

345354
function classifyRecipePhaseFailure(phase: string): string {
346355
switch (phase) {
356+
case "provision_runtime_services":
347357
case "runtime_startup":
348358
case "run_blueprint_steps":
349359
return "startup"

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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ export interface RecipeDiagnosticArtifactRef {
276276
sha256?: string
277277
}
278278

279-
export type RecipePhaseName = "runtime_startup" | "mount_plugins" | "activate_plugins" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_probes" | "collect_artifacts"
279+
export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "activate_plugins" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_probes" | "collect_artifacts"
280280

281281
export interface RecipePhaseEvidence {
282282
schema: "wp-codebox/recipe-phase-evidence/v1"
@@ -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: 53 additions & 4 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"
@@ -29,6 +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, provisionRuntimeServicesForRecipe, runtimeServiceEvidenceFromError, type RuntimeServiceEvidence } from "../runtime-services.js"
3233
import { distributionStartupProbeFailure, executeRecipeCollectWorkloadResult, executeRecipeWorkflowStep, recipeAdvisoryFailure, recipeBrowserEvidence, recipeStepFailure, recipeWorkflowArgsEvidence, recipeWorkflowStepIsAdvisory, runDistributionSetupArtifacts, runDistributionStartupProbes, runRecipeProbes, withRecipeExecutionPhase } from "./recipe-run-workflow-evidence.js"
3334
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"
3435

@@ -95,7 +96,7 @@ export async function runRecipeValidateCommand(args: string[]): Promise<number>
9596
return output.success ? 0 : 1
9697
}
9798

98-
async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterruptionController): Promise<RecipeRunOutput> {
99+
export async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterruptionController): Promise<RecipeRunOutput> {
99100
const mountConflictFailure = await recipeArtifactsMountConflictFailure(options)
100101
if (mountConflictFailure) {
101102
return mountConflictFailure
@@ -160,6 +161,14 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
160161
let artifacts: ArtifactBundle | undefined
161162
let startupDurationMs: number | undefined
162163
let cleanupEvidence: RunResourceCleanupEvidence | undefined
164+
let managedServices: Awaited<ReturnType<typeof provisionRuntimeServices>> | undefined
165+
let managedServicesReleased = false
166+
let serviceEvidence: RuntimeServiceEvidence[] = []
167+
const releaseManagedServices = async (): Promise<void> => {
168+
if (!managedServices || managedServicesReleased) return
169+
await managedServices.release()
170+
managedServicesReleased = true
171+
}
163172
let runtimeDestroyed = false
164173
const destroyActiveRuntime = async (): Promise<void> => {
165174
if (!runtime || runtimeDestroyed) {
@@ -180,6 +189,13 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
180189
interruption?.throwIfInterrupted()
181190

182191
runRecord = await runRegistry.update(runRecord.runId, { status: "booting" })
192+
managedServices = await phaseTracker.run("provision_runtime_services", { services: recipe.inputs?.services?.map(({ id, kind }) => ({ id, kind })) ?? [] }, async () => await provisionRuntimeServicesForRecipe(
193+
recipe.inputs?.services ?? [],
194+
async (provisioning) => await awaitRecipe("runtime-services.provision", provisioning),
195+
{ signal: interruption?.signal, onEvidence: (evidence) => { serviceEvidence = evidence } },
196+
))
197+
serviceEvidence = managedServices.evidence
198+
Object.assign(runtimeEnv, managedServices.env)
183199
const runtimeEnvironment = {
184200
kind: "wordpress" as const,
185201
name: plan.runtime.name,
@@ -200,6 +216,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
200216
metadata: {
201217
...runtimeMetadata(configuredArtifactsDirectory, plan.runtime.wp),
202218
run: { runId: runRecord.runId, registryDirectory: runRegistry.directory },
219+
...(serviceEvidence.length > 0 ? { managedRuntimeServices: serviceEvidence } : {}),
203220
...recipeRunMetadata(recipe, recipePath, workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, backendPackage, effectivePreview),
204221
},
205222
preview: previewSpec(effectivePreview.publicUrl, effectivePreview.port, effectivePreview.bind, effectivePreview.siteUrl, effectivePreview.lease),
@@ -346,7 +363,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
346363
await markPreviewLeaseAvailable(options.previewLeaseFile, { runId: runRecord.runId, preview: artifacts.preview, holdSeconds: options.previewHoldSeconds })
347364
}
348365
const activeRuntime = runtime
349-
cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, async () => {
366+
cleanupEvidence = await runManagedServiceCleanup(runRegistry, runRecord, serviceEvidence, false, async () => {
350367
await awaitRecipe("runtime.release", async () => {
351368
try {
352369
await releaseRuntime(activeRuntime, successfulRecipe && options.previewHoldBlocking ? options.previewHoldSeconds : 0, async () => {
@@ -360,6 +377,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
360377
interruption.clear()
361378
}
362379
})
380+
await releaseManagedServices()
363381
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
364382
await cleanupInputMountBaselines(inputMountBaselinePaths)
365383
})
@@ -414,6 +432,11 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
414432
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) },
415433
})
416434
} catch (error) {
435+
const failedServiceEvidence = runtimeServiceEvidenceFromError(error)
436+
if (failedServiceEvidence) {
437+
serviceEvidence = failedServiceEvidence
438+
await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: serviceEvidence } })
439+
}
417440
await markPreviewLeaseFailed(options.previewLeaseFile, error)
418441
const serializedError = interruption?.metadata ? recipeInterruptionSerializedError(interruption.metadata) : serializeRecipeRunError(error)
419442
const failureDiagnostics = recipeFailureRuntimeEvidenceFile({
@@ -493,7 +516,8 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
493516
])
494517
}
495518

496-
cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, async () => {
519+
cleanupEvidence = await runManagedServiceCleanup(runRegistry, runRecord, serviceEvidence, true, async () => {
520+
await releaseManagedServices()
497521
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
498522
await cleanupInputMountBaselines(inputMountBaselinePaths)
499523
})
@@ -534,10 +558,35 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
534558
},
535559
})
536560
} finally {
561+
if (managedServices && !managedServicesReleased) {
562+
await managedServices.release().catch(() => undefined)
563+
await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: managedServices.evidence } }).catch(() => undefined)
564+
}
537565
cancellationWatcher?.dispose()
538566
}
539567
}
540568

569+
export async function runManagedServiceCleanup(
570+
runRegistry: RuntimeRunRegistry,
571+
runRecord: Awaited<ReturnType<RuntimeRunRegistry["read"]>>,
572+
serviceEvidence: RuntimeServiceEvidence[],
573+
preservePrimaryFailure: boolean,
574+
cleanup: () => Promise<void>,
575+
): Promise<RunResourceCleanupEvidence> {
576+
try {
577+
return await runRecipeCleanup(runRegistry, runRecord, cleanup)
578+
} catch (error) {
579+
if (preservePrimaryFailure && error instanceof RunResourceCleanupError) {
580+
return error.evidence
581+
}
582+
throw error
583+
} finally {
584+
await runRegistry.update(runRecord.runId, {
585+
metadata: { managedRuntimeServices: serviceEvidence },
586+
})
587+
}
588+
}
589+
541590
async function recipeArtifactsMountConflictFailure(options: RecipeRunOptions): Promise<RecipeRunOutput | undefined> {
542591
const recipePath = resolve(options.recipePath)
543592
const recipeDirectory = dirname(recipePath)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { recipeExternalServiceBoundarySummaries, type RecipeExternalServiceBound
88
import { composerPackageVendorPath, defaultWorkspaceTarget, installMuPluginsCode, pluginTarget, recipeBlueprintWithBootActivePlugins, recipeExtraPluginFile, recipeExtraPluginSlug, recipeExtraPluginSource, recipeExtraPluginSourceRoot, recipeExtraPluginSourceSubpath, recipeExtraPlugins, recipeMountType, recipeSource, recipeSourceProvenance, resolveRecipeExtraPluginFile, stagedFileMountType, stagedFileProvenance, type RecipeSourceProvenance, type RecipeSourceType, type RecipeStagedFileProvenance } from "./recipe-sources.js"
99
import { hasExplicitSiteSeedSelectors, loadWorkspaceRecipe, pluginRuntimeHealthProbeStep, recipePolicy, recipeWorkflowSteps, validateRecipeRuntimePolicy, validateWorkspaceRecipe, type RecipeValidationIssue, type RecipeWorkflowPhase } from "./recipe-validation.js"
1010
import { runtimeOverlayTarget } from "./runtime-overlay-registry.js"
11+
import { runtimeServicePlan } from "./runtime-services.js"
1112

1213
export interface RecipeDryRunOptions {
1314
recipePath: string
@@ -69,6 +70,7 @@ export interface RecipePlan {
6970
siteSeeds: RecipeDryRunSiteSeed[]
7071
stagedFiles: RecipeDryRunStagedFile[]
7172
externalServices: RecipeExternalServiceBoundarySummary[]
73+
services: ReturnType<typeof runtimeServicePlan>
7274
probes: RecipeDryRunProbe[]
7375
secretEnv: Array<{ name: string; available: boolean; status: RecipeSecretEnvSummaryEntry["status"]; source?: string }>
7476
policy: RuntimePolicy & {
@@ -453,6 +455,7 @@ export async function planWorkspaceRecipe(recipe: WorkspaceRecipe, recipeDirecto
453455
siteSeeds,
454456
stagedFiles,
455457
externalServices: recipeExternalServiceBoundarySummaries(recipe),
458+
services: runtimeServicePlan(recipe.inputs?.services ?? []),
456459
probes,
457460
secretEnv: secretEnvSummary.map(recipeDryRunSecretEnvEntry),
458461
policy: {

0 commit comments

Comments
 (0)