Skip to content
Merged
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,7 @@ unless this index says otherwise.
- JSON Schema factory: `packages/runtime-core/src/recipe-schema.ts`.
- Default check coverage: `npm run check` includes
`npm run test:generic-primitives` through the smoke manifest `core` group.
- Disposable MySQL integration coverage runs through
`npm run test:disposable-mysql-mysqli-e2e`. The test detects Docker with
`docker info`; Docker-capable CI/Lab runs the public recipe path and PHP-WASM
`mysqli` assertion, while hosts without a Docker daemon report an explicit skip.
8 changes: 8 additions & 0 deletions docs/recipe-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,14 @@ runners:
{
"pluginSlug": "woocommerce",
"pluginSource": "../woocommerce/plugins/woocommerce",
"services": [
{
"id": "mysql",
"kind": "mysql",
"configuration": { "rootAuthentication": "empty-password" },
"outputs": { "port": "TC_MYSQL_PORT" }
}
],
"cwd": "/wordpress/wp-content/plugins/woocommerce",
"autoloadFile": "/wp-codebox-vendor/autoload.php",
"testsDir": "/wp-codebox-vendor/wp-phpunit/wp-phpunit",
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",
"test:runtime-requirements-readiness": "tsx tests/runtime-requirements-readiness.test.ts",
"test:runtime-services": "tsx tests/runtime-services.test.ts",
"test:runtime-services-lifecycle": "tsx tests/runtime-services-lifecycle.test.ts",
"test:disposable-mysql-mysqli-e2e": "tsx tests/disposable-mysql-mysqli.integration.test.ts",
"test:runtime-contract-manifest": "tsx tests/runtime-contract-manifest.test.ts",
"test:runtime-contract-package-exports": "tsx tests/runtime-contract-package-exports.test.ts",
"test:runtime-command-result-envelope": "tsx tests/runtime-command-result-envelope.test.ts",
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/recipe-build.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFile, writeFile } from "node:fs/promises"
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"
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"

