Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
18.18% |
24 / 132 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
ConfigUtils | |
18.18% |
24 / 132 |
|
0.00% |
0 / 13 |
1309.92 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
checkElasticsearchVersion | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
pickIndexIdentifierFromOption | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
56 | |||
getAllIndicesByType | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
scanModulesOrPlugins | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
4.16 | |||
scanAvailablePlugins | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
5.01 | |||
scanAvailableModules | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
output | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
outputIndented | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
waitForGreen | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
isIndex | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
getIndicesWithAlias | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
isIndexLive | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
90 |
1 | <?php |
2 | |
3 | namespace CirrusSearch\Maintenance; |
4 | |
5 | use Elastica\Client; |
6 | use Elastica\Exception\ResponseException; |
7 | use Elasticsearch\Endpoints; |
8 | use MediaWiki\Extension\Elastica\MWElasticUtils; |
9 | use 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 | */ |
27 | class 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 | } |