Skip to content

Commit f4af30b

Browse files
authored
[Blueprints v2] Playground CLI compatibility (#128)
Implements a series of bugfixes and improvements to enable integration with WordPress Playground. Some highlights: * Add `blueprint.http_client` filter so Playground can setup its own HTTP Client instance * Add `blueprint.resolved` filter to enable injecting additional steps in Playground * `RequestReadStream` doesn't expect `Content-Length` when `Content-Encoding` is present. * `WPCLIStep` now gets an explicit `--path` argument to avoid relying on WordPress autodetection heuristics * Restored 64KB as the default byte stream chunk size * `latest` is now a valid WordPress version identifier * Importing content is now supported even when running as phar * StreamImporter now requires `new_site_content_root_url` and a few other options when they can't be backfilled via WordPress functions. For example, when WordPress hasn't yet been loaded * Magic strings, such as `create-new-site`, are replaced with constants, such as `self::EXECUTION_MODE_CREATE_NEW_SITE` * ...a lot more other tiny improvements. ## Testing instructions Confirm the CLI failures are in line with `trunk` (most successes, some flaky failures)
1 parent 4218487 commit f4af30b

File tree

26 files changed

+967
-192
lines changed

26 files changed

+967
-192
lines changed

components/Blueprints/DataReference/DataReferenceResolver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public function __construct( Client $client, ?string $tmpRoot = null ) {
5252
$this->tmpRoot = $tmpRoot ?: wp_unix_sys_get_temp_dir();
5353
}
5454

55-
public function setExecutionContext( Filesystem $executionContext ) {
55+
public function setExecutionContext( ?Filesystem $executionContext ) {
5656
$this->executionContext = $executionContext;
5757
}
5858

components/Blueprints/Runner.php

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@
6060
use function WordPress\Zip\is_zip_file_stream;
6161

6262
class Runner {
63+
const EXECUTION_MODE_CREATE_NEW_SITE = 'create-new-site';
64+
const EXECUTION_MODE_APPLY_TO_EXISTING_SITE = 'apply-to-existing-site';
65+
const EXECUTION_MODES = [
66+
self::EXECUTION_MODE_CREATE_NEW_SITE,
67+
self::EXECUTION_MODE_APPLY_TO_EXISTING_SITE,
68+
];
69+
6370
/**
6471
* @var RunnerConfiguration
6572
*/
@@ -114,7 +121,7 @@ public function __construct( RunnerConfiguration $configuration ) {
114121
$this->configuration = $configuration;
115122
$this->validateConfiguration( $configuration );
116123

117-
$this->client = new Client();
124+
$this->client = apply_filters('blueprint.http_client', new Client());
118125
$this->mainTracker = new Tracker();
119126

120127
// Set up progress logging
@@ -135,17 +142,17 @@ private function validateConfiguration( RunnerConfiguration $config ): void {
135142

136143
// Validate execution mode
137144
$mode = $config->getExecutionMode();
138-
if ( ! in_array( $mode, [ 'create-new-site', 'apply-to-existing-site' ], true ) ) {
139-
throw new BlueprintExecutionException( "Execution mode must be either 'create-new-site' or 'apply-to-existing-site'." );
145+
if ( ! in_array( $mode, self::EXECUTION_MODES, true ) ) {
146+
throw new BlueprintExecutionException( "Execution mode must be one of: " . implode( ', ', self::EXECUTION_MODES ) );
140147
}
141148

142149
// Validate site URL
143150
// Note: $options is not defined in this context, so we skip this block.
144151
// If you want to validate the site URL, you should use $config->getTargetSiteUrl().
145152
$siteUrl = $config->getTargetSiteUrl();
146-
if ( $mode === 'create-new-site' ) {
153+
if ( $mode === self::EXECUTION_MODE_CREATE_NEW_SITE ) {
147154
if ( empty( $siteUrl ) ) {
148-
throw new BlueprintExecutionException( "Site URL is required when the execution mode is 'create-new-site'." );
155+
throw new BlueprintExecutionException( sprintf( "Site URL is required when the execution mode is '%s'.", self::EXECUTION_MODE_CREATE_NEW_SITE ) );
149156
}
150157
}
151158
if ( ! empty( $siteUrl ) && ! filter_var( $siteUrl, FILTER_VALIDATE_URL ) ) {
@@ -195,6 +202,7 @@ private function validateConfiguration( RunnerConfiguration $config ): void {
195202

196203
public function run(): void {
197204
$tempRoot = wp_unix_sys_get_temp_dir() . '/wp-blueprints-runtime-' . uniqid();
205+
198206
// TODO: Are there cases where we should not have these permissions?
199207
mkdir( $tempRoot, 0777, true );
200208

@@ -227,8 +235,13 @@ public function run(): void {
227235
$targetSiteFs = LocalFilesystem::create( $this->configuration->getTargetSiteRoot() );
228236
$wpCliReference = DataReference::create( 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar' );
229237

230-
$execution_context_root = $this->blueprintExecutionContext->get_meta()['root'];
231-
assert(is_string($execution_context_root) && strlen($execution_context_root) > 0, 'Assertion failed: Execution context root was empty.');
238+
$execution_context = $this->blueprintExecutionContext->get_meta();
239+
if(
240+
isset($execution_context['root']) &&
241+
( !is_string($execution_context['root']) || strlen($execution_context['root']) === 0)
242+
) {
243+
throw new BlueprintExecutionException('Execution context was a local directory, but the Runner could not determine the root directory. This should never happen. Please report this as a bug.');
244+
}
232245

233246
$this->runtime = new Runtime(
234247
$targetSiteFs,
@@ -238,7 +251,7 @@ public function run(): void {
238251
$this->blueprintArray,
239252
$tempRoot,
240253
$wpCliReference,
241-
$execution_context_root
254+
isset($execution_context['root']) ? $execution_context['root'] : null
242255
);
243256
$this->progressObserver->setRuntime( $this->runtime );
244257
$progress['wpCli']->setCaption( 'Downloading WP-CLI' );
@@ -247,16 +260,21 @@ public function run(): void {
247260
], $progress['wpCli'] );
248261

249262
$progress['targetResolution']->setCaption( 'Resolving target site' );
250-
if ( $this->configuration->getExecutionMode() === 'apply-to-existing-site' ) {
263+
if ( $this->configuration->getExecutionMode() === self::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) {
251264
ExistingSiteResolver::resolve( $this->runtime, $progress['targetResolution'], $this->wpVersionConstraint );
252265
} else {
253266
NewSiteResolver::resolve( $this->runtime, $progress['targetResolution'], $this->wpVersionConstraint, $this->recommendedWpVersion );
254267
}
255268
$progress['targetResolution']->finish();
256269

270+
do_action('blueprint.target_resolved');
271+
257272
$progress['data']->setCaption( 'Resolving data references' );
258273
$this->assets->startEagerResolution( $this->dataReferencesToAutoResolve, $progress['data'] );
259274
$this->executePlan( $progress['execution'], $plan, $this->runtime );
275+
276+
// @TODO: Assert WordPress is still correctly installed
277+
260278
$progress->finish();
261279
} finally {
262280
// TODO: Optionally preserve workspace in case of error? Support resuming after error?
@@ -289,7 +307,14 @@ private function loadBlueprint() {
289307
$blueprintString = $resolved->getStream()->consume_all();
290308
$this->blueprintExecutionContext = LocalFilesystem::create( dirname( $reference->get_path() ) );
291309
} else {
310+
// For the purposes of Blueprint resolution, the execution context is the
311+
// current working directory. This way, a path such as ./blueprint.json
312+
// will mean "a blueprint.json file in the current working directory" and not
313+
// "a ./blueprint.json path without a point of reference".
314+
$this->assets->setExecutionContext( LocalFilesystem::create( getcwd() ) );
292315
$resolved = $this->assets->resolve( $reference );
316+
$this->assets->setExecutionContext( null );
317+
293318
if ( $resolved instanceof File ) {
294319
$stream = $resolved->getStream();
295320

@@ -377,6 +402,8 @@ private function validateBlueprint(): void {
377402

378403
$this->configuration->getLogger()->debug( 'Final resolved Blueprint: ' . json_encode( $this->blueprintArray, JSON_PRETTY_PRINT ) );
379404

405+
$this->blueprintArray = apply_filters( 'blueprint.resolved', $this->blueprintArray );
406+
380407
// Assert the Blueprint conforms to the latest JSON schema.
381408
$v = new HumanFriendlySchemaValidator(
382409
json_decode( file_get_contents( __DIR__ . '/Versions/Version2/json-schema/schema-v2.json' ), true )
@@ -439,7 +466,7 @@ private function validateBlueprint(): void {
439466
// WordPress Version Constraint
440467
if ( isset( $this->blueprintArray['wordpressVersion'] ) ) {
441468
$wp_version = $this->blueprintArray['wordpressVersion'];
442-
$recommended = null;
469+
$min = $max = $recommended = null;
443470
if ( is_string( $wp_version ) ) {
444471
$this->recommendedWpVersion = $wp_version;
445472
$recommended = WordPressVersion::fromString( $wp_version );
@@ -466,6 +493,8 @@ private function validateBlueprint(): void {
466493
$this->recommendedWpVersion = $wp_version['max'];
467494
$max = WordPressVersion::fromString( $wp_version['max'] );
468495
if ( ! $max ) {
496+
// @TODO: Reuse this error message
497+
// 'Unrecognized WordPress version. Please use "latest", a URL, or a numeric version such as "6.2", "6.0.1", "6.2-beta1", or "6.2-RC1"'
469498
throw new BlueprintExecutionException( 'Invalid WordPress version string in wordpressVersion.max: ' . $wp_version['max'] );
470499
}
471500
}
@@ -487,6 +516,14 @@ private function validateBlueprint(): void {
487516
// correctly. The actual version check for WordPress is done in
488517
// NewSiteResolver and ExistingSiteResolver.
489518
}
519+
520+
// Validate the override constraint if it was set
521+
if ( $this->wpVersionConstraint ) {
522+
$wpConstraintErrors = $this->wpVersionConstraint->validate();
523+
if ( ! empty( $wpConstraintErrors ) ) {
524+
throw new BlueprintExecutionException( 'Invalid WordPress version constraint from CLI override: ' . implode( '; ', $wpConstraintErrors ) );
525+
}
526+
}
490527
}
491528

492529
private function createExecutionPlan(): array {
@@ -627,9 +664,9 @@ private function createExecutionPlan(): array {
627664
// @TODO: Make sure this doesn't get included twice in the execution plan,
628665
// e.g. if the Blueprint specified this step manually.
629666
if ( $step instanceof ImportContentStep ) {
630-
if($this->configuration->isRunningAsPhar()) {
631-
throw new InvalidArgumentException( '@TODO: Importing content is not supported when running as phar.' );
632-
} else {
667+
// if($this->configuration->isRunningAsPhar()) {
668+
// throw new InvalidArgumentException( '@TODO: Importing content is not supported when running as phar.' );
669+
// } else {
633670
$libraries_phar_path = __DIR__ . '/../../dist/php-toolkit.phar';
634671
if(!file_exists($libraries_phar_path)) {
635672
throw new InvalidArgumentException(
@@ -642,7 +679,7 @@ private function createExecutionPlan(): array {
642679
'filename' => 'php-toolkit.phar',
643680
'content' => file_get_contents( $libraries_phar_path )
644681
] ) );
645-
}
682+
// }
646683
array_unshift( $plan, $this->createStepObject( 'writeFiles', [
647684
'files' => [
648685
'php-toolkit.phar' => $source,
@@ -923,14 +960,6 @@ static function () {
923960

924961
return new WriteFilesStep( $files );
925962

926-
case 'runPHP':
927-
return new RunPHPStep(
928-
$this->createDataReference( [
929-
'filename' => 'run-php.php',
930-
'content' => $data['code'],
931-
] ),
932-
$data['env'] ?? []
933-
);
934963
case 'unzip':
935964
$zipFile = $this->createDataReference( $data['zipFile'], [ ExecutionContextPath::class ] );
936965

components/Blueprints/RunnerConfiguration.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class RunnerConfiguration {
2323
/**
2424
* @var string
2525
*/
26-
private $mode = 'create-new-site'; // or apply-to-existing-site
26+
private $mode = Runner::EXECUTION_MODE_CREATE_NEW_SITE;
2727
/**
2828
* @var string
2929
*/

components/Blueprints/Runtime.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ public function __construct(
7575
array $blueprint,
7676
string $tempRoot,
7777
DataReference $wpCliReference,
78-
string $executionContextRoot
78+
?string $executionContextRoot=null
7979
) {
8080
$this->targetFs = $targetFs;
8181
$this->configuration = $configuration;
@@ -87,7 +87,7 @@ public function __construct(
8787
$this->executionContextRoot = $executionContextRoot;
8888
}
8989

90-
public function getExecutionContextRoot(): string {
90+
public function getExecutionContextRoot(): ?string {
9191
return $this->executionContextRoot;
9292
}
9393

@@ -231,8 +231,9 @@ public function evalPhpCodeInSubProcess(
231231
$process = $this->createPhpSubProcess( $code, $env, $input, $timeout );
232232
$process->mustRun();
233233

234+
$output = $process->getOutputStream(Process::OUTPUT_FILE)->consume_all();
234235
return new EvalResult(
235-
$process->getOutputStream(Process::OUTPUT_FILE)->consume_all(),
236+
$output,
236237
$process
237238
);
238239
}
@@ -262,7 +263,7 @@ public function createPhpSubProcess(
262263
$phpBinary = null;
263264
if ( getenv('PHP_BINARY') ) {
264265
$phpBinary = getenv('PHP_BINARY');
265-
} elseif ( PHP_SAPI === 'cli' && isset($_SERVER['argv'][0]) ) {
266+
} elseif ( PHP_BINARY ) {
266267
$phpBinary = PHP_BINARY;
267268
} else {
268269
$phpBinary = 'php';

components/Blueprints/SiteResolver/NewSiteResolver.php

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
6767

6868
// If SQLite integration zip provided, unzip into appropriate folder
6969
if ( $runtime->getConfiguration()->getDatabaseEngine() === 'sqlite' ) {
70+
/*
71+
* @TODO: Ensure DB_NAME gets defined in wp-config.php before installing the SQLite plugin.
72+
*/
73+
7074
$progress['resolve_assets']->setCaption( 'Downloading SQLite integration plugin' );
7175
$resolved = $runtime->resolve( $assets['sqlite-integration'] );
7276
if ( ! $resolved instanceof File ) {
@@ -97,22 +101,7 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
97101
// Technically, this is a "new site" resolver, but it's entirely possible
98102
// the developer-provided WordPress zip already has a sqlite database with the
99103
// a WordPress site installed..
100-
$installCheck = $runtime->evalPhpCodeInSubProcess(
101-
<<<'PHP'
102-
<?php
103-
$wp_load = getenv('DOCROOT') . '/wp-load.php';
104-
if (!file_exists($wp_load)) {
105-
append_output('0');
106-
exit;
107-
}
108-
require $wp_load;
109-
110-
append_output( function_exists('is_blog_installed') && is_blog_installed() ? '1' : '0' );
111-
PHP
112-
113-
)->outputFileContent;
114-
115-
if ( trim( $installCheck ) !== '1' ) {
104+
if ( ! self::isWordPressInstalled( $runtime, $progress ) ) {
116105
if ( ! $targetFs->exists( '/wp-config.php' ) ) {
117106
if ( $targetFs->exists( 'wp-config-sample.php' ) ) {
118107
$targetFs->copy( 'wp-config-sample.php', 'wp-config.php' );
@@ -130,6 +119,8 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
130119
$wp_cli_path,
131120
'core',
132121
'install',
122+
'--path=' . $runtime->getConfiguration()->getTargetSiteRoot(),
123+
133124
// For Docker compatibility. If we got this far, Blueprint runner was already
134125
// allowed to run as root.
135126
'--allow-root',
@@ -141,10 +132,39 @@ static public function resolve( Runtime $runtime, Tracker $progress, ?VersionCon
141132
'--skip-email',
142133
] );
143134
$process->mustRun();
135+
136+
if ( ! self::isWordPressInstalled( $runtime, $progress ) ) {
137+
// @TODO: This breaks in Playground CLI
138+
throw new BlueprintExecutionException( 'WordPress installation failed' );
139+
}
144140
}
145141
$progress->finish();
146142
}
147143

144+
static private function isWordPressInstalled( Runtime $runtime, Tracker $progress ) {
145+
$installCheck = $runtime->evalPhpCodeInSubProcess(
146+
<<<'PHP'
147+
<?php
148+
$wp_load = getenv('DOCROOT') . '/wp-load.php';
149+
if (!file_exists($wp_load)) {
150+
append_output('0');
151+
exit;
152+
}
153+
require $wp_load;
154+
155+
append_output( function_exists('is_blog_installed') && is_blog_installed() ? '1' : '0' );
156+
PHP
157+
,
158+
[
159+
'DOCROOT' => $runtime->getConfiguration()->getTargetSiteRoot(),
160+
],
161+
null,
162+
5
163+
)->outputFileContent;
164+
165+
return trim( $installCheck ) === '1';
166+
}
167+
148168
static private function resolveWordPressZipUrl( Client $client, string $version_string ): string {
149169
if ( $version_string === 'latest' ) {
150170
return 'https://wordpress.org/latest.zip';
@@ -166,8 +186,14 @@ static private function resolveWordPressZipUrl( Client $client, string $version_
166186
$latestVersions = array_filter( $latestVersions['offers'], function ( $v ) {
167187
return $v['response'] === 'autoupdate';
168188
} );
169-
189+
$latestNonBeta = null;
190+
170191
foreach ( $latestVersions as $apiVersion ) {
192+
// Keep track of the first non-beta version (which is the latest)
193+
if ( $latestNonBeta === null && strpos( $apiVersion['version'], 'beta' ) === false ) {
194+
$latestNonBeta = $apiVersion;
195+
}
196+
171197
if ( $version_string === 'beta' && strpos( $apiVersion['version'], 'beta' ) !== false ) {
172198
return $apiVersion['download'];
173199
} elseif (
@@ -190,6 +216,11 @@ static private function resolveWordPressZipUrl( Client $client, string $version_
190216
return $apiVersion['download'];
191217
}
192218
}
219+
220+
// If we're looking for beta but no beta was found, return latest
221+
if ( $version_string === 'beta' && $latestNonBeta !== null ) {
222+
return $latestNonBeta['download'];
223+
}
193224

194225
/**
195226
* If we didn't get a useful match in the API response, it could be version that's not

components/Blueprints/Steps/ImportContentStep.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ private function importWxr( Runtime $runtime, array $content_definition, Tracker
6868
<?php
6969
run_content_import([
7070
'mode' => 'wxr',
71-
'execution_context_root' => getenv('EXECUTION_CONTEXT'),
71+
'execution_context_root' => getenv('EXECUTION_CONTEXT') ? getenv('EXECUTION_CONTEXT') : null,
7272
'source' => json_decode(getenv('DATA_SOURCE_DEFINITION'), true),
7373
// @TODO: Support arbitrary media URLs to enable fetching assets during import.
7474
// 'media_url' => 'https://pd.w.org/'
@@ -99,8 +99,11 @@ private function importWxr( Runtime $runtime, array $content_definition, Tracker
9999
break;
100100
case 'error':
101101
throw new BlueprintExecutionException( $data['message'] );
102+
case 'completion':
103+
$progress->finish();
104+
break;
102105
default:
103-
throw new BlueprintExecutionException( 'Unknown messagetype: ' . $data['type'] );
106+
throw new BlueprintExecutionException( 'Unknown message type: ' . $data['type'] );
104107
}
105108
}
106109

0 commit comments

Comments
 (0)