Skip to content

Commit 4b63491

Browse files
authored
Merge pull request #2046 from Automattic/feat/2043-external-mysql-provider
feat(runtime-services): support isolated external MySQL providers
2 parents dc9c981 + 28a9d4c commit 4b63491

14 files changed

Lines changed: 695 additions & 54 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@
247247
"test:generic-ability-runtime-run": "tsx tests/generic-ability-runtime-run.test.ts",
248248
"test:provider-runtime-contracts": "tsx tests/provider-runtime-contracts.test.ts",
249249
"test:runtime-requirements-readiness": "tsx tests/runtime-requirements-readiness.test.ts",
250-
"test:runtime-services": "tsx tests/runtime-services.test.ts",
250+
"test:runtime-services": "tsx tests/runtime-services.test.ts && tsx tests/external-mysql-runtime-service.test.ts",
251251
"test:runtime-services-lifecycle": "tsx tests/runtime-services-lifecycle.test.ts",
252252
"test:disposable-mysql-mysqli-e2e": "tsx tests/disposable-mysql-mysqli.integration.test.ts",
253253
"test:runtime-contract-manifest": "tsx tests/runtime-contract-manifest.test.ts",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface RecipeRunOptions {
2323
timeoutMs: number
2424
adversarialReplayPath?: string
2525
policy?: RuntimePolicy
26+
externalServiceWritesApproved: boolean
2627
json: boolean
2728
summary: boolean
2829
dryRun: boolean

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

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { parsePreviewBind, parsePreviewHoldSeconds, parsePreviewLease, parsePrev
1111
import { dryRunRecipe, planWorkspaceRecipe, recipeDryRunSiteSeeds } from "../recipe-dry-run.js"
1212
import { appendRecipeRuntimeEvidence, collectAndFinalizeFailedRecipeArtifacts, collectRecipeRuntimeArtifacts, finalizeAgentSandboxEvidence, finalizeRecipeArtifactEvidence, recipeAgentResultFailure, recipeArtifactEvidenceFailure, recipeReplayStatusOutput, recipeVerifyStepFailure } from "../recipe-evidence.js"
1313
import { recipeExternalServiceBoundarySummaries } from "../recipe-external-services.js"
14-
import { resolveRecipeSecretEnv } from "../recipe-secret-env.js"
14+
import { mergeRecipeSecretEnvSummary, resolveRecipeSecretEnv } from "../recipe-secret-env.js"
1515
import type { PreparedRuntimeBackendPackage } from "../recipe-backend-package.js"
1616
import { cleanupRecipePreparedSources, recipeBlueprintWithBootActivePlugins, recipeExtraPlugins, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js"
1717
import { loadWorkspaceRecipe, recipePolicy, recipeWorkflowSteps, validateRecipeRuntimePolicy, validateWorkspaceRecipe, type RecipeWorkflowPhase } from "../recipe-validation.js"
@@ -110,7 +110,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
110110
await artifactPointer.update({ commandStatus: "queued" })
111111
const issues = [
112112
...await validateWorkspaceRecipe(recipe, recipePath),
113-
...validateRecipeRuntimePolicy(recipe, options.policy),
113+
...validateRecipeRuntimePolicy(recipe, options.policy ?? recipePolicy(recipe)),
114114
]
115115
if (issues.length > 0) {
116116
const failure = {
@@ -140,7 +140,8 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
140140
}
141141
const secretEnvResolution = resolveRecipeSecretEnv(recipe.inputs?.secretEnv ?? [], { field: "--secret-env name" })
142142
const secretEnv = secretEnvResolution.values
143-
const effectivePolicy = Object.keys(secretEnv).length > 0 ? { ...policy, secrets: "connector-scoped" as const } : policy
143+
let secretEnvSummary = secretEnvResolution.summary
144+
let effectivePolicy = Object.keys(secretEnv).length > 0 ? { ...policy, secrets: "connector-scoped" as const } : policy
144145
let workspaceMounts: PreparedWorkspaceMount[] = []
145146
let extraPlugins: PreparedExtraPlugin[] = []
146147
let dependencyOverlays: PreparedDependencyOverlay[] = []
@@ -194,10 +195,22 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
194195
managedServices = await phaseTracker.run("provision_runtime_services", { services: recipe.inputs?.services?.map(({ id, kind }) => ({ id, kind })) ?? [] }, async () => await provisionRuntimeServicesForRecipe(
195196
recipe.inputs?.services ?? [],
196197
async (provisioning) => await awaitRecipe("runtime-services.provision", provisioning),
197-
{ signal: interruption?.signal, onEvidence: (evidence) => { serviceEvidence = evidence } },
198+
{
199+
signal: interruption?.signal,
200+
policy,
201+
externalServices: recipe.inputs?.externalServices ?? [],
202+
externalServiceWritesApproved: options.externalServiceWritesApproved,
203+
onEvidence: (evidence) => { serviceEvidence = evidence },
204+
},
198205
))
199206
serviceEvidence = managedServices.evidence
200207
Object.assign(runtimeEnv, managedServices.env)
208+
for (const [name, value] of Object.entries(managedServices.secretEnv)) {
209+
delete runtimeEnv[name]
210+
secretEnv[name] = value
211+
}
212+
secretEnvSummary = mergeRecipeSecretEnvSummary(secretEnvSummary, Object.keys(managedServices.secretEnv))
213+
if (Object.keys(managedServices.secretEnv).length > 0) effectivePolicy = { ...policy, secrets: "connector-scoped" }
201214
const runtimeEnvironment = {
202215
kind: "wordpress" as const,
203216
name: plan.runtime.name,
@@ -353,7 +366,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
353366
if (declaredArtifactFailure) {
354367
throw declaredArtifactFailure
355368
}
356-
const recipeEvidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvResolution.summary)
369+
const recipeEvidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvSummary)
357370
const agentEvidence = await finalizeAgentSandboxEvidence(artifacts, executions)
358371
Object.assign(recipeEvidence, agentEvidence)
359372
markRecipeArtifactsFinalized(interruption, true)
@@ -374,7 +387,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
374387
declaredArtifacts = await collectRecipeDeclaredArtifacts(recipe, runtime)
375388
await materializeTypedRecipeDeclaredArtifacts(artifacts, declaredArtifacts)
376389
await appendRecipeRuntimeEvidence(artifacts, recipeRuntimeEvidenceFiles(fixtureDatabases, distributionSetupArtifacts, distributionStartupProbes, probes, declaredArtifacts))
377-
evidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvResolution.summary)
390+
evidence = await finalizeRecipeArtifactEvidence(artifacts, recipe, workspaceMounts, stagedFiles, effectivePolicy, secretEnvSummary)
378391
const previewAgentEvidence = await finalizeAgentSandboxEvidence(artifacts, executions)
379392
Object.assign(evidence, previewAgentEvidence)
380393
}
@@ -489,7 +502,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
489502
workspaceMounts,
490503
stagedFiles,
491504
policy: effectivePolicy,
492-
secretEnv: secretEnvResolution.summary,
505+
secretEnv: secretEnvSummary,
493506
executions,
494507
interruption,
495508
}))
@@ -714,7 +727,7 @@ async function validateRecipe(options: RecipeValidateOptions): Promise<RecipeVal
714727
const recipe = await loadWorkspaceRecipe(recipePath)
715728
const issues = [
716729
...await validateWorkspaceRecipe(recipe, recipePath),
717-
...validateRecipeRuntimePolicy(recipe, options.policy),
730+
...validateRecipeRuntimePolicy(recipe, options.policy ?? recipePolicy(recipe)),
718731
]
719732

