includes/Settings/Source/EtcdSource.php
<?php
namespace MediaWiki\Settings\Source;
use DnsSrvDiscoverer;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Psr7\Uri;
use MediaWiki\Settings\Cache\CacheableSource;
use MediaWiki\Settings\SettingsBuilderException;
use MediaWiki\Settings\Source\Format\JsonFormat;
use Stringable;
use UnexpectedValueException;
/**
* Settings loaded from an etcd server.
*
* @since 1.38
*/
class EtcdSource implements Stringable, CacheableSource {
/**
* Default HTTP client connection and request timeout (2 seconds).
*/
private const TIMEOUT = 2;
/**
* Cache expiry TTL for etcd sources (10 seconds).
*
* @see getExpiryTtl()
* @see CacheableSource::getExpiryTtl()
*/
private const EXPIRY_TTL = 10;
/**
* Early expiry weight. This value influences the margin by which
* processes are selected to expire cached etcd settings early to avoid
* cache stampedes.
*
* @see getExpiryWeight()
* @see CacheableSource::getExpiryWeight()
*/
private const EXPIRY_WEIGHT = 1.0;
/** @var Client */
private $client;
/** @var Uri */
private $uri;
/** @var callable */
private $mapper;
/** @var callable */
private $resolver;
/** @var JsonFormat */
private $format;
/**
* Constructs a new EtcdSource for the given etcd server details.
*
* @param array $params Parameter map:
* - host: Etcd server host/domain. Note that an empty host value will
* result in SRV discovery relative to the host's configured search
* domain.
* - port: Etcd server port. Defaults to 2379.
* - protocol: Endpoint protocol (http/https). Defaults to 'https'.
* - directory: Top level etcd directory to query for settings.
* - discover: Whether to perform SRV discovery on the given
* host/domain. Defaults to true.
* - service: service name used in SRV discovery of the default
* <code>$resolver</code>. 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 <code>_etcd._tcp.$host</code>
* - 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'];
}
}
}
}