Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.18% covered (danger)
18.18%
24 / 132
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConfigUtils
18.18% covered (danger)
18.18%
24 / 132
0.00% covered (danger)
0.00%
0 / 13
1309.92
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 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 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 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 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
5.01
 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 / 23
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2
3namespace CirrusSearch\Maintenance;
4
5use Elastica\Client;
6use Elastica\Exception\ResponseException;
7use Elasticsearch\Endpoints;
8use MediaWiki\Extension\Elastica\MWElasticUtils;
9use MediaWiki\Status\Status;
10
11/**
12 * This program is free software; you can redistribute it and/or modify
13 * it under the terms of the GNU General Public License as published by
14 * the Free Software Foundation; either version 2 of the License, or
15 * (at your option) any later version.
16 *
17 * This program is distributed in the hope that it will be useful,
18 * but WITHOUT ANY WARRANTY; without even the implied warranty of
19 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 * GNU General Public License for more details.
21 *
22 * You should have received a copy of the GNU General Public License along
23 * with this program; if not, write to the Free Software Foundation, Inc.,
24 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
25 * http://www.gnu.org/copyleft/gpl.html
26 */
27class ConfigUtils {
28    /**
29     * @var Client
30     */
31    private $client;
32
33    /**
34     * @var Printer
35     */
36    private $out;
37
38    /**
39     * @param Client $client
40     * @param Printer $out
41     */
42    public function __construct( Client $client, Printer $out ) {
43        $this->client = $client;
44        $this->out = $out;
45    }
46
47    public function checkElasticsearchVersion(): Status {
48        $this->outputIndented( 'Fetching Elasticsearch version...' );
49        $response = $this->client->request( '' );
50        if ( !$response->isOK() ) {
51            return Status::newFatal( "Cannot fetch elasticsearch version: "
52                . $response->getError() );
53        }
54        $result = $response->getData();
55        if ( !isset( $result['version']['number'] ) ) {
56            return Status::newFatal( 'unable to determine, aborting.' );
57        }
58        $result = $result[ 'version' ][ 'number' ];
59        $this->output( "$result..." );
60        if ( strpos( $result, '7.10' ) !== 0 ) {
61            $this->output( "Not supported!\n" );
62            return Status::newFatal( "Only Elasticsearch 7.10.x is supported.  Your version: $result." );
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 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     * @return Status holds string[] with list of indices
121     */
122    public function getAllIndicesByType( $typeName ): Status {
123        $response = $this->client->requestEndpoint( ( new Endpoints\Indices\Get() )
124            ->setIndex( $typeName . '*' ) );
125        if ( !$response->isOK() ) {
126            return Status::newFatal( "Cannot fetch index names for $typeName"
127                . $response->getError() );
128        }
129        return Status::newGood( array_keys( $response->getData() ) );
130    }
131
132    /**
133     * @param string $what generally plugins or modules
134     * @return Status holds string[] list of modules or plugins
135     */
136    private function scanModulesOrPlugins( $what ): Status {
137        $response = $this->client->request( '_nodes' );
138        if ( !$response->isOK() ) {
139            return Status::newFatal( "Cannot fetch node state from cluster: "
140                . $response->getError() );
141        }
142        $result = $response->getData();
143        $availables = [];
144        $first = true;
145        foreach ( array_values( $result[ 'nodes' ] ) as $node ) {
146            // The plugins section may not exist, default to [] when not found.
147            $plugins = array_column( $node[$what] ?? [], 'name' );
148            if ( $first ) {
149                $availables = $plugins;
150                $first = false;
151            } else {
152                $availables = array_intersect( $availables, $plugins );
153            }
154        }
155        return Status::newGood( $availables );
156    }
157
158    /**
159     * @param string[] $bannedPlugins
160     * @return Status holds string[]
161     */
162    public function scanAvailablePlugins( array $bannedPlugins = [] ): Status {
163        $this->outputIndented( "Scanning available plugins..." );
164        $availablePluginsStatus = $this->scanModulesOrPlugins( 'plugins' );
165        if ( !$availablePluginsStatus->isGood() ) {
166            return $availablePluginsStatus;
167        }
168        $availablePlugins = $availablePluginsStatus->getValue();
169
170        if ( $availablePlugins === [] ) {
171            $this->output( 'none' );
172        }
173        $this->output( "\n" );
174        if ( count( $bannedPlugins ) ) {
175            $availablePlugins = array_diff( $availablePlugins, $bannedPlugins );
176        }
177        foreach ( array_chunk( $availablePlugins, 5 ) as $pluginChunk ) {
178            $plugins = implode( ', ', $pluginChunk );
179            $this->outputIndented( "\t$plugins\n" );
180        }
181
182        return Status::newGood( $availablePlugins );
183    }
184
185    /**
186     * @return Status holds string[]
187     */
188    public function scanAvailableModules(): Status {
189        $this->outputIndented( "Scanning available modules..." );
190        $availableModulesStatus = $this->scanModulesOrPlugins( 'modules' );
191        if ( !$availableModulesStatus->isGood() ) {
192            return $availableModulesStatus;
193        }
194        $availableModules = $availableModulesStatus->getValue();
195
196        if ( $availableModules === [] ) {
197            $this->output( 'none' );
198        }
199        $this->output( "\n" );
200        foreach ( array_chunk( $availableModules, 5 ) as $moduleChunk ) {
201            $modules = implode( ', ', $moduleChunk );
202            $this->outputIndented( "\t$modules\n" );
203        }
204
205        return Status::newGood( $availableModules );
206    }
207
208    // @todo: bring below options together in some abstract class where Validator & Reindexer also extend from
209
210    /**
211     * @param string $message
212     * @param mixed|null $channel
213     */
214    protected function output( $message, $channel = null ) {
215        if ( $this->out ) {
216            $this->out->output( $message, $channel );
217        }
218    }
219
220    /**
221     * @param string $message
222     */
223    protected function outputIndented( $message ) {
224        if ( $this->out ) {
225            $this->out->outputIndented( $message );
226        }
227    }
228
229    /**
230     * Wait for the index to go green
231     *
232     * @param string $indexName
233     * @param int $timeout
234     * @return bool true if the index is green false otherwise.
235     */
236    public function waitForGreen( $indexName, $timeout ) {
237        $statuses = MWElasticUtils::waitForGreen(
238            $this->client, $indexName, $timeout );
239        foreach ( $statuses as $message ) {
240            $this->outputIndented( $message . "\n" );
241        }
242        return $statuses->getReturn();
243    }
244
245    /**
246     * Checks if this is an index (not an alias)
247     * @param string $indexName
248     * @return Status holds true if this is an index, false if it's an alias or if unknown
249     */
250    public function isIndex( $indexName ): Status {
251        // We must emit a HEAD request before calling the _alias
252        // as it may return an error if the index/alias is missing
253        if ( !$this->client->getIndex( $indexName )->exists() ) {
254            return Status::newGood( false );
255        }
256
257        $response = $this->client->request( $indexName . '/_alias' );
258        if ( !$response->isOK() ) {
259            return Status::newFatal( "Cannot determine if $indexName is an index: "
260                . $response->getError() );
261        }
262        // Only index names are listed as top level keys So if
263        // HEAD /$indexName returns HTTP 200 but $indexName is
264        // not a top level json key then it's an alias
265        return Status::newGood( isset( $response->getData()[$indexName] ) );
266    }
267
268    /**
269     * Return a list of index names that points to $aliasName
270     * @param string $aliasName
271     * @return Status holds string[] with index names
272     */
273    public function getIndicesWithAlias( $aliasName ): Status {
274        // We must emit a HEAD request before calling the _alias
275        // as it may return an error if the index/alias is missing
276        if ( !$this->client->getIndex( $aliasName )->exists() ) {
277            return Status::newGood( [] );
278        }
279        $response = $this->client->request( $aliasName . '/_alias' );
280        if ( !$response->isOK() ) {
281            return Status::newFatal( "Cannot fetch indices with alias $aliasName"
282                . $response->getError() );
283        }
284        return Status::newGood( array_keys( $response->getData() ) );
285    }
286
287    /**
288     * Returns true is this is an index thats never been unsed
289     *
290     * Used as a pre-check when deleting indices. This checks that there are no
291     * aliases pointing at it, as all traffic flows through aliases. It
292     * additionally checks the index stats to verify it's never been queried.
293     *
294     * $indexName should be an index and it should exist. If it is an alias or
295     * the index does not exist a fatal status will be returned.
296     *
297     * @param string $indexName The specific name of the index
298     * @return Status When ok, contains true if the index is unused or false otherwise.
299     *   When not good indicates some sort of communication error with elasticsearch.
300     */
301    public function isIndexLive( $indexName ): Status {
302        try {
303            // primary check, verify no aliases point at our index. This invokes
304            // the endpoint directly, rather than Index::getAliases, as that method
305            // does not check http status codes and can incorrectly report no aliases.
306            $aliasResponse = $this->client->requestEndpoint( ( new Endpoints\Indices\GetAlias() )
307                ->setIndex( $indexName ) );
308            // secondary check, verify no queries have previously run through this index.
309            $stats = $this->client->getIndex( $indexName )->getStats();
310        } catch ( ResponseException $e ) {
311            // Would have expected a NotFoundException? in testing we get ResponseException instead
312            if ( $e->getResponse()->getStatus() === 404 ) {
313                // We could return an "ok" and false, but since we use this as a check against deletion
314                // seems best to return a fatal indicating the calling code should do nothing.
315                return Status::newFatal( "Index {$indexName} does not exist" );
316            }
317            throw $e;
318        }
319        if ( !$aliasResponse->isOK() ) {
320            return Status::newFatal( "Received error response from elasticsearch for GetAliases on $indexName" );
321        }
322        $aliases = $aliasResponse->getData();
323        if ( !isset( $aliases[$indexName] ) ) {
324            // Can happen if $indexName is actually an alias and not a real index.
325            $keys = count( $aliases ) ? implode( ', ', array_keys( $aliases ) ) : 'empty response';
326            return Status::newFatal( "Unexpected aliases response from elasticsearch for $indexName" .
327                "recieved: $keys" );
328        }
329        if ( $aliases[$indexName]['aliases'] !== [] ) {
330            // Any index with aliases is likely to be live
331            return Status::newGood( true );
332        }
333        // On a newly created and promoted index this would only trigger on
334        // indices with volume, some low volume indices might be promoted but
335        // not recieve a query between promotion and this check.
336        $searchStats = $stats->get( '_all', 'total', 'search' );
337        if ( $searchStats['query_total'] !== 0 || $searchStats['suggest_total'] !== 0 ) {
338            // Something has run through here, it might not be live now (no aliases) but
339            // it was used at some point. Call it live-enough to not delete automatically.
340            $status = Status::newGood( true );
341            $status->warning( "Broken index {$indexName} appears to be in use, " .
342                "please check and delete." );
343            return $status;
344        }
345        return Status::newGood( false );
346    }
347}