720733
return {
@@ -765,7 +778,7 @@ function distributionRuntimeEnv(recipe: WorkspaceRecipe): Record<string, string>
765778
}
766779

767780
function parseRecipeRunOptions(args: string[]): RecipeRunOptions {
768-
const parsed = parseCommandOptions(args, new Set(["--json", "--summary", "--summary-only", "--dry-run", "--preview-hold-blocking", "--preview-lease", "--preview-lease-child"]))
781+
const parsed = parseCommandOptions(args, new Set(["--json", "--summary", "--summary-only", "--dry-run", "--preview-hold-blocking", "--preview-lease", "--preview-lease-child", "--approve-external-service-writes"]))
769782
if (parsed.positionals.length > 0) {
770783
throw new Error(`Invalid argument: ${parsed.positionals[0]}`)
771784
}
@@ -776,6 +789,7 @@ function parseRecipeRunOptions(args: string[]): RecipeRunOptions {
776789
previewHoldBlocking: parsed.options.get("--preview-hold-blocking") === true,
777790
previewLeaseRequested: parsed.options.get("--preview-lease") === true,
778791
previewLeaseChild: parsed.options.get("--preview-lease-child") === true,
792+
externalServiceWritesApproved: parsed.options.get("--approve-external-service-writes") === true,
779793
timeoutMs: DEFAULT_RECIPE_RUN_TIMEOUT_MS,
780794
}
781795
for (const [name, value] of parsed.options) {

packages/cli/src/output.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,8 @@ Options:
431431
wp-codebox/preview-lease/v1 envelope for public/local URL, expiry, alignment, and handoff metadata.
432432
--timeout <duration> Maximum live recipe-run duration before emitting a structured timeout failure. Defaults to 25m.
433433
--policy <json|file> Runtime policy JSON or path to a JSON file. For recipe-run and recipe validate, this overrides the recipe-derived runtime policy and must include every command required by the recipe setup, probes, and workflow.
434+
--approve-external-service-writes
435+
Explicitly approve short-lived managed writes to declared external-service boundaries when policy.approvals is on-write.
434436
--dry-run Validate recipe-run and emit a resolved JSON plan without booting Playground or writing temp workspaces.
435437
--json Emit machine-readable JSON.
436438

packages/cli/src/recipe-evidence.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,13 +1344,7 @@ async function buildRecipeRunAttestation(args: {
13441344
resultRef: artifactVerificationRef,
13451345
}),
13461346
},
1347-
secretEnvelope: {
1348-
schema: "wp-codebox/redacted-secret-envelope/v1",
1349-
provided: args.secretEnv.some((entry) => entry.status === "available"),
1350-
count: args.secretEnv.filter((entry) => entry.status === "available").length,
1351-
secrets: [...args.secretEnv].sort((a, b) => a.name.localeCompare(b.name)),
1352-
redaction: "names-only",
1353-
},
1347+
secretEnvelope: recipeSecretEnvelope(args.secretEnv),
13541348
externalServices: {
13551349
schema: "wp-codebox/external-service-boundaries-attestation/v1",
13561350
boundaries: recipeExternalServiceBoundarySummaries(args.recipe),
@@ -1378,6 +1372,22 @@ async function buildRecipeRunAttestation(args: {
13781372
}
13791373
}
13801374

1375+
export function recipeSecretEnvelope(secretEnv: readonly RecipeSecretEnvSummaryEntry[]): {
1376+
schema: "wp-codebox/redacted-secret-envelope/v1"
1377+
provided: boolean
1378+
count: number
1379+
secrets: Array<{ name: string; status: RecipeSecretEnvSummaryEntry["status"]; source?: string }>
1380+
redaction: "names-only"
1381+
} {
1382+
return {
1383+
schema: "wp-codebox/redacted-secret-envelope/v1",
1384+
provided: secretEnv.some((entry) => entry.status === "available"),
1385+
count: secretEnv.filter((entry) => entry.status === "available").length,
1386+
secrets: [...secretEnv].sort((a, b) => a.name.localeCompare(b.name)),
1387+
redaction: "names-only",
1388+
}
1389+
}
1390+
13811391
function enforcedPolicyField(value: RuntimePolicy[keyof RuntimePolicy]): RunAttestationPolicyField {
13821392
return { value, enforcement: "enforced" }
13831393
}

packages/cli/src/recipe-secret-env.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@ export function resolveRecipeSecretEnv(names: readonly string[], options: Resolv
5454
return { values, summary }
5555
}
5656

57+
export function mergeRecipeSecretEnvSummary(summary: readonly RecipeSecretEnvSummaryEntry[], generatedNames: readonly string[]): RecipeSecretEnvSummaryEntry[] {
58+
const entries = new Map(summary.map((entry) => [entry.name, { ...entry }]))
59+
for (const name of generatedNames) {
60+
assertRuntimeEnvName(name, "managed runtime service secret env")
61+
entries.set(name, { name, status: "available", source: "managed-runtime-service" })
62+
}
63+
return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name))
64+
}
65+
5766
export function defaultRecipeSecretEnvProviders(source: Record<string, string | undefined> = process.env): RecipeSecretEnvProvider[] {
5867
return [
5968
directProcessEnvSecretProvider,

packages/cli/src/recipe-validation.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,26 @@ export function validateRecipeRuntimePolicy(recipe: WorkspaceRecipe, policy: Run
536536
}
537537
}
538538

539+
const externalMysqlServices = (recipe.inputs?.services ?? []).filter((service) => service.kind === "mysql" && service.configuration?.provider === "external")
540+
if (externalMysqlServices.length > 0) {
541+
if (policy.network === "deny") {
542+
issues.push({ code: "runtime-policy-external-service-network-denied", path: "$.policy.network", message: "External MySQL runtime services require network access to their declared external-service hosts." })
543+
}
544+
if (policy.approvals !== "on-write") {
545+
issues.push({ code: "runtime-policy-external-service-approval-required", path: "$.policy.approvals", message: "External MySQL runtime services require approvals=on-write and explicit write approval at execution time." })
546+
}
547+
if (typeof policy.network === "object") {
548+
for (const service of externalMysqlServices) {
549+
const boundary = recipe.inputs?.externalServices?.find((candidate) => candidate.id === service.configuration?.externalService)
550+
for (const host of boundary?.allowedHosts ?? []) {
551+
if (!policyHostListIncludes(policy.network.allowHosts, host)) {
552+
issues.push({ code: "runtime-policy-external-service-host-denied", path: "$.policy.network.allowHosts", message: `Runtime network policy must allow external-service host: ${host}` })
553+
}
554+
}
555+
}
556+
}
557+
}
558+
539559
return issues
540560
}
541561

