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
33 changes: 29 additions & 4 deletions packages/runtime-playground/src/playground-cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
emitProgress("preview:activating-dependencies", "running", "Activating site features", blueprintSummary)
}

const server = useProgrammaticRunner ? await startPlaygroundCliWithDynamicPortRetry(async (port) => {
const server = withPhpEnvOnPlaygroundRun(useProgrammaticRunner ? await startPlaygroundCliWithDynamicPortRetry(async (port) => {
return startProgrammaticPlaygroundServer({
...spec,
preview: {
Expand Down Expand Up @@ -192,7 +192,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
} finally {
await localAssetServer?.close()
}
}, Boolean(spec.preview?.port))
}, Boolean(spec.preview?.port)), spec)

emitProgress("preview:connecting-client", "running", "Connecting preview", {
localUrl: server.serverUrl,
Expand Down Expand Up @@ -544,11 +544,18 @@ if ($wpcb_db_endpoint['host_class'] === 'absent' || !$wpcb_db_endpoint['port']['
if (!$wpcb_db_diagnostic['transport']['stream_socket_client'] || !$wpcb_db_diagnostic['transport']['mysqli']) $wpcb_db_fail('transport_unavailable', 'Use a runtime with TCP sockets and the mysqli extension enabled.');
$wpcb_db_target = strpos($wpcb_db_host, ':') !== false ? 'tcp://[' . $wpcb_db_host . ']:' . $wpcb_db_port : 'tcp://' . $wpcb_db_host . ':' . $wpcb_db_port;
$wpcb_db_diagnostic['tcp']['attempted'] = true; $wpcb_db_socket = @stream_socket_client($wpcb_db_target, $wpcb_db_tcp_errno, $wpcb_db_tcp_error, 2, STREAM_CLIENT_CONNECT); if (!$wpcb_db_socket) { $wpcb_db_diagnostic['tcp']['error_code'] = (int) $wpcb_db_tcp_errno; $wpcb_db_fail('endpoint_unreachable', 'Verify runtime network access to the managed database endpoint.'); } fclose($wpcb_db_socket); $wpcb_db_diagnostic['tcp']['connected'] = true;
$wpcb_db_diagnostic['mysqli']['attempted'] = true; $wpcb_db = @mysqli_init(); if (!$wpcb_db) $wpcb_db_fail('transport_unavailable', 'The mysqli client could not initialize in this runtime.'); @mysqli_options($wpcb_db, MYSQLI_OPT_CONNECT_TIMEOUT, 2); $wpcb_db_user = getenv('DB_USER') ?: 'root'; $wpcb_db_name = getenv('DB_NAME') ?: 'runtime'; $wpcb_db_connected = @mysqli_real_connect($wpcb_db, $wpcb_db_host, $wpcb_db_user, getenv('DB_PASSWORD'), $wpcb_db_name, (int) $wpcb_db_port); if (!$wpcb_db_connected) { $wpcb_db_errno = (int) mysqli_connect_errno(); $wpcb_db_diagnostic['mysqli']['error_code'] = $wpcb_db_errno; $wpcb_db_fail($wpcb_db_errno === 1045 ? 'authentication_failed' : ($wpcb_db_errno === 1049 ? 'database_missing' : 'endpoint_unreachable'), 'Verify the managed database credentials and selected database.'); } mysqli_close($wpcb_db); $wpcb_db_diagnostic['mysqli']['connected'] = true;
$wpcb_db_password = getenv('DB_PASSWORD');
if (is_string($wpcb_db_password) && $wpcb_db_password !== '') {
try {
$wpcb_db_diagnostic['mysqli']['attempted'] = true; $wpcb_db = @mysqli_init(); if (!$wpcb_db) $wpcb_db_fail('transport_unavailable', 'The mysqli client could not initialize in this runtime.'); @mysqli_options($wpcb_db, MYSQLI_OPT_CONNECT_TIMEOUT, 2); $wpcb_db_user = getenv('DB_USER') ?: 'root'; $wpcb_db_name = getenv('DB_NAME') ?: 'runtime'; $wpcb_db_connected = @mysqli_real_connect($wpcb_db, $wpcb_db_host, $wpcb_db_user, $wpcb_db_password, $wpcb_db_name, (int) $wpcb_db_port); if (!$wpcb_db_connected) { $wpcb_db_errno = (int) mysqli_connect_errno(); $wpcb_db_diagnostic['mysqli']['error_code'] = $wpcb_db_errno; $wpcb_db_fail($wpcb_db_errno === 1045 ? 'authentication_failed' : ($wpcb_db_errno === 1049 ? 'database_missing' : 'endpoint_unreachable'), 'Verify the managed database credentials and selected database.'); } mysqli_close($wpcb_db); $wpcb_db_diagnostic['mysqli']['connected'] = true;
} catch (Throwable $wpcb_db_error) {
$wpcb_db_errno = (int) mysqli_connect_errno(); $wpcb_db_diagnostic['mysqli']['error_code'] = $wpcb_db_errno; $wpcb_db_fail($wpcb_db_errno === 1045 ? 'authentication_failed' : ($wpcb_db_errno === 1049 ? 'database_missing' : 'endpoint_unreachable'), 'Verify the managed database credentials and selected database.');
}
}
`
}

function runtimePhpEnvironment(spec: RuntimeCreateSpec): Record<string, string> | undefined {
export function runtimePhpEnvironment(spec: RuntimeCreateSpec): Record<string, string> | undefined {
if (spec.environment.databaseSetup !== "external") return undefined
const environment = {
...(spec.runtimeEnv ?? {}),
Expand All @@ -557,6 +564,24 @@ function runtimePhpEnvironment(spec: RuntimeCreateSpec): Record<string, string>
return Object.keys(environment).length > 0 ? environment : undefined
}

export function playgroundRunOptionsWithPhpEnv<T extends { env?: Record<string, string> }>(options: T, phpEnv: Record<string, string> | undefined): T {
if (!phpEnv) return options
return { ...options, env: { ...options.env, ...phpEnv } }
}

function withPhpEnvOnPlaygroundRun(server: PlaygroundCliServer, spec: RuntimeCreateSpec): PlaygroundCliServer {
const phpEnv = runtimePhpEnvironment(spec)
if (!phpEnv) return server
const inner = server.playground
return {
...server,
playground: {
...inner,
run: (options) => inner.run(playgroundRunOptionsWithPhpEnv(options, phpEnv)),
},
}
}

function distributionBootstrapPhp(spec: RuntimeCreateSpec): string {
const distribution = recipeDistribution(spec)
if (!distribution) {
Expand Down
5 changes: 3 additions & 2 deletions packages/runtime-playground/src/playground-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { argValue, cleanWpCliOutput, runWithTemporaryWpCliScript, shellArgv, wor
import { bootstrapPhpCode } from "./php-bootstrap.js"
import { observeHttpResponse as observeHttpResponseArtifact, observeWordPressState as observeWordPressStateArtifact } from "./observation-artifacts.js"
import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, terminalizeOnPhpWasmRuntimeRejection, type PlaygroundRunResponse } from "./playground-command-errors.js"
import { startPlaygroundCliServer, type PlaygroundCliModule } from "./playground-cli-runner.js"
import { runtimePhpEnvironment, startPlaygroundCliServer, type PlaygroundCliModule } from "./playground-cli-runner.js"
import type { PlaygroundCliServer } from "./preview-server.js"
import { collectPlaygroundArtifacts } from "./runtime-artifact-helpers.js"
import { materializePlaygroundMountsFromVfs, materializePlaygroundStagedFiles } from "./mount-materialization.js"
Expand Down Expand Up @@ -1807,9 +1807,10 @@ class PlaygroundRuntime implements Runtime {
private async runPlaygroundCommand(command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }): Promise<PlaygroundRunResponse> {
try {
const requestWorkerEnvironment = this.requestWorkerExecutions.getStore()
const phpEnv = runtimePhpEnvironment(this.spec)
if (requestWorkerEnvironment && "code" in options && server.requestWorkerEndpoint) {
await this.prepareRequestWorker(server)
const response = await this.executeRequestWorker(server, options.code, requestWorkerEnvironment, this.executionSignals.getStore())
const response = await this.executeRequestWorker(server, options.code, { ...requestWorkerEnvironment, ...phpEnv }, this.executionSignals.getStore())
return { text: response.text, exitCode: response.ok ? 0 : 1, ...(!response.ok ? { errors: response.text } : {}) }
}
const response = await abortable(server.playground.run(options), this.executionSignals.getStore())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface ProgrammaticPHP {
mount(vfsPath: string, mountHandler: unknown): Promise<unknown>
onMessage(listener: (data: string) => Promise<string | void> | string | void): () => Promise<void>
readFileAsText(path: string): string
run(options: { code: string } | { scriptPath: string }): Promise<ProgrammaticPHPResponse>
run(options: ({ code: string } | { scriptPath: string }) & { env?: Record<string, string> }): Promise<ProgrammaticPHPResponse>
unlink(path: string): void
writeFile(path: string, contents: string): void
}
Expand Down Expand Up @@ -217,7 +217,7 @@ async function applyBlueprint(php: ProgrammaticPHP, spec: RuntimeCreateSpec): Pr
await compiled.run(php as never)
}

async function runPhp(php: ProgrammaticPHP, options: { code: string } | { scriptPath: string }): Promise<PlaygroundServerRunResponse> {
async function runPhp(php: ProgrammaticPHP, options: ({ code: string } | { scriptPath: string }) & { env?: Record<string, string> }): Promise<PlaygroundServerRunResponse> {
const response = await php.run(options)
return normalizePhpResponse(response)
}
Expand Down
4 changes: 2 additions & 2 deletions tests/external-mysql-runtime-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ try {
const bootstrapRuns: Array<({ code: string } | { scriptPath: string }) & { env?: Record<string, string> }> = []
const cliModule: PlaygroundCliModule = { async runCLI(options) {
bootstrapCalls.push(options)
return { serverUrl: "http://127.0.0.1:65535", playground: { async run(runOptions) { bootstrapRuns.push(runOptions); return { text: options.phpEnv?.DB_PASSWORD ?? "" } } }, async [Symbol.asyncDispose]() {} }
return { serverUrl: "http://127.0.0.1:65535", playground: { async run(runOptions) { bootstrapRuns.push(runOptions); return { text: runOptions.env?.DB_PASSWORD ?? "" } } }, async [Symbol.asyncDispose]() {} }
} }
const secondaryConnectorSecret = "secondary-connector-secret"
const runtimeSpec: RuntimeCreateSpec = {
Expand All @@ -210,7 +210,7 @@ try {
await server.playground.run({ code: generatedCommandPhp })
await server.playground.run({ code: generatedAbilityPhp })
assert.equal(connectorResponse.text, generatedPassword, "generated password reaches PHP through the ephemeral run environment")
assert.equal(bootstrapRuns[0]?.env?.DB_PASSWORD, undefined, "direct runs rely on the isolated PHP runtime environment")
assert.equal(bootstrapRuns[0]?.env?.DB_PASSWORD, generatedPassword, "direct runs receive connector secrets through PHP.run env")
assert.equal(bootstrapRuns.every((run) => !("code" in run) || !run.code.includes(generatedPassword)), true, "captured PHP source never contains the connector password")
assert.equal(bootstrapCalls[0]?.phpEnv?.DB_PASSWORD, generatedPassword, "Playground startup receives the generated password through its in-memory PHP environment")
assert.equal(bootstrapCalls[0]?.phpEnv?.CACHE_AUTH, secondaryConnectorSecret, "multiple connector targets resolve through the same in-memory channel")
Expand Down
97 changes: 97 additions & 0 deletions tests/playground-cli-php-env-propagation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import assert from "node:assert/strict"
import { createServer } from "node:http"
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createRuntime } from "../packages/runtime-core/src/index.js"
import { createPlaygroundRuntimeBackend } from "../packages/runtime-playground/src/index.js"
import { playgroundRunOptionsWithPhpEnv, runtimePhpEnvironment, type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js"

const root = await mkdtemp(join(tmpdir(), "wp-codebox-php-env-propagation-"))
const wordpressDirectory = join(root, "wordpress")
const artifactsDirectory = join(root, "artifacts")
await mkdir(wordpressDirectory)
const payloads: Array<{ code?: string; environment?: Record<string, string> }> = []

const upstream = createServer(async (request, response) => {
const payloadId = request.headers["x-wp-codebox-execution-payload"]
if (typeof payloadId !== "string") {
response.writeHead(200)
response.end("ok")
return
}
const payload = JSON.parse(await readFile(join(artifactsDirectory, "playground-internal-shared", `execution-${payloadId}.json`), "utf8")) as { code?: string; environment?: Record<string, string> }
payloads.push(payload)
response.writeHead(200, { "content-type": "text/plain" })
response.end(payload.code?.includes("echo 'ready'") ? "ready" : String(payload.environment?.DB_PASSWORD ?? ""))
})

await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve))
const address = upstream.address()
assert.ok(address && typeof address === "object")
const serverUrl = `http://127.0.0.1:${address.port}`

const cliModule: PlaygroundCliModule = {
async runCLI() {
return {
serverUrl,
playground: {
async run() {
return { text: "" }
},
},
async [Symbol.asyncDispose]() {},
}
},
}

const spec = {
backend: "wordpress-playground" as const,
artifactsDirectory,
environment: {
kind: "wordpress" as const,
name: "php-env-propagation",
version: "mounted-wordpress-source",
phpVersion: "8.4",
wordpressInstallMode: "do-not-attempt-installing" as const,
databaseSetup: "external" as const,
assets: { wordpressDirectory },
blueprint: {},
},
policy: {
network: "deny" as const,
filesystem: "sandbox" as const,
commands: ["wordpress.run-php"],
secrets: "none" as const,
approvals: "never" as const,
},
runtimeEnv: { DB_HOST: "127.0.0.1", DB_PORT: "33061", DB_USER: "runtime", DB_NAME: "runtime" },
secretEnv: { DB_PASSWORD: "connector-secret" },
secretEnvTargets: { DB_PASSWORD: "DB_PASSWORD" },
}

assert.equal(playgroundRunOptionsWithPhpEnv({ code: "<?php echo 1;" }, undefined).env, undefined)
assert.deepEqual(playgroundRunOptionsWithPhpEnv({ code: "<?php echo 1;", env: { EXTRA: "1" } }, { DB_PASSWORD: "secret" }).env, {
EXTRA: "1",
DB_PASSWORD: "secret",
})
assert.equal(runtimePhpEnvironment(spec)?.DB_PASSWORD, "connector-secret")

const runtime = await createRuntime(spec, createPlaygroundRuntimeBackend({ cliModule }))

try {
const execution = await runtime.execute({
command: "wordpress.run-php",
args: ["bootstrap=none", "code=<?php echo getenv('DB_PASSWORD');"],
processIdentity: "phpunit-one",
})
const commandPayload = payloads.find((payload) => payload.code?.includes("getenv('DB_PASSWORD')"))
assert.equal(commandPayload?.environment?.DB_PASSWORD, "connector-secret", "isolated PHPUnit request workers must receive connector secrets")
assert.equal(execution.stdout, "connector-secret")
} finally {
await runtime.destroy()
await new Promise<void>((resolve, reject) => upstream.close((error) => error ? reject(error) : resolve()))
await rm(root, { recursive: true, force: true })
}

console.log("playground cli php env propagation ok")
5 changes: 3 additions & 2 deletions tests/playground-cli-runner-bootstrap-ini.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const cliModule: PlaygroundCliModule = {
playground: {
async run(runOptions) {
runs.push(runOptions)
return { text: options.phpEnv?.DB_PASSWORD ?? "" }
return { text: runOptions.env?.DB_PASSWORD ?? "" }
},
},
async [Symbol.asyncDispose]() {},
Expand Down Expand Up @@ -120,7 +120,8 @@ try {
assert.match(sharedAutoPrepend, /getenv\('DB_USER'\) \?: 'root'/)
assert.match(sharedAutoPrepend, /getenv\('DB_NAME'\) \?: 'runtime'/)
assert.doesNotMatch(sharedAutoPrepend, /secret/)
assert.equal(runs[0]?.env?.DB_PASSWORD, undefined)
assert.equal(runs[0]?.env?.DB_PASSWORD, "secret")
assert.deepEqual(runs[0]?.env, calls[0].phpEnv)
const requestWorkerPath = calls[0]["mount-before-install"]?.[3]?.hostPath
assert.equal(typeof requestWorkerPath, "string")
assert.doesNotMatch(await readFile(requestWorkerPath as string, "utf8"), /secret/)
Expand Down
Loading