Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.95% covered (success)
91.95%
80 / 87
66.67% covered (warning)
66.67%
6 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
EtcdSource
91.95% covered (success)
91.95%
80 / 87
66.67% covered (warning)
66.67%
6 / 9
24.30
0.00% covered (danger)
0.00%
0 / 1
 __construct
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
4.00
 allowsStaleLoad
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 getExpiryTtl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExpiryWeight
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHashKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadFromEtcdServer
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
7.18
 parseDirectory
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace MediaWiki\Settings\Source;
4
5use DnsSrvDiscoverer;
6use GuzzleHttp\Client;
7use GuzzleHttp\Exception\ClientException;
8use GuzzleHttp\Exception\ConnectException;
9use GuzzleHttp\Exception\ServerException;
10use GuzzleHttp\Psr7\Uri;
11use MediaWiki\Settings\Cache\CacheableSource;
12use MediaWiki\Settings\SettingsBuilderException;
13use MediaWiki\Settings\Source\Format\JsonFormat;
14use Stringable;
15use UnexpectedValueException;
16
17/**
18 * Settings loaded from an etcd server.
19 *
20 * @since 1.38
21 */
22class EtcdSource implements Stringable, CacheableSource {
23    /**
24     * Default HTTP client connection and request timeout (2 seconds).
25     */
26    private const TIMEOUT = 2;
27
28    /**
29     * Cache expiry TTL for etcd sources (10 seconds).
30     *
31     * @see getExpiryTtl()
32     * @see CacheableSource::getExpiryTtl()
33     */
34    private const EXPIRY_TTL = 10;
35
36    /**
37     * Early expiry weight. This value influences the margin by which
38     * processes are selected to expire cached etcd settings early to avoid
39     * cache stampedes.
40     *
41     * @see getExpiryWeight()
42     * @see CacheableSource::getExpiryWeight()
43     */
44    private const EXPIRY_WEIGHT = 1.0;
45
46    /** @var Client */
47    private $client;
48
49    /** @var Uri */
50    private $uri;
51
52    /** @var callable */
53    private $mapper;
54
55    /** @var callable */
56    private $resolver;
57
58    /** @var JsonFormat */
59    private $format;
60
61    /**
62     * Constructs a new EtcdSource for the given etcd server details.
63     *
64     * @param array $params Parameter map:
65     *   - host: Etcd server host/domain. Note that an empty host value will
66     *     result in SRV discovery relative to the host's configured search
67     *     domain.
68     *   - port: Etcd server port. Defaults to 2379.
69     *   - protocol: Endpoint protocol (http/https). Defaults to 'https'.
70     *   - directory: Top level etcd directory to query for settings.
71     *   - discover: Whether to perform SRV discovery on the given
72     *     host/domain. Defaults to true.
73     *   - service: service name used in SRV discovery of the default
74     *     <code>$resolver</code>. Defaults to 'etcd-client-ssl' or
75     *     'etcd-client' when protocol is 'https' or 'http' respectively.
76     * @param ?callable $mapper Function that maps etcd entries to valid
77     *  MediaWiki config/schema/php-ini values. Defaults to simply returning
78     *  the structure stored in etcd.
79     *  Signature: function ( array $settings ): array
80     * @param ?Client $client Guzzle HTTP client used to query etcd.
81     * @param ?callable $resolver Function that must return an array of server
82     *  hostname/port pairs to try. The default resolver will either:
83     *   - use an explicitly given hostname/port if both are provided
84     *   - otherwise attempt DNS SRV discovery at <code>_etcd._tcp.$host</code>
85     *   - fallback to using the host as the etcd server directly
86     *  Signature: function (): array
87     *
88     * @throws SettingsBuilderException if the given host is invalid.
89     */
90    public function __construct(
91        array $params = [],
92        ?callable $mapper = null,
93        ?Client $client = null,
94        ?callable $resolver = null
95    ) {
96        $params += [
97            'host' => '',
98            'port' => 2379,
99            'protocol' => 'https',
100            'directory' => 'mediawiki',
101            'discover' => true,
102            'service' => null,
103        ];
104
105        $service =
106            $params['service'] ??
107            $params['protocol'] == 'https'
108            ? 'etcd-client-ssl'
109            : 'etcd-client';
110
111        $this->mapper = $mapper ?? static function ( $settings ) {
112            return $settings;
113        };
114
115        $this->client = $client ?? new Client( [
116            'timeout' => self::TIMEOUT,
117            'connect_timeout' => self::TIMEOUT,
118        ] );
119
120        $this->uri = ( new Uri() )
121            ->withHost( $params['host'] )
122            ->withPort( $params['port'] )
123            ->withPath( '/v2/keys/' . trim( $params['directory'], '/' ) . '/' )
124            ->withScheme( $params['protocol'] )
125            ->withQuery( 'recursive=true' );
126
127        if ( $resolver !== null ) {
128            $this->resolver = $resolver;
129        } elseif ( $params['discover'] ) {
130            $discoverer = new DnsSrvDiscoverer( $service, 'tcp', $params['host'] );
131            $this->uri = $this->uri->withHost( $discoverer->getSrvName() )->withPort( null );
132            $this->resolver = static function () use ( $discoverer ) {
133                return $discoverer->getServers();
134            };
135        } else {
136            $this->resolver = static function () use ( $params ) {
137                return [ [ $params['host'], $params['port'] ] ];
138            };
139        }
140
141        $this->format = new JsonFormat();
142    }
143
144    /**
145     * Allow stale results from etcd sources in case all servers become
146     * temporarily unavailable.
147     *
148     * @return bool
149     */
150    public function allowsStaleLoad(): bool {
151        return true;
152    }
153
154    /**
155     * Loads and returns settings from the etcd server.
156     *
157     * @throws SettingsBuilderException
158     * @return array
159     */
160    public function load(): array {
161        $lastException = false;
162
163        foreach ( ( $this->resolver )() as [ $host, $port ] ) {
164            try {
165                return $this->loadFromEtcdServer( $host, $port );
166            } catch ( ConnectException | ServerException $e ) {
167                $lastException = $e;
168            }
169        }
170
171        throw new SettingsBuilderException(
172            'failed to load settings from etcd source: {source}: {message}',
173            [
174                'source' => $this,
175                'message' => $lastException ? $lastException->getMessage() : '',
176            ]
177        );
178    }
179
180    /**
181     * The cache expiry TTL (in seconds) for this source.
182     *
183     * @return int
184     */
185    public function getExpiryTtl(): int {
186        return self::EXPIRY_TTL;
187    }
188
189    /**
190     * Coefficient used in determining early expiration of cached settings to
191     * avoid stampedes.
192     *
193     * @return float
194     */
195    public function getExpiryWeight(): float {
196        return self::EXPIRY_WEIGHT;
197    }
198
199    /**
200     * Returns a naive hash key for use in caching based on an etcd request
201     * URL constructed using the etcd request URL. In the case where SRV
202     * discovery is performed, the host in the URL will be the SRV record
203     * name.
204     *
205     * @return string
206     */
207    public function getHashKey(): string {
208        return (string)$this->uri;
209    }
210
211    /**
212     * Returns this etcd source as a string.
213     *
214     * @return string
215     */
216    public function __toString(): string {
217        return (string)$this->uri;
218    }
219
220    /**
221     * @param string $host
222     * @param int $port
223     *
224     * @throws SettingsBuilderException
225     * @return array
226     */
227    private function loadFromEtcdServer( string $host, int $port ): array {
228        $uri = $this->uri->withHost( $host )->withPort( $port );
229
230        try {
231            $response = $this->client->get( $uri, [ 'http_errors' => true ] );
232        } catch ( ClientException $e ) {
233            throw new SettingsBuilderException(
234                'bad request made to etcd server: {message}: uri {uri}',
235                [ 'message' => $e->getMessage(), 'uri' => $uri ]
236            );
237        }
238
239        $settings = [];
240
241        try {
242            $resp = $this->format->decode( $response->getBody()->getContents() );
243
244            if (
245                !isset( $resp['node'] ) || !is_array( $resp['node'] )
246                || !isset( $resp['node']['dir'] ) || !$resp['node']['dir']
247            ) {
248                throw new SettingsBuilderException(
249                    'etcd request to {uri} did not return a valid directory node',
250                    [ 'uri' => $uri ]
251                );
252            }
253
254            $this->parseDirectory(
255                $resp['node'],
256                strlen( $resp['node']['key'] ) + 1,
257                $settings
258            );
259        } catch ( UnexpectedValueException $e ) {
260            throw new SettingsBuilderException(
261                'failed to parse etcd response body: {message}',
262                [ 'message' => $e->getMessage() ]
263            );
264        }
265
266        return ( $this->mapper )( $settings );
267    }
268
269    /**
270     * @param array $dir Directory node.
271     * @param int $prefix Length of the directory prefix to remove.
272     * @param array &$settings Flattened settings array to which to write.
273     */
274    private function parseDirectory( array $dir, int $prefix, array &$settings ) {
275        foreach ( $dir['nodes'] as $node ) {
276            if ( isset( $node['dir'] ) && $node['dir'] ) {
277                $this->parseDirectory( $node, $prefix, $settings );
278            } else {
279                $key = substr( $node['key'], $prefix );
280                $value = $this->format->decode( $node['value'] );
281                $settings[$key] = $value['val'];
282            }
283        }
284    }
285}