From 2057913d28cb52998989e2627fa1febecdf5d766 Mon Sep 17 00:00:00 2001 From: Extra Chill Bot Date: Sun, 1 Nov 2026 00:24:38 +0000 Subject: [PATCH] feat(runtime): add initial Docker-backed wordpress-native backend Refs #2578 --- docs/native-runtime-backend.md | 41 +++++ packages/cli/src/commands/doctor.ts | 6 + packages/cli/src/recipe-backend-package.ts | 4 + packages/cli/src/runtime-backends.ts | 4 +- packages/runtime-playground/src/index.ts | 1 + .../runtime-playground/src/native-runtime.ts | 156 ++++++++++++++++++ tests/native-runtime.test.ts | 48 ++++++ 7 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 docs/native-runtime-backend.md create mode 100644 packages/runtime-playground/src/native-runtime.ts create mode 100644 tests/native-runtime.test.ts diff --git a/docs/native-runtime-backend.md b/docs/native-runtime-backend.md new file mode 100644 index 000000000..0ce55d1c6 --- /dev/null +++ b/docs/native-runtime-backend.md @@ -0,0 +1,41 @@ +# Native WordPress runtime (initial slice) + +Select `runtime.backend: "wordpress-native"` in a workspace recipe. Playground remains the default; `wordpress` still means `wordpress-playground`. + +```json +{ + "schema": "wp-codebox/workspace-recipe/v1", + "runtime": { "backend": "wordpress-native", "wp": "6.8", "phpVersion": "8.4" }, + "workflow": { "steps": [ + { "command": "wordpress.run-php", "args": ["code=echo PHP_VERSION, opcache_get_status() ? 'opcache' : 'no-opcache';"] }, + { "command": "wordpress.wp-cli", "args": ["command=option get siteurl"] } + ] } +} +``` + +Docker must be running. Creation probes `docker info` and throws code `wordpress-native-docker-unavailable` when unavailable. Containers boot lazily on the first supported command so mounts can be declared first. Mounts use their original absolute targets and honor readonly mode; adding mounts after startup is rejected. + +## Images and execution + +The backend resolves the official `php:-apache` image to its repository digest before building. PHP defaults to 8.4, matching Playground. The cached Docker layers add mysqli, gd, intl, zip, exif, in-memory OPcache, and WP-CLI. Each run uses its own immutable built image ID. MariaDB 11.4 runs on a per-runtime Docker network with tmpfs database storage and generated credentials; no database port is published. + +WordPress archives use the same release resolver, validation, and cache as Playground. PHP commands use the existing PHP argument parser and bootstrap; requests go through Apache workers so OPcache persists across requests. WP-CLI uses the existing command-string parser and a real phar. Other commands return `wordpress-native-command-unsupported` in the standard error envelope. + +`info().environment.metadata.native` records PHP version, built image ID, base digest, MariaDB version, and OPcache status. Artifact runtime-info observations include these values as well. + +Destroy removes containers (including anonymous volumes), the WordPress volume, network, and temporary build directory. Startup failures and command cancellation/timeouts also trigger cleanup. Built images and the shared WordPress archive cache remain reusable. + +## Current limits / follow-up + +This is a tested **partial slice** of #2578, not the complete backend: + +- Single-site installation only, with site URL `http://localhost`. Arbitrary Blueprints (including multisite setup) are rejected. Subdomain multisite and host mapping remain pending. +- No published web port, preview lease, browser commands, snapshots, or checkpoints. +- Declare mounts before execution. Mount phase sequencing and directory-based core assets are not implemented. +- Execution environment/cwd overrides, result-path capture, and structured diagnostics are not implemented. +- No runtime backend JS package override; this backend owns its Docker image. +- This initial backend is for trusted local/CI recipes: Docker network egress policy enforcement is not implemented. It is not a replacement for Playground's untrusted-code isolation. +- WP-CLI and OS packages follow upstream sources; only the PHP base is resolved to a digest per build. MariaDB's tag is not digest-pinned. +- Process crashes/forced host termination require manual Docker cleanup. Cleanup errors are best-effort, and startup uses bounded individual operations rather than a single episode-wide deadline. + +Validation: `npm run build` and `npx tsx --test tests/native-runtime.test.ts`. The Docker test skips when `docker info` fails and otherwise checks PHP/OPcache, WP-CLI, and resource teardown. On hosts with long temporary directory paths, use `TMPDIR=/tmp` for tsx's Unix socket. diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index f0e5dba96..a3bd71aae 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -115,6 +115,7 @@ async function buildDoctorOutput(options: DoctorOptions): Promise const checks = [ { id: "node", status: "ok" as const, message: "node is available", details: { path: process.execPath, version: process.version } }, await commandToolCheck("npm", ["--version"]), + await nativeDockerCheck(), await binaryCheck(), await sourceCheck(), await staleRecipeRunCheck(options), @@ -137,6 +138,11 @@ async function buildDoctorOutput(options: DoctorOptions): Promise } } +async function nativeDockerCheck(): Promise { + const check = await commandToolCheck("docker", ["info", "--format", "{{.ServerVersion}}"]) + return { ...check, id: "wordpress-native.docker", status: check.status === "ok" ? "ok" : "warning", message: check.status === "ok" ? "Docker is available for wordpress-native" : "Optional wordpress-native backend requires a running Docker daemon" } +} + async function tempRuntimeCheck(options: DoctorOptions): Promise { const inventory = await inventoryTempRuntimeDirectories({ cleanup: options.cleanup, diff --git a/packages/cli/src/recipe-backend-package.ts b/packages/cli/src/recipe-backend-package.ts index 040b1f833..7ef4e7d2d 100644 --- a/packages/cli/src/recipe-backend-package.ts +++ b/packages/cli/src/recipe-backend-package.ts @@ -111,6 +111,10 @@ export async function prepareRecipeRuntimeBackendPackage(recipe: WorkspaceRecipe return undefined } + if (normalizeRuntimeBackendKind(backendKind) === "wordpress-native") { + throw backendPackageError(backendPackage, "wordpress-native uses a Docker image, not runtime.backendPackage") + } + const resolvedSource = resolve(recipeDirectory, backendPackage.source) const sourceStat = await statBackendSource(backendPackage, resolvedSource) const packageRoot = sourceStat.isDirectory() ? resolvedSource : dirname(resolvedSource) diff --git a/packages/cli/src/runtime-backends.ts b/packages/cli/src/runtime-backends.ts index e05912d55..a171abbe5 100644 --- a/packages/cli/src/runtime-backends.ts +++ b/packages/cli/src/runtime-backends.ts @@ -1,8 +1,8 @@ import { createRuntimeBackendRegistry, runtimeBackendRecipeAliases, type RuntimeBackend, type RuntimeBackendFactoryContext, type RuntimeBackendKind, type RuntimeBackendRecipePolicy } from "@automattic/wp-codebox-core" import type { CommandDefinition } from "@automattic/wp-codebox-core/contracts" -import { playgroundRuntimeBackendProvider } from "@automattic/wp-codebox-playground" +import { nativeRuntimeBackendProvider, playgroundRuntimeBackendProvider } from "@automattic/wp-codebox-playground" -const cliRuntimeBackendRegistry = createRuntimeBackendRegistry([playgroundRuntimeBackendProvider]) +const cliRuntimeBackendRegistry = createRuntimeBackendRegistry([playgroundRuntimeBackendProvider, nativeRuntimeBackendProvider]) export function listCliRuntimeBackendKinds(): RuntimeBackendKind[] { return cliRuntimeBackendRegistry.list().flatMap((kind) => [kind, ...runtimeBackendRecipeAliases(kind)]) diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 07e4189dd..9f1b24903 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -28,3 +28,4 @@ export { applyVfsMountSnapshots, materializePlaygroundMountsFromVfs, type HostMo export { buildReplayExportBlueprint, buildReplayableWordPressSiteBlueprint, buildReplayableWordPressSiteLimitations, writeReplayExportPackage, writeReplayableWordPressSiteBundle, type ReplayExportPackage, type ReplayExportPackageOptions, type ReplayableWordPressSiteBundle, type ReplayableWordPressSiteBundleManifest, type ReplayableWordPressSiteBundleOptions } from "./replayable-wordpress-site-bundle.js" export { RUNTIME_CAPTURE_STATUS_SCHEMA, runtimeCaptureStatus, type RuntimeCaptureDiagnostic, type RuntimeCaptureState, type RuntimeCaptureStatus, type RuntimeCaptureStatusInput } from "./runtime-capture-status.js" export type { RuntimeSnapshotArtifact } from "./runtime-snapshot.js" +export { NativeRuntime, nativeRuntimeBackendProvider } from "./native-runtime.js" diff --git a/packages/runtime-playground/src/native-runtime.ts b/packages/runtime-playground/src/native-runtime.ts new file mode 100644 index 000000000..f9620f48e --- /dev/null +++ b/packages/runtime-playground/src/native-runtime.ts @@ -0,0 +1,156 @@ +import { execFile } from "node:child_process" +import { randomUUID } from "node:crypto" +import { mkdtemp, readFile, writeFile, rm, mkdir } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join, resolve } from "node:path" +import { promisify } from "node:util" +import type { Runtime, RuntimeBackendProvider, RuntimeCreateSpec, RuntimeInfo, MountSpec, ExecutionSpec, ExecutionResult, ObservationSpec, ObservationResult, Snapshot, ArtifactSpec } from "@automattic/wp-codebox-core" +import { bootstrapPhpCode, phpCodeFromArgs } from "./php-bootstrap.js" +import { shellArgv, wpCliCommandFromArgs } from "./commands.js" +import { resolvePlaygroundWordPressStartupAsset } from "./playground-cli-runner.js" +import { collectPlaygroundArtifacts } from "./runtime-artifact-helpers.js" + +const exec = promisify(execFile) +export const NATIVE_BACKEND = "wordpress-native" +export const nativeRuntimeBackendProvider: RuntimeBackendProvider = { + kind: NATIVE_BACKEND, + createBackend: () => ({ kind: NATIVE_BACKEND, create: async (spec) => { + try { await docker(["info"], 10_000) } catch { + throw Object.assign(new Error("wordpress-native requires a running Docker daemon"), { code: "wordpress-native-docker-unavailable" }) + } + return new NativeRuntime(spec) + } }), +} + +async function docker(args: string[], timeout = 120_000, signal?: AbortSignal): Promise { + const deadline = AbortSignal.timeout(timeout) + const combined = signal ? AbortSignal.any([signal, deadline]) : deadline + const result = await exec("docker", args, { signal: combined, maxBuffer: 16 * 1024 * 1024 }) + return result.stdout +} + +/** Lazy startup lets the orchestrator declare bind mounts before containers start. */ +export class NativeRuntime implements Runtime { + readonly id = `wp-codebox-native-${randomUUID()}` + readonly createdAt = new Date().toISOString() + private status: RuntimeInfo["status"] = "created" + private mounts: MountSpec[] = [] + private commands: ExecutionResult[] = [] + private bootPromise?: Promise + private directory?: string + private provenance: Record = {} + private readonly artifactRoot: string + constructor(private readonly spec: RuntimeCreateSpec) { + this.artifactRoot = resolve(spec.artifactsDirectory ?? join(tmpdir(), `${this.id}-artifacts`)) + } + async info(): Promise { + return { id: this.id, backend: NATIVE_BACKEND, environment: { ...this.spec.environment, metadata: { ...this.spec.environment.metadata, native: this.provenance } }, createdAt: this.createdAt, status: this.status } + } + async mount(spec: MountSpec): Promise { + if (this.bootPromise) throw new Error("wordpress-native mounts must be declared before the first command") + if (!spec.target.startsWith("/") || spec.target.includes(",") || spec.source.includes(",")) throw new Error("Invalid native mount path") + this.mounts.push({ ...spec, source: resolve(spec.source) }) + } + private async boot(): Promise { + if (this.status === "destroyed") throw new Error("wordpress-native runtime is destroyed") + this.bootPromise ??= this.start().catch(async (error) => { await this.destroy(); throw error }) + return this.bootPromise + } + private async start(): Promise { + const php = this.spec.environment.phpVersion ?? "8.4" + if (!/^8\.[1-5]$/.test(php)) throw new Error(`Unsupported native PHP version: ${php}`) + // Arbitrary Blueprint steps are not silently ignored by this initial backend. + if (this.spec.environment.blueprint) throw new Error("Blueprints are not yet supported on wordpress-native") + this.directory = await mkdtemp(join(tmpdir(), "wp-codebox-native-")) + const base = `php:${php}-apache` + await docker(["pull", base], 300_000) + const digest = (await docker(["image", "inspect", base, "--format", "{{index .RepoDigests 0}}"])).trim() + const tag = `wp-codebox-native:${php}` + await writeFile(join(this.directory, "Dockerfile"), `FROM ${digest}\nRUN apt-get update && apt-get install -y --no-install-recommends libpng-dev libjpeg62-turbo-dev libfreetype6-dev libicu-dev libzip-dev unzip curl mariadb-client && docker-php-ext-configure gd --with-freetype --with-jpeg && docker-php-ext-install mysqli gd intl zip exif opcache && rm -rf /var/lib/apt/lists/*\nRUN curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o /usr/local/bin/wp && chmod +x /usr/local/bin/wp\nRUN printf 'opcache.enable=1\\nopcache.enable_cli=1\\nopcache.file_cache_only=0\\nopcache.memory_consumption=256\\n' > /usr/local/etc/php/conf.d/native-opcache.ini && ln -s /wordpress /var/www/html/native\nWORKDIR /wordpress\n`) + await docker(["build", "-t", tag, "--iidfile", join(this.directory, "image-id"), this.directory], 600_000) + // The per-build ID avoids a race with another run updating the cache tag. + const image = (await readFile(join(this.directory, "image-id"), "utf8")).trim() + await docker(["network", "create", this.id]) + await docker(["volume", "create", `${this.id}-wp`]) + const password = randomUUID() + await docker(["run", "-d", "--name", `${this.id}-db`, "--network", this.id, "--network-alias", "db", "--tmpfs", "/var/lib/mysql", "-e", `MARIADB_ROOT_PASSWORD=${password}`, "-e", "MARIADB_DATABASE=wordpress", "-e", "MARIADB_USER=wordpress", "-e", `MARIADB_PASSWORD=${password}`, "mariadb:11.4"]) + let ready = false + for (let i = 0; i < 90; i++) { + try { await docker(["exec", `${this.id}-db`, "healthcheck.sh", "--connect", "--innodb_initialized"], 5_000); ready = true; break } catch { await new Promise((r) => setTimeout(r, 1_000)) } + } + if (!ready) throw new Error("wordpress-native MariaDB startup timed out") + const bindArgs = this.mounts.flatMap((m) => ["--mount", `type=bind,src=${m.source},dst=${m.target}${m.mode === "readonly" ? ",readonly" : ""}`]) + const envArgs = Object.entries({ ...this.spec.runtimeEnv, ...this.spec.secretEnv }).flatMap(([k, v]) => ["-e", `${k}=${v}`]) + await docker(["run", "-d", "--name", `${this.id}-php`, "--network", this.id, "--mount", `type=volume,src=${this.id}-wp,dst=/wordpress`, ...bindArgs, ...envArgs, image]) + const asset = await resolvePlaygroundWordPressStartupAsset(this.spec.environment.version, this.spec.environment.assets?.wordpressZip) + try { + if (!asset.localPath) throw new Error("Native WordPress source must resolve to a local archive") + await docker(["cp", asset.localPath, `${this.id}-php:/tmp/wordpress.zip`]) + await docker(["exec", `${this.id}-php`, "unzip", "-oq", "/tmp/wordpress.zip", "-d", "/tmp/core"]) + await docker(["exec", `${this.id}-php`, "sh", "-c", "cp -an /tmp/core/wordpress/. /wordpress/ && rm -rf /tmp/core /tmp/wordpress.zip"]) + } finally { await asset.archiveReference?.release() } + await this.wp(["config", "create", "--dbname=wordpress", "--dbuser=wordpress", `--dbpass=${password}`, "--dbhost=db"]) + await this.wp(["core", "install", "--url=http://localhost", "--title=WP Codebox", "--admin_user=admin", `--admin_password=${randomUUID()}`, "--admin_email=admin@example.org", "--skip-email"]) + const phpVersion = (await docker(["exec", `${this.id}-php`, "php", "-r", "echo PHP_VERSION;"])).trim() + const mariadbVersion = (await docker(["exec", `${this.id}-db`, "mariadbd", "--version"])).trim() + const opcache = (await docker(["exec", `${this.id}-php`, "php", "-r", "echo opcache_get_status() !== false ? 'enabled' : 'disabled';"])).trim() === "enabled" + this.provenance = { phpVersion, imageId: image, baseImageDigest: digest, mariadbVersion, opcache } + } + private wp(argv: string[], timeout?: number, signal?: AbortSignal): Promise { + return docker(["exec", `${this.id}-php`, "wp", "--allow-root", "--path=/wordpress", ...argv], timeout, signal) + } + async execute(spec: ExecutionSpec): Promise { + const startedAt = new Date().toISOString() + const result: ExecutionResult = { id: randomUUID(), command: spec.command, args: spec.args ?? [], exitCode: 0, stdout: "", stderr: "", startedAt, finishedAt: startedAt } + try { + if (!["wordpress.run-php", "wordpress.wp-cli"].includes(spec.command)) { + throw Object.assign(new Error(`Command ${spec.command} is not supported on wordpress-native`), { code: "wordpress-native-command-unsupported" }) + } + await this.boot() + if (spec.command === "wordpress.wp-cli") { + const argv = shellArgv(wpCliCommandFromArgs(spec.args ?? [])) + if (!argv.length) throw new Error("wordpress.wp-cli requires a non-empty command") + result.stdout = await this.wp(argv, spec.timeoutMs, spec.signal) + } else { + const code = bootstrapPhpCode(this.spec, await phpCodeFromArgs(spec.args ?? []), spec.args ?? []) + const name = `.wp-codebox-${randomUUID()}.php` + const local = join(this.directory!, name) + await writeFile(local, code) + try { + await docker(["cp", local, `${this.id}-php:/wordpress/${name}`]) + // Apache workers share in-memory OPcache; a fresh CLI process would not. + result.stdout = await docker(["exec", `${this.id}-php`, "curl", "--silent", "--show-error", "--fail-with-body", `http://127.0.0.1/native/${name}`], spec.timeoutMs, spec.signal) + } finally { + await rm(local, { force: true }) + await docker(["exec", `${this.id}-php`, "rm", "-f", `/wordpress/${name}`]).catch(() => {}) + } + } + } catch (error) { + const e = error as Error & { code?: string; killed?: boolean; stdout?: string; stderr?: string } + result.exitCode = 1 + result.stdout = e.stdout ?? "" + result.stderr = e.stderr || e.message + result.result = { schema: "wp-codebox/runtime-command-result/v1", status: "error", stdout: result.stdout, stderr: result.stderr, error: { code: typeof e.code === "string" ? e.code : "wordpress-native-execution-failed", message: e.message } } + if (e.killed || e.code === "ABORT_ERR" || spec.signal?.aborted) await this.destroy() + } + result.finishedAt = new Date().toISOString() + this.commands.push(result) + return result + } + async observe(spec: ObservationSpec): Promise { + const data = spec.type === "runtime-info" ? await this.info() : spec.type === "mounts" ? this.mounts : spec.type === "command-result" ? this.commands.find((c) => c.id === spec.commandId) : { error: "Observation not supported on wordpress-native" } + return { type: spec.type, data, observedAt: new Date().toISOString() } + } + async snapshot(): Promise { throw new Error("Snapshots are not supported on wordpress-native") } + async collectArtifacts(spec: ArtifactSpec = {}) { + await mkdir(this.artifactRoot, { recursive: true }) + return collectPlaygroundArtifacts({ artifactRoot: this.artifactRoot, runtimeId: this.id, createdAt: this.createdAt, spec: this.spec, mounts: this.mounts, commands: this.commands, observations: [{ type: "runtime-info", data: await this.info(), observedAt: new Date().toISOString() }], snapshots: [], events: [], info: () => this.info(), previewInfo: async () => undefined, recordArtifactsCollected: () => {}, browserProbes: [], pluginChecks: [], themeChecks: [] }, spec) + } + async destroy(): Promise { + this.status = "destroyed" + await docker(["rm", "-f", "-v", `${this.id}-php`, `${this.id}-db`]).catch(() => {}) + await docker(["volume", "rm", "-f", `${this.id}-wp`]).catch(() => {}) + await docker(["network", "rm", this.id]).catch(() => {}) + if (this.directory) await rm(this.directory, { recursive: true, force: true }) + } +} diff --git a/tests/native-runtime.test.ts b/tests/native-runtime.test.ts new file mode 100644 index 000000000..adb3c43fe --- /dev/null +++ b/tests/native-runtime.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { NativeRuntime, nativeRuntimeBackendProvider } from "../packages/runtime-playground/src/native-runtime.js" +import { listCliRuntimeBackendKinds, resolveCliRuntimeBackend } from "../packages/cli/src/runtime-backends.js" +import type { RuntimeCreateSpec } from "@automattic/wp-codebox-core" + +const spec = { backend: "wordpress-native", environment: { kind: "wordpress", version: "6.8", phpVersion: "8.4" }, policy: {} } as RuntimeCreateSpec + +test("native registration leaves Playground default and alias unchanged", () => { + assert.ok(listCliRuntimeBackendKinds().includes("wordpress-native")) + assert.equal(resolveCliRuntimeBackend("wordpress").kind, "wordpress-playground") + assert.equal(resolveCliRuntimeBackend("").kind, "wordpress-playground") + assert.equal(resolveCliRuntimeBackend("wordpress-native").kind, nativeRuntimeBackendProvider.kind) +}) + +test("unsupported commands return a structured result without booting Docker", async () => { + const runtime = new NativeRuntime(spec) + const result = await runtime.execute({ command: "wordpress.browser" }) + assert.equal(result.exitCode, 1) + assert.equal(result.result?.error?.code, "wordpress-native-command-unsupported") + assert.match(result.stderr, /not supported on wordpress-native/) +}) + +let docker = true +try { execFileSync("docker", ["info"], { stdio: "ignore", timeout: 10_000 }) } catch { docker = false } +test("Docker: boot, route PHP and WP-CLI, remove all runtime resources", { skip: !docker, timeout: 900_000 }, async () => { + const runtime = await nativeRuntimeBackendProvider.createBackend().create(spec) + const id = (await runtime.info()).id + try { + const php = await runtime.execute({ command: "wordpress.run-php", args: ['code=echo PHP_VERSION, opcache_get_status() ? "opcache" : "no-opcache";'], timeoutMs: 30_000 }) + assert.equal(php.exitCode, 0, php.stderr) + assert.match(php.stdout, /8\.4\..*opcache/) + assert.ok(!php.stdout.includes("no-opcache")) + const cli = await runtime.execute({ command: "wordpress.wp-cli", args: ["command=option get siteurl"], timeoutMs: 30_000 }) + assert.equal(cli.exitCode, 0, cli.stderr) + assert.equal(cli.stdout.trim(), "http://localhost") + const info = await runtime.info() + assert.equal(info.backend, "wordpress-native") + assert.ok(info.environment.metadata?.native) + const timedOut = await runtime.execute({ command: "wordpress.run-php", args: ["bootstrap=none", "code=sleep(10);"], timeoutMs: 250 }) + assert.equal(timedOut.exitCode, 1, JSON.stringify(timedOut)) + assert.equal((await runtime.info()).status, "destroyed") + } finally { await runtime.destroy() } + for (const [kind, name] of [["container", `${id}-php`], ["container", `${id}-db`], ["network", id], ["volume", `${id}-wp`]]) { + assert.throws(() => execFileSync("docker", [kind!, "inspect", name!], { stdio: "ignore" })) + } +})