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
89 changes: 6 additions & 83 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"packages/cli/dist",
"packages/cli/package.json",
"scripts/apply-development-patches.mjs",
"patches",
"README.md",
"LICENSE"
],
Expand Down Expand Up @@ -284,7 +285,6 @@
"devDependencies": {
"@cloudflare/workers-types": "^5.20260718.1",
"@types/node": "^24.0.0",
"patch-package": "^8.0.1",
"tsx": "^4.20.0",
"wrangler": "^4.112.0"
},
Expand Down Expand Up @@ -420,6 +420,7 @@
"on-finished": "^2.4.1",
"once": "^1.4.0",
"pako": "^1.0.11",
"patch-package": "^8.0.1",
"parseurl": "^1.3.3",
"path-expression-matcher": "^1.5.0",
"path-to-regexp": "^0.1.13",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/recipe-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
policy,
externalServices: recipe.inputs?.externalServices ?? [],
externalServiceWritesApproved: options.externalServiceWritesApproved,
reservedEnvNames: [...Object.keys(runtimeEnv), ...(recipe.inputs?.secretEnv ?? [])],
onEvidence: (evidence) => { serviceEvidence = evidence },
},
))
Expand Down Expand Up @@ -229,6 +230,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
policy: effectivePolicy,
runtimeEnv,
secretEnv,
secretEnvTargets: managedServices.secretEnvTargets,
artifactsDirectory: configuredArtifactsDirectory,
metadata: {
...runtimeMetadata(configuredArtifactsDirectory, plan.runtime.wp),
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/recipe-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,18 +753,36 @@ export async function validateWorkspaceRecipeSemantics(recipe: WorkspaceRecipe,

function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: string, path: string, message: string) => void): void {
const ids = new Set<string>()
const services = recipe.inputs?.services ?? []
const exposedEnvironment = new Set<string>([
...Object.keys(recipe.distribution?.env ?? {}),
...Object.keys(recipe.inputs?.runtimeEnv ?? {}),
...(recipe.inputs?.secretEnv ?? []),
])
const environment = new Set(exposedEnvironment)
for (const [index, service] of (recipe.inputs?.services ?? []).entries()) {
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)
}
}
const connectorTargets = new Set<string>()
for (const [index, service] of 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 (!["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 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))
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)
}
if (service.configuration?.provider === "external") {
if (service.kind !== "mysql") addIssue("unsupported-runtime-service-provider", `${path}.configuration.provider`, "The external provider supports only MySQL-compatible services.")
const boundary = recipe.inputs?.externalServices?.find((candidate) => candidate.id === service.configuration?.externalService)
Expand Down
87 changes: 79 additions & 8 deletions packages/cli/src/runtime-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function runtimeServiceEvidenceFromError(error: unknown): RuntimeServiceE
interface ManagedRuntimeService {
env: Record<string, string>
secretEnv: Record<string, string>
secretEnvTargets: Record<string, string>
evidence: RuntimeServiceEvidence
release(): Promise<void>
control(action: RuntimeServiceControlAction, options?: Record<string, unknown>): Promise<RuntimeServiceControlResult>
Expand All @@ -66,6 +67,7 @@ export interface RuntimeServiceProvider {
readonly name: string
readonly kind: string
version(service: WorkspaceRecipeRuntimeService): string
secretEnvTargets(service: WorkspaceRecipeRuntimeService): Record<string, string>
provision(service: WorkspaceRecipeRuntimeService, dependencies: RuntimeServiceDependencies, context: RuntimeServiceProvisionContext, evidence: RuntimeServiceEvidence[]): Promise<ManagedRuntimeService>
}

Expand All @@ -82,6 +84,7 @@ export interface ProvisionRuntimeServicesOptions {
policy?: RuntimePolicy
externalServices?: WorkspaceRecipeExternalServiceBoundary[]
externalServiceWritesApproved?: boolean
reservedEnvNames?: readonly string[]
}

const defaultDependencies: RuntimeServiceDependencies = {
Expand All @@ -99,21 +102,24 @@ export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): A
})
}

