X-Git-Url: https://git.cyclocoop.org/%242?a=blobdiff_plain;f=includes%2Fconfig%2FEtcdConfig.php;h=7020159fd7d72cfcc0a278a00b4ec265ca82e445;hb=f3c58982eff0a5fb061db1d4519ffcdb1713213f;hp=d7dc45a5372ef03f6e00df95e94f521e73d63645;hpb=feaf1daca9a0614efd35278e196a0fc18cb381a3;p=lhc%2Fweb%2Fwiklou.git diff --git a/includes/config/EtcdConfig.php b/includes/config/EtcdConfig.php index d7dc45a537..7020159fd7 100644 --- a/includes/config/EtcdConfig.php +++ b/includes/config/EtcdConfig.php @@ -20,6 +20,7 @@ use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; +use Wikimedia\ObjectFactory; use Wikimedia\WaitConditionLoop; /** @@ -118,6 +119,11 @@ class EtcdConfig implements Config, LoggerAwareInterface { return $this->procCache['config'][$name]; } + public function getModifiedIndex() { + $this->load(); + return $this->procCache['modifiedIndex']; + } + /** * @throws ConfigException */ @@ -150,13 +156,17 @@ class EtcdConfig implements Config, LoggerAwareInterface { // refresh the cache from etcd, using a mutex to reduce stampedes... if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) { try { - list( $config, $error, $retry ) = $this->fetchAllFromEtcd(); - if ( is_array( $config ) ) { + $etcdResponse = $this->fetchAllFromEtcd(); + $error = $etcdResponse['error']; + if ( is_array( $etcdResponse['config'] ) ) { // Avoid having all servers expire cache keys at the same time $expiry = microtime( true ) + $this->baseCacheTTL; $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL; - - $data = [ 'config' => $config, 'expires' => $expiry ]; + $data = [ + 'config' => $etcdResponse['config'], + 'expires' => $expiry, + 'modifiedIndex' => $etcdResponse['modifiedIndex'] + ]; $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE ); $this->logger->info( "Refreshed stale etcd configuration cache." ); @@ -164,7 +174,7 @@ class EtcdConfig implements Config, LoggerAwareInterface { return WaitConditionLoop::CONDITION_REACHED; } else { $this->logger->error( "Failed to fetch configuration: $error" ); - if ( !$retry ) { + if ( !$etcdResponse['retry'] ) { // Fail fast since the error is likely to keep happening return WaitConditionLoop::CONDITION_FAILED; } @@ -194,9 +204,10 @@ class EtcdConfig implements Config, LoggerAwareInterface { } /** - * @return array (config array or null, error string, allow retries) + * @return array (containing the keys config, error, retry, modifiedIndex) */ public function fetchAllFromEtcd() { + // TODO: inject DnsSrvDiscoverer in order to be able to test this method $dsd = new DnsSrvDiscoverer( $this->host ); $servers = $dsd->getServers(); if ( !$servers ) { @@ -208,8 +219,8 @@ class EtcdConfig implements Config, LoggerAwareInterface { $server = $dsd->pickServer( $servers ); $host = IP::combineHostAndPort( $server['target'], $server['port'] ); // Try to load the config from this particular server - list( $config, $error, $retry ) = $this->fetchAllFromEtcdServer( $host ); - if ( is_array( $config ) || !$retry ) { + $response = $this->fetchAllFromEtcdServer( $host ); + if ( is_array( $response['config'] ) || $response['retry'] ) { break; } @@ -217,51 +228,95 @@ class EtcdConfig implements Config, LoggerAwareInterface { $servers = $dsd->removeServer( $server, $servers ); } while ( $servers ); - return [ $config, $error, $retry ]; + return $response; } /** * @param string $address Host and port - * @return array (config array or null, error string, whether to allow retries) + * @return array (containing the keys config, error, retry, modifiedIndex) */ protected function fetchAllFromEtcdServer( $address ) { // Retrieve all the values under the MediaWiki config directory list( $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ) = $this->http->run( [ 'method' => 'GET', - 'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/", + 'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/?recursive=true", 'headers' => [ 'content-type' => 'application/json' ] ] ); + $response = [ 'config' => null, 'error' => null, 'retry' => false, 'modifiedIndex' => 0 ]; + static $terminalCodes = [ 404 => true ]; if ( $rcode < 200 || $rcode > 399 ) { - return [ - null, - strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)", - empty( $terminalCodes[$rcode] ) - ]; + $response['error'] = strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)"; + $response['retry'] = empty( $terminalCodes[$rcode] ); + return $response; } - $info = json_decode( $rbody, true ); - if ( $info === null || !isset( $info['node']['nodes'] ) ) { - return [ null, "Unexpected JSON response; missing 'nodes' list.", false ]; + try { + $parsedResponse = $this->parseResponse( $rbody ); + } catch ( EtcdConfigParseError $e ) { + $parsedResponse = [ 'error' => $e->getMessage() ]; } + return array_merge( $response, $parsedResponse ); + } + /** + * Parse a response body, throwing EtcdConfigParseError if there is a validation error + * + * @param string $rbody + * @return array + */ + protected function parseResponse( $rbody ) { + $info = json_decode( $rbody, true ); + if ( $info === null ) { + throw new EtcdConfigParseError( "Error unserializing JSON response." ); + } + if ( !isset( $info['node'] ) || !is_array( $info['node'] ) ) { + throw new EtcdConfigParseError( + "Unexpected JSON response: Missing or invalid node at top level." ); + } $config = []; - foreach ( $info['node']['nodes'] as $node ) { - if ( !empty( $node['dir'] ) ) { - continue; // skip directories - } - - $name = basename( $node['key'] ); - $value = $this->unserialize( $node['value'] ); - if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) { - return [ null, "Failed to parse value for '$name'.", false ]; - } + $lastModifiedIndex = $this->parseDirectory( '', $info['node'], $config ); + return [ 'modifiedIndex' => $lastModifiedIndex, 'config' => $config ]; + } - $config[$name] = $value['val']; + /** + * Recursively parse a directory node and populate the array passed by + * reference, throwing EtcdConfigParseError if there is a validation error + * + * @param string $dirName The relative directory name + * @param array $dirNode The decoded directory node + * @param array &$config The output array + * @return int lastModifiedIndex The maximum last modified index across all keys in the directory + */ + protected function parseDirectory( $dirName, $dirNode, &$config ) { + $lastModifiedIndex = 0; + if ( !isset( $dirNode['nodes'] ) ) { + throw new EtcdConfigParseError( + "Unexpected JSON response in dir '$dirName'; missing 'nodes' list." ); + } + if ( !is_array( $dirNode['nodes'] ) ) { + throw new EtcdConfigParseError( + "Unexpected JSON response in dir '$dirName'; 'nodes' is not an array." ); } - return [ $config, null, false ]; + foreach ( $dirNode['nodes'] as $node ) { + $baseName = basename( $node['key'] ); + $fullName = $dirName === '' ? $baseName : "$dirName/$baseName"; + if ( !empty( $node['dir'] ) ) { + $lastModifiedIndex = max( + $this->parseDirectory( $fullName, $node, $config ), + $lastModifiedIndex ); + } else { + $value = $this->unserialize( $node['value'] ); + if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) { + throw new EtcdConfigParseError( "Failed to parse value for '$fullName'." ); + } + $lastModifiedIndex = max( $node['modifiedIndex'], $lastModifiedIndex ); + $config[$fullName] = $value['val']; + } + } + return $lastModifiedIndex; } /**