Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
24.57% covered (danger)
24.57%
72 / 293
16.13% covered (danger)
16.13%
5 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
MaintenanceRunner
24.57% covered (danger)
24.57%
72 / 293
16.13% covered (danger)
16.13%
5 / 31
5302.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getConfig
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 addDefaultParams
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 showHelpAndExit
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 initFromWrapper
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 initForClass
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 initInternal
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 isAbsolutePath
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
8.12
 getExtensionInfo
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 loadScriptFile
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
3.33
 splitScript
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getMwInstallPath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 expandScriptFile
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 expandScriptClass
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 preloadScriptFile
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
8.23
 getScriptClass
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
6.99
 findScriptClass
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
7.01
 setup
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 memoryLimit
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 adjustMemoryLimit
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 defineSettings
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 emulateConfig
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 overrideConfig
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
 getServiceContainer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 fatalError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 error
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 shouldExecute
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 cleanup
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 shutdown
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3namespace MediaWiki\Maintenance;
4
5use Exception;
6use LCStoreNull;
7use LogicException;
8use MediaWiki;
9use MediaWiki\Config\Config;
10use MediaWiki\Deferred\DeferredUpdates;
11use MediaWiki\Logger\LoggerFactory;
12use MediaWiki\MainConfigNames;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Settings\SettingsBuilder;
15use Profiler;
16use ReflectionClass;
17use Throwable;
18
19/**
20 * A runner for maintenance scripts.
21 *
22 * @since 1.39
23 * @unstable
24 */
25class MaintenanceRunner {
26
27    /**
28     * Identifies the script to execute. This may be a class name, the relative or absolute
29     * path of a script file, a plain name with or without an extension prefix, etc.
30     *
31     * @var ?string
32     */
33    private $script = null;
34
35    /**
36     * The class name of the script to execute.
37     *
38     * @var ?class-string<Maintenance>
39     */
40    private $scriptClass = null;
41
42    /** @var string[]|null */
43    private $scriptArgv = null;
44
45    /** @var Maintenance|null */
46    private $scriptObject = null;
47
48    /** @var MaintenanceParameters */
49    private $parameters;
50
51    /** @var bool */
52    private $runFromWrapper = false;
53    private bool $withoutLocalSettings = false;
54    private ?Config $config = null;
55
56    /**
57     * Default constructor. Children should call this *first* if implementing
58     * their own constructors
59     *
60     * @stable to call
61     */
62    public function __construct() {
63        $this->parameters = new MaintenanceParameters();
64        $this->addDefaultParams();
65    }
66
67    private function getConfig(): Config {
68        if ( $this->config === null ) {
69            $this->config = $this->getServiceContainer()->getMainConfig();
70        }
71
72        return $this->config;
73    }
74
75    /**
76     * Add the default parameters to the scripts
77     */
78    protected function addDefaultParams() {
79        // Generic (non-script-dependent) options:
80
81        $this->parameters->addOption( 'conf', 'Location of LocalSettings.php, if not default', false, true );
82        $this->parameters->addOption( 'wiki', 'For specifying the wiki ID', false, true );
83        $this->parameters->addOption( 'globals', 'Output globals at the end of processing for debugging' );
84        $this->parameters->addOption(
85            'memory-limit',
86            'Set a specific memory limit for the script, '
87            . '"max" for no limit or "default" to avoid changing it',
88            false,
89            true
90        );
91        $this->parameters->addOption( 'server', "The protocol and server name to use in URLs, e.g. " .
92            "https://en.wikipedia.org. This is sometimes necessary because " .
93            "server name detection may fail in command line scripts.", false, true );
94        $this->parameters->addOption( 'profiler', 'Profiler output format (usually "text")', false, true );
95
96        // Save generic options to display them separately in help
97        $generic = $this->parameters->getOptionNames();
98        $this->parameters->assignGroup( Maintenance::GENERIC_MAINTENANCE_PARAMETERS, $generic );
99    }
100
101    /**
102     * @param int $code
103     *
104     * @return never
105     */
106    private function showHelpAndExit( $code = 0 ) {
107        foreach ( $this->parameters->getErrors() as $error ) {
108            $this->error( "$error\n" );
109            $code = 1;
110        }
111
112        $this->parameters->setDescription( 'Runner for maintenance scripts' );
113
114        $help = $this->parameters->getHelp();
115        echo $help;
116        exit( $code );
117    }
118
119    /**
120     * Initialize the runner from the given command line arguments
121     * as passed to a wrapper script.
122     *
123     * @note Called before Setup.php
124     *
125     * @param string[] $argv The arguments passed from the command line,
126     *        including the wrapper script at index 0, and usually
127     *        the script to run at index 1.
128     */
129    public function initFromWrapper( array $argv ) {
130        $script = null;
131
132        $this->parameters->setName( $argv[0] );
133        $this->parameters->setAllowUnregisteredOptions( true );
134        $this->parameters->addArg(
135            'script',
136            'The name of the maintenance script to run. ' .
137                'Can be given as a class name or file path. The `.php` suffix is optional. ' .
138                'Paths starting with `./` or `../` are interpreted to be relative to the current working directory. ' .
139                'Other relative paths are interpreted relative to the maintenance script directory. ' .
140                'Dots (.) are supported as namespace separators in class names. ' .
141                'An extension name may be provided as a prefix, followed by a colon, e.g. "MyExtension:...", ' .
142                'to indicate that the path or class name should be interpreted relative to the extension.'
143        );
144
145        $this->runFromWrapper = true;
146        $this->parameters->loadWithArgv( $argv, 1 );
147
148        // script params
149        $argv = array_slice( $argv, 2 );
150
151        if ( $this->parameters->validate() ) {
152            $script = $this->parameters->getArg( 0 );
153
154            // Special handling for the 'help' command
155            if ( $script === 'help' ) {
156                if ( $this->parameters->hasArg( 1 ) ) {
157                    $script = $this->parameters->getArg( 1 );
158
159                    // turn <help> <command> into <command> --help
160                    $this->parameters->loadWithArgv( [ $script ] );
161                    $argv = [ '--help' ];
162                } else {
163                    // same as no command
164                    $script = null;
165                }
166            }
167        }
168
169        if ( $script ) {
170            // Strip another argument from $argv!
171            $this->initInternal( $script, $argv );
172        } else {
173            $this->showHelpAndExit();
174        }
175    }
176
177    /**
178     * Initialize the runner for the given class.
179     * This is used when running scripts directly, without a wrapper.
180     *
181     * @note Called before Setup.php
182     *
183     * @param string $scriptClass The script class to run
184     * @param string[] $argv The arguments to passed to the script, including
185     *        the script itself at index 0.
186     */
187    public function initForClass( string $scriptClass, $argv ) {
188        $this->runFromWrapper = false;
189        $this->script = $scriptClass;
190        $this->scriptClass = $scriptClass;
191        $this->parameters->setName( $argv[0] );
192        $this->parameters->loadWithArgv( $argv );
193        $this->initInternal( $scriptClass, array_slice( $argv, 1 ) );
194    }
195
196    /**
197     * Initialize the runner.
198     *
199     * @note Called before Setup.php
200     *
201     * @param string $script The script to run
202     * @param string[] $scriptArgv The arguments to pass to the maintenance script,
203     *        not including the script itself.
204     */
205    private function initInternal( string $script, array $scriptArgv ) {
206        $this->script = $script;
207        $this->scriptArgv = $scriptArgv;
208
209        // Send PHP warnings and errors to stderr instead of stdout.
210        // This aids in diagnosing problems, while keeping messages
211        // out of redirected output.
212        if ( ini_get( 'display_errors' ) ) {
213            ini_set( 'display_errors', 'stderr' );
214        }
215
216        // make sure we clean up after ourselves.
217        register_shutdown_function( [ $this, 'cleanup' ] );
218
219        // Turn off output buffering if it's on
220        while ( ob_get_level() > 0 ) {
221            ob_end_flush();
222        }
223    }
224
225    private static function isAbsolutePath( string $path ): bool {
226        if ( str_starts_with( $path, '/' ) ) {
227            return true;
228        }
229
230        if ( wfIsWindows() ) {
231            if ( str_starts_with( $path, '\\' ) ) {
232                return true;
233            }
234            if ( preg_match( '!^[a-zA-Z]:[/\\\\]!', $path ) ) {
235                return true;
236            }
237        }
238
239        return false;
240    }
241
242    protected function getExtensionInfo( string $extName ): ?array {
243        // NOTE: Don't go by the extension registry, since some extensions
244        //       register under a name different from what is used in wfLoadExtension.
245        //       E.g. AbuseFilter is registered as "Abuse Filter" with a space.
246
247        $config = SettingsBuilder::getInstance()->getConfig();
248        $extDir = $config->get( MainConfigNames::ExtensionDirectory );
249        $skinDir = $config->get( MainConfigNames::StyleDirectory );
250
251        $extension = [];
252        if ( file_exists( "$extDir/$extName/extension.json" ) ) {
253            $extension['path'] = "$extDir/$extName/extension.json";
254            $extension['namespace'] = "MediaWiki\\Extension\\$extName";
255        } elseif ( file_exists( "$skinDir/$extName/skin.json" ) ) {
256            $extension['path'] = "$skinDir/$extName/skin.json";
257            $extension['namespace'] = "MediaWiki\\Skins\\$extName";
258        } else {
259            return null;
260        }
261
262        return $extension;
263    }
264
265    private function loadScriptFile( string $scriptFile ): string {
266        $maintClass = null;
267
268        // It's a file, include it
269        // If it returns something, it should be the name of the maintenance class.
270        $scriptClass = include $scriptFile;
271
272        // Traditional script files set the $maintClass variable
273        // at the end of the file.
274        // @phan-suppress-next-line PhanImpossibleCondition Phan doesn't understand includes.
275        if ( $maintClass ) {
276            $scriptClass = $maintClass;
277        }
278
279        if ( !is_string( $scriptClass ) ) {
280            $this->error( "ERROR: The script file '{$scriptFile}' cannot be executed using MaintenanceRunner.\n" );
281            $this->error( "It does not set \$maintClass and does not return a class name.\n" );
282            $this->fatalError( "Try running it directly as a php script: php $scriptFile\n" );
283        }
284
285        return $scriptClass;
286    }
287
288    private function splitScript( string $script ): array {
289        // Support "$ext:$script" format for extensions
290        if ( preg_match( '!^(\w+):(.*)$!', $script, $m ) ) {
291            return [ $m[1], $m[2] ];
292        }
293
294        return [ null, $script ];
295    }
296
297    /**
298     * @return string The value of the constant MW_INSTALL_PATH. This method mocked in unit tests.
299     */
300    protected function getMwInstallPath(): string {
301        return MW_INSTALL_PATH;
302    }
303
304    private function expandScriptFile( string $scriptName, ?array $extension ): string {
305        // Append ".php" if not present
306        $scriptFile = $scriptName;
307        if ( !str_ends_with( $scriptFile, '.php' ) ) {
308            $scriptFile .= '.php';
309        }
310
311        // If the path is not explicitly relative (starting with "./" or "../") and not absolute,
312        // then look in the maintenance dir.
313        if ( !preg_match( '!^\.\.?[/\\\\]!', $scriptFile ) && !self::isAbsolutePath( $scriptFile ) ) {
314            if ( $extension !== null ) {
315                // Look in the extension's maintenance dir
316                $scriptFile = dirname( $extension['path'] ) . "/maintenance/{$scriptFile}";
317            } else {
318                // It's a core script.
319                $scriptFile = $this->getMwInstallPath() . "/maintenance/{$scriptFile}";
320            }
321        }
322
323        return $scriptFile;
324    }
325
326    private function expandScriptClass( string $scriptName, ?array $extension ): string {
327        $scriptClass = $scriptName;
328
329        // Support "$ext:$script" format
330        if ( $extension ) {
331            $scriptClass = "{$extension['namespace']}\\Maintenance\\$scriptClass";
332        }
333
334        // Accept dot (.) as namespace separators as well.
335        // Backslashes are just annoying on the command line.
336        $scriptClass = strtr( $scriptClass, '.', '\\' );
337
338        return $scriptClass;
339    }
340
341    /**
342     * Preload the script file, so any defines in file level code are executed.
343     * This way, scripts can control what Setup.php does.
344     *
345     * @internal
346     * @param string $script
347     */
348    protected function preloadScriptFile( string $script ): void {
349        if ( $this->scriptClass !== null && class_exists( $this->scriptClass ) ) {
350            // We know the script class, and file-level code was executed because class_exists triggers auto-loading.
351            return;
352        }
353
354        [ $extName, $scriptName ] = $this->splitScript( $script );
355
356        if ( $extName !== null ) {
357            // Preloading is not supported. findScriptClass() will try to find the script later.
358            return;
359        }
360
361        $scriptClass = $this->expandScriptClass( $scriptName, null );
362        $scriptFile = $this->expandScriptFile( $scriptName, null );
363
364        if ( !class_exists( $scriptClass ) && file_exists( $scriptFile ) ) {
365            $scriptFileClass = $this->loadScriptFile( $scriptFile );
366            if ( $scriptFileClass ) {
367                $scriptClass = $scriptFileClass;
368            }
369        }
370
371        // NOTE: class_exists will trigger auto-loading, so file-level code in the script file will run.
372        if ( class_exists( $scriptClass ) ) {
373            // Set the script class name we found, so we don't try to load the file again!
374            $this->scriptClass = $scriptClass;
375        }
376
377        // Preloading failed. Let findScriptClass() try to find the script later.
378    }
379
380    /**
381     * @return class-string<Maintenance>
382     */
383    protected function getScriptClass(): string {
384        if ( $this->scriptClass === null ) {
385            if ( $this->runFromWrapper ) {
386                $this->scriptClass = $this->findScriptClass( $this->script );
387            } else {
388                $this->scriptClass = $this->script;
389            }
390        }
391
392        if ( !class_exists( $this->scriptClass ) ) {
393            $this->fatalError( "Script class {$this->scriptClass} not found.\n" );
394        }
395
396        return $this->scriptClass;
397    }
398
399    /**
400     * @internal
401     * @param string $script
402     *
403     * @return class-string<Maintenance>
404     */
405    protected function findScriptClass( string $script ): string {
406        [ $extName, $scriptName ] = $this->splitScript( $script );
407
408        if ( $extName !== null ) {
409            $extension = $this->getExtensionInfo( $extName );
410
411            if ( !$extension ) {
412                $this->fatalError( "Extension '{$extName}' not found.\n" );
413            }
414        } else {
415            $extension = null;
416        }
417
418        $scriptClass = $this->expandScriptClass( $scriptName, $extension );
419        $scriptFile = $this->expandScriptFile( $scriptName, $extension );
420
421        if ( !class_exists( $scriptClass ) && file_exists( $scriptFile ) ) {
422            $scriptFileClass = $this->loadScriptFile( $scriptFile );
423            if ( $scriptFileClass ) {
424                $scriptClass = $scriptFileClass;
425            }
426        }
427
428        if ( !class_exists( $scriptClass ) ) {
429            $this->fatalError( "Script '{$script}' not found (tried path '$scriptFile' and class '$scriptClass').\n" );
430        }
431
432        return $scriptClass;
433    }
434
435    /**
436     * MW_FINAL_SETUP_CALLBACK handler, for setting up the Maintenance object.
437     */
438    public function setup( SettingsBuilder $settings ) {
439        // NOTE: this has to happen after the autoloader has been initialized.
440        $scriptClass = $this->getScriptClass();
441
442        $cls = new ReflectionClass( $scriptClass );
443        if ( !$cls->isSubclassOf( Maintenance::class ) ) {
444            $this->fatalError( "Class {$this->script} is not a subclass of Maintenance.\n" );
445        }
446
447        // Initialize the actual Maintenance object
448        try {
449            $this->scriptObject = new $scriptClass;
450            $this->scriptObject->setName( $this->getName() );
451        } catch ( Throwable $ex ) {
452            $this->fatalError(
453                "Failed to initialize Maintenance object.\n" .
454                "(Did you forget to call parent::__construct() in your maintenance script?)\n" .
455                "$ex\n"
456            );
457        }
458
459        if ( !$this->scriptObject instanceof Maintenance ) {
460            // This should never happen, we already checked if the class is a subclass of Maintenance!
461            throw new LogicException( 'Incompatible script object' );
462        }
463
464        // Inject runner stuff into the script's parameter definitions.
465        // This is mainly used when printing help.
466        $scriptParameters = $this->scriptObject->getParameters();
467
468        if ( $this->runFromWrapper ) {
469            $scriptParameters->setUsagePrefix( 'php ' . $this->parameters->getName() );
470        }
471
472        $scriptParameters->mergeOptions( $this->parameters );
473        $this->parameters = $scriptParameters;
474
475        // Ingest argv
476        $this->scriptObject->loadWithArgv( $this->scriptArgv );
477
478        // Basic checks and such
479        $this->scriptObject->setup();
480
481        // Set the memory limit
482        $this->adjustMemoryLimit();
483
484        // Override any config settings
485        $this->overrideConfig( $settings );
486    }
487
488    /**
489     * Returns the maintenance script name to show in the help message.
490     */
491    public function getName(): string {
492        // Once one of the init methods was called, getArg( 0 ) should always
493        // return something.
494        return $this->parameters->getArg( 0 ) ?? 'UNKNOWN';
495    }
496
497    /**
498     * Normally we disable the memory_limit when running admin scripts.
499     * Some scripts may wish to actually set a limit, however, to avoid
500     * blowing up unexpectedly.
501     * @see Maintenance::memoryLimit()
502     * @return string
503     */
504    private function memoryLimit() {
505        if ( $this->parameters->hasOption( 'memory-limit' ) ) {
506            $limit = $this->parameters->getOption( 'memory-limit', 'max' );
507            $limit = trim( $limit, "\" '" ); // trim quotes in case someone misunderstood
508            return $limit;
509        }
510
511        $limit = $this->scriptObject->memoryLimit();
512        return $limit ?: 'max';
513    }
514
515    /**
516     * Adjusts PHP's memory limit to better suit our needs, if needed.
517     */
518    private function adjustMemoryLimit() {
519        $limit = $this->memoryLimit();
520        if ( $limit == 'max' ) {
521            $limit = -1; // no memory limit
522        }
523        if ( $limit != 'default' ) {
524            ini_set( 'memory_limit', $limit );
525        }
526    }
527
528    /**
529     * Define how settings are loaded (e.g. LocalSettings.php)
530     * @note Called before Setup.php
531     *
532     * @internal
533     * @return void
534     */
535    public function defineSettings() {
536        global $IP;
537
538        if ( $this->parameters->hasOption( 'conf' ) ) {
539            // Define the constant instead of directly setting $settingsFile
540            // to ensure consistency. wfDetectLocalSettingsFile() will return
541            // MW_CONFIG_FILE if it is defined.
542            define( 'MW_CONFIG_FILE', $this->parameters->getOption( 'conf' ) );
543
544            if ( !is_readable( MW_CONFIG_FILE ) ) {
545                $this->fatalError( "\nConfig file " . MW_CONFIG_FILE . " was not found or is not readable.\n\n" );
546            }
547        }
548        $settingsFile = wfDetectLocalSettingsFile( $IP );
549
550        if ( $this->parameters->hasOption( 'wiki' ) ) {
551            $wikiName = $this->parameters->getOption( 'wiki' );
552            $bits = explode( '-', $wikiName, 2 );
553            define( 'MW_DB', $bits[0] );
554            define( 'MW_PREFIX', $bits[1] ?? '' );
555            define( 'MW_WIKI_NAME', $wikiName );
556        } elseif ( $this->parameters->hasOption( 'server' ) ) {
557            // Provide the option for site admins to detect and configure
558            // multiple wikis based on server names. This offers --server
559            // as alternative to --wiki.
560            // See https://www.mediawiki.org/wiki/Manual:Wiki_family
561            $_SERVER['SERVER_NAME'] = $this->parameters->getOption( 'server' );
562        }
563
564        // Try to load the script file before running Setup.php if possible.
565        // This allows the script file to define constants that change the behavior
566        // of Setup.php.
567        // Note that this will only work reliably for core scripts.
568        if ( $this->runFromWrapper ) {
569            $this->preloadScriptFile( $this->script );
570        }
571
572        if ( !is_readable( $settingsFile ) ) {
573            // NOTE: Some maintenance scripts can (and need to) run without LocalSettings.
574            //       But we only know that once we have instantiated the Maintenance object.
575            //       So go into no-settings mode for now, and fail later of the script doesn't support it.
576            if ( !defined( 'MW_CONFIG_CALLBACK' ) ) {
577                define( 'MW_CONFIG_CALLBACK', __CLASS__ . '::emulateConfig' );
578            }
579            $this->withoutLocalSettings = true;
580        }
581    }
582
583    /**
584     * @param SettingsBuilder $settings
585     *
586     * @internal Handler for MW_CONFIG_CALLBACK, used when no LocalSettings.php was found.
587     */
588    public static function emulateConfig( SettingsBuilder $settings ) {
589        // NOTE: The config schema is already loaded at this point, so default values are known.
590
591        $settings->overrideConfigValues( [
592            // Server must be set, but we don't care to what
593            MainConfigNames::Server => 'https://unknown.invalid',
594            // If InvalidateCacheOnLocalSettingsChange is enabled, filemtime( MW_CONFIG_FILE ),
595            // which will produce a warning if there is no settings file.
596            MainConfigNames::InvalidateCacheOnLocalSettingsChange => false,
597        ] );
598    }
599
600    /**
601     * @param SettingsBuilder $settingsBuilder
602     *
603     * @return void
604     */
605    private function overrideConfig( SettingsBuilder $settingsBuilder ) {
606        $config = $settingsBuilder->getConfig();
607
608        if ( $this->scriptObject->getDbType() === Maintenance::DB_NONE ) {
609            $cacheConf = $config->get( MainConfigNames::LocalisationCacheConf );
610            if ( $cacheConf['storeClass'] === false
611                && ( $cacheConf['store'] == 'db'
612                    || ( $cacheConf['store'] == 'detect'
613                        && !$config->get( MainConfigNames::CacheDirectory ) ) )
614            ) {
615                $cacheConf['storeClass'] = LCStoreNull::class;
616                $settingsBuilder->putConfigValue( MainConfigNames::LocalisationCacheConf, $cacheConf );
617            }
618        }
619
620        $output = $this->parameters->getOption( 'profiler' );
621        if ( $output ) {
622            // Per-script profiling; useful for debugging
623            $profilerConf = $config->get( MainConfigNames::Profiler );
624            if ( isset( $profilerConf['class'] ) ) {
625                $profilerConf = [
626                    'sampling' => 1,
627                    'output' => [ $output ],
628                    'cliEnable' => true,
629                ] + $profilerConf;
630                // Override $wgProfiler. This is passed to Profiler::init() by Setup.php.
631                $settingsBuilder->putConfigValue( MainConfigNames::Profiler, $profilerConf );
632            }
633        }
634
635        $this->scriptObject->finalSetup( $settingsBuilder );
636    }
637
638    private function getServiceContainer(): MediaWikiServices {
639        return MediaWikiServices::getInstance();
640    }
641
642    /**
643     * Run the maintenance script.
644     *
645     * @note The process should exit immediately after this method returns.
646     * At that point, MediaWiki will already have been shut down.
647     * It is no longer safe to perform any write operations on the database.
648     *
649     * @note Any exceptions thrown by the maintenance script will cause this
650     * method to terminate the process after reporting the error to the user,
651     * without shutdown and cleanup.
652     *
653     * @return bool true on success, false on failure,
654     *         passed through from Maintenance::execute().
655     */
656    public function run(): bool {
657        $config = $this->getConfig();
658
659        // Apply warning thresholds and output mode to Profiler.
660        // This MUST happen after Setup.php calls MaintenanceRunner::setup,
661        // $wgSettings->apply(), and Profiler::init(). Otherwise, calling
662        // Profiler::instance() would create a ProfilerStub even when $wgProfiler
663        // and --profiler are set.
664        $limits = $config->get( MainConfigNames::TrxProfilerLimits );
665        $trxProfiler = Profiler::instance()->getTransactionProfiler();
666        $trxProfiler->setLogger( LoggerFactory::getInstance( 'rdbms' ) );
667        $trxProfiler->setExpectations( $limits['Maintenance'], __METHOD__ );
668        Profiler::instance()->setAllowOutput();
669
670        // Initialize main config instance
671        $this->scriptObject->setConfig( $config );
672
673        // Double check required extensions are installed
674        $this->scriptObject->checkRequiredExtensions();
675
676        if ( $this->withoutLocalSettings && !$this->scriptObject->canExecuteWithoutLocalSettings() ) {
677            $this->fatalError(
678                "\nThe LocalSettings.php file was not found or is not readable.\n" .
679                "Use --conf to specify an alternative config file.\n\n"
680            );
681        }
682
683        if ( $this->scriptObject->getDbType() == Maintenance::DB_NONE || $this->withoutLocalSettings ) {
684            // Be strict with maintenance tasks that claim to not need a database by
685            // disabling the storage backend.
686            MediaWikiServices::resetGlobalInstance( $config );
687            MediaWikiServices::getInstance()->disableStorage();
688        }
689
690        $this->scriptObject->validateParamsAndArgs();
691
692        // Do the work
693        try {
694            $success = $this->scriptObject->execute() !== false;
695
696            // Potentially debug globals
697            if ( $this->parameters->hasOption( 'globals' ) ) {
698                print_r( $GLOBALS );
699            }
700
701            $this->shutdown();
702
703            return $success;
704        } catch ( Exception $ex ) {
705            $exReportMessage = '';
706            while ( $ex ) {
707                $cls = get_class( $ex );
708                $exReportMessage .= "$cls from line {$ex->getLine()} of {$ex->getFile()}{$ex->getMessage()}\n";
709                $exReportMessage .= $ex->getTraceAsString() . "\n";
710                $ex = $ex->getPrevious();
711            }
712            $this->error( $exReportMessage );
713
714            // Exit now because process is in an unsafe state.
715            // Also to avoid DBTransactionError (T305730).
716            // Do not commit database writes, do not run deferreds, do not pass Go.
717            exit( 1 );
718        }
719    }
720
721    /**
722     * Output a message and terminate the current script.
723     *
724     * @param string $msg Error message
725     * @param int $exitCode PHP exit status. Should be in range 1-254.
726     * @return never
727     */
728    protected function fatalError( $msg, $exitCode = 1 ) {
729        $this->error( $msg );
730        exit( $exitCode );
731    }
732
733    protected function error( string $msg ) {
734        // Print to stderr if possible, don't mix it in with stdout output.
735        if ( defined( 'STDERR' ) ) {
736            fwrite( STDERR, $msg );
737        } else {
738            echo $msg;
739        }
740    }
741
742    /**
743     * Should we execute the maintenance script, or just allow it to be included
744     * as a standalone class? It checks that the call stack only includes this
745     * function and "requires" (meaning was called from the file scope)
746     *
747     * @return bool
748     */
749    public static function shouldExecute() {
750        if ( !function_exists( 'debug_backtrace' ) ) {
751            // If someone has a better idea...
752            return MW_ENTRY_POINT === 'cli';
753        }
754
755        $bt = debug_backtrace();
756        $count = count( $bt );
757        if ( $bt[0]['class'] !== self::class || $bt[0]['function'] !== 'shouldExecute' ) {
758            return false; // last call should be to this function
759        }
760        $includeFuncs = [ 'require_once', 'require', 'include', 'include_once' ];
761        for ( $i = 1; $i < $count; $i++ ) {
762            if ( !in_array( $bt[$i]['function'], $includeFuncs ) ) {
763                return false; // previous calls should all be "requires"
764            }
765        }
766
767        return true;
768    }
769
770    /**
771     * Handler for register_shutdown_function
772     * @internal
773     * @return void
774     */
775    public function cleanup() {
776        if ( $this->scriptObject ) {
777            $this->scriptObject->cleanupChanneled();
778        }
779    }
780
781    /**
782     * Call before exiting CLI process for the last DB commit, and flush
783     * any remaining buffers and other deferred work.
784     *
785     * Equivalent to MediaWiki::restInPeace which handles shutdown for web requests,
786     * and should perform the same operations and in the same order.
787     *
788     * @since 1.41
789     */
790    private function shutdown() {
791        $lbFactory = null;
792        if (
793            $this->scriptObject->getDbType() !== Maintenance::DB_NONE &&
794            // Service might be disabled, e.g. when running install.php
795            !$this->getServiceContainer()->isServiceDisabled( 'DBLoadBalancerFactory' )
796        ) {
797            $lbFactory = $this->getServiceContainer()->getDBLoadBalancerFactory();
798            if ( $lbFactory->isReadyForRoundOperations() ) {
799                $lbFactory->commitPrimaryChanges( get_class( $this ) );
800            }
801
802            DeferredUpdates::doUpdates();
803        }
804
805        // Handle profiler outputs
806        // NOTE: MaintenanceRunner ensures Profiler::setAllowOutput() during setup
807        $profiler = Profiler::instance();
808        $profiler->logData();
809        $profiler->logDataPageOutputOnly();
810
811        MediaWiki::emitBufferedStats(
812            $this->getServiceContainer()->getStatsFactory()
813        );
814
815        if ( $lbFactory ) {
816            if ( $lbFactory->isReadyForRoundOperations() ) {
817                $lbFactory->shutdown( $lbFactory::SHUTDOWN_NO_CHRONPROT );
818            }
819        }
820    }
821
822}