Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.19% covered (danger)
13.19%
24 / 182
0.00% covered (danger)
0.00%
0 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConfigUtils
13.19% covered (danger)
13.19%
24 / 182
0.00% covered (danger)
0.00%
0 / 20
2829.29
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 checkElasticsearchVersion
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 pickIndexIdentifierFromOption
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 getAllIndicesByType
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getAllAlternativeIndicesByType
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 scanModulesOrPlugins
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
4.16
 scanAvailablePlugins
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 removeBannedPlugins
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 scanAvailableModules
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 output
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 outputIndented
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 waitForGreen
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isIndex
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getIndicesWithAlias
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 isIndexLive
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 safeRefresh
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 safeRefreshOrFail
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 safeCount
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 safeCountOrFail
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 listIndices
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace CirrusSearch\Maintenance;
4
5use CirrusSearch\Connection;
6use Elastica\Client;
7use Elastica\Exception\ResponseException;
8use Elastica\Index;
9use Elasticsearch\Endpoints;
10use LogicException;
11use MediaWiki\Extension\Elastica\MWElasticUtils;
12use MediaWiki\Language\RawMessage;
13use MediaWiki\Status\Status;
14use RuntimeException;
15
16/**
17 * @license GPL-2.0-or-later
18 */
19class ConfigUtils {
20    /**
21     * @var Client
22     */
23    private $client;
24
25    /**
26     * @var Printer
27     */
28    private $out;
29
30    public function __construct( Client $client, Printer $out ) {
31        $this->client = $client;
32        $this->out = $out;
33    }
34
35    public function checkElasticsearchVersion(): Status {
36        $this->outputIndented( 'Fetching server version...' );
37        $response = $this->client->request( '' );
38        if ( !$response->isOK() ) {
39            return Status::newFatal( "Cannot fetch elasticsearch version: "
40                . $response->getError() );
41        }
42        $banner = $response->getData();
43        if ( !isset( $banner['version']['number'] ) ) {
44            return Status::newFatal( 'unable to determine, aborting.' );
45        }
46        $distribution = $banner['version']['distribution'] ?? 'elasticsearch';
47        $version = $banner['version']['number'];
48        $this->output( "$distribution $version..." );
49
50        $required = $distribution === 'opensearch' ? '1.3' : '7.10';
51        if ( strpos( $version, $required ) !== 0 ) {
52            $this->output( "Not supported!\n" );
53            return Status::newFatal(
54                "Only OpenSearch 1.3.x is supported; Elasticsearch 7.10.x is now deprecated "
55                . " and support will be removed soon.\n  Your version: $distribution $version." );
56        }
57        if ( $distribution === 'elasticsearch' ) {
58            $this->output( "deprecated.\n" );
59            $this->outputIndented(
60                "*** ElasticSearch support is deprecated and will be End-of-Life in the next "
61                . "release. Upgrading to OpenSearch will be required. ***\n"
62            );
63        } else {
64            $this->output( "ok\n" );
65        }
66        return Status::newGood();
67    }
68
69    /**
70     * Pick the index identifier from the provided command line option.
71     *
72     * @param string $option command line option
73     *          'now'        => current time
74     *          'current'    => if there is just one index for this type then use its identifier
75     *          other string => that string back
76     * @param string $typeName
77     * @return Status<string> holds string index identifier to use
78     */
79    public function pickIndexIdentifierFromOption( $option, $typeName ): Status {
80        if ( $option === 'now' ) {
81            $identifier = strval( time() );
82            $this->outputIndented( "Setting index identifier...{$typeName}_{$identifier}\n" );
83            return Status::newGood( $identifier );
84        }
85        if ( $option === 'current' ) {
86            $this->outputIndented( 'Inferring index identifier...' );
87            $foundStatus = $this->getAllIndicesByType( $typeName );
88            if ( !$foundStatus->isGood() ) {
89                return $foundStatus;
90            }
91            $found = $foundStatus->getValue();
92
93            if ( count( $found ) > 1 ) {
94                $this->output( "error\n" );
95                return Status::newFatal(
96                    "Looks like the index has more than one identifier. You should delete all\n" .
97                    "but the one of them currently active. Here is the list: " . implode( ',', $found ) );
98            }
99            if ( $found ) {
100                $identifier = substr( $found[0], strlen( $typeName ) + 1 );
101                if ( !$identifier ) {
102                    // This happens if there is an index named what the alias should be named.
103                    // If the script is run with --startOver it should nuke it.
104                    $identifier = 'first';
105                }
106            } else {
107                $identifier = 'first';
108            }
109            $this->output( "{$typeName}_{$identifier}\n" );
110            return Status::newGood( $identifier );
111        }
112        return Status::newGood( $option );
113    }
114
115    /**
116     * Scan the indices and return the ones that match the
117     * type $typeName
118     *
119     * @param string $typeName the type to filter with
120     * @param bool $excludeAltIndices exclude alternative indices
121     * @return Status holds string[] with list of indices
122     */
123    public function getAllIndicesByType( $typeName, bool $excludeAltIndices = true ): Status {
124        $indexQuery = "$typeName*";
125        if ( $excludeAltIndices ) {
126            $altIndexSuffix = Connection::ALT_SUFFIX;
127            $indexQuery .= ",-{$typeName}_{$altIndexSuffix}_*";
128        }
129
130        return $this->listIndices( $indexQuery );
131    }
132
133    /**
134     * Scan the indices and return the ones that match the
135     * type $typeName and are alternative indices
136     *
137     * @param string $typeName the type to filter with
138     * @return Status holds string[] with list of indices
139     */
140    public function getAllAlternativeIndicesByType( $typeName ): Status {
141        $altIndexSuffix = Connection::ALT_SUFFIX;
142        return $this->listIndices( "{$typeName}_{$altIndexSuffix}_*" );
143    }
144
145    /**
146     * @param string $what generally plugins or modules
147     * @return Status holds string[] list of modules or plugins
148     */
149    private function scanModulesOrPlugins( $what ): Status {
150        $response = $this->client->request( '_nodes' );
151        if ( !$response->isOK() ) {
152            return Status::newFatal( "Cannot fetch node state from cluster: "
153                . $response->getError() );
154        }
155        $result = $response->getData();
156        $availables = [];
157        $first = true;
158        foreach ( array_values( $result[ 'nodes' ] ) as $node ) {
159            // The plugins section may not exist, default to [] when not found.
160            $plugins = array_column( $node[$what] ?? [], 'name' );
161            if ( $first ) {
162                $availables = $plugins;
163                $first = false;
164            } else {
165                $availables = array_intersect( $availables, $plugins );
166            }
167        }
168        return Status::newGood( $availables );
169    }
170
171    /**
172     * @param string[] $bannedPlugins
173     * @return Status holds string[]
174     */
175    public function scanAvailablePlugins( array $bannedPlugins = [] ): Status {
176        $this->outputIndented( "Scanning available plugins..." );
177        $availablePluginsStatus = $this->scanModulesOrPlugins( 'plugins' );
178        if ( !$availablePluginsStatus->isGood() ) {
179            return $availablePluginsStatus;
180        }
181        $availablePlugins = $availablePluginsStatus->getValue();
182
183        if ( $availablePlugins === [] ) {
184            $this->output( 'none' );
185        }
186        $this->output( "\n" );
187        $filteredPlugins = $this->removeBannedPlugins(
188            $availablePlugins, $bannedPlugins );
189        foreach ( array_chunk( $filteredPlugins, 5 ) as $pluginChunk ) {
190            $plugins = implode( ', ', $pluginChunk );
191            $this->outputIndented( "\t$plugins\n" );
192        }
193
194        return Status::newGood( $filteredPlugins );
195    }
196
197    /**
198     * @param string[] $availablePlugins
199     * @param string[] $bannedPlugins
200     * @return string[] Filtered plugins list
201     */
202    public function removeBannedPlugins(
203        array $availablePlugins,
204        array $bannedPlugins
205    ): array {
206        if ( count( $bannedPlugins ) ) {
207            return array_diff( $availablePlugins, $bannedPlugins );
208        }
209        return $availablePlugins;
210    }
211
212    /**
213     * @return Status holds string[]
214     */
215    public function scanAvailableModules(): Status {
216        $this->outputIndented( "Scanning available modules..." );
217        $availableModulesStatus = $this->scanModulesOrPlugins( 'modules' );
218        if ( !$availableModulesStatus->isGood() ) {
219            return $availableModulesStatus;
220        }
221        $availableModules = $availableModulesStatus->getValue();
222
223        if ( $availableModules === [] ) {
224            $this->output( 'none' );
225        }
226        $this->output( "\n" );
227        foreach ( array_chunk( $availableModules, 5 ) as $moduleChunk ) {
228            $modules = implode( ', ', $moduleChunk );
229            $this->outputIndented( "\t$modules\n" );
230        }
231
232        return Status::newGood( $availableModules );
233    }
234
235    // @todo: bring below options together in some abstract class where Validator & Reindexer also extend from
236
237    /**
238     * @param string $message
239     * @param string|null $channel
240     */
241    protected function output( $message, $channel = null ) {
242        if ( $this->out ) {
243            $this->out->output( $message, $channel );
244        }
245    }
246
247    /**
248     * @param string $message
249     */
250    protected function outputIndented( $message ) {
251        if ( $this->out ) {
252            $this->out->outputIndented( $message );
253        }
254    }
255
256    /**
257     * Wait for the index to go green
258     *
259     * @param string $indexName
260     * @param int $timeout
261     * @return bool true if the index is green false otherwise.
262     */
263    public function waitForGreen( $indexName, $timeout ) {
264        $statuses = MWElasticUtils::waitForGreen(
265            $this->client, $indexName, $timeout );
266        foreach ( $statuses as $message ) {
267            $this->outputIndented( $message . "\n" );
268        }
269        return $statuses->getReturn();
270    }
271
272    /**
273     * Checks if this is an index (not an alias)
274     * @param string $indexName
275     * @return Status holds true if this is an index, false if it's an alias or if unknown
276     */
277    public function isIndex( $indexName ): Status {
278        // We must emit a HEAD request before calling the _alias
279        // as it may return an error if the index/alias is missing
280        if ( !$this->client->getIndex( $indexName )->exists() ) {
281            return Status::newGood( false );
282        }
283
284        $response = $this->client->request( $indexName . '/_alias' );
285        if ( !$response->isOK() ) {
286            return Status::newFatal( "Cannot determine if $indexName is an index: "
287                . $response->getError() );
288        }
289        // Only index names are listed as top level keys So if
290        // HEAD /$indexName returns HTTP 200 but $indexName is
291        // not a top level json key then it's an alias
292        return Status::newGood( isset( $response->getData()[$indexName] ) );
293    }
294
295    /**
296     * Return a list of index names that points to $aliasName
297     * @param string $aliasName
298     * @return Status holds string[] with index names
299     */
300    public function getIndicesWithAlias( $aliasName ): Status {
301        // We must emit a HEAD request before calling the _alias
302        // as it may return an error if the index/alias is missing
303        if ( !$this->client->getIndex( $aliasName )->exists() ) {
304            return Status::newGood( [] );
305        }
306        $response = $this->client->request( $aliasName . '/_alias' );
307        if ( !$response->isOK() ) {
308            return Status::newFatal( "Cannot fetch indices with alias $aliasName"
309                . $response->getError() );
310        }
311        return Status::newGood( array_keys( $response->getData() ) );
312    }
313
314    /**
315     * Returns true is this is an index thats never been unsed
316     *
317     * Used as a pre-check when deleting indices. This checks that there are no
318     * aliases pointing at it, as all traffic flows through aliases. It
319     * additionally checks the index stats to verify it's never been queried.
320     *
321     * $indexName should be an index and it should exist. If it is an alias or
322     * the index does not exist a fatal status will be returned.
323     *
324     * @param string $indexName The specific name of the index
325     * @return Status When ok, contains true if the index is unused or false otherwise.
326     *   When not good indicates some sort of communication error with elasticsearch.
327     */
328    public function isIndexLive( $indexName ): Status {
329        try {
330            // primary check, verify no aliases point at our index. This invokes
331            // the endpoint directly, rather than Index::getAliases, as that method
332            // does not check http status codes and can incorrectly report no aliases.
333            $aliasResponse = $this->client->requestEndpoint( ( new Endpoints\Indices\GetAlias() )
334                ->setIndex( $indexName ) );
335        } catch ( ResponseException $e ) {
336            // Would have expected a NotFoundException? in testing we get ResponseException instead
337            if ( $e->getResponse()->getStatus() === 404 ) {
338                // We could return an "ok" and false, but since we use this as a check against deletion
339                // seems best to return a fatal indicating the calling code should do nothing.
340                return Status::newFatal( "Index {$indexName} does not exist" );
341            }
342            throw $e;
343        }
344        if ( !$aliasResponse->isOK() ) {
345            return Status::newFatal( "Received error response from elasticsearch for GetAliases on $indexName" );
346        }
347        $aliases = $aliasResponse->getData();
348        if ( !isset( $aliases[$indexName] ) ) {
349            // Can happen if $indexName is actually an alias and not a real index.
350            $keys = count( $aliases ) ? implode( ', ', array_keys( $aliases ) ) : 'empty response';
351            return Status::newFatal( "Unexpected aliases response from elasticsearch for $indexName" .
352                "recieved: $keys" );
353        }
354        if ( $aliases[$indexName]['aliases'] !== [] ) {
355            // Any index with aliases is likely to be live
356            return Status::newGood( true );
357        }
358        return Status::newGood( false );
359    }
360
361    /**
362     * Refresh the index, return a failed Status after $attempts
363     * @param Index $index
364     * @param int $attempts
365     * @return Status
366     */
367    public static function safeRefresh( Index $index, int $attempts = 3 ): Status {
368        try {
369            MWElasticUtils::withRetry( $attempts, static function () use ( $index ) {
370                $resp = $index->refresh();
371                $data = $resp->getData();
372                if ( is_array( $data ) && isset( $data['_shards'] ) ) {
373                    $shards = $data['_shards'];
374                    $tot = $shards['total'] ?? -1;
375                    $success = $shards['successful'] ?? -1;
376                    $failed = $shards['failed'] ?? -1;
377                    if ( $tot - ( $success + $failed ) !== 0 ) {
378                        throw new RuntimeException( "Inconsistent shard results from response " . json_encode( $data ) );
379                    }
380                    if ( $tot !== $success ) {
381                        throw new RuntimeException( "Some shards failed $failed failed, $success succeeded out of $tot total $success " );
382                    }
383                } else {
384                    throw new RuntimeException( "Inconsistent refresh response " . print_r( $data, true ) );
385                }
386            } );
387        } catch ( RuntimeException $e ) {
388            return Status::newFatal( new RawMessage( $e->getMessage() ) );
389        }
390        return Status::newGood();
391    }
392
393    /**
394     * Count the number of doc in this index, fail the maintenance script with fatalError after $attempts.
395     * @param Index $index
396     * @param callable $failureFunction a function that accepts a StatusValue with the error message. must fail.
397     * @param int $attempts
398     */
399    public static function safeRefreshOrFail( Index $index, callable $failureFunction, int $attempts = 3 ): void {
400        $status = self::safeRefresh( $index, $attempts );
401        if ( !$status->isGood() ) {
402            $failureFunction( $status );
403            throw new LogicException( '$failureFunction must fail' );
404        }
405    }
406
407    /**
408     * Count the number of doc in this index, return a failed Status after $attempts.
409     * @param Index $index
410     * @param int $attempts
411     * @return Status<int>
412     */
413    public static function safeCount( Index $index, int $attempts = 3 ): Status {
414        try {
415            $count = MWElasticUtils::withRetry( $attempts, static function () use ( $index ) {
416                $resp = $index->createSearch( '' )->count( '', true );
417                $count = $resp->getResponse()->getData()['hits']['total']['value'] ?? null;
418                if ( $count === null ) {
419                    throw new RuntimeException( "Received search response without total hits: " .
420                                                 json_encode( $resp->getResponse()->getData() ) );
421                }
422                return (int)$count;
423            } );
424        } catch ( ResponseException | RuntimeException $e ) {
425            return Status::newFatal( new RawMessage( "Failed to count {$index->getName()}{$e->getMessage()}" ) );
426        }
427        return Status::newGood( $count );
428    }
429
430    /**
431     * Count the number of doc in this index, fail the maintenance script with fatalError after $attempts.
432     * @param Index $index
433     * @param callable $failureFunction a function that accepts a StatusValue with the error message. must fail.
434     * @param int $attempts
435     * @return int
436     */
437    public static function safeCountOrFail( Index $index, callable $failureFunction, int $attempts = 3 ): int {
438        $status = self::safeCount( $index, $attempts );
439        if ( $status->isGood() ) {
440            return $status->getValue();
441        } else {
442            $failureFunction( $status );
443            throw new \LogicException( '$failureFunction must fail' );
444        }
445    }
446
447    /**
448     * @param string $indexQuery
449     * @return Status
450     */
451    private function listIndices( string $indexQuery ): Status {
452        $response =
453            $this->client->requestEndpoint( ( new Endpoints\Indices\Get() )->setIndex( $indexQuery ) );
454        if ( !$response->isOK() ) {
455            return Status::newFatal( "Cannot fetch index names for $indexQuery" .
456                                     $response->getError() );
457        }
458
459        return Status::newGood( array_keys( $response->getData() ) );
460    }
461}