Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions packages/cli/src/commands/recipe-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { createRequire } from "node:module"
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
import { DEFAULT_WORDPRESS_VERSION, createRuntime, normalizeRecipeRunSummary, normalizeRuntimeEnvRecord, parseCommandOptions, validateRuntimePolicy, type ArtifactBundle, type ArtifactPackageIdentity, type ArtifactPackageProvenance, type Runtime, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeComponentManifest, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core"
import { DEFAULT_WORDPRESS_VERSION, RecipeJsonSchemaValidationError, createRuntime, normalizeRecipeRunSummary, normalizeRuntimeEnvRecord, parseCommandOptions, validateRuntimePolicy, type ArtifactBundle, type ArtifactPackageIdentity, type ArtifactPackageProvenance, type Runtime, type RuntimeAssetSpec, type RuntimePolicy, type RuntimePreviewSpec, type RuntimeRunRegistry, type WorkspaceRecipe, type WorkspaceRecipeComponentManifest, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeFixtureDatabase, type WorkspaceRecipeFuzzCasePhase } from "@automattic/wp-codebox-core"
import { stripUndefined } from "@automattic/wp-codebox-core/internals"
import { recipeExecutionSpec, sandboxWorkspaceContract } from "../agent-sandbox.js"
import { captureStdout, printRecipeHumanOutput, printRecipeValidateHumanOutput, serializeError } from "../output.js"
Expand Down Expand Up @@ -42,7 +42,19 @@ const SUCCESSFUL_RECIPE_RUNTIME_SNAPSHOT_TIMEOUT_MS = 120 * 1000
const packageRequire = createRequire(import.meta.url)
export async function runRecipeRunCommand(args: string[]): Promise<number> {
const options = parseRecipeRunOptions(args)
const replayExitCode = await replayWithHostNodeHeap(args, options.hostNodeHeapMiB, (await loadWorkspaceRecipe(options.recipePath)).runtime?.hostNodeHeap)
let recipe: WorkspaceRecipe
try {
recipe = await loadWorkspaceRecipe(options.recipePath)
} catch (error) {
if (!isRecipeJsonSchemaValidationError(error)) {
throw error
}
const output = recipeJsonSchemaValidationOutput(options, error)
if (options.json) await writeRecipeJsonOutput(output, options.outputPath)
else printRecipeHumanOutput(output)
return 1
}
const replayExitCode = await replayWithHostNodeHeap(args, options.hostNodeHeapMiB, recipe.runtime?.hostNodeHeap)
if (replayExitCode !== undefined) return replayExitCode
if (options.previewLeaseRequested && !options.previewLeaseChild) {
return startPreviewLeaseRecipeRun({ args, json: options.json, recipePath: options.recipePath, artifactsDirectory: options.artifactsDirectory, runRegistryDirectory: options.runRegistryDirectory, previewHoldSeconds: options.previewHoldSeconds })
Expand Down Expand Up @@ -90,6 +102,30 @@ export async function runRecipeRunCommand(args: string[]): Promise<number> {
}
}

function isRecipeJsonSchemaValidationError(error: unknown): error is RecipeJsonSchemaValidationError {
return error instanceof RecipeJsonSchemaValidationError
|| (error instanceof Error && (error.name === "RecipeJsonSchemaValidationError" || error.message.startsWith("Recipe JSON schema validation failed")))
}

function recipeJsonSchemaValidationOutput(options: RecipeRunOptions, error: RecipeJsonSchemaValidationError | Error): RecipeRunOutput {
const issues = error instanceof RecipeJsonSchemaValidationError
? error.issues.map((issue) => ({ code: issue.keyword, path: issue.path, message: issue.message }))
: []
return {
success: false,
schema: "wp-codebox/recipe-run/v1",
recipePath: options.recipePath,
executions: [],
validation: { issues },
error: {
...serializeError(error),
name: "RecipeJsonSchemaValidationError",
code: "recipe-json-schema-validation-failed",
issues,
},
}
}

export function createRecipeRunOptions(options: RecipeRunOptionsInput): RecipeRunOptions {
return {
previewHoldBlocking: false,
Expand Down
16 changes: 15 additions & 1 deletion packages/runtime-core/src/recipe-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ export interface AssertWorkspaceRecipeJsonSchemaOptions extends WorkspaceRecipeJ
recipePath?: string
}

export class RecipeJsonSchemaValidationError extends Error {
readonly code = "recipe-json-schema-validation-failed"
readonly issues: WorkspaceRecipeJsonSchemaValidationIssue[]

constructor(message: string, issues: WorkspaceRecipeJsonSchemaValidationIssue[]) {
super(message)
this.name = "RecipeJsonSchemaValidationError"
this.issues = issues
}
}

export type WorkspaceRecipeRuntimeCollectedArtifact =
| { kind: "path"; index: number; artifact: WorkspaceRecipeDeclaredArtifact }
| { kind: "typed"; index: number; artifact: WorkspaceRecipeTypedArtifact }
Expand All @@ -60,7 +71,7 @@ export function assertWorkspaceRecipeJsonSchema(recipe: unknown, options: Assert

const location = options.recipePath ? ` in ${options.recipePath}` : ""
const details = result.issues.map((issue) => `${issue.path} ${issue.message}`).join("; ")
throw new Error(`Recipe JSON schema validation failed${location}: ${details}`)
throw new RecipeJsonSchemaValidationError(`Recipe JSON schema validation failed${location}: ${details}`, result.issues)
}

export function workspaceRecipeRuntimeCollectedArtifacts(recipe: WorkspaceRecipe): WorkspaceRecipeRuntimeCollectedArtifact[] {
Expand All @@ -84,6 +95,9 @@ function jsonPointerToJsonPath(pointer: string, error: ErrorObject): string {
if (error.keyword === "required" && typeof error.params.missingProperty === "string") {
segments.push(error.params.missingProperty)
}
if ((error.keyword === "additionalProperties" || error.keyword === "unevaluatedProperties") && typeof error.params.additionalProperty === "string") {
segments.push(error.params.additionalProperty)
}
let path = "$"
for (const segment of segments) {
path += /^\d+$/.test(segment) ? `[${segment}]` : `.${segment}`
Expand Down
48 changes: 48 additions & 0 deletions tests/recipe-json-schema-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from "node:assert/strict"
import { writeFile } from "node:fs/promises"
import { join } from "node:path"

import { captureStdout } from "../packages/cli/src/output.js"
import { runRecipeRunCommand } from "../packages/cli/src/commands/recipe-run.js"
import { RecipeJsonSchemaValidationError, validateWorkspaceRecipeJsonSchema } from "../packages/runtime-core/src/index.js"
import { withTempDir } from "../scripts/test-kit.js"

const extraKeyRecipe = {
schema: "wp-codebox/workspace-recipe/v1",
inputs: {
services: [{
id: "mysql",
kind: "mysql",
configuration: { rootAuthentication: "empty-password", unexpectedFlag: true },
outputs: { host: "DB_HOST", port: "DB_PORT" },
}],
},
workflow: { steps: [{ command: "wordpress.run-php" }] },
}

const extraKeyResult = validateWorkspaceRecipeJsonSchema(extraKeyRecipe)
assert.equal(extraKeyResult.valid, false)
assert.ok(extraKeyResult.issues.some((issue) => issue.path === "$.inputs.services[0].configuration.unexpectedFlag"), extraKeyResult.issues.map((issue) => issue.path).join("; "))

assert.throws(
() => {
throw new RecipeJsonSchemaValidationError("Recipe JSON schema validation failed: $.inputs.services[0].configuration.unexpectedFlag must NOT have additional properties", extraKeyResult.issues)
},
(error: unknown) => error instanceof RecipeJsonSchemaValidationError && error.code === "recipe-json-schema-validation-failed",
)

await withTempDir("wp-codebox-recipe-schema-failure-envelope-", async (directory) => {
const recipePath = join(directory, "recipe.json")
await writeFile(recipePath, `${JSON.stringify(extraKeyRecipe, null, 2)}\n`)
const { result: exitCode, logs } = await captureStdout(async () => await runRecipeRunCommand(["--recipe", recipePath, "--json"]))
assert.equal(exitCode, 1)
const output = JSON.parse(logs[0])
assert.equal(output.schema, "wp-codebox/recipe-run/v1")
assert.equal(output.success, false)
assert.equal(output.error.code, "recipe-json-schema-validation-failed")
assert.equal(output.error.name, "RecipeJsonSchemaValidationError")
assert.match(output.error.message, /configuration\.unexpectedFlag/)
assert.ok((output.validation?.issues ?? []).some((issue: { path: string }) => issue.path === "$.inputs.services[0].configuration.unexpectedFlag"))
})

console.log("recipe json schema validation envelope ok")
Loading