Skip to content

Commit 94150df

Browse files
committed
fix: harden managed multisite dependency lifecycle
1 parent f5bca51 commit 94150df

11 files changed

Lines changed: 232 additions & 61 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe
296296
})
297297
interruption?.throwIfInterrupted()
298298

299-
executions.push(...(await applyRecipeRuntimeSetup({ recipe, recipeDirectory, runtime, prepared: preparedRuntimeSetup, phaseExecutor, interruption })).executions)
299+
executions.push(...(await applyRecipeRuntimeSetup({ recipe, recipeDirectory, runtime, runtimeSpec: runtimeCreateSpec, prepared: preparedRuntimeSetup, phaseExecutor, interruption })).executions)
300300

301301
fixtureDatabases = await phaseTracker.run("import_fixture_databases", phaseFixtureDatabaseData(recipe), async () => await awaitRecipe("fixture-databases.import", importRecipeFixtureDatabases(recipe, recipeDirectory, runtime!, executions)))
302302
const siteSeeds = await awaitRecipe("site-seeds.import", importRecipeSiteSeeds(recipe, recipeDirectory, runtime!, executions))

packages/cli/src/commands/recipe-runtime-setup.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { cp, mkdtemp, rm, stat } from "node:fs/promises"
22
import { tmpdir } from "node:os"
33
import { join, posix, resolve } from "node:path"
4-
import { phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core"
4+
import { phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type RuntimeCreateSpec, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core"
5+
import { requiresManagedMysqlMultisitePreinstall } from "@automattic/wp-codebox-playground"
56
import { installMuPluginsCode, installPluginComposerAutoloadersCode, prepareRecipeDependencyOverlays, prepareRecipeExtraPlugins, prepareRecipeRuntimeOverlays, prepareRecipeStagedFiles, prepareRecipeWorkspacePreloads, prepareRecipeWorkspaces, recipeMountType, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js"
67
import { pluginRuntimeHealthProbeStep, type RecipeWorkflowPhase } from "../recipe-validation.js"
78
import { pluginRuntimeHealthProbeStepIndex, pluginRuntimeSetupStepIndex } from "../recipe-dry-run.js"
@@ -52,9 +53,10 @@ export async function applyRecipeRuntimeSetup(args: {
5253
runtime: Runtime
5354
prepared: PreparedRecipeRuntimeSetup
5455
phaseExecutor: RecipeRunPhaseExecutor
56+
runtimeSpec: Pick<RuntimeCreateSpec, "environment" | "runtimeEnv">
5557
interruption?: RecipeInterruptionController
5658
}): Promise<RecipeRuntimeSetupResult> {
57-
const { recipe, recipeDirectory, runtime, prepared, phaseExecutor, interruption } = args
59+
const { recipe, recipeDirectory, runtime, runtimeSpec, prepared, phaseExecutor, interruption } = args
5860
const { workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, inputMountBaselinePaths, inputMountPathMap } = prepared
5961
const executions: RecipeExecutionResult[] = []
6062
const phaseTracker = phaseExecutor.tracker
@@ -207,21 +209,19 @@ export async function applyRecipeRuntimeSetup(args: {
207209
interruption?.throwIfInterrupted()
208210
}
209211

210-
const muPluginInstallCode = installMuPluginsCode(extraPlugins)
212+
const isolateManagedMultisitePreinstall = recipeHasManagedMysqlMultisitePhpunit(recipe, runtimeSpec)
213+
const muPluginInstallCode = isolateManagedMultisitePreinstall ? null : installMuPluginsCode(extraPlugins)
211214
if (muPluginInstallCode) {
212215
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(muPluginInstallCode) }), "setup", -2, "extra-plugin.install-mu-loader"))
213216
}
214217

215-
const composerAutoloaderInstallCode = installPluginComposerAutoloadersCode(extraPlugins)
218+
const composerAutoloaderInstallCode = isolateManagedMultisitePreinstall ? null : installPluginComposerAutoloadersCode(extraPlugins)
216219
if (composerAutoloaderInstallCode) {
217220
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(composerAutoloaderInstallCode) }), "setup", -2, "extra-plugin.install-composer-autoloaders"))
218221
}
219222

