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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@
"test:external-native-package-materialization": "tsx tests/external-native-package-materialization.test.ts",
"test:runtime-sources-materialization": "tsx tests/runtime-sources-materialization.test.ts",
"test:runtime-sources-playground-integration": "tsx tests/runtime-sources-playground-integration.test.ts",
"test:playground-readonly-mounts": "tsx tests/playground-readonly-mounts.test.ts",
"test:playground-readonly-mounts-integration": "tsx tests/playground-readonly-mounts-integration.test.ts",
"test:browser-task-builder": "tsx tests/browser-task-builder.test.ts",
"test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts",
"test:browser-runtime-file-ops": "tsx tests/browser-runtime-file-ops.test.ts",
Expand Down
45 changes: 43 additions & 2 deletions packages/runtime-playground/src/mount-materialization.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto"
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core"
import type { PlaygroundCliServer } from "./preview-server.js"
import { SKIPPED_CAPTURE_DIRECTORIES } from "./artifacts.js"
Expand Down Expand Up @@ -32,6 +33,11 @@ export interface MountMaterializationResult {

export type StagedInputMaterializationResult = MountMaterializationResult

export interface ReadonlyMountStaging {
mounts: MountSpec[]
[Symbol.asyncDispose](): Promise<void>
}

interface HostMountFilePayload {
target: string
contentsBase64: string
Expand All @@ -56,6 +62,41 @@ interface HostMountFileMaterializationResponse {
const HOST_MOUNT_FILE_BATCH_SIZE = 100
const HOST_MOUNT_DIRECTORY_BATCH_SIZE = 500

/**
* Playground's Node filesystem mount handler is writable. Snapshot readonly
* inputs into a runtime-owned directory before handing them to that handler.
*/
export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promise<ReadonlyMountStaging> {
const readonlyMounts = mounts.filter((mount) => mount.mode === "readonly")
if (readonlyMounts.length === 0) {
return {
mounts,
async [Symbol.asyncDispose]() {},
}
}

const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-"))
try {
const stagedMounts = await Promise.all(mounts.map(async (mount, index) => {
if (mount.mode !== "readonly") {
return mount
}
const source = join(root, `${index}-${basename(mount.source) || "mount"}`)
await cp(mount.source, source, { recursive: mount.type !== "file", dereference: true })
return { ...mount, source }
}))
return {
mounts: stagedMounts,
async [Symbol.asyncDispose]() {
await rm(root, { recursive: true, force: true })
},
}
} catch (error) {
await rm(root, { recursive: true, force: true })
throw error
}
}

function mountMaterializationResult(input: Omit<MountMaterializationResult, "phaseResult">): MountMaterializationResult {
return {
...input,
Expand Down
20 changes: 17 additions & 3 deletions packages/runtime-playground/src/playground-cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { createServer as createNetServer } from "node:net"
import * as PlaygroundStorage from "@wp-playground/storage"
import { resolveWordPressRelease } from "@wp-playground/wordpress"
import { phpEnvAssignments, phpWpConfigDefineAssignments } from "./php-snippets.js"
import { stageReadonlyPlaygroundMounts, type ReadonlyMountStaging } from "./mount-materialization.js"

const DEFAULT_RUNTIME_PHP_INI_ENTRIES = { memory_limit: "512M" }

Expand Down Expand Up @@ -62,6 +63,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
mounts: mounts.length,
})
emitProgress("preview:loading-client", "running", "Loading preview")
let readonlyMountStaging: ReadonlyMountStaging | undefined
try {
if (spec.preview?.port) {
await assertPreviewPortAvailable(spec.preview.port)
Expand All @@ -74,6 +76,8 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files"
const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec)
const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options)
readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts)
const stagedMounts = readonlyMountStaging.mounts
const wordpressStartupAsset = wordpressDirectory ? undefined : await resolvePlaygroundWordPressStartupAsset(spec.environment.version, spec.environment.assets?.wordpressZip)
const cacheValidation = wordpressStartupAsset?.cacheValidation ?? {
version: spec.environment.version ?? "mounted-wordpress-source",
Expand All @@ -99,7 +103,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
...spec.preview,
port,
},
}, mounts, {
}, stagedMounts, {
bootstrapIniEntries: bootstrapIniEntries!,
phpIniEntries: pluginRuntimePhpIniEntries(spec),
wordpressDirectory: wordpressDirectory!,
Expand All @@ -119,7 +123,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
skipBrowser: true,
workers: 6,
mount: [
...mounts.map((mount) => ({
...stagedMounts.map((mount) => ({
hostPath: mount.source,
vfsPath: mount.target,
})),
Expand Down Expand Up @@ -154,12 +158,22 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
upstreamUrl: server.serverUrl,
lease: proxiedServer.previewLease ? previewLeaseDetail(proxiedServer.previewLease) : undefined,
})
return proxiedServer
return {
...proxiedServer,
async [Symbol.asyncDispose]() {
try {
await proxiedServer[Symbol.asyncDispose]()
} finally {
await readonlyMountStaging?.[Symbol.asyncDispose]()
}
},
}
} catch (error) {
emitProgress("preview:error", "failed", "Preview failed to start", {
error: errorDetail(error),
})

await readonlyMountStaging?.[Symbol.asyncDispose]()
if (spec.preview?.port && errorHasCode(error, "EADDRINUSE")) {
throw new PlaygroundPreviewPortUnavailableError(spec.preview.port, error)
}
Expand Down
123 changes: 123 additions & 0 deletions tests/playground-readonly-mounts-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import assert from "node:assert/strict"
import { execFile } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { promisify } from "node:util"

const execFileAsync = promisify(execFile)
const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-integration-"))
const readonlySource = join(root, "readonly.bin")
const readwriteSource = join(root, "readwrite.bin")
const recipePath = join(root, "recipe.json")
const artifactsPath = join(root, "artifacts")
const readonlyBytes = Buffer.from([0, 255, 1, 2, 3, 127, 128])
const overwrittenBytes = Buffer.from([128, 127, 3, 2, 1, 255, 0])
const stagingDirectoriesBefore = await readonlyStagingDirectories()

try {
await writeFile(readonlySource, readonlyBytes)
await writeFile(readwriteSource, readonlyBytes)
await writeFile(recipePath, `${JSON.stringify({
schema: "wp-codebox/workspace-recipe/v1",
runtime: { backend: "wordpress-playground", wp: "6.5", blueprint: { steps: [] } },
inputs: {
mounts: [
{ source: readonlySource, target: "/wordpress/readonly.bin", mode: "readonly" },
{ source: readwriteSource, target: "/wordpress/readwrite.bin", mode: "readwrite" },
],
},
workflow: {
steps: [{
command: "wordpress.run-php",
args: [`code=$contents = base64_decode('${overwrittenBytes.toString("base64")}'); file_put_contents('/wordpress/readonly.bin', $contents); file_put_contents('/wordpress/readwrite.bin', $contents);`],
}],
},
})}\n`)

const output = await runRecipe()
if (output) {
assert.equal(output.success, true, JSON.stringify(output))
assert.equal(sha256(await readFile(readonlySource)), sha256(readonlyBytes), "readonly host bytes must survive an actual Playground PHP overwrite")
assert.deepEqual(await readFile(readwriteSource), overwrittenBytes, "readwrite host bytes must reflect an actual Playground PHP overwrite")
assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "recipe-run cleanup must remove readonly mount staging")
}
} finally {
await rm(root, { recursive: true, force: true })
}

assert.equal(isUnavailableWordPressRuntimeSource({
stdout: JSON.stringify({
schema: "wp-codebox/recipe-run/v1",
success: false,
phaseEvidence: [{ name: "runtime_startup", status: "failed", error: { message: "Unable to resolve Playground startup asset wordpress-archive-cache for WordPress 6.5: fetch failed" } }],
}),
}), true, "only a structured pre-runtime WordPress archive acquisition failure may skip")

assert.equal(isUnavailableWordPressRuntimeSource({
stdout: JSON.stringify({
schema: "wp-codebox/recipe-run/v1",
success: false,
phaseEvidence: [{ name: "runtime_startup", status: "completed" }, { name: "run_workloads", status: "failed", error: { message: "fetch failed" } }],
}),
}), false, "a post-start failure containing fetch failed must fail the integration test")

function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex")
}

async function readonlyStagingDirectories(): Promise<string[]> {
return (await readdir(tmpdir())).filter((entry) => entry.startsWith("wp-codebox-readonly-mounts-")).sort()
}

async function runRecipe(): Promise<RecipeRunOutput | undefined> {
try {
const result = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], {
cwd: process.cwd(),
timeout: 300_000,
maxBuffer: 2 * 1024 * 1024,
})
return recipeRunOutput(result.stdout)
} catch (error) {
if (isUnavailableWordPressRuntimeSource(error)) {
console.log("playground readonly mount integration skipped: WordPress runtime source was unavailable before runtime startup")
return undefined
}
throw error
}
}