export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: ProvisionRuntimeServicesOptions = {}): Promise<{ env: Record<string, string>; secretEnv: Record<string, string>; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record<string, unknown>): Promise<RuntimeServiceControlResult>; release(): Promise<void> }> {
export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeService[], options: ProvisionRuntimeServicesOptions = {}): Promise<{ env: Record<string, string>; secretEnv: Record<string, string>; secretEnvTargets: Record<string, string>; evidence: RuntimeServiceEvidence[]; control(serviceId: string, action: RuntimeServiceControlAction, controlOptions?: Record<string, unknown>): Promise<RuntimeServiceControlResult>; release(): Promise<void> }> {
const dependencies = options.dependencies ?? defaultDependencies
const provisioned: ManagedRuntimeService[] = []
const evidence: RuntimeServiceEvidence[] = []
let environment: ReturnType<typeof aggregateRuntimeServiceEnvironment>
const context: RuntimeServiceProvisionContext = {
signal: options.signal,
policy: options.policy,
externalServices: options.externalServices ?? [],
externalServiceWritesApproved: options.externalServiceWritesApproved ?? false,
}
try {
validateDeclaredRuntimeServiceSecretTargets(services, options.reservedEnvNames ?? [])
for (const service of services) {
const managed = await runtimeServiceProvider(service).provision(service, dependencies, context, evidence)
provisioned.push(managed)
}
environment = aggregateRuntimeServiceEnvironment(provisioned, options.reservedEnvNames ?? [])
} catch (error) {
await releaseServices(provisioned).catch(() => undefined)
if (error instanceof RuntimeServiceProvisionError) throw error
Expand All @@ -126,8 +132,7 @@ export async function provisionRuntimeServices(services: WorkspaceRecipeRuntimeS
const lease = provisioned.length > 0 ? setInterval(() => undefined, 1_000) : undefined

return {
env: Object.assign({}, ...provisioned.map((service) => service.env)),
secretEnv: Object.assign({}, ...provisioned.map((service) => service.secretEnv)),
...environment,
evidence,
async control(serviceId, action, controlOptions) {
const service = provisioned.find((candidate) => candidate.evidence.id === serviceId)
Expand Down Expand Up @@ -159,6 +164,7 @@ export async function provisionRuntimeServicesForRecipe(
policy: options.policy,
externalServices: options.externalServices,
externalServiceWritesApproved: options.externalServiceWritesApproved,
reservedEnvNames: options.reservedEnvNames,
})
try {
return await guard(provisioning)
Expand All @@ -185,24 +191,30 @@ const mysqlDockerProvider: RuntimeServiceProvider = {
name: "docker",
kind: "mysql",
version: mysqlDockerImage,
secretEnvTargets: mysqlRuntimeServiceSecretTargets,
provision: provisionMysqlDockerService,
}

const mysqlExternalProvider: RuntimeServiceProvider = {
name: "external",
kind: "mysql",
version: (service) => `mysql-compatible:${service.configuration?.engine ?? "mysql"}`,
secretEnvTargets: mysqlRuntimeServiceSecretTargets,
provision: provisionMysqlExternalService,
}

const redisDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "redis", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.redis, provision: provisionRedisDockerService }
const smtpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "smtp", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.smtp, provision: provisionSmtpDockerService }
const httpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "http", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.http, provision: provisionHttpDockerService }
const redisDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "redis", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.redis, secretEnvTargets: () => ({}), provision: provisionRedisDockerService }
const smtpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "smtp", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.smtp, secretEnvTargets: () => ({}), provision: provisionSmtpDockerService }
const httpDockerProvider: RuntimeServiceProvider = { name: "docker", kind: "http", version: (service) => service.configuration?.image ?? SERVICE_IMAGES.http, secretEnvTargets: () => ({}), provision: provisionHttpDockerService }

function mysqlDockerImage(service: WorkspaceRecipeRuntimeService): string {
return MYSQL_IMAGES[service.configuration?.engine ?? "mysql"]
}

function mysqlRuntimeServiceSecretTargets(service: WorkspaceRecipeRuntimeService): Record<string, string> {
return service.outputs.password ? { DB_PASSWORD: service.outputs.password } : {}
}

function runtimeServiceProvider(service: WorkspaceRecipeRuntimeService): RuntimeServiceProvider {
if (service.kind === mysqlDockerProvider.kind) return service.configuration?.provider === "external" ? mysqlExternalProvider : mysqlDockerProvider
if (service.configuration?.provider === "external") throw new Error(`Managed runtime service kind does not support the external provider: ${service.kind}`)
Expand Down Expand Up @@ -243,8 +255,9 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic
evidence.lifecycle = "provisioned"
const values: Record<string, string> = { 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] ?? ""])),
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
secretEnvTargets: mysqlRuntimeServiceSecretTargets(service),
evidence,
async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, async (customAction) => {
if (customAction === "flush") {
Expand Down Expand Up @@ -352,8 +365,9 @@ async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeServ
evidence.lifecycle = "provisioned"
const values: Record<string, string> = { host: connection.host, port: String(connection.port), username, password, database }
return {
env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])),
env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])),
secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {},
secretEnvTargets: mysqlRuntimeServiceSecretTargets(service),
evidence,
async control(action) {
const result: RuntimeServiceControlResult = { serviceId: service.id, action, status: "unsupported", fidelity: "unsupported", reason: `The ${service.kind} provider does not support ${action}.` }
Expand Down Expand Up @@ -419,6 +433,62 @@ function mysqlConnectionArgs(host: string, port: number, username: string): stri
return ["--batch", "--skip-column-names", "--protocol=TCP", "--host", host, "--port", String(port), "--user", username]
}

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] ?? ""]))
}

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)
}
}
const targets = new Map<string, string>()
for (const [serviceIndex, service] of services.entries()) {
for (const [target, source] of Object.entries(runtimeServiceProvider(service).secretEnvTargets(service))) {
if (reserved.has(target)) throw new Error(`Managed runtime service secret target is reserved by injected environment: ${target}`)
const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === serviceIndex && owner.output === "password" && source === target))
if (conflictingOutput) throw new Error(`Managed runtime service secret target collides with managed output: ${target}`)
if (targets.has(target)) throw new Error(`Managed runtime service secret target is ambiguous: ${target}`)
targets.set(target, source)
}
}
}

