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 Stringable; |
15 | use UnexpectedValueException; |
16 | |
17 | /** |
18 | * Settings loaded from an etcd server. |
19 | * |
20 | * @since 1.38 |
21 | */ |
22 | class 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 | } |