$resolver. Defaults to 'etcd-client-ssl' or * 'etcd-client' when protocol is 'https' or 'http' respectively. * @param ?callable $mapper Function that maps etcd entries to valid * MediaWiki config/schema/php-ini values. Defaults to simply returning * the structure stored in etcd. * Signature: function ( array $settings ): array * @param ?Client $client Guzzle HTTP client used to query etcd. * @param ?callable $resolver Function that must return an array of server * hostname/port pairs to try. The default resolver will either: * - use an explicitly given hostname/port if both are provided * - otherwise attempt DNS SRV discovery at _etcd._tcp.$host * - fallback to using the host as the etcd server directly * Signature: function (): array * * @throws SettingsBuilderException if the given host is invalid. */ public function __construct( array $params = [], ?callable $mapper = null, ?Client $client = null, ?callable $resolver = null ) { $params += [ 'host' => '', 'port' => 2379, 'protocol' => 'https', 'directory' => 'mediawiki', 'discover' => true, 'service' => null, ]; $service = $params['service'] ?? $params['protocol'] == 'https' ? 'etcd-client-ssl' : 'etcd-client'; $this->mapper = $mapper ?? static function ( $settings ) { return $settings; }; $this->client = $client ?? new Client( [ 'timeout' => self::TIMEOUT, 'connect_timeout' => self::TIMEOUT, ] ); $this->uri = ( new Uri() ) ->withHost( $params['host'] ) ->withPort( $params['port'] ) ->withPath( '/v2/keys/' . trim( $params['directory'], '/' ) . '/' ) ->withScheme( $params['protocol'] ) ->withQuery( 'recursive=true' ); if ( $resolver !== null ) { $this->resolver = $resolver; } elseif ( $params['discover'] ) { $discoverer = new DnsSrvDiscoverer( $service, 'tcp', $params['host'] ); $this->uri = $this->uri->withHost( $discoverer->getSrvName() )->withPort( null ); $this->resolver = static function () use ( $discoverer ) { return $discoverer->getServers(); }; } else { $this->resolver = static function () use ( $params ) { return [ [ $params['host'], $params['port'] ] ]; }; } $this->format = new JsonFormat(); } /** * Allow stale results from etcd sources in case all servers become * temporarily unavailable. * * @return bool */ public function allowsStaleLoad(): bool { return true; } /** * Loads and returns settings from the etcd server. * * @throws SettingsBuilderException * @return array */ public function load(): array { $lastException = false; foreach ( ( $this->resolver )() as [ $host, $port ] ) { try { return $this->loadFromEtcdServer( $host, $port ); } catch ( ConnectException | ServerException $e ) { $lastException = $e; } } throw new SettingsBuilderException( 'failed to load settings from etcd source: {source}: {message}', [ 'source' => $this, 'message' => $lastException ? $lastException->getMessage() : '', ] ); } /** * The cache expiry TTL (in seconds) for this source. * * @return int */ public function getExpiryTtl(): int { return self::EXPIRY_TTL; } /** * Coefficient used in determining early expiration of cached settings to * avoid stampedes. * * @return float */ public function getExpiryWeight(): float { return self::EXPIRY_WEIGHT; } /** * Returns a naive hash key for use in caching based on an etcd request * URL constructed using the etcd request URL. In the case where SRV * discovery is performed, the host in the URL will be the SRV record * name. * * @return string */ public function getHashKey(): string { return (string)$this->uri; } /** * Returns this etcd source as a string. * * @return string */ public function __toString(): string { return (string)$this->uri; } /** * @param string $host * @param int $port * * @throws SettingsBuilderException * @return array */ private function loadFromEtcdServer( string $host, int $port ): array { $uri = $this->uri->withHost( $host )->withPort( $port ); try { $response = $this->client->get( $uri, [ 'http_errors' => true ] ); } catch ( ClientException $e ) { throw new SettingsBuilderException( 'bad request made to etcd server: {message}: uri {uri}', [ 'message' => $e->getMessage(), 'uri' => $uri ] ); } $settings = []; try { $resp = $this->format->decode( $response->getBody()->getContents() ); if ( !isset( $resp['node'] ) || !is_array( $resp['node'] ) || !isset( $resp['node']['dir'] ) || !$resp['node']['dir'] ) { throw new SettingsBuilderException( 'etcd request to {uri} did not return a valid directory node', [ 'uri' => $uri ] ); } $this->parseDirectory( $resp['node'], strlen( $resp['node']['key'] ) + 1, $settings ); } catch ( UnexpectedValueException $e ) { throw new SettingsBuilderException( 'failed to parse etcd response body: {message}', [ 'message' => $e->getMessage() ] ); } return ( $this->mapper )( $settings ); } /** * @param array $dir Directory node. * @param int $prefix Length of the directory prefix to remove. * @param array &$settings Flattened settings array to which to write. */ private function parseDirectory( array $dir, int $prefix, array &$settings ) { foreach ( $dir['nodes'] as $node ) { if ( isset( $node['dir'] ) && $node['dir'] ) { $this->parseDirectory( $node, $prefix, $settings ); } else { $key = substr( $node['key'], $prefix ); $value = $this->format->decode( $node['value'] ); $settings[$key] = $value['val']; } } } }