function aggregateRuntimeServiceEnvironment(services: readonly ManagedRuntimeService[], reservedEnvNames: readonly string[]): { env: Record<string, string>; secretEnv: Record<string, string>; secretEnvTargets: Record<string, string> } {
const env: Record<string, string> = {}
const secretEnv: Record<string, string> = {}
const secretEnvTargets: Record<string, string> = {}
for (const service of services) {
mergeUniqueEnvironment(env, service.env, "runtime service environment")
mergeUniqueEnvironment(secretEnv, service.secretEnv, "runtime service secret environment")
for (const [target, source] of Object.entries(service.secretEnvTargets)) {
const existing = secretEnvTargets[target]
if (existing !== undefined && existing !== source) throw new Error(`Managed runtime service secret target is ambiguous: ${target}`)
secretEnvTargets[target] = source
}
}
for (const [target, source] of Object.entries(secretEnvTargets)) {
if (!(source in secretEnv)) throw new Error(`Managed runtime service secret target references an unavailable secret: ${target}`)
if (target in env) throw new Error(`Managed runtime service secret target collides with non-secret environment: ${target}`)
if (reservedEnvNames.includes(target)) throw new Error(`Managed runtime service secret target is reserved by injected environment: ${target}`)
}
return { env, secretEnv, secretEnvTargets }
}

function mergeUniqueEnvironment(target: Record<string, string>, source: Record<string, string>, label: string): void {
for (const [name, value] of Object.entries(source)) {
if (name in target) throw new Error(`Duplicate ${label} name: ${name}`)
target[name] = value
}
}

function validateGeneratedMysqlIdentifier(identifier: string): string {
if (!/^[a-z][a-z0-9_]{0,63}$/.test(identifier)) throw new Error("Generated MySQL isolation identifier is unsafe")
return identifier
Expand Down Expand Up @@ -501,6 +571,7 @@ async function provisionSimpleDockerService(
return {
env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])),
secretEnv: {},
secretEnvTargets: {},
evidence,
async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, spec.customControl ? async (candidate, candidateOptions) => await spec.customControl?.(container, candidate, candidateOptions) ?? false : undefined, async () => await dependencies.waitForReady("127.0.0.1", ports[0] as number, 30_000)) },
async release() { await releaseService(container, evidence, dependencies) },
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-core/src/runtime-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface RuntimeCreateSpec {
artifactsDirectory?: string
runtimeEnv?: Record<string, string>
secretEnv?: Record<string, string>
/** Maps an environment target to the secretEnv name that supplies its value. */
secretEnvTargets?: Record<string, string>
metadata?: Record<string, unknown>
preview?: RuntimePreviewSpec
onBrowserStartupProgress?: BrowserStartupProgressListener
Expand Down
18 changes: 18 additions & 0 deletions packages/runtime-core/src/runtime-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ export function resolveSecretEnvNames(names: readonly string[], options: Resolve
return secretEnv
}

export function resolveRuntimeSecretEnvTargets(secretEnv: Record<string, string>, targets: Record<string, string> = {}): Record<string, string> {
const resolved: Record<string, string> = {}
for (const [target, source] of Object.entries(targets)) {
assertRuntimeEnvName(target, "secret env target")
assertRuntimeEnvName(source, "secret env source")
if (!(source in secretEnv)) throw new Error(`Secret env target ${target} references unavailable source: ${source}`)
resolved[target] = secretEnv[source] as string
}
return resolved
}

export function assertRuntimeSecretEnvTargetsAvailable(targets: Record<string, string> = {}, ...environmentSources: Array<Record<string, unknown>>): void {
for (const target of Object.keys(targets)) {
assertRuntimeEnvName(target, "secret env target")
if (environmentSources.some((source) => target in source)) throw new Error(`Secret env target collides with injected environment: ${target}`)
}
}

export function registerRuntimeSecretRedactions(secretEnv: Record<string, string>, registrar: RuntimeEnvRedactionRegistrar): void {
for (const [name, value] of Object.entries(secretEnv)) {
registrar.registerSecretName(name)
Expand Down
Loading
Loading