@@ -733,17 +753,34 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe,
733753

734754
function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void {
735755
const ids = new Set<string>()
736-
const environment = new Set<string>([
756+
const exposedEnvironment = new Set<string>([
737757
...Object.keys(recipe.distribution?.env ?? {}),
738758
...Object.keys(recipe.inputs?.runtimeEnv ?? {}),
739759
...(recipe.inputs?.secretEnv ?? []),
740760
])
761+
const environment = new Set(exposedEnvironment)
741762
for (const [index, service] of (recipe.inputs?.services ?? []).entries()) {
742763
const path = `$.inputs.services[${index}]`
743764
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.")
744765
if (ids.has(service.id)) addIssue("duplicate-runtime-service-id", `${path}.id`, `Runtime service ids must be unique: ${service.id}`)
745766
ids.add(service.id)
746767
if (!["mysql", "redis", "smtp", "http"].includes(service.kind)) addIssue("unsupported-runtime-service-kind", `${path}.kind`, `Unsupported managed runtime service kind: ${service.kind}`)
768+
if (service.configuration?.provider === "external") {
769+
if (service.kind !== "mysql") addIssue("unsupported-runtime-service-provider", `${path}.configuration.provider`, "The external provider supports only MySQL-compatible services.")
770+
const boundary = recipe.inputs?.externalServices?.find((candidate) => candidate.id === service.configuration?.externalService)
771+
if (!boundary) addIssue("missing-runtime-service-external-boundary", `${path}.configuration.externalService`, "External MySQL services must reference an inputs.externalServices boundary.")
772+
else {
773+
if ((boundary.allowedHosts ?? []).length === 0) addIssue("missing-runtime-service-host-allowlist", `${path}.configuration.externalService`, "External MySQL service boundaries must explicitly allow at least one host.")
774+
if (boundary.writes !== "allowed-with-approval") addIssue("runtime-service-writes-not-approved", `${path}.configuration.externalService`, "External MySQL service boundaries must declare writes=allowed-with-approval.")
775+
}
776+
for (const field of ["hostEnv", "portEnv", "usernameEnv", "passwordEnv"] as const) {
777+
const name = service.configuration[field]
778+
if (name && exposedEnvironment.has(name)) addIssue("runtime-service-admin-env-exposed", `${path}.configuration.${field}`, `External service administration environment must remain host-only: ${name}`)
779+
}
780+
for (const field of ["image", "rootAuthentication", "foreignKeyTargetPolicy"] as const) {
781+
if (service.configuration[field] !== undefined) addIssue("unsupported-external-runtime-service-option", `${path}.configuration.${field}`, `External MySQL services do not support ${field}.`)
782+
}
783+
}
747784
const supportedOutputs: Record<string, RegExp> = {
748785
mysql: /^(host|port|username|password|database)$/,
749786
redis: /^(host|port|url)$/,
@@ -759,6 +796,15 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code:
759796
}
760797
}
761798

799+
function policyHostListIncludes(policyHosts: readonly string[], boundaryHost: string): boolean {
800+
const normalizedBoundary = boundaryHost.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "")
801+
const boundaryHasPort = /:\d+$/.test(normalizedBoundary)
802+
return policyHosts.some((host) => {
803+
const normalized = host.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "")
804+
return normalized === normalizedBoundary || (!boundaryHasPort && normalized.replace(/:\d+$/, "") === normalizedBoundary)
805+
})
806+
}
807+
762808
function validateRecipeExternalServiceBoundaries(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void {
763809
const seenIds = new Set<string>()
764810
for (const [index, boundary] of (recipe.inputs?.externalServices ?? []).entries()) {

0 commit comments

Comments
 (0)