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