interface RecipeRunOutput {
schema?: string
success?: boolean
phaseEvidence?: Array<{
name?: string
status?: string
error?: { message?: string }
}>
}

function isUnavailableWordPressRuntimeSource(error: unknown): boolean {
const output = recipeRunOutput(error && typeof error === "object" && "stdout" in error ? error.stdout : undefined)
const startup = output?.phaseEvidence?.find((phase) => phase.name === "runtime_startup")
const message = startup?.error?.message ?? ""
return output?.schema === "wp-codebox/recipe-run/v1"
&& startup?.status === "failed"
&& /Unable to resolve Playground startup asset (wordpress-archive-cache|wordpress-release-metadata)/.test(message)
&& /fetch failed|Could not resolve host|Connection timed out|network is unreachable/i.test(message)
}

function recipeRunOutput(value: unknown): RecipeRunOutput | undefined {
if (typeof value !== "string") {
return undefined
}
try {
const output = JSON.parse(value) as RecipeRunOutput
return output && typeof output === "object" ? output : undefined
} catch {
return undefined
}
}

console.log("playground readonly mount integration ok")
64 changes: 64 additions & 0 deletions tests/playground-readonly-mounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict"
import { createHash } from "node:crypto"
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"

import { startPlaygroundCliServer, type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js"
import type { RuntimeCreateSpec } from "../packages/runtime-core/src/index.js"

const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-"))
const readonlySource = join(root, "readonly.bin")
const readwriteSource = join(root, "readwrite.bin")
const readonlyBytes = Buffer.from([0, 255, 1, 2, 3, 127, 128])
const readwriteBytes = Buffer.from([10, 20, 30])
await writeFile(readonlySource, readonlyBytes)
await writeFile(readwriteSource, readwriteBytes)

const spec: RuntimeCreateSpec = {
backend: "wordpress-playground",
environment: { version: "6.8", phpVersion: "8.4", blueprint: {} },
policy: { network: "deny", filesystem: "readwrite-mounts", commands: ["wordpress.run-php"], secrets: "none", approvals: "never" },
}

let mountedReadonlyPath = ""
const cliModule: PlaygroundCliModule = {
async runCLI(options) {
const readonlyMount = options.mount.find((mount) => mount.vfsPath === "/readonly")
const readwriteMount = options.mount.find((mount) => mount.vfsPath === "/readwrite")
assert.ok(readonlyMount)
assert.ok(readwriteMount)
mountedReadonlyPath = readonlyMount.hostPath
// This is the host path Playground's writable Node mount handler receives.
await writeFile(readonlyMount.hostPath, Buffer.from("sandbox overwrite"))
await writeFile(readwriteMount.hostPath, Buffer.from("sandbox overwrite"))
return {
serverUrl: "http://127.0.0.1:65535",
playground: { async run() { return { text: "" } } },
async [Symbol.asyncDispose]() {},
}
},
}

try {
const beforeReadonlyHash = sha256(await readFile(readonlySource))
const server = await startPlaygroundCliServer(spec, [
{ type: "file", source: readonlySource, target: "/readonly", mode: "readonly" },
{ type: "file", source: readwriteSource, target: "/readwrite", mode: "readwrite" },
], { cliModule })

assert.equal(sha256(await readFile(readonlySource)), beforeReadonlyHash, "readonly source bytes must survive a sandbox overwrite")
assert.deepEqual(await readFile(readwriteSource), Buffer.from("sandbox overwrite"), "readwrite mounts must retain host-write behavior")
assert.notEqual(mountedReadonlyPath, readonlySource, "readonly mounts must use a private staged path")

await server[Symbol.asyncDispose]()
await assert.rejects(access(mountedReadonlyPath), /ENOENT/, "readonly mount staging must be removed with the runtime")
} finally {
await rm(root, { recursive: true, force: true })
}

function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex")
}

console.log("playground readonly mount isolation ok")
Loading