interface RecipeBuildOptions {
recipeType: "phpunit" | "bench" | "template" | "generic-ability-runtime-run" | "runtime-package-run"
Expand All @@ -11,6 +11,7 @@ interface WordPressPhpunitBuilderOptions {
blueprint?: unknown
wordpressVersion?: string
mounts?: WorkspaceRecipeMount[]
services?: WorkspaceRecipeRuntimeService[]
extra_plugins?: WorkspaceRecipeExtraPlugin[]
pluginSource?: string
pluginSlug: string
Expand Down Expand Up @@ -74,6 +75,7 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word
blueprint: phpunitOptions.blueprint,
wordpressVersion: stringOrUndefined(phpunitOptions.wordpressVersion),
mounts: Array.isArray(phpunitOptions.mounts) ? phpunitOptions.mounts : [],
services: Array.isArray(phpunitOptions.services) ? phpunitOptions.services : [],
extra_plugins: Array.isArray(phpunitOptions.extra_plugins) ? phpunitOptions.extra_plugins : [],
pluginSource: stringOrUndefined(phpunitOptions.pluginSource),
pluginSlug: requiredString(phpunitOptions.pluginSlug, "pluginSlug"),
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/commands/recipe-run-finalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export interface RunResourceCleanupEvidence {
error?: RunOutput["error"]
}

export class RunResourceCleanupError extends Error {
constructor(readonly evidence: RunResourceCleanupEvidence, options: ErrorOptions) {
super("Recipe resource cleanup failed", options)
this.name = "RunResourceCleanupError"
}
}

interface RunResourceEvidenceOptions {
startedAtMs: number
status: RuntimeRunRecord["status"]
Expand Down Expand Up @@ -227,8 +234,10 @@ export async function runRecipeCleanup(runRegistry: RuntimeRunRegistry, runRecor
} catch (error) {
const updatedRunRecord = await runRegistry.update(runRecord.runId, { cleanup: { status: "failed", error: serializeError(error) } })
const cleanupError = serializeRecipeRunError(error)
cleanupEvidenceFromRunRecord(updatedRunRecord, Date.now() - startedAtMs, cleanupError)
throw error
throw new RunResourceCleanupError(
cleanupEvidenceFromRunRecord(updatedRunRecord, Date.now() - startedAtMs, cleanupError),
{ cause: error },
)
}
}

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

function classifyRecipePhaseFailure(phase: string): string {
switch (phase) {
case "provision_runtime_services":
case "runtime_startup":
case "run_blueprint_steps":
return "startup"
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/recipe-run-interruption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ export function createRecipeInterruptionController(): RecipeInterruptionControll
let parentWatcher: NodeJS.Timeout | undefined
let stdinWatcherInstalled = false
let stdioErrorWatcherInstalled = false
const abortController = new AbortController()
const initialParentPid = process.ppid
const signals: RecipeInterruptionSignal[] = ["SIGINT", "SIGTERM", "SIGHUP"]
const interrupt = (signal: RecipeInterruptionSignal, reason: RecipeInterruptionReason): void => {
if (!metadata) {
metadata = { signal, reason, receivedAt: new Date().toISOString(), artifactsFinalized: false }
abortController.abort()
}
rejectInterrupted?.(new RecipeInterruptedError(metadata.signal, metadata.reason, metadata.receivedAt))
}
Expand All @@ -45,6 +47,9 @@ export function createRecipeInterruptionController(): RecipeInterruptionControll
get metadata() {
return metadata
},
get signal() {
return abortController.signal
},
install() {
if (installed) {
return
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/recipe-run-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export interface RecipeDiagnosticArtifactRef {
sha256?: string
}

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"
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"

export interface RecipePhaseEvidence {
schema: "wp-codebox/recipe-phase-evidence/v1"
Expand Down Expand Up @@ -439,6 +439,7 @@ export interface RecipeInterruptionMetadata {

export interface RecipeInterruptionController {
readonly metadata: RecipeInterruptionMetadata | undefined
readonly signal: AbortSignal
install(): void
dispose(): void
interruptible<T>(promise: Promise<T>): Promise<T>
Expand Down
57 changes: 53 additions & 4 deletions packages/cli/src/commands/recipe-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { previewSpec, releaseRuntime, runtimeMetadata, type RunOutput } from "..
import { artifactManifestFilesByPath, parseBenchResults, writeBenchmarkArtifactEvidence } from "./recipe-run-benchmark-artifacts.js"
import { createRecipeRunContext } from "./recipe-run-context.js"
import { collectRecipeDeclaredArtifacts, materializeTypedRecipeDeclaredArtifacts, recipeDeclaredArtifactFailure, recipeProbeFailure, recipeRuntimeEvidenceFiles } from "./recipe-declared-artifacts.js"
import { completedRecipeOutputFields, finalizeCompletedRecipeRun, finalizeRecipeValidationFailure, finalizeRecoveredRecipeFailure, runRecipeCleanup, type RunResourceCleanupEvidence } from "./recipe-run-finalizer.js"
import { completedRecipeOutputFields, finalizeCompletedRecipeRun, finalizeRecipeValidationFailure, finalizeRecoveredRecipeFailure, runRecipeCleanup, RunResourceCleanupError, type RunResourceCleanupEvidence } from "./recipe-run-finalizer.js"
import { RecipeRunPhaseExecutor } from "./recipe-run-phase-executor.js"
import { RecipeArtifactsMountConflictError, recipeArtifactsMountConflict } from "./recipe-run-artifacts-mount-guard.js"
import { createRecipeInterruptionController, interruptedRecipeOutput, markRecipeArtifactsFinalized, recipeInterruptionSerializedError } from "./recipe-run-interruption.js"
Expand All @@ -29,6 +29,7 @@ import { RecipePhaseError } from "./recipe-run-phases.js"
import { markPreviewLeaseAvailable, markPreviewLeaseFailed, markPreviewLeaseReleased, startPreviewLeaseRecipeRun } from "./preview-lease.js"
import { importRecipeSiteSeeds } from "./recipe-site-seeds.js"
import { applyRecipeRuntimeSetup, cleanupInputMountBaselines, prepareRecipeRuntimeSetup, recipeRunDependencyOverlay, recipeRunExtraPlugin, recipeRunStagedFile, rewriteInputMountPathArgs } from "./recipe-runtime-setup.js"
import { provisionRuntimeServices, provisionRuntimeServicesForRecipe, runtimeServiceEvidenceFromError, type RuntimeServiceEvidence } from "../runtime-services.js"
import { distributionStartupProbeFailure, executeRecipeCollectWorkloadResult, executeRecipeWorkflowStep, recipeAdvisoryFailure, recipeBrowserEvidence, recipeStepFailure, recipeWorkflowArgsEvidence, recipeWorkflowStepIsAdvisory, runDistributionSetupArtifacts, runDistributionStartupProbes, runRecipeProbes, withRecipeExecutionPhase } from "./recipe-run-workflow-evidence.js"
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"

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

async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterruptionController): Promise<RecipeRunOutput> {
export async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterruptionController): Promise<RecipeRunOutput> {
const mountConflictFailure = await recipeArtifactsMountConflictFailure(options)
if (mountConflictFailure) {
return mountConflictFailure
Expand Down Expand Up @@ -160,6 +161,14 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
let artifacts: ArtifactBundle | undefined
let startupDurationMs: number | undefined
let cleanupEvidence: RunResourceCleanupEvidence | undefined
let managedServices: Awaited<ReturnType<typeof provisionRuntimeServices>> | undefined
let managedServicesReleased = false
let serviceEvidence: RuntimeServiceEvidence[] = []
const releaseManagedServices = async (): Promise<void> => {
if (!managedServices || managedServicesReleased) return
await managedServices.release()
managedServicesReleased = true
}
let runtimeDestroyed = false
const destroyActiveRuntime = async (): Promise<void> => {
if (!runtime || runtimeDestroyed) {
Expand All @@ -180,6 +189,13 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
interruption?.throwIfInterrupted()

runRecord = await runRegistry.update(runRecord.runId, { status: "booting" })
managedServices = await phaseTracker.run("provision_runtime_services", { services: recipe.inputs?.services?.map(({ id, kind }) => ({ id, kind })) ?? [] }, async () => await provisionRuntimeServicesForRecipe(
recipe.inputs?.services ?? [],
async (provisioning) => await awaitRecipe("runtime-services.provision", provisioning),
{ signal: interruption?.signal, onEvidence: (evidence) => { serviceEvidence = evidence } },
))
serviceEvidence = managedServices.evidence
Object.assign(runtimeEnv, managedServices.env)
const runtimeEnvironment = {
kind: "wordpress" as const,
name: plan.runtime.name,
Expand All @@ -200,6 +216,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
metadata: {
...runtimeMetadata(configuredArtifactsDirectory, plan.runtime.wp),
run: { runId: runRecord.runId, registryDirectory: runRegistry.directory },
...(serviceEvidence.length > 0 ? { managedRuntimeServices: serviceEvidence } : {}),
...recipeRunMetadata(recipe, recipePath, workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, backendPackage, effectivePreview),
},
preview: previewSpec(effectivePreview.publicUrl, effectivePreview.port, effectivePreview.bind, effectivePreview.siteUrl, effectivePreview.lease),
Expand Down Expand Up @@ -346,7 +363,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
await markPreviewLeaseAvailable(options.previewLeaseFile, { runId: runRecord.runId, preview: artifacts.preview, holdSeconds: options.previewHoldSeconds })
}
const activeRuntime = runtime
cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, async () => {
cleanupEvidence = await runManagedServiceCleanup(runRegistry, runRecord, serviceEvidence, false, async () => {
await awaitRecipe("runtime.release", async () => {
try {
await releaseRuntime(activeRuntime, successfulRecipe && options.previewHoldBlocking ? options.previewHoldSeconds : 0, async () => {
Expand All @@ -360,6 +377,7 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
interruption.clear()
}
})
await releaseManagedServices()
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
await cleanupInputMountBaselines(inputMountBaselinePaths)
})
Expand Down Expand Up @@ -414,6 +432,11 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
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) },
})
} catch (error) {
const failedServiceEvidence = runtimeServiceEvidenceFromError(error)
if (failedServiceEvidence) {
serviceEvidence = failedServiceEvidence
await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: serviceEvidence } })
}
await markPreviewLeaseFailed(options.previewLeaseFile, error)
const serializedError = interruption?.metadata ? recipeInterruptionSerializedError(interruption.metadata) : serializeRecipeRunError(error)
const failureDiagnostics = recipeFailureRuntimeEvidenceFile({
Expand Down Expand Up @@ -493,7 +516,8 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
])
}

cleanupEvidence = await runRecipeCleanup(runRegistry, runRecord, async () => {
cleanupEvidence = await runManagedServiceCleanup(runRegistry, runRecord, serviceEvidence, true, async () => {
await releaseManagedServices()
await cleanupRecipePreparedSources(workspaceMounts, extraPlugins, stagedFiles, overlays, dependencyOverlays)
await cleanupInputMountBaselines(inputMountBaselinePaths)
})
Expand Down Expand Up @@ -534,10 +558,35 @@ async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterru
},
})
} finally {
if (managedServices && !managedServicesReleased) {
await managedServices.release().catch(() => undefined)
await runRegistry.update(runRecord.runId, { metadata: { managedRuntimeServices: managedServices.evidence } }).catch(() => undefined)
}
cancellationWatcher?.dispose()
}
}

export async function runManagedServiceCleanup(
runRegistry: RuntimeRunRegistry,
runRecord: Awaited<ReturnType<RuntimeRunRegistry["read"]>>,
serviceEvidence: RuntimeServiceEvidence[],
preservePrimaryFailure: boolean,
cleanup: () => Promise<void>,
): Promise<RunResourceCleanupEvidence> {
try {
return await runRecipeCleanup(runRegistry, runRecord, cleanup)
} catch (error) {
if (preservePrimaryFailure && error instanceof RunResourceCleanupError) {
return error.evidence
}
throw error
} finally {
await runRegistry.update(runRecord.runId, {
metadata: { managedRuntimeServices: serviceEvidence },
})
}
}

async function recipeArtifactsMountConflictFailure(options: RecipeRunOptions): Promise<RecipeRunOutput | undefined> {
const recipePath = resolve(options.recipePath)
const recipeDirectory = dirname(recipePath)
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/recipe-dry-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { recipeExternalServiceBoundarySummaries, type RecipeExternalServiceBound
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"
import { hasExplicitSiteSeedSelectors, loadWorkspaceRecipe, pluginRuntimeHealthProbeStep, recipePolicy, recipeWorkflowSteps, validateRecipeRuntimePolicy, validateWorkspaceRecipe, type RecipeValidationIssue, type RecipeWorkflowPhase } from "./recipe-validation.js"
import { runtimeOverlayTarget } from "./runtime-overlay-registry.js"
import { runtimeServicePlan } from "./runtime-services.js"

export interface RecipeDryRunOptions {
recipePath: string
Expand Down Expand Up @@ -69,6 +70,7 @@ export interface RecipePlan {
siteSeeds: RecipeDryRunSiteSeed[]
stagedFiles: RecipeDryRunStagedFile[]
externalServices: RecipeExternalServiceBoundarySummary[]
services: ReturnType<typeof runtimeServicePlan>
probes: RecipeDryRunProbe[]
secretEnv: Array<{ name: string; available: boolean; status: RecipeSecretEnvSummaryEntry["status"]; source?: string }>
policy: RuntimePolicy & {
Expand Down Expand Up @@ -453,6 +455,7 @@ export async function planWorkspaceRecipe(recipe: WorkspaceRecipe, recipeDirecto
siteSeeds,
stagedFiles,
externalServices: recipeExternalServiceBoundarySummaries(recipe),
services: runtimeServicePlan(recipe.inputs?.services ?? []),
probes,
secretEnv: secretEnvSummary.map(recipeDryRunSecretEnvEntry),
policy: {
Expand Down
Loading
Loading