Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
13.19% |
24 / 182 |
|
0.00% |
0 / 20 |
CRAP | |
0.00% |
0 / 1 |
| ConfigUtils | |
13.19% |
24 / 182 |
|
0.00% |
0 / 20 |
2829.29 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| checkElasticsearchVersion | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
42 | |||
| pickIndexIdentifierFromOption | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
56 | |||
| getAllIndicesByType | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| getAllAlternativeIndicesByType | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| scanModulesOrPlugins | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
4.16 | |||
| scanAvailablePlugins | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
4.01 | |||
| removeBannedPlugins | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| 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 / 16 |
|
0.00% |
0 / 1 |
56 | |||
| safeRefresh | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 | |||
| safeRefreshOrFail | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| safeCount | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
| safeCountOrFail | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
| listIndices | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace CirrusSearch\Maintenance; |
| 4 | |
| 5 | use CirrusSearch\Connection; |
| 6 | use Elastica\Client; |
| 7 | use Elastica\Exception\ResponseException; |
| 8 | use Elastica\Index; |
| 9 | use Elasticsearch\Endpoints; |
| 10 | use LogicException; |
| 11 | use MediaWiki\Extension\Elastica\MWElasticUtils; |
| 12 | use MediaWiki\Language\RawMessage; |
| 13 | use MediaWiki\Status\Status; |
| 14 | use RuntimeException; |
| 15 | |
| 16 | /** |
| 17 | * @license GPL-2.0-or-later |
| 18 | */ |
| 19 | class 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 | } |