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