220-
const deferPluginActivation = recipe.workflow.steps.some((step) => step.command === "wordpress.phpunit"
221-
&& step.args?.includes("bootstrap-mode=managed")
222-
&& step.args?.includes("database-type=mysql")
223-
&& step.args?.includes("multisite=1"))
224-
const activatedPlugins = deferPluginActivation ? [] : extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false)
223+
const deferredPluginFiles = managedPhpunitDeferredPluginFiles(recipe, runtimeSpec)
224+
const activatedPlugins = extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false && !deferredPluginFiles.has(plugin.pluginFile))
225225
if (activatedPlugins.length > 0) {
226226
const activePluginsAfterActivation = await phaseTracker.run("activate_plugins", phasePluginActivationData(activatedPlugins), async () => {
227227
for (const plugin of activatedPlugins) {
@@ -249,6 +249,28 @@ export async function applyRecipeRuntimeSetup(args: {
249249
return { executions }
250250
}
251251

252+
function managedPhpunitDeferredPluginFiles(recipe: WorkspaceRecipe, runtimeSpec: Pick<RuntimeCreateSpec, "environment" | "runtimeEnv">): Set<string> {
253+
const deferred = new Set<string>()
254+
for (const step of [...(recipe.workflow.before ?? []), ...recipe.workflow.steps, ...(recipe.workflow.after ?? [])]) {
255+
if (step.command !== "wordpress.phpunit" || !requiresManagedMysqlMultisitePreinstall(step.args ?? [], runtimeSpec)) continue
256+
const raw = (step.args ?? []).find((arg) => arg.startsWith("dependency-plugins-json="))?.slice("dependency-plugins-json=".length)
257+
if (!raw) continue
258+
const plugins = JSON.parse(raw) as unknown
259+
if (!Array.isArray(plugins)) throw new Error("dependency-plugins-json must be a JSON array")
260+
for (const plugin of plugins) {
261+
if (plugin && typeof plugin === "object" && !Array.isArray(plugin) && typeof (plugin as { pluginFile?: unknown }).pluginFile === "string") {
262+
deferred.add((plugin as { pluginFile: string }).pluginFile)
263+
}
264+
}
265+
}
266+
return deferred
267+
}
268+
269+
function recipeHasManagedMysqlMultisitePhpunit(recipe: WorkspaceRecipe, runtimeSpec: Pick<RuntimeCreateSpec, "environment" | "runtimeEnv">): boolean {
270+
return [...(recipe.workflow.before ?? []), ...recipe.workflow.steps, ...(recipe.workflow.after ?? [])]
271+
.some((step) => step.command === "wordpress.phpunit" && requiresManagedMysqlMultisitePreinstall(step.args ?? [], runtimeSpec))
272+
}
273+
252274
export async function cleanupInputMountBaselines(paths: string[]): Promise<void> {
253275
await Promise.all(paths.map((path) => rm(path, { recursive: true, force: true })))
254276
paths.length = 0

packages/runtime-core/src/recipe-builders.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export { buildRuntimePackageRunRecipe, CODEBOX_RUN_RUNTIME_PACKAGE_ABILITY, RUNT
44
export { RUNTIME_PACKAGE_DIAGNOSTIC_SCHEMA, RUNTIME_PACKAGE_RESULT_SCHEMA, RUNTIME_PACKAGE_TASK_SCHEMA, normalizeRuntimePackageResult, normalizeRuntimePackageTask, validateRuntimePackageTask, type RuntimePackageDiagnostic, type RuntimePackageResult, type RuntimePackageTask } from "./runtime-package-contracts.js"
55
import { normalizeSharedMounts } from "./mount-primitives.js"
66
import { DEFAULT_WORDPRESS_VERSION } from "./runtime-defaults.js"
7+
import { resolvePluginEntrypointContract } from "./component-contracts.js"
78

89
type JsonObject = Record<string, unknown>
910

@@ -76,6 +77,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
7677
const pluginTarget = `/wordpress/wp-content/plugins/${pluginSlug}`
7778
const autoloadFile = options.autoloadFile ?? (options.bootstrapMode === "project" ? "" : "/wp-codebox-vendor/autoload.php")
7879
const services = phpunitRuntimeServices(options.databaseType, options.services)
80+
const extraPlugins = normalizeExtraPlugins(options.extra_plugins)
7981

8082
return {
8183
schema: "wp-codebox/workspace-recipe/v1",
@@ -90,7 +92,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
9092
...(options.backendPackage ? { backendPackage: options.backendPackage } : {}),
9193
},
9294
inputs: {
93-
extra_plugins: normalizeExtraPlugins(options.extra_plugins),
95+
extra_plugins: extraPlugins,
9496
...(services.length > 0 ? { services } : {}),
9597
mounts: normalizeRecipeMounts([
9698
...(options.pluginSource ? [{ source: options.pluginSource, target: pluginTarget } satisfies WorkspaceRecipeMount] : []),
@@ -116,7 +118,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
116118
commandArg("phpunit-xml", options.phpunitXml ?? `${pluginTarget}/phpunit.xml.dist`),
117119
commandArg("phpunit-xml-default", options.phpunitXml === undefined ? "1" : ""),
118120
commandStringListArg("dependency-mounts", options.dependencyMounts ?? []),
119-
commandJsonArg("dependency-plugins-json", phpunitDependencyPlugins(options.dependencyMounts ?? [], options.extra_plugins ?? [])),
121+
commandJsonArg("dependency-plugins-json", phpunitDependencyPlugins(options.dependencyMounts ?? [], extraPlugins)),
120122
commandJsonArg("bootstrap-files-json", options.bootstrapFiles ?? []),
121123
commandJsonArg("preload-files-json", options.preloadFiles ?? []),
122124
commandJsonArg("phpunit-args-json", options.phpunitArgs ?? []),
@@ -130,12 +132,21 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
130132
}
131133
}
132134

133-
function phpunitDependencyPlugins(mounts: readonly string[], plugins: readonly WorkspaceRecipeExtraPlugin[]): Array<{ path: string; activate: boolean }> {
135+
function phpunitDependencyPlugins(mounts: readonly string[], plugins: readonly WorkspaceRecipeExtraPlugin[]): Array<{ path: string; pluginFile: string; activate: boolean; loadAs: "plugin" | "mu-plugin" }> {
134136
return mounts.map((path) => {
135137
const normalized = path.replace(/\/+$/g, "")
136138
const slug = normalized.slice(normalized.lastIndexOf("/") + 1)
137139
const plugin = plugins.find((candidate) => candidate.slug === slug || candidate.mountSlug === slug)
138-
return { path, activate: plugin?.activate !== false }
140+
const loadAs = plugin?.loadAs === "mu-plugin" ? "mu-plugin" : "plugin"
141+
const pluginFile = plugin
142+
? resolvePluginEntrypointContract({ source: plugin.source ?? plugin.sourcePath ?? "", slug, pluginFile: plugin.pluginFile, loadAs }).pluginFile
143+
: `${slug}/${slug}.php`
144+
return {
145+
path: loadAs === "mu-plugin" ? `/wordpress/wp-content/mu-plugins/contained-runtime/${slug}` : path,
146+
pluginFile,
147+
activate: plugin?.activate !== false,
148+
loadAs,
149+
}
139150
})
140151
}
141152

packages/runtime-playground/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export { PLAYWRIGHT_CLOCK_CONTROL_CAPABILITIES, createBrowserClockController, ty
2020
export { PLAYWRIGHT_BROWSER_ENVIRONMENT_CAPABILITIES, applyPlaywrightPageEnvironment, browserEnvironmentCell, createPlaywrightBrowserEnvironmentContext, observePlaywrightBrowserEnvironment, resolvePlaywrightBrowserEnvironment, runPlaywrightBrowserEnvironmentMatrix, type BrowserEnvironmentCpuProfile, type BrowserEnvironmentNetworkProfile, type PlaywrightBrowserEnvironmentExecutionInput, type PlaywrightBrowserEnvironmentOptions, type PlaywrightBrowserEnvironmentRuntime, type PlaywrightBrowserEnvironmentSession } from "./browser-environment-matrix.js"
2121
export { WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA, WORDPRESS_ADVERSARIAL_CAPABILITIES, WORDPRESS_ADVERSARIAL_ORACLES, WORDPRESS_CLOCK_CONTROL_CAPABILITIES, WORDPRESS_HTTP_TRANSPORT_FAULT_CAPABILITIES, createWordPressAdversarialAdapter, evaluateWordPressAdversarialOracles, negotiateWordPressHttpTransportFaults, wordpressAdversarialActionSpec, wordpressHttpFaultConfigurationAction, wordpressNoveltySignals, wordpressSchedulerClockAction, type WordPressAdapterFidelity, type WordPressAdversarialAction, type WordPressAdversarialAdapter, type WordPressAdversarialCapability, type WordPressAdversarialSurface } from "./wordpress-adversarial-adapter.js"
2222
export { normalizePreviewReviewerAccess, previewReviewerAccess } from "./preview-reviewer-access.js"
23+
export { phpunitExecutionSemantics, requiresManagedMysqlMultisitePreinstall, type PhpunitExecutionSemantics } from "./phpunit-command-semantics.js"
2324
export { applyVfsMountSnapshots, materializePlaygroundMountsFromVfs, materializePlaygroundStagedInputs, type HostMountSnapshot, type MountMaterializationResult, type StagedInputMaterializationResult, type VfsMountSnapshot } from "./mount-materialization.js"
2425
export { buildReplayExportBlueprint, buildReplayableWordPressSiteBlueprint, buildReplayableWordPressSiteLimitations, writeReplayExportPackage, writeReplayableWordPressSiteBundle, type ReplayExportPackage, type ReplayExportPackageOptions, type ReplayableWordPressSiteBundle, type ReplayableWordPressSiteBundleManifest, type ReplayableWordPressSiteBundleOptions } from "./replayable-wordpress-site-bundle.js"
2526
export { RUNTIME_CAPTURE_STATUS_SCHEMA, runtimeCaptureStatus, type RuntimeCaptureDiagnostic, type RuntimeCaptureState, type RuntimeCaptureStatus, type RuntimeCaptureStatusInput } from "./runtime-capture-status.js"

packages/runtime-playground/src/phpunit-command-handlers.ts

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface PhpunitRunCodeOptions {
1616
env: Record<string, unknown>
1717
wpConfigDefines: Record<string, unknown>
1818
dependencyMounts: string[]
19-
dependencyPlugins?: Array<{ path: string; activate: boolean }>
19+
dependencyPlugins?: Array<{ path: string; pluginFile: string; activate: boolean; loadAs: "plugin" | "mu-plugin" }>
2020
bootstrapFiles: string[]
2121
preloadFiles?: string[]
2222
bootstrapMode: string
@@ -386,9 +386,10 @@ CONFIG;
386386

387387
export function phpunitMultisitePreinstallCode(options: PhpunitMultisitePreinstallCodeOptions): string {
388388
const wpConfigDefines = { ...options.wpConfigDefines }
389-
for (const name of ["MULTISITE", "SUBDOMAIN_INSTALL", "DOMAIN_CURRENT_SITE", "PATH_CURRENT_SITE", "SITE_ID_CURRENT_SITE", "BLOG_ID_CURRENT_SITE"]) {
389+
for (const name of ["MULTISITE", "SUBDOMAIN_INSTALL", "DOMAIN_CURRENT_SITE", "PATH_CURRENT_SITE", "SITE_ID_CURRENT_SITE", "BLOG_ID_CURRENT_SITE", "WPMU_PLUGIN_DIR"]) {
390390
delete wpConfigDefines[name]
391391
}
392+
wpConfigDefines.WPMU_PLUGIN_DIR = "/tmp/wp-codebox-preinstall-mu-plugins"
392393
return `error_reporting(E_ALL);
393394
ini_set('display_errors', '1');
394395
ini_set('display_startup_errors', '1');
@@ -416,6 +417,9 @@ ${phpWpConfigDefineAppenderFunction("pg_append_wp_config_defines", "error_log('S
416417
${managedPhpunitConfigWriterPhp()}
417418
418419
pg_apply_env($bench_env);
420+
if (!is_dir('/tmp/wp-codebox-preinstall-mu-plugins') && !mkdir('/tmp/wp-codebox-preinstall-mu-plugins', 0700, true) && !is_dir('/tmp/wp-codebox-preinstall-mu-plugins')) {
421+
throw new RuntimeException('Could not create isolated multisite preinstall mu-plugin directory.');
422+
}
419423
$config_path = pg_write_managed_test_config($wp_config_defines, 'wptests_', $database_type);
420424
if (!defined('WP_TESTS_MULTISITE')) {
421425
define('WP_TESTS_MULTISITE', true);
@@ -1036,42 +1040,48 @@ function pg_run_load_deps_stage(array $cfg): array {
10361040
if (empty($plugins)) {
10371041
foreach (explode("\n", (string) ($cfg['dep_mounts'] ?? '')) as $path) {
10381042
if (trim($path) !== '') {
1039-
$plugins[] = array('path' => $path, 'activate' => true);
1043+
$slug = basename(rtrim(str_replace('\\\\', '/', trim($path)), '/'));
1044+
$plugins[] = array('path' => $path, 'pluginFile' => $slug . '/' . $slug . '.php', 'activate' => true, 'loadAs' => 'plugin');
10401045
}
10411046
}
10421047
}
1043-
$plugin_root = realpath(WP_PLUGIN_DIR);
1044-
if ($plugin_root === false || !is_dir($plugin_root)) {
1045-
throw new RuntimeException('WordPress plugin directory is unavailable while loading managed PHPUnit dependencies');
1046-
}
10471048
foreach ($plugins as $plugin) {
1048-
if (!is_array($plugin) || !is_string($plugin['path'] ?? null)) {
1049-
throw new RuntimeException('managed PHPUnit dependency metadata must contain a plugin path');
1049+
if (!is_array($plugin) || !is_string($plugin['path'] ?? null) || !is_string($plugin['pluginFile'] ?? null)) {
1050+
throw new RuntimeException('managed PHPUnit dependency metadata must contain a plugin path and entrypoint');
1051+
}
1052+
$load_as = ($plugin['loadAs'] ?? 'plugin') === 'mu-plugin' ? 'mu-plugin' : 'plugin';
1053+
$declared_plugin_root = $load_as === 'mu-plugin' ? WPMU_PLUGIN_DIR . '/contained-runtime' : WP_PLUGIN_DIR;
1054+
$plugin_root = realpath($declared_plugin_root);
1055+
if ($plugin_root === false || !is_dir($plugin_root)) {
1056+
throw new RuntimeException('WordPress dependency root is unavailable while loading managed PHPUnit dependencies');
10501057
}
10511058
$dep_mount = rtrim(str_replace('\\\\', '/', trim($plugin['path'])), '/');
10521059
$dep_real = realpath($dep_mount);
10531060
if ($dep_real === false || !is_dir($dep_real) || dirname($dep_real) !== $plugin_root) {
1054-
throw new RuntimeException('managed PHPUnit dependency must be a direct child directory of WP_PLUGIN_DIR: ' . $dep_mount);
1061+
throw new RuntimeException('managed PHPUnit dependency must be a direct child of its declared WordPress plugin root: ' . $dep_mount);
10551062
}
1056-
if ($dep_mount !== WP_PLUGIN_DIR . '/' . basename($dep_mount)) {
1063+
if ($dep_mount !== $declared_plugin_root . '/' . basename($dep_mount)) {
10571064
throw new RuntimeException('managed PHPUnit dependency path is not canonical: ' . $dep_mount);
10581065
}
1059-
$loaded_file = null;
1060-
foreach (glob($dep_real . '/*.php') ?: array() as $dep_file) {
1061-
if (basename($dep_file) === 'db.php') {
1062-
continue;
1063-
}
1064-
if (strpos(file_get_contents($dep_file), 'Plugin Name:') !== false) {
1065-
require_once $dep_file;
1066-
$loaded_file = $dep_file;
1066+
$plugin_file = trim(str_replace('\\\\', '/', $plugin['pluginFile']));
1067+
$slug = basename($dep_mount);
1068+
if ($plugin_file === '' || str_starts_with($plugin_file, '/') || str_contains($plugin_file, '..') || !str_starts_with($plugin_file, $slug . '/') || !str_ends_with($plugin_file, '.php')) {
1069+
throw new RuntimeException('managed PHPUnit dependency entrypoint is unsafe or outside its plugin slug: ' . $plugin_file);
1070+
}
1071+
$entrypoint = $dep_real . '/' . substr($plugin_file, strlen($slug) + 1);
1072+
$loaded_file = realpath($entrypoint);
1073+
if ($loaded_file === false || !is_file($loaded_file) || !is_readable($loaded_file) || strpos($loaded_file, rtrim($dep_real, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) !== 0) {
1074+
throw new RuntimeException('managed PHPUnit dependency entrypoint is not a readable contained file: ' . $plugin_file);
1075+
}
1076+
foreach (array($dep_real . '/vendor/autoload_packages.php', $dep_real . '/vendor/autoload.php') as $composer_autoload) {
1077+
if (is_file($composer_autoload) && is_readable($composer_autoload)) {
1078+
require_once $composer_autoload;
10671079
break;
10681080
}
10691081
}
1070-
if ($loaded_file === null) {
1071-
throw new RuntimeException('managed PHPUnit dependency has no readable plugin entrypoint: ' . $dep_mount);
1072-
}
1082+
require_once $loaded_file;
10731083
$result['loaded'][] = $loaded_file;
1074-
if (!empty($plugin['activate'])) {
1084+
if ($load_as === 'plugin' && !empty($plugin['activate'])) {
10751085
$result['activate'][] = $loaded_file;
10761086
}
10771087
}
@@ -1146,7 +1156,7 @@ function pg_activate_plugin_file(string $plugin_file, bool $network_wide): void
11461156
pg_log('PLUGIN_ACTIVATE_BEGIN ' . $plugin_basename . ' ' . pg_diagnostic_context());
11471157
do_action('activate_' . $plugin_basename, $network_wide);
11481158
pg_mark_plugin_active($plugin_basename, $network_wide);
1149-
do_action('activated_plugin', $plugin_basename, false, $network_wide);
1159+
do_action('activated_plugin', $plugin_basename, $network_wide);
11501160
pg_log('PLUGIN_ACTIVATE_OK ' . $plugin_basename . ' ' . pg_diagnostic_context());
11511161
}
11521162

0 commit comments

Comments
 (0)