Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
91.95% |
80 / 87 |
|
66.67% |
6 / 9 |
CRAP | |
0.00% |
0 / 1 |
EtcdSource | |
91.95% |
80 / 87 |
|
66.67% |
6 / 9 |
24.30 | |
0.00% |
0 / 1 |
__construct | |
94.74% |
36 / 38 |
|
0.00% |
0 / 1 |
4.00 | |||
allowsStaleLoad | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
load | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
getExpiryTtl | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getExpiryWeight | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getHashKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__toString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
loadFromEtcdServer | |
84.62% |
22 / 26 |
|
0.00% |
0 / 1 |
7.18 | |||
parseDirectory | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Settings\Source; |
4 | |
5 | use DnsSrvDiscoverer; |
6 | use GuzzleHttp\Client; |
7 | use GuzzleHttp\Exception\ClientException; |
8 | use GuzzleHttp\Exception\ConnectException; |
9 | use GuzzleHttp\Exception\ServerException; |
10 | use GuzzleHttp\Psr7\Uri; |
11 | use MediaWiki\Settings\Cache\CacheableSource; |
12 | use MediaWiki\Settings\SettingsBuilderException; |
13 | use MediaWiki\Settings\Source\Format\JsonFormat; |
14 | use UnexpectedValueException; |
15 | |
16 | /** |
17 | * Settings loaded from an etcd server. |
18 | * |
19 | * @since 1.38 |
20 | */ |
21 | class 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 | } |