diff --git a/docs/README.md b/docs/README.md index 050d5a197..cf9cd945e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/recipe-contract.md b/docs/recipe-contract.md index a5e606d08..876b1130f 100644 --- a/docs/recipe-contract.md +++ b/docs/recipe-contract.md @@ -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", diff --git a/package.json b/package.json index 5cedc86f4..0a3945087 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/src/commands/recipe-build.ts b/packages/cli/src/commands/recipe-build.ts index 67347cf65..619abb731 100644 --- a/packages/cli/src/commands/recipe-build.ts +++ b/packages/cli/src/commands/recipe-build.ts @@ -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" @@ -11,6 +11,7 @@ interface WordPressPhpunitBuilderOptions { blueprint?: unknown wordpressVersion?: string mounts?: WorkspaceRecipeMount[] + services?: WorkspaceRecipeRuntimeService[] extra_plugins?: WorkspaceRecipeExtraPlugin[] pluginSource?: string pluginSlug: string @@ -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"), diff --git a/packages/cli/src/commands/recipe-run-finalizer.ts b/packages/cli/src/commands/recipe-run-finalizer.ts index 274435016..1a25b3a0c 100644 --- a/packages/cli/src/commands/recipe-run-finalizer.ts +++ b/packages/cli/src/commands/recipe-run-finalizer.ts @@ -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"] @@ -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 }, + ) } } @@ -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" diff --git a/packages/cli/src/commands/recipe-run-interruption.ts b/packages/cli/src/commands/recipe-run-interruption.ts index f4e75efec..b9568fbad 100644 --- a/packages/cli/src/commands/recipe-run-interruption.ts +++ b/packages/cli/src/commands/recipe-run-interruption.ts @@ -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)) } @@ -45,6 +47,9 @@ export function createRecipeInterruptionController(): RecipeInterruptionControll get metadata() { return metadata }, + get signal() { + return abortController.signal + }, install() { if (installed) { return diff --git a/packages/cli/src/commands/recipe-run-types.ts b/packages/cli/src/commands/recipe-run-types.ts index 61805a7c5..a5b6e80d8 100644 --- a/packages/cli/src/commands/recipe-run-types.ts +++ b/packages/cli/src/commands/recipe-run-types.ts @@ -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" @@ -439,6 +439,7 @@ export interface RecipeInterruptionMetadata { export interface RecipeInterruptionController { readonly metadata: RecipeInterruptionMetadata | undefined + readonly signal: AbortSignal install(): void dispose(): void interruptible(promise: Promise): Promise diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index 0887ae63e..ef63f13d5 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -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" @@ -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" @@ -95,7 +96,7 @@ export async function runRecipeValidateCommand(args: string[]): Promise return output.success ? 0 : 1 } -async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterruptionController): Promise { +export async function runRecipe(options: RecipeRunOptions, interruption?: RecipeInterruptionController): Promise { const mountConflictFailure = await recipeArtifactsMountConflictFailure(options) if (mountConflictFailure) { return mountConflictFailure @@ -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> | undefined + let managedServicesReleased = false + let serviceEvidence: RuntimeServiceEvidence[] = [] + const releaseManagedServices = async (): Promise => { + if (!managedServices || managedServicesReleased) return + await managedServices.release() + managedServicesReleased = true + } let runtimeDestroyed = false const destroyActiveRuntime = async (): Promise => { if (!runtime || runtimeDestroyed) { @@ -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, @@ -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), @@ -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 () => { @@ -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) }) @@ -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({ @@ -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) }) @@ -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>, + serviceEvidence: RuntimeServiceEvidence[], + preservePrimaryFailure: boolean, + cleanup: () => Promise, +): Promise { + 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 { const recipePath = resolve(options.recipePath) const recipeDirectory = dirname(recipePath) diff --git a/packages/cli/src/recipe-dry-run.ts b/packages/cli/src/recipe-dry-run.ts index 6b57d650c..344c9ec4e 100644 --- a/packages/cli/src/recipe-dry-run.ts +++ b/packages/cli/src/recipe-dry-run.ts @@ -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 @@ -69,6 +70,7 @@ export interface RecipePlan { siteSeeds: RecipeDryRunSiteSeed[] stagedFiles: RecipeDryRunStagedFile[] externalServices: RecipeExternalServiceBoundarySummary[] + services: ReturnType probes: RecipeDryRunProbe[] secretEnv: Array<{ name: string; available: boolean; status: RecipeSecretEnvSummaryEntry["status"]; source?: string }> policy: RuntimePolicy & { @@ -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: { diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 3ab6222b5..79b0a16ca 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -664,6 +664,7 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe, } validateRecipeExternalServiceBoundaries(recipe, addIssue) + validateRecipeRuntimeServices(recipe, addIssue) for (const [name, value] of Object.entries(recipe.inputs?.runtimeEnv ?? {})) { if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) { @@ -699,6 +700,28 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe, return issues } +function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void { + const ids = new Set() + const environment = new Set([ + ...Object.keys(recipe.distribution?.env ?? {}), + ...Object.keys(recipe.inputs?.runtimeEnv ?? {}), + ...(recipe.inputs?.secretEnv ?? []), + ]) + for (const [index, service] of (recipe.inputs?.services ?? []).entries()) { + const path = `$.inputs.services[${index}]` + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(service.id)) addIssue("invalid-runtime-service-id", `${path}.id`, "Runtime service ids must be stable identifiers.") + if (ids.has(service.id)) addIssue("duplicate-runtime-service-id", `${path}.id`, `Runtime service ids must be unique: ${service.id}`) + ids.add(service.id) + if (service.kind !== "mysql") addIssue("unsupported-runtime-service-kind", `${path}.kind`, `Unsupported managed runtime service kind: ${service.kind}`) + for (const [output, name] of Object.entries(service.outputs)) { + if (!/^(host|port|username|password|database)$/.test(output)) addIssue("unknown-runtime-service-output", `${path}.outputs.${output}`, `Unsupported ${service.kind} service output: ${output}`) + if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) addIssue("invalid-runtime-service-env", `${path}.outputs.${output}`, "Runtime service environment variable names must match /^[A-Z_][A-Z0-9_]*$/.") + if (environment.has(name)) addIssue("duplicate-runtime-service-env", `${path}.outputs.${output}`, `Runtime service output environment variable is already declared: ${name}`) + environment.add(name) + } + } +} + function validateRecipeExternalServiceBoundaries(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void { const seenIds = new Set() for (const [index, boundary] of (recipe.inputs?.externalServices ?? []).entries()) { diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts new file mode 100644 index 000000000..80de01da2 --- /dev/null +++ b/packages/cli/src/runtime-services.ts @@ -0,0 +1,280 @@ +import { execFile } from "node:child_process" +import { randomBytes } from "node:crypto" +import { createConnection } from "node:net" +import { promisify } from "node:util" +import type { WorkspaceRecipeRuntimeService } from "@automattic/wp-codebox-core" + +const execFileAsync = promisify(execFile) +const MYSQL_IMAGE = "mysql:8.4" + +export interface RuntimeServiceEvidence { + id: string + kind: string + provider: string + version: string + readiness: "pending" | "ready" | "failed" + lifecycle: "provisioning" | "provisioned" | "released" | "failed" + teardown?: "completed" | "failed" + diagnostic?: { code: "readiness-failed" | "provision-failed" | "teardown-failed" | "interrupted" } +} + +export class RuntimeServiceProvisionError extends Error { + constructor(message: string, readonly evidence: RuntimeServiceEvidence[]) { + super(message) + this.name = "RuntimeServiceProvisionError" + } +} + +export function runtimeServiceEvidenceFromError(error: unknown): RuntimeServiceEvidence[] | undefined { + let current = error + const seen = new Set() + while (current instanceof Error && !seen.has(current)) { + if (current instanceof RuntimeServiceProvisionError) return current.evidence + seen.add(current) + current = current.cause + } + return undefined +} + +interface ManagedRuntimeService { + env: Record + evidence: RuntimeServiceEvidence + release(): Promise +} + +export interface RuntimeServiceDependencies { + execute(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeout: number }): Promise<{ stdout: string }> + waitForReady(host: string, port: number, timeoutMs: number, signal?: AbortSignal): Promise + randomBytes(size: number): Buffer +} + +export interface RuntimeServiceProvider { + readonly name: string + readonly kind: string + readonly version: string + provision(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidence: RuntimeServiceEvidence[]): Promise +} + +const defaultDependencies: RuntimeServiceDependencies = { + execute: async (command, args, options) => await execFileAsync(command, args, options), + waitForReady: waitForMysqlProtocol, + randomBytes, +} + +export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback"; port: "ephemeral"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record }> { + return services.map((service) => { + const provider = runtimeServiceProvider(service.kind) + return { id: service.id, kind: service.kind, provider: provider.name, version: provider.version, bind: "loopback", port: "ephemeral", persistentVolume: false, ...(service.configuration ? { configuration: service.configuration } : {}), outputs: service.outputs } + }) +} + +export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: { signal?: AbortSignal; dependencies?: RuntimeServiceDependencies } = {}): Promise<{ env: Record; evidence: RuntimeServiceEvidence[]; release(): Promise }> { + const dependencies = options.dependencies ?? defaultDependencies + const provisioned: ManagedRuntimeService[] = [] + const evidence: RuntimeServiceEvidence[] = [] + try { + for (const service of services) { + const managed = await runtimeServiceProvider(service.kind).provision(service, dependencies, options.signal, evidence) + provisioned.push(managed) + } + } catch (error) { + await releaseServices(provisioned).catch(() => undefined) + if (error instanceof RuntimeServiceProvisionError) throw error + throw new RuntimeServiceProvisionError("Managed runtime service provisioning failed", evidence) + } + + // A provisioned host service is an active runtime resource. Keep Node alive + // until release so temporarily handle-free PHP-WASM startup can still reach + // its timeout/cancellation finalizer instead of exiting with unsettled await. + const lease = provisioned.length > 0 ? setInterval(() => undefined, 1_000) : undefined + + return { + env: Object.assign({}, ...provisioned.map((service) => service.env)), + evidence, + async release() { + try { + await releaseServices(provisioned) + } finally { + if (lease) clearInterval(lease) + } + }, + } +} + +export async function provisionRuntimeServicesForRecipe( + services: WorkspaceRecipeRuntimeService[], + guard: (promise: Promise) => Promise, + options: { signal?: AbortSignal; dependencies?: RuntimeServiceDependencies; onEvidence?: (evidence: RuntimeServiceEvidence[]) => void } = {}, +): Promise>> { + const controller = new AbortController() + const abort = () => controller.abort() + options.signal?.addEventListener("abort", abort, { once: true }) + if (options.signal?.aborted) controller.abort() + const provisioning = provisionRuntimeServices(services, { signal: controller.signal, dependencies: options.dependencies }) + try { + return await guard(provisioning) + } catch (error) { + controller.abort() + try { + const provisioned = await provisioning + try { + await provisioned.release() + } finally { + options.onEvidence?.(provisioned.evidence) + } + } catch (provisionError) { + const evidence = runtimeServiceEvidenceFromError(provisionError) + if (evidence) options.onEvidence?.(evidence) + } + throw error + } finally { + options.signal?.removeEventListener("abort", abort) + } +} + +const mysqlDockerProvider: RuntimeServiceProvider = { + name: "docker", + kind: "mysql", + version: MYSQL_IMAGE, + provision: provisionMysqlDockerService, +} + +function runtimeServiceProvider(kind: string): RuntimeServiceProvider { + if (kind === mysqlDockerProvider.kind) return mysqlDockerProvider + throw new Error(`Unsupported managed runtime service kind: ${kind}`) +} + +async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, signal: AbortSignal | undefined, evidenceList: RuntimeServiceEvidence[]): Promise { + const evidence: RuntimeServiceEvidence = { id: service.id, kind: service.kind, provider: "docker", version: MYSQL_IMAGE, readiness: "pending", lifecycle: "provisioning" } + evidenceList.push(evidence) + const container = `wp-codebox-${service.id}-${dependencies.randomBytes(6).toString("hex")}` + const password = dependencies.randomBytes(24).toString("base64url") + const emptyRootPassword = service.configuration?.rootAuthentication === "empty-password" + const rootEnvironment = emptyRootPassword ? { MYSQL_ALLOW_EMPTY_PASSWORD: "yes" } : { MYSQL_ROOT_PASSWORD: password } + const childEnvironment = { ...process.env, MYSQL_DATABASE: "runtime", MYSQL_USER: "runtime", MYSQL_PASSWORD: password, ...rootEnvironment } + const rootEnvironmentName = emptyRootPassword ? "MYSQL_ALLOW_EMPTY_PASSWORD" : "MYSQL_ROOT_PASSWORD" + 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", rootEnvironmentName, MYSQL_IMAGE] + let started = false + try { + throwIfAborted(signal) + await ensureDockerImage(dependencies, signal) + await dependencies.execute("docker", runArgs, { env: childEnvironment, signal, timeout: 30_000 }) + started = true + const { stdout } = await dependencies.execute("docker", ["port", container, "3306/tcp"], { signal, timeout: 10_000 }) + const port = parseLoopbackPort(stdout) + await dependencies.waitForReady("127.0.0.1", port, 30_000, signal) + throwIfAborted(signal) + evidence.readiness = "ready" + evidence.lifecycle = "provisioned" + const values: Record = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" } + return { env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), evidence, async release() { await releaseService(container, evidence, dependencies) } } + } catch (error) { + evidence.readiness = "failed" + evidence.lifecycle = "failed" + evidence.diagnostic = { code: signal?.aborted ? "interrupted" : started ? "readiness-failed" : "provision-failed" } + if (started) await releaseService(container, evidence, dependencies, undefined).catch(() => undefined) + throw new RuntimeServiceProvisionError(`Managed runtime service failed: ${service.id}`, evidenceList) + } +} + +async function ensureDockerImage(dependencies: RuntimeServiceDependencies, signal?: AbortSignal): Promise { + try { + await dependencies.execute("docker", ["image", "inspect", MYSQL_IMAGE], { signal, timeout: 10_000 }) + } catch { + throwIfAborted(signal) + await dependencies.execute("docker", ["pull", MYSQL_IMAGE], { signal, timeout: 5 * 60_000 }) + } +} + +async function releaseServices(services: ManagedRuntimeService[]): Promise { + const results = await Promise.allSettled([...services].reverse().map(async (service) => await service.release())) + const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected") + if (failure) throw failure.reason +} + +async function releaseService(container: string, evidence: RuntimeServiceEvidence, dependencies: RuntimeServiceDependencies, signal?: AbortSignal): Promise { + if (evidence.teardown === "completed") return + try { + await dependencies.execute("docker", ["rm", "--force", container], { signal, timeout: 30_000 }) + evidence.lifecycle = "released" + evidence.teardown = "completed" + } catch (error) { + if (dockerContainerIsAbsent(error)) { + evidence.lifecycle = "released" + evidence.teardown = "completed" + return + } + evidence.lifecycle = "failed" + evidence.teardown = "failed" + evidence.diagnostic = { code: "teardown-failed" } + throw new Error(`Managed runtime service teardown failed: ${evidence.id}`) + } +} + +function dockerContainerIsAbsent(error: unknown): boolean { + if (!(error instanceof Error)) return false + const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr : "" + return /No such container/i.test(`${error.message}\n${stderr}`) +} + +export function parseLoopbackPort(output: string): number { + const match = output.trim().match(/^127\.0\.0\.1:(\d+)$/m) + const port = match ? Number(match[1]) : NaN + if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Managed runtime service did not publish a loopback port") + return port +} + +export async function waitForMysqlProtocol(host: string, port: number, timeoutMs: number, signal?: AbortSignal): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + throwIfAborted(signal) + try { + await mysqlHandshake(host, port, signal) + return + } catch (error) { + if (signal?.aborted) throw error + await abortableDelay(100, signal) + } + } + throw new Error(`MySQL protocol readiness timed out after ${timeoutMs}ms`) +} + +function mysqlHandshake(host: string, port: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ host, port }) + let settled = false + const timer = setTimeout(() => socket.destroy(new Error("connection timeout")), 1_000) + const abort = () => socket.destroy(new Error("aborted")) + const fail = (error: Error) => { + if (settled) return + settled = true + reject(error) + } + signal?.addEventListener("abort", abort, { once: true }) + socket.once("error", fail) + socket.once("data", (chunk: Buffer) => { + if (settled) return + settled = true + clearTimeout(timer) + socket.destroy() + if (chunk.length < 5 || chunk[4] !== 10) reject(new Error("invalid MySQL protocol handshake")) + else resolve() + }) + socket.once("close", () => { + clearTimeout(timer) + signal?.removeEventListener("abort", abort) + fail(new Error("connection closed before MySQL protocol handshake")) + }) + }) +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new Error("Managed runtime service provisioning interrupted") +} + +function abortableDelay(milliseconds: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(resolve, milliseconds) + signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new Error("Managed runtime service provisioning interrupted")) }, { once: true }) + }) +} diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index ed30af328..4b825d223 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -1,4 +1,4 @@ -import type { WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipeMount, WorkspaceRecipeStep } from "./runtime-contracts.js" +import type { WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipeMount, WorkspaceRecipeRuntimeService, WorkspaceRecipeStep } from "./runtime-contracts.js" import { commandArg, commandJsonArg, commandStringListArg } from "./command-codecs.js" export { buildRuntimePackageRunRecipe, CODEBOX_RUN_RUNTIME_PACKAGE_ABILITY, RUNTIME_PACKAGE_ARTIFACT_DECLARATION_SCHEMA, RUNTIME_PACKAGE_EXECUTION_INPUT_SCHEMA, RUNTIME_PACKAGE_EXECUTION_RESULT_SCHEMA, RUNTIME_PACKAGE_OUTPUT_PROJECTION_SCHEMA, runtimePackageExecutionInput, type RuntimePackageArtifactDeclaration, type RuntimePackageExecutionInput, type RuntimePackageOutputProjection, type RuntimePackageRunRecipeOptions } from "./runtime-package-execution.js" export { RUNTIME_PACKAGE_DIAGNOSTIC_SCHEMA, RUNTIME_PACKAGE_RESULT_SCHEMA, RUNTIME_PACKAGE_TASK_SCHEMA, normalizeRuntimePackageResult, normalizeRuntimePackageTask, validateRuntimePackageTask, type RuntimePackageDiagnostic, type RuntimePackageResult, type RuntimePackageTask } from "./runtime-package-contracts.js" @@ -15,6 +15,7 @@ export interface WordPressPhpunitRecipeOptions { wordpressVersion?: string blueprint?: unknown mounts?: WorkspaceRecipeMount[] + services?: WorkspaceRecipeRuntimeService[] extra_plugins?: WorkspaceRecipeExtraPlugin[] pluginSource?: string pluginSlug: string @@ -75,6 +76,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio }, inputs: { extra_plugins: normalizeExtraPlugins(options.extra_plugins), + ...(options.services && options.services.length > 0 ? { services: options.services } : {}), mounts: normalizeRecipeMounts([ ...(options.pluginSource ? [{ source: options.pluginSource, target: pluginTarget } satisfies WorkspaceRecipeMount] : []), ...(options.mounts ?? []), diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 32cbe78c3..7e8d48b65 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -171,6 +171,11 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche type: "array", items: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" }, }, + services: { + type: "array", + description: "Disposable host services provisioned before runtime creation. Outputs must be explicitly mapped into runtime environment variable names.", + items: { $ref: "#/$defs/runtimeService" }, + }, externalServices: { type: "array", description: "Declared external service boundaries used for reviewer-safe browser and recipe evidence correlation. Secret material is represented by environment variable names only.", @@ -887,6 +892,27 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche metadata: { $ref: "#/$defs/metadata" }, }, }, + runtimeService: { + type: "object", + additionalProperties: false, + required: ["id", "kind", "outputs"], + properties: { + id: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9_.-]*$" }, + kind: { const: "mysql" }, + configuration: { + type: "object", + additionalProperties: false, + properties: { + rootAuthentication: { enum: ["generated-password", "empty-password"] }, + }, + }, + outputs: { + type: "object", + minProperties: 1, + additionalProperties: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" }, + }, + }, + }, fixtureUser: { type: "object", additionalProperties: false, diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index 3c6fe9bf6..185a22e11 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -131,6 +131,17 @@ export interface WorkspaceRecipeExternalServiceBoundary { metadata?: Record } +/** A short-lived host resource provisioned before the sandbox runtime starts. */ +export interface WorkspaceRecipeRuntimeService { + id: string + kind: "mysql" | (string & {}) + configuration?: { + rootAuthentication?: "generated-password" | "empty-password" + } + /** Explicit map from a provider output (for example `port`) to a runtime env name. */ + outputs: Record +} + export interface WorkspaceRecipeRuntimeStack { mounts?: WorkspaceRecipeMount[] } @@ -583,6 +594,7 @@ export interface WorkspaceRecipe { dependency_overlays?: WorkspaceRecipeDependencyOverlay[] runtimeEnv?: Record secretEnv?: string[] + services?: WorkspaceRecipeRuntimeService[] externalServices?: WorkspaceRecipeExternalServiceBoundary[] pluginRuntime?: WorkspaceRecipePluginRuntime fixtureDatabases?: WorkspaceRecipeFixtureDatabase[] diff --git a/scripts/smoke-manifest.ts b/scripts/smoke-manifest.ts index eaf3d7d8f..f202afcd1 100644 --- a/scripts/smoke-manifest.ts +++ b/scripts/smoke-manifest.ts @@ -129,6 +129,9 @@ export const smokeGroups = { description: "Package build contract smoke checks.", commands: [ npmScript("build"), + npmScript("test:runtime-services"), + npmScript("test:runtime-services-lifecycle"), + npmScript("test:disposable-mysql-mysqli-e2e"), ], }, agent: { diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts new file mode 100644 index 000000000..44028148c --- /dev/null +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" +import { runRecipe } from "../packages/cli/src/commands/recipe-run.ts" + +const execFileAsync = promisify(execFile) + +async function dockerAvailable(): Promise { + try { + await execFileAsync("docker", ["info", "--format", "{{.ServerVersion}}"], { timeout: 10_000 }) + return true + } catch { + return false + } +} + +if (!await dockerAvailable()) { + console.log("SKIP disposable MySQL mysqli E2E: docker info is unavailable") +} else { + const directory = await mkdtemp(join(tmpdir(), "wp-codebox-mysql-e2e-")) + try { + const recipePath = join(directory, "recipe.json") + const code = "if (!function_exists('mysqli_init')) { throw new RuntimeException('mysqli is unavailable'); } $db = mysqli_init(); if (!mysqli_real_connect($db, getenv('DB_HOST'), 'root', '', null, (int) getenv('DB_PORT'))) { throw new RuntimeException(mysqli_connect_error()); } $result = mysqli_query($db, 'SELECT 1 AS connected'); echo mysqli_fetch_assoc($result)['connected'];" + await writeFile(recipePath, JSON.stringify({ + schema: "wp-codebox/workspace-recipe/v1", + inputs: { + services: [{ id: "mysql", kind: "mysql", configuration: { rootAuthentication: "empty-password" }, outputs: { host: "DB_HOST", port: "DB_PORT" } }], + }, + workflow: { steps: [{ command: "wordpress.run-php", args: [`code=${code}`] }] }, + })) + const result = await runRecipe({ + recipePath, + previewHoldBlocking: false, + previewLeaseRequested: false, + previewLeaseChild: false, + timeoutMs: 180_000, + json: true, + summary: false, + dryRun: false, + }) + assert.equal(result.success, true) + assert.equal(result.executions.at(-1)?.stdout.trim(), "1") + console.log("disposable MySQL mysqli E2E passed") + } finally { + await rm(directory, { recursive: true, force: true }) + } +} diff --git a/tests/runtime-services-lifecycle.test.ts b/tests/runtime-services-lifecycle.test.ts new file mode 100644 index 000000000..982d35bac --- /dev/null +++ b/tests/runtime-services-lifecycle.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { RuntimeRunRegistry } from "../packages/runtime-core/src/run-registry.ts" +import { runManagedServiceCleanup } from "../packages/cli/src/commands/recipe-run.ts" +import { RunResourceCleanupError } from "../packages/cli/src/commands/recipe-run-finalizer.ts" +import type { RuntimeServiceEvidence } from "../packages/cli/src/runtime-services.ts" + +const directory = await mkdtemp(join(tmpdir(), "wp-codebox-service-lifecycle-")) +try { + const registry = new RuntimeRunRegistry(directory) + + const succeeded = await registry.create({ runId: "service-success", status: "running", metadata: {} }) + const succeededEvidence: RuntimeServiceEvidence[] = [{ id: "mysql", kind: "mysql", provider: "test", version: "test", readiness: "ready", lifecycle: "provisioned" }] + const cleanup = await runManagedServiceCleanup(registry, succeeded, succeededEvidence, false, async () => { + succeededEvidence[0]!.lifecycle = "released" + succeededEvidence[0]!.teardown = "completed" + }) + assert.equal(cleanup.state, "completed") + assert.deepEqual((await registry.read(succeeded.runId)).metadata.managedRuntimeServices, succeededEvidence) + + const failed = await registry.create({ runId: "service-failure", status: "running", metadata: {} }) + const failedEvidence: RuntimeServiceEvidence[] = [{ id: "mysql", kind: "mysql", provider: "test", version: "test", readiness: "ready", lifecycle: "provisioned" }] + const preserved = await runManagedServiceCleanup(registry, failed, failedEvidence, true, async () => { + failedEvidence[0]!.lifecycle = "failed" + failedEvidence[0]!.teardown = "failed" + failedEvidence[0]!.diagnostic = { code: "teardown-failed" } + throw new Error("fixture teardown failure") + }) + assert.equal(preserved.state, "failed", "a primary recipe failure keeps structured cleanup evidence") + assert.deepEqual((await registry.read(failed.runId)).metadata.managedRuntimeServices, failedEvidence) + + const terminal = await registry.create({ runId: "service-terminal-cleanup-failure", status: "running", metadata: {} }) + await assert.rejects( + runManagedServiceCleanup(registry, terminal, [], false, async () => { throw new Error("fixture teardown failure") }), + (error: unknown) => error instanceof RunResourceCleanupError && error.evidence.state === "failed", + "cleanup failure becomes the terminal error when there is no earlier recipe failure", + ) +} finally { + await rm(directory, { recursive: true, force: true }) +} + +console.log("runtime service lifecycle cleanup tests passed") diff --git a/tests/runtime-services.test.ts b/tests/runtime-services.test.ts new file mode 100644 index 000000000..840d16a71 --- /dev/null +++ b/tests/runtime-services.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict" +import { createServer } from "node:net" +import { parseLoopbackPort, provisionRuntimeServices, provisionRuntimeServicesForRecipe, RuntimeServiceProvisionError, runtimeServiceEvidenceFromError, runtimeServicePlan, waitForMysqlProtocol, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" +import { planWorkspaceRecipe } from "../packages/cli/src/recipe-dry-run.ts" +import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" +import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.ts" +import { validateWorkspaceRecipeJsonSchema, type WorkspaceRecipe } from "../packages/runtime-core/src/index.ts" + +const service = { id: "test-db", kind: "mysql", outputs: { host: "DB_HOST", port: "DB_PORT", password: "DB_PASSWORD" } } as const +const plan = runtimeServicePlan([service]) +assert.deepEqual(plan, [{ id: "test-db", kind: "mysql", provider: "docker", version: "mysql:8.4", bind: "loopback", port: "ephemeral", persistentVolume: false, outputs: service.outputs }]) +assert.equal(parseLoopbackPort("127.0.0.1:44001\n"), 44001) +assert.throws(() => parseLoopbackPort("0.0.0.0:3306"), /loopback/) + +const valid = validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [service] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }) +assert.equal(valid.valid, true) +const unsafe = validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [{ ...service, outputs: { port: "bad-name" } }] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }) +assert.equal(unsafe.valid, false) +const emptyRootService = { ...service, configuration: { rootAuthentication: "empty-password" as const } } +assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [emptyRootService] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }).valid, true) +assert.deepEqual(buildWordPressPhpunitRecipe({ pluginSlug: "example", services: [emptyRootService] }).inputs?.services, [emptyRootService]) +const recipe: WorkspaceRecipe = { schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [service] }, workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 'ok';"] }] } } +assert.deepEqual(await validateWorkspaceRecipeSemantics(recipe, "recipe.json"), []) +const dryRun = await planWorkspaceRecipe(recipe, process.cwd(), { recipePath: "recipe.json" }, { + defaultWordPressVersion: "latest", + resolveExecutionSpec: async (step) => ({ command: step.command, args: step.args ?? [] }), +}) +assert.deepEqual(dryRun.services, plan) +const collisions: WorkspaceRecipe = { + ...recipe, + distribution: { name: "fixture", wordpress: { root: "/wordpress" }, env: { DB_HOST: "distribution" } }, + inputs: { runtimeEnv: { DB_PORT: "3306" }, secretEnv: ["DB_PASSWORD"], services: [service] }, +} +assert.deepEqual( + (await validateWorkspaceRecipeSemantics(collisions, "recipe.json")).map((issue) => issue.code), + ["duplicate-runtime-service-env", "duplicate-runtime-service-env", "duplicate-runtime-service-env"], +) + +const server = createServer((socket) => socket.end(Buffer.from([1, 0, 0, 0, 10]))) +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) +const address = server.address() +assert.ok(address && typeof address !== "string") +await waitForMysqlProtocol("127.0.0.1", address.port, 250) +await new Promise((resolve) => server.close(() => resolve())) +await assert.rejects(waitForMysqlProtocol("127.0.0.1", address.port, 25), /readiness timed out/) + +const closingServer = createServer((socket) => socket.end()) +await new Promise((resolve) => closingServer.listen(0, "127.0.0.1", resolve)) +const closingAddress = closingServer.address() +assert.ok(closingAddress && typeof closingAddress !== "string") +await assert.rejects(waitForMysqlProtocol("127.0.0.1", closingAddress.port, 25), /readiness timed out/, "a pre-handshake close remains retryable instead of leaving an unsettled promise") +await new Promise((resolve) => closingServer.close(() => resolve())) + +const calls: Array<{ args: string[]; env?: NodeJS.ProcessEnv; signal?: AbortSignal }> = [] +const dependencies: RuntimeServiceDependencies = { + randomBytes: (size) => Buffer.alloc(size, 7), + async execute(_command, args, options) { + calls.push({ args, env: options.env, signal: options.signal }) + if (args[0] === "port") return { stdout: "127.0.0.1:41001\n" } + return { stdout: "" } + }, + async waitForReady() {}, +} +const provisioned = await provisionRuntimeServices([service], { dependencies }) +assert.equal(provisioned.env.DB_PORT, "41001") +assert.equal(provisioned.env.DB_PASSWORD, Buffer.alloc(24, 7).toString("base64url")) +const runCall = calls.find((call) => call.args[0] === "run") +assert.ok(runCall?.args.includes("MYSQL_PASSWORD")) +assert.ok(runCall?.args.includes("127.0.0.1::3306"), "Docker publishes MySQL on a loopback ephemeral port") +assert.deepEqual(runCall?.args.slice(runCall.args.indexOf("--tmpfs"), runCall.args.indexOf("--tmpfs") + 2), ["--tmpfs", "/var/lib/mysql"]) +assert.equal(runCall?.args.includes("--volume") || runCall?.args.includes("--mount"), false, "Docker uses no persistent volume") +assert.equal(runCall?.args.some((arg) => arg.includes(provisioned.env.DB_PASSWORD)), false, "credentials never enter Docker argv") +assert.equal(JSON.stringify(provisioned.evidence).includes(provisioned.env.DB_PASSWORD), false, "credentials never enter evidence") +assert.equal(runCall?.env?.DOCKER_HOST, process.env.DOCKER_HOST, "Docker provider context is preserved") +assert.equal(calls[0]?.args[0], "image", "the provider checks the image before starting the service") +await provisioned.release() +await provisioned.release() +assert.equal(calls.filter((call) => call.args[0] === "rm").length, 1, "release is idempotent") + +const emptyRootCalls: Array<{ args: string[]; env?: NodeJS.ProcessEnv }> = [] +const emptyRootDependencies: RuntimeServiceDependencies = { + ...dependencies, + async execute(command, args, options) { + emptyRootCalls.push({ args, env: options.env }) + return dependencies.execute(command, args, options) + }, +} +const emptyRoot = await provisionRuntimeServices([emptyRootService], { dependencies: emptyRootDependencies }) +const emptyRootRun = emptyRootCalls.find((call) => call.args[0] === "run") +assert.ok(emptyRootRun?.args.includes("MYSQL_ALLOW_EMPTY_PASSWORD"), "empty root auth is explicit in the provider plan") +assert.equal(emptyRootRun?.args.includes("MYSQL_ROOT_PASSWORD"), false) +assert.equal(emptyRootRun?.env?.MYSQL_ALLOW_EMPTY_PASSWORD, "yes") +assert.equal(emptyRootRun?.env?.MYSQL_ROOT_PASSWORD, undefined) +await emptyRoot.release() + +const interruptedAfterProvision = new AbortController() +const provisionedBeforeAbort = await provisionRuntimeServices([service], { dependencies, signal: interruptedAfterProvision.signal }) +interruptedAfterProvision.abort() +await provisionedBeforeAbort.release() +const cleanupCall = calls.filter((call) => call.args[0] === "rm").at(-1) +assert.equal(cleanupCall?.signal, undefined, "teardown has an independent cleanup context after interruption") + +let finishLateProvisioning: (() => void) | undefined +let lateRemoval = false +const lateDependencies: RuntimeServiceDependencies = { + ...dependencies, + async execute(command, args, options) { + if (args[0] === "run") await new Promise((resolve) => { finishLateProvisioning = resolve }) + if (args[0] === "rm") lateRemoval = true + return dependencies.execute(command, args, options) + }, +} +const guardedProvisioning = provisionRuntimeServicesForRecipe([service], async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + throw new Error("recipe timeout") +}, { dependencies: lateDependencies }) +while (!finishLateProvisioning) await new Promise((resolve) => setTimeout(resolve, 1)) +finishLateProvisioning() +await assert.rejects(guardedProvisioning, /recipe timeout/) +assert.equal(lateRemoval, true, "a timeout waits for and tears down late provisioning") + +const absentDependencies: RuntimeServiceDependencies = { + ...dependencies, + async execute(command, args, options) { + if (args[0] === "rm") { + const error = new Error("docker rm failed") as Error & { stderr: string } + error.stderr = `Error response from daemon: No such container: fixture` + throw error + } + return dependencies.execute(command, args, options) + }, +} +const alreadyAbsent = await provisionRuntimeServices([service], { dependencies: absentDependencies }) +await alreadyAbsent.release() +assert.equal(alreadyAbsent.evidence[0]?.teardown, "completed", "an already absent container is idempotently released") + +let failedCleanup = false +const failingDependencies: RuntimeServiceDependencies = { + ...dependencies, + async execute(_command, args, options) { + if (args[0] === "port") return { stdout: "127.0.0.1:41001\n" } + if (args[0] === "rm") { failedCleanup = true; throw new Error("remove failed") } + return dependencies.execute("docker", args, options) + }, + async waitForReady() { throw new Error("not ready") }, +} +await assert.rejects(provisionRuntimeServices([service], { dependencies: failingDependencies }), (error: unknown) => { + assert.ok(error instanceof RuntimeServiceProvisionError) + assert.equal(error.evidence[0]?.readiness, "failed") + assert.equal(error.evidence[0]?.teardown, "failed") + assert.equal(error.evidence[0]?.diagnostic?.code, "teardown-failed") + return true +}) +assert.equal(failedCleanup, true) + +const controller = new AbortController() +controller.abort() +await assert.rejects(provisionRuntimeServices([service], { dependencies, signal: controller.signal }), (error: unknown) => error instanceof RuntimeServiceProvisionError && error.evidence[0]?.diagnostic?.code === "interrupted") + +const nestedEvidence = [{ id: "nested", kind: "mysql", provider: "test", version: "test", readiness: "failed", lifecycle: "failed" }] satisfies import("../packages/cli/src/runtime-services.ts").RuntimeServiceEvidence[] +const nestedError = new Error("phase failed", { cause: new RuntimeServiceProvisionError("service failed", nestedEvidence) }) +assert.equal(runtimeServiceEvidenceFromError(nestedError), nestedEvidence, "phase wrappers retain structured service evidence") +console.log("runtime services tests passed")