Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.75% covered (danger)
12.75%
13 / 102
4.76% covered (danger)
4.76%
1 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Maintenance
12.87% covered (danger)
12.87%
13 / 101
4.76% covered (danger)
4.76%
1 / 21
1208.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 finalSetup
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setupUserTest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 createChild
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getConnection
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getSearchConfig
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getMetaStore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 decideCluster
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 loadSpecialVars
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 done
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 output
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outputIndented
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 error
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 disablePoolCountersAndLogging
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 maybeCreateMetastore
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 requireCirrusReady
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 requireManagedCluster
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getBackCompatOption
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 unwrap
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 safeCount
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 safeRefresh
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace CirrusSearch\Maintenance;
4
5use CirrusSearch\Connection;
6use CirrusSearch\MetaStore\MetaStoreIndex;
7use CirrusSearch\SearchConfig;
8use CirrusSearch\UserTestingEngine;
9use Elastica\Index;
10use MediaWiki\Maintenance\Maintenance as MWMaintenance;
11use MediaWiki\MediaWikiServices;
12use MediaWiki\Settings\SettingsBuilder;
13use MediaWiki\Status\Status;
14use RuntimeException;
15use StatusValue;
16
17// Maintenance class is loaded before autoload, so we need to pull the interface
18require_once __DIR__ . '/Printer.php';
19
20/**
21 * Cirrus helpful extensions to Maintenance.
22 *
23 * @license GPL-2.0-or-later
24 */
25abstract class Maintenance extends MWMaintenance implements Printer {
26    /**
27     * @var string The string to indent output with
28     */
29    protected static $indent = null;
30
31    /**
32     * @var Connection|null
33     */
34    private $connection;
35
36    /**
37     * @var SearchConfig
38     */
39    protected $searchConfig;
40
41    public function __construct( ?SearchConfig $searchConfig = null ) {
42        parent::__construct();
43        if ( $searchConfig !== null ) {
44            $this->searchConfig = $searchConfig;
45        }
46        $this->addOption( 'cluster', 'Perform all actions on the specified elasticsearch cluster',
47            false, true );
48        $this->addOption( 'userTestTrigger', 'Use config var and profiles set in the user testing ' .
49            'framework, e.g. --userTestTrigger=trigger', false, true );
50        $this->requireExtension( 'CirrusSearch' );
51    }
52
53    public function finalSetup( SettingsBuilder $settingsBuilder ) {
54        parent::finalSetup( $settingsBuilder );
55
56        if ( $this->hasOption( 'userTestTrigger' ) ) {
57            $this->setupUserTest();
58        }
59    }
60
61    /**
62     * Setup config vars with the UserTest framework
63     */
64    private function setupUserTest() {
65        // Configure the UserTesting framework
66        // Useful in case an index needs to be built with a
67        // test config that is not meant to be the default.
68        // This is realistically only usefull to test across
69        // multiple clusters.
70        // Perhaps setting $wgCirrusSearchIndexBaseName to an
71        // alternate value would testing on the same cluster
72        // but this index would not receive updates.
73        $trigger = $this->getOption( 'userTestTrigger' );
74        $engine = UserTestingEngine::fromConfig( $this->getConfig() );
75        $status = $engine->decideTestByTrigger( $trigger );
76        if ( !$status->isActive() ) {
77            $this->fatalError( "Unknown user test trigger: $trigger" );
78        }
79        $engine->activateTest( $status );
80    }
81
82    /** @inheritDoc */
83    public function createChild( string $maintClass, ?string $classFile = null ): MWMaintenance {
84        $child = parent::createChild( $maintClass, $classFile );
85        if ( $child instanceof self ) {
86            $child->searchConfig = $this->searchConfig;
87        }
88
89        return $child;
90    }
91
92    /**
93     * @param string|null $cluster
94     * @return Connection
95     */
96    public function getConnection( $cluster = null ) {
97        if ( $cluster ) {
98            $connection = Connection::getPool( $this->getSearchConfig(), $cluster );
99        } else {
100            if ( $this->connection === null ) {
101                $cluster = $this->decideCluster();
102                $this->connection = Connection::getPool( $this->getSearchConfig(), $cluster );
103            }
104            $connection = $this->connection;
105        }
106
107        $connection->setTimeout( $this->getSearchConfig()->get( 'CirrusSearchMaintenanceTimeout' ) );
108
109        return $connection;
110    }
111
112    public function getSearchConfig(): SearchConfig {
113        if ( $this->searchConfig == null ) {
114            $this->searchConfig = MediaWikiServices::getInstance()
115                ->getConfigFactory()
116                ->makeConfig( 'CirrusSearch' );
117            if ( !$this->searchConfig instanceof SearchConfig ) {
118                // We shouldn't ever get here ... but the makeConfig type signature returns the parent
119                // class of SearchConfig so just being extra careful...
120                throw new \RuntimeException( 'Expected instanceof CirrusSearch\SearchConfig, but received ' .
121                    get_class( $this->searchConfig ) );
122            }
123        }
124        return $this->searchConfig;
125    }
126
127    public function getMetaStore( ?Connection $conn = null ): MetaStoreIndex {
128        return new MetaStoreIndex( $conn ?? $this->getConnection(), $this, $this->getSearchConfig() );
129    }
130
131    /**
132     * @return string|null
133     */
134    protected function decideCluster() {
135        $config = $this->getSearchConfig();
136        $assignment = $config->getClusterAssignment();
137
138        $cluster = $this->getOption( 'cluster', null );
139        if ( $cluster !== null && $config->has( 'CirrusSearchServers' ) ) {
140            $this->fatalError( 'Not configured for cluster operations.' );
141        }
142        if ( $cluster === null ) {
143            $cluster = $assignment->getSearchCluster();
144        }
145        if ( $this->requireManagedCluster() && !$assignment->canManageCluster( $cluster ) ) {
146            $this->fatalError(
147                "Named cluster ($cluster) is not configured for maintenance operations. " .
148                "Allowed clusters: " . implode( ", ", $assignment->getManagedClusters() )
149            );
150        }
151        return $cluster;
152    }
153
154    /**
155     * Execute a callback function at the end of initialisation
156     */
157    public function loadSpecialVars() {
158        parent::loadSpecialVars();
159        if ( self::$indent === null ) {
160            // First script gets no indentation
161            self::$indent = '';
162        } else {
163            // Others get one tab beyond the last
164            self::$indent .= "\t";
165        }
166    }
167
168    /**
169     * Call to signal that execution of this maintenance script is complete so
170     * the next one gets the right indentation.
171     */
172    public function done() {
173        self::$indent = substr( self::$indent, 1 );
174    }
175
176    /**
177     * @param string $message
178     * @param string|null $channel
179     */
180    public function output( $message, $channel = null ) {
181        parent::output( $message );
182    }
183
184    /** @inheritDoc */
185    public function outputIndented( $message ) {
186        $this->output( self::$indent . $message );
187    }
188
189    /**
190     * @param string $err
191     * @param int $die deprecated, do not use
192     */
193    public function error( $err, $die = 0 ) {
194        parent::error( $err, $die );
195    }
196
197    /**
198     * Disable all pool counters and cirrus query logs.
199     * Only useful for maint scripts
200     *
201     * Ideally this method could be run in the constructor
202     * but apparently globals are reset just before the
203     * call to execute()
204     */
205    protected function disablePoolCountersAndLogging() {
206        global $wgPoolCounterConf, $wgCirrusSearchLogElasticRequests;
207
208        // Make sure we don't flood the pool counter
209        unset( $wgPoolCounterConf['CirrusSearch-Search'] );
210
211        // Don't skew the dashboards by logging these requests to
212        // the global request log.
213        $wgCirrusSearchLogElasticRequests = false;
214    }
215
216    /**
217     * Create metastore only if the alias does not already exist
218     * @return MetaStoreIndex
219     */
220    protected function maybeCreateMetastore() {
221        $metastore = new MetaStoreIndex(
222            $this->getConnection(),
223            $this,
224            $this->getSearchConfig() );
225        $status = $metastore->createIfNecessary();
226        $this->unwrap( $status );
227        return $metastore;
228    }
229
230    protected function requireCirrusReady() {
231        // If the version does not exist it's certainly because nothing has been indexed.
232        if ( !$this->getMetaStore()->cirrusReady() ) {
233            throw new RuntimeException(
234                "Cirrus meta store does not exist, you must index your data first"
235            );
236        }
237    }
238
239    /**
240     * @return bool True if this script only operates on clusters specified
241     *  in CirrusSearchManagedClusters. Can be set to false for read-only
242     *  scripts that don't care where they read from.
243     */
244    protected function requireManagedCluster() {
245        return true;
246    }
247
248    /**
249     * Provides support for backward compatible CLI options
250     *
251     * Requires either one or neither of the two options to be provided.
252     *
253     * @param string $current The current option to request
254     * @param string $bc The old option to provide BC support for
255     * @param bool $required True if the option must be provided. When false and no option
256     *  is provided null is returned.
257     * @return mixed
258     */
259    protected function getBackCompatOption( string $current, string $bc, bool $required = true ) {
260        if ( $this->hasOption( $current ) && $this->hasOption( $bc ) ) {
261            $this->error( "\nERROR: --$current cannot be provided with --$bc" );
262            $this->maybeHelp( true );
263        } elseif ( $this->hasOption( $current ) ) {
264            return $this->getOption( $current );
265        } elseif ( $this->hasOption( $bc ) ) {
266            return $this->getOption( $bc );
267        } elseif ( $required ) {
268            $this->error( "\nERROR: Param $current is required" );
269            $this->maybeHelp( true );
270        } else {
271            return null;
272        }
273    }
274
275    /**
276     * Helper method for Status returning methods, such as via ConfigUtils
277     *
278     * @template T
279     * @param Status<T> $status
280     * @return T
281     */
282    protected function unwrap( Status $status ) {
283        if ( !$status->isGood() ) {
284            $this->fatalError( (string)$status );
285        }
286        return $status->getValue();
287    }
288
289    /**
290     * Count the number of doc in this index.
291     * @param Index $index
292     * @return int
293     */
294    protected function safeCount( Index $index, int $attempts = 3 ): int {
295        return ConfigUtils::safeCountOrFail(
296            $index,
297            function ( StatusValue $error ): never {
298                $this->fatalError( $error );
299            },
300            $attempts
301        );
302    }
303
304    /**
305     * Refresh the index.
306     * @param Index $index
307     * @param int $attempts
308     * @return void
309     */
310    protected function safeRefresh( Index $index, int $attempts = 3 ): void {
311        ConfigUtils::safeRefreshOrFail(
312            $index,
313            function ( StatusValue $error ): never {
314                $this->fatalError( $error );
315            },
316            $attempts
317        );
318    }
319
320}