Skip to content
Open
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
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 39 additions & 3 deletions packages/cli/src/bounded-recipe-plan.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
import { join } from "node:path"
import { commandArgValue, executeBoundedRuntimePlan, parseCommandJsonObject, type BoundedRuntimePlan, type BoundedRuntimePlanResult, type ExecutionResult, type Runtime } from "@automattic/wp-codebox-core"
import { commandArgValue, executeBoundedRuntimePlan, parseCommandJsonObject, type BoundedRuntimePlan, type BoundedRuntimePlanEntryResult, type BoundedRuntimePlanResult, type ExecutionResult, type Runtime } from "@automattic/wp-codebox-core"
import { recipeExecutionSpec } from "./agent-sandbox.js"

export interface BoundedRecipePlanExecutionOptions {
artifactRoot: string
recipeDirectory: string
}

const BOUNDED_RUNTIME_PLAN_PROGRESS_SCHEMA = "wp-codebox/bounded-runtime-plan-progress/v1"

export async function executeBoundedRecipePlan(runtime: Runtime, plan: BoundedRuntimePlan, options: BoundedRecipePlanExecutionOptions): Promise<BoundedRuntimePlanResult> {
const progressPath = join(options.artifactRoot, "bounded-plan", "progress.json")
const completed = new Map<string, BoundedRuntimePlanEntryResult>()
let progressWrite = Promise.resolve()
await mkdir(join(options.artifactRoot, "bounded-plan"), { recursive: true })
await writeBoundedPlanProgress(progressPath, plan, completed, false)
const aggregate = await executeBoundedRuntimePlan(plan, {
async materialize() { return { workspace: options.recipeDirectory, runtime } },
async startServices() { return undefined },
Expand Down Expand Up @@ -50,10 +57,15 @@ export async function executeBoundedRecipePlan(runtime: Runtime, plan: BoundedRu
}, null, 2)}\n`, "utf8")
return { success: exitCode === 0, exitCode, message: stderr, stdoutRef, stderrRef, resultRef, artifactRefs }
},
async onEntryResult(result) {
completed.set(result.id, result)
progressWrite = progressWrite.then(async () => writeBoundedPlanProgress(progressPath, plan, completed, false))
await progressWrite
},
async stopServices() {},
async dispose() {},
})
await mkdir(join(options.artifactRoot, "bounded-plan"), { recursive: true })
await writeBoundedPlanProgress(progressPath, plan, completed, true)
await writeFile(join(options.artifactRoot, "bounded-plan/result.json"), `${JSON.stringify(aggregate, null, 2)}\n`, "utf8")
return aggregate
}
Expand All @@ -76,3 +88,27 @@ function runtimeArtifactRefs(execution: ExecutionResult | undefined): string[] {
return typeof value === "string" && value ? [value] : []
})
}

async function writeBoundedPlanProgress(path: string, plan: BoundedRuntimePlan, completed: Map<string, BoundedRuntimePlanEntryResult>, complete: boolean): Promise<void> {
const entries = plan.entries.flatMap((entry) => {
const result = completed.get(entry.id)
return result ? [result] : []
})
const progress = {
schema: BOUNDED_RUNTIME_PLAN_PROGRESS_SCHEMA,
complete,
concurrency: Math.min(plan.concurrency, plan.entries.length),
counts: {
total: plan.entries.length,
succeeded: entries.filter((entry) => entry.status === "succeeded").length,
failed: entries.filter((entry) => entry.status === "failed").length,
timedOut: entries.filter((entry) => entry.status === "timed_out").length,
cancelled: entries.filter((entry) => entry.status === "cancelled").length,
unfinished: plan.entries.length - entries.length,
},
entries,
}
const temporaryPath = `${path}.tmp`
await writeFile(temporaryPath, `${JSON.stringify(progress, null, 2)}\n`, "utf8")
await rename(temporaryPath, path)
}
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 RuntimeWordPressInstallMode, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipePHPWasmExtensionManifest, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeService, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core"
import { buildGenericAbilityRuntimeRunRecipe, buildRuntimePackageRunRecipe, buildWordPressBenchRecipe, buildWordPressPhpunitRecipe, compileRecipeTemplate, type GenericAbilityRuntimeRunOptions, type RecipeTemplateInput, type RuntimePackageRunRecipeOptions, type RuntimeWordPressInstallMode, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipePHPWasmExtensionManifest, type WorkspaceRecipePluginRuntime, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeService, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core"

interface RecipeBuildOptions {
recipeType: "phpunit" | "bench" | "template" | "generic-ability-runtime-run" | "runtime-package-run"
Expand All @@ -17,6 +17,7 @@ interface WordPressPhpunitBuilderOptions {
backendPackage?: WorkspaceRecipeRuntimeBackendPackage
mounts?: WorkspaceRecipeMount[]
services?: WorkspaceRecipeRuntimeService[]
pluginRuntime?: WorkspaceRecipePluginRuntime
extra_plugins?: WorkspaceRecipeExtraPlugin[]
pluginSource?: string
pluginSlug: string
Expand Down Expand Up @@ -88,6 +89,7 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word
backendPackage: phpunitOptions.backendPackage,
mounts: Array.isArray(phpunitOptions.mounts) ? phpunitOptions.mounts : [],
services: Array.isArray(phpunitOptions.services) ? phpunitOptions.services : [],
pluginRuntime: phpunitOptions.pluginRuntime ? plainObject(phpunitOptions.pluginRuntime) as WorkspaceRecipePluginRuntime : undefined,
extra_plugins: Array.isArray(phpunitOptions.extra_plugins) ? phpunitOptions.extra_plugins : [],
pluginSource: stringOrUndefined(phpunitOptions.pluginSource),
pluginSlug: requiredString(phpunitOptions.pluginSlug, "pluginSlug"),
Expand Down
29 changes: 19 additions & 10 deletions packages/cli/src/recipe-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,10 +769,12 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code:
const environment = new Set(exposedEnvironment)
const outputOwners = new Map<string, Array<{ serviceIndex: number; output: string }>>()
for (const [serviceIndex, service] of services.entries()) {
for (const [output, name] of Object.entries(service.outputs)) {
const owners = outputOwners.get(name) ?? []
owners.push({ serviceIndex, output })
outputOwners.set(name, owners)
for (const [output, names] of Object.entries(service.outputs)) {
for (const name of runtimeServiceOutputNames(names)) {
const owners = outputOwners.get(name) ?? []
owners.push({ serviceIndex, output })
outputOwners.set(name, owners)
}
}
}
const connectorTargets = new Set<string>()
Expand All @@ -782,10 +784,11 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code:
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 (!["mysql", "redis", "smtp", "http"].includes(service.kind)) addIssue("unsupported-runtime-service-kind", `${path}.kind`, `Unsupported managed runtime service kind: ${service.kind}`)
if (service.kind === "mysql" && service.outputs.password) {
const passwordTargets = runtimeServiceOutputNames(service.outputs.password)
if (service.kind === "mysql" && passwordTargets.length > 0) {
const target = "DB_PASSWORD"
if (exposedEnvironment.has(target)) addIssue("runtime-service-secret-target-collision", `${path}.outputs.password`, `Managed connector secret target is already injected by recipe environment: ${target}`)
const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === index && owner.output === "password" && service.outputs.password === target))
const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === index && owner.output === "password" && passwordTargets.includes(target)))
if (conflictingOutput) addIssue("runtime-service-secret-target-collision", `${path}.outputs.password`, `Managed connector secret target collides with a managed output: ${target}`)
if (connectorTargets.has(target)) addIssue("ambiguous-runtime-service-secret-target", `${path}.outputs.password`, `Multiple managed connectors target the same runtime environment name: ${target}`)
connectorTargets.add(target)
Expand Down Expand Up @@ -819,15 +822,21 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code:
smtp: /^(host|port|httpPort|url)$/,
http: /^(host|port|url)$/,
}
for (const [output, name] of Object.entries(service.outputs)) {
for (const [output, names] of Object.entries(service.outputs)) {
if (!(supportedOutputs[service.kind] ?? /^$/).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)
for (const name of runtimeServiceOutputNames(names)) {
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 runtimeServiceOutputNames(value: string | string[] | undefined): string[] {
return value === undefined ? [] : Array.isArray(value) ? value : [value]
}

function policyHostListIncludes(policyHosts: readonly string[], boundaryHost: string): boolean {
const normalizedBoundary = boundaryHost.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "")
const boundaryHasPort = /:\d+$/.test(normalizedBoundary)
Expand Down
34 changes: 23 additions & 11 deletions packages/cli/src/runtime-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const defaultDependencies: RuntimeServiceDependencies = {
environment: process.env,
}

export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback" | "configured"; port: "ephemeral" | "configured"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record<string, string> }> {
export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback" | "configured"; port: "ephemeral" | "configured"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record<string, string | string[]> }> {
return services.map((service) => {
const provider = runtimeServiceProvider(service)
const external = provider.name === "external"
Expand Down Expand Up @@ -232,7 +232,9 @@ function mysqlDockerImage(service: WorkspaceRecipeRuntimeService): string {
}

function mysqlRuntimeServiceSecretTargets(service: WorkspaceRecipeRuntimeService): Record<string, string> {
return service.outputs.password ? { DB_PASSWORD: service.outputs.password } : {}
const names = runtimeServiceOutputNames(service.outputs.password)
if (names.length === 0) return {}
return { DB_PASSWORD: names.includes("DB_PASSWORD") ? "DB_PASSWORD" : names[0] as string }
}

function runtimeServiceProvider(service: WorkspaceRecipeRuntimeService): RuntimeServiceProvider {
Expand Down Expand Up @@ -280,7 +282,7 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic
const values: Record<string, string> = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" }
return {
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
secretEnv: runtimeServiceSecretOutputEnvironment(service, "password", password),
secretEnvTargets: mysqlRuntimeServiceSecretTargets(service),
evidence,
async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, async (customAction) => {
Expand Down Expand Up @@ -483,7 +485,7 @@ async function provisionMysqlNativeService(service: WorkspaceRecipeRuntimeServic
const values: Record<string, string> = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" }
return {
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
secretEnv: runtimeServiceSecretOutputEnvironment(service, "password", password),
secretEnvTargets: mysqlRuntimeServiceSecretTargets(service),
evidence,
async control(action) {
Expand Down Expand Up @@ -1142,7 +1144,7 @@ async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeServ
const values: Record<string, string> = { host: connection.host, port: String(connection.port), username, password, database }
return {
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
secretEnv: runtimeServiceSecretOutputEnvironment(service, "password", password),
secretEnvTargets: mysqlRuntimeServiceSecretTargets(service),
evidence,
async control(action) {
Expand Down Expand Up @@ -1212,17 +1214,27 @@ function mysqlConnectionArgs(host: string, port: number, username: string): stri
function runtimeServiceOutputEnvironment(service: WorkspaceRecipeRuntimeService, values: Record<string, string>, secretOutputs: ReadonlySet<string> = new Set()): Record<string, string> {
return Object.fromEntries(Object.entries(service.outputs)
.filter(([output]) => !secretOutputs.has(output))
.map(([output, name]) => [name, values[output] ?? ""]))
.flatMap(([output, names]) => runtimeServiceOutputNames(names).map((name) => [name, values[output] ?? ""])))
}

function runtimeServiceSecretOutputEnvironment(service: WorkspaceRecipeRuntimeService, output: string, value: string): Record<string, string> {
return Object.fromEntries(runtimeServiceOutputNames(service.outputs[output]).map((name) => [name, value]))
}

function runtimeServiceOutputNames(value: string | string[] | undefined): string[] {
return value === undefined ? [] : Array.isArray(value) ? value : [value]
}

function validateDeclaredRuntimeServiceSecretTargets(services: readonly WorkspaceRecipeRuntimeService[], reservedEnvNames: readonly string[]): void {
const reserved = new Set(reservedEnvNames)
const outputOwners = new Map<string, Array<{ serviceIndex: number; output: string }>>()
for (const [serviceIndex, service] of services.entries()) {
for (const [output, name] of Object.entries(service.outputs)) {
const owners = outputOwners.get(name) ?? []
owners.push({ serviceIndex, output })
outputOwners.set(name, owners)
for (const [output, names] of Object.entries(service.outputs)) {
for (const name of runtimeServiceOutputNames(names)) {
const owners = outputOwners.get(name) ?? []
owners.push({ serviceIndex, output })
outputOwners.set(name, owners)
}
}
}
const targets = new Map<string, string>()
Expand Down Expand Up @@ -1345,7 +1357,7 @@ async function provisionSimpleDockerService(
evidence.lifecycle = "provisioned"
const values = spec.values(ports)
return {
env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])),
env: runtimeServiceOutputEnvironment(service, values),
secretEnv: {},
secretEnvTargets: {},
evidence,
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-core/src/bounded-runtime-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface BoundedRuntimePlanAdapter<TWorkspace = unknown, TRuntime = unkn
materialize(): Promise<{ workspace: TWorkspace; runtime: TRuntime }>
startServices(context: { workspace: TWorkspace; runtime: TRuntime }): Promise<TServices>
execute(context: BoundedRuntimePlanExecution<TWorkspace, TRuntime, TServices>): Promise<{ success: boolean; exitCode?: number; message?: string; stdoutRef?: string; stderrRef?: string; resultRef?: string; artifactRefs?: string[] }>
onEntryResult?(result: BoundedRuntimePlanEntryResult): Promise<void>
stopServices(context: { workspace: TWorkspace; runtime: TRuntime; services: TServices }): Promise<void>
dispose(context: { workspace: TWorkspace; runtime: TRuntime }): Promise<void>
}
Expand Down Expand Up @@ -134,10 +135,12 @@ async function executeEntries<TWorkspace, TRuntime, TServices>(plan: BoundedRunt
const entry = plan.entries[index]!
if (failed && plan.failFast) {
results[index] = cancelledResult(entry)
await adapter.onEntryResult?.(results[index])
continue
}
const result = await executeEntry(entry, materialized, services, adapter)
results[index] = result
await adapter.onEntryResult?.(result)
if (!result.success) failed = true
}
}
Expand Down
Loading
Loading