Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.20% |
139 / 143 |
|
72.73% |
8 / 11 |
CRAP | |
0.00% |
0 / 1 |
EtcdConfig | |
97.89% |
139 / 142 |
|
72.73% |
8 / 11 |
44 | |
0.00% |
0 / 1 |
__construct | |
97.06% |
33 / 34 |
|
0.00% |
0 / 1 |
6 | |||
setLogger | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
has | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
get | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getModifiedIndex | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
load | |
100.00% |
43 / 43 |
|
100.00% |
1 / 1 |
9 | |||
fetchAllFromEtcd | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
5.12 | |||
fetchAllFromEtcdServer | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
6 | |||
parseResponse | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
parseDirectory | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
8 | |||
unserialize | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | |
21 | namespace MediaWiki\Config; |
22 | |
23 | use BagOStuff; |
24 | use DnsSrvDiscoverer; |
25 | use HashBagOStuff; |
26 | use MultiHttpClient; |
27 | use Psr\Log\LoggerAwareInterface; |
28 | use Psr\Log\LoggerInterface; |
29 | use Wikimedia\IPUtils; |
30 | use Wikimedia\ObjectFactory\ObjectFactory; |
31 | use Wikimedia\WaitConditionLoop; |
32 | |
33 | /** |
34 | * Interface for configuration instances |
35 | * |
36 | * @since 1.29 |
37 | */ |
38 | class EtcdConfig implements Config, LoggerAwareInterface { |
39 | /** @var MultiHttpClient */ |
40 | private $http; |
41 | /** @var BagOStuff */ |
42 | private $srvCache; |
43 | /** @var array */ |
44 | private $procCache; |
45 | /** @var DnsSrvDiscoverer */ |
46 | private $dsd; |
47 | |
48 | /** @var string */ |
49 | private $service; |
50 | /** @var string */ |
51 | private $host; |
52 | /** @var ?int */ |
53 | private $port; |
54 | /** @var string */ |
55 | private $protocol; |
56 | /** @var string */ |
57 | private $directory; |
58 | /** @var int */ |
59 | private $baseCacheTTL; |
60 | /** @var int */ |
61 | private $skewCacheTTL; |
62 | /** @var int */ |
63 | private $timeout; |
64 | |
65 | /** |
66 | * @param array $params Parameter map: |
67 | * - host: the host address |
68 | * - directory: the etc "directory" were MediaWiki specific variables are located |
69 | * - service: service name used in SRV discovery. Defaults to 'etcd'. [optional] |
70 | * - port: custom host port [optional] |
71 | * - protocol: one of ("http", "https"). Defaults to http. [optional] |
72 | * - cache: BagOStuff instance or ObjectFactory spec thereof for a server cache. |
73 | * The cache will also be used as a fallback if etcd is down. [optional] |
74 | * - cacheTTL: logical cache TTL in seconds [optional] |
75 | * - skewTTL: maximum seconds to randomly lower the assigned TTL on cache save [optional] |
76 | * - timeout: seconds to wait for etcd before throwing an error [optional] |
77 | */ |
78 | public function __construct( array $params ) { |
79 | $params += [ |
80 | 'service' => 'etcd', |
81 | 'port' => null, |
82 | 'protocol' => 'http', |
83 | 'cacheTTL' => 10, |
84 | 'skewTTL' => 1, |
85 | 'timeout' => 2 |
86 | ]; |
87 | |
88 | $this->service = $params['service']; |
89 | $this->host = $params['host']; |
90 | $this->port = $params['port']; |
91 | $this->protocol = $params['protocol']; |
92 | $this->directory = trim( $params['directory'], '/' ); |
93 | $this->skewCacheTTL = $params['skewTTL']; |
94 | $this->baseCacheTTL = max( $params['cacheTTL'] - $this->skewCacheTTL, 0 ); |
95 | $this->timeout = $params['timeout']; |
96 | |
97 | // For backwards compatibility, check the host for an embedded port |
98 | $hostAndPort = IPUtils::splitHostAndPort( $this->host ); |
99 | |
100 | if ( $hostAndPort ) { |
101 | $this->host = $hostAndPort[0]; |
102 | |
103 | if ( $hostAndPort[1] ) { |
104 | $this->port = $hostAndPort[1]; |
105 | } |
106 | } |
107 | |
108 | // Also for backwards compatibility, check for a host in the format of |
109 | // an SRV record and use the service specified therein |
110 | if ( preg_match( '/^_([^\.]+)\._tcp\.(.+)$/', $this->host, $m ) ) { |
111 | $this->service = $m[1]; |
112 | $this->host = $m[2]; |
113 | } |
114 | |
115 | if ( !isset( $params['cache'] ) ) { |
116 | $this->srvCache = new HashBagOStuff(); |
117 | } elseif ( $params['cache'] instanceof BagOStuff ) { |
118 | $this->srvCache = $params['cache']; |
119 | } else { |
120 | $this->srvCache = ObjectFactory::getObjectFromSpec( $params['cache'] ); |
121 | } |
122 | |
123 | $this->http = new MultiHttpClient( [ |
124 | 'connTimeout' => $this->timeout, |
125 | 'reqTimeout' => $this->timeout, |
126 | ] ); |
127 | $this->dsd = new DnsSrvDiscoverer( $this->service, 'tcp', $this->host ); |
128 | } |
129 | |
130 | /** |
131 | * @deprecated since 1.41 No longer used and did not work in practice |
132 | */ |
133 | public function setLogger( LoggerInterface $logger ) { |
134 | trigger_error( __METHOD__ . ' is deprecated since 1.41', E_USER_DEPRECATED ); |
135 | } |
136 | |
137 | public function has( $name ) { |
138 | $this->load(); |
139 | |
140 | return array_key_exists( $name, $this->procCache['config'] ); |
141 | } |
142 | |
143 | public function get( $name ) { |
144 | $this->load(); |
145 | |
146 | if ( !array_key_exists( $name, $this->procCache['config'] ) ) { |
147 | throw new ConfigException( "No entry found for '$name'." ); |
148 | } |
149 | |
150 | return $this->procCache['config'][$name]; |
151 | } |
152 | |
153 | public function getModifiedIndex() { |
154 | $this->load(); |
155 | return $this->procCache['modifiedIndex']; |
156 | } |
157 | |
158 | /** |
159 | * @throws ConfigException |
160 | */ |
161 | private function load() { |
162 | if ( $this->procCache !== null ) { |
163 | return; // already loaded |
164 | } |
165 | |
166 | $now = microtime( true ); |
167 | $key = $this->srvCache->makeGlobalKey( |
168 | __CLASS__, |
169 | $this->host, |
170 | $this->directory |
171 | ); |
172 | |
173 | // Get the cached value or block until it is regenerated (by this or another thread)... |
174 | $data = null; // latest config info |
175 | $error = null; // last error message |
176 | $loop = new WaitConditionLoop( |
177 | function () use ( $key, $now, &$data, &$error ) { |
178 | // Check if the values are in cache yet... |
179 | $data = $this->srvCache->get( $key ); |
180 | if ( is_array( $data ) && $data['expires'] > $now ) { |
181 | return WaitConditionLoop::CONDITION_REACHED; |
182 | } |
183 | |
184 | // Cache is either empty or stale; |
185 | // refresh the cache from etcd, using a mutex to reduce stampedes... |
186 | if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) { |
187 | try { |
188 | $etcdResponse = $this->fetchAllFromEtcd(); |
189 | $error = $etcdResponse['error']; |
190 | if ( is_array( $etcdResponse['config'] ) ) { |
191 | // Avoid having all servers expire cache keys at the same time |
192 | $expiry = microtime( true ) + $this->baseCacheTTL; |
193 | // @phan-suppress-next-line PhanTypeMismatchArgumentInternal |
194 | $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL; |
195 | $data = [ |
196 | 'config' => $etcdResponse['config'], |
197 | 'expires' => $expiry, |
198 | 'modifiedIndex' => $etcdResponse['modifiedIndex'] |
199 | ]; |
200 | $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE ); |
201 | |
202 | return WaitConditionLoop::CONDITION_REACHED; |
203 | } else { |
204 | trigger_error( "EtcdConfig failed to fetch data: $error", E_USER_WARNING ); |
205 | if ( !$etcdResponse['retry'] ) { |
206 | // Fail fast since the error is likely to keep happening |
207 | return WaitConditionLoop::CONDITION_FAILED; |
208 | } |
209 | } |
210 | } finally { |
211 | $this->srvCache->unlock( $key ); // release mutex |
212 | } |
213 | } else { |
214 | $error = 'lost lock'; |
215 | } |
216 | |
217 | if ( is_array( $data ) ) { |
218 | trigger_error( "EtcdConfig using stale data: $error", E_USER_NOTICE ); |
219 | |
220 | return WaitConditionLoop::CONDITION_REACHED; |
221 | } |
222 | |
223 | return WaitConditionLoop::CONDITION_CONTINUE; |
224 | }, |
225 | $this->timeout |
226 | ); |
227 | |
228 | if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) { |
229 | // No cached value exists and etcd query failed; throw an error |
230 | // @phan-suppress-next-line PhanTypeSuspiciousStringExpression WaitConditionLoop throws or error set |
231 | throw new ConfigException( "Failed to load configuration from etcd: $error" ); |
232 | } |
233 | |
234 | // @phan-suppress-next-line PhanTypeMismatchProperty WaitConditionLoop throws ore data set |
235 | $this->procCache = $data; |
236 | } |
237 | |
238 | /** |
239 | * @return array (containing the keys config, error, retry, modifiedIndex) |
240 | */ |
241 | public function fetchAllFromEtcd() { |
242 | $servers = $this->dsd->getServers() ?: [ [ $this->host, $this->port ] ]; |
243 | |
244 | foreach ( $servers as [ $host, $port ] ) { |
245 | // Try to load the config from this particular server |
246 | $response = $this->fetchAllFromEtcdServer( $host, $port ); |
247 | if ( is_array( $response['config'] ) || $response['retry'] ) { |
248 | break; |
249 | } |
250 | } |
251 | |
252 | return $response; |
253 | } |
254 | |
255 | /** |
256 | * @param string $address Host |
257 | * @param ?int $port Port |
258 | * @return array (containing the keys config, error, retry, modifiedIndex) |
259 | */ |
260 | protected function fetchAllFromEtcdServer( string $address, ?int $port = null ) { |
261 | $host = $address; |
262 | |
263 | if ( $port !== null ) { |
264 | $host = IPUtils::combineHostAndPort( $address, $port ); |
265 | } |
266 | |
267 | // Retrieve all the values under the MediaWiki config directory |
268 | [ $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ] = $this->http->run( [ |
269 | 'method' => 'GET', |
270 | 'url' => "{$this->protocol}://{$host}/v2/keys/{$this->directory}/?recursive=true", |
271 | 'headers' => [ |
272 | 'content-type' => 'application/json', |
273 | ] |
274 | ] ); |
275 | |
276 | $response = [ 'config' => null, 'error' => null, 'retry' => false, 'modifiedIndex' => 0 ]; |
277 | |
278 | static $terminalCodes = [ 404 => true ]; |
279 | if ( $rcode < 200 || $rcode > 399 ) { |
280 | $response['error'] = strlen( $rerr ?? '' ) ? $rerr : "HTTP $rcode ($rdesc)"; |
281 | $response['retry'] = empty( $terminalCodes[$rcode] ); |
282 | return $response; |
283 | } |
284 | |
285 | try { |
286 | $parsedResponse = $this->parseResponse( $rbody ); |
287 | } catch ( EtcdConfigParseError $e ) { |
288 | $parsedResponse = [ 'error' => $e->getMessage() ]; |
289 | } |
290 | return array_merge( $response, $parsedResponse ); |
291 | } |
292 | |
293 | /** |
294 | * Parse a response body, throwing EtcdConfigParseError if there is a validation error |
295 | * |
296 | * @param string $rbody |
297 | * @return array |
298 | */ |
299 | protected function parseResponse( $rbody ) { |
300 | $info = json_decode( $rbody, true ); |
301 | if ( $info === null ) { |
302 | throw new EtcdConfigParseError( "Error unserializing JSON response." ); |
303 | } |
304 | if ( !isset( $info['node'] ) || !is_array( $info['node'] ) ) { |
305 | throw new EtcdConfigParseError( |
306 | "Unexpected JSON response: Missing or invalid node at top level." ); |
307 | } |
308 | $config = []; |
309 | $lastModifiedIndex = $this->parseDirectory( '', $info['node'], $config ); |
310 | return [ 'modifiedIndex' => $lastModifiedIndex, 'config' => $config ]; |
311 | } |
312 | |
313 | /** |
314 | * Recursively parse a directory node and populate the array passed by |
315 | * reference, throwing EtcdConfigParseError if there is a validation error |
316 | * |
317 | * @param string $dirName The relative directory name |
318 | * @param array $dirNode The decoded directory node |
319 | * @param array &$config The output array |
320 | * @return int lastModifiedIndex The maximum last modified index across all keys in the directory |
321 | */ |
322 | protected function parseDirectory( $dirName, $dirNode, &$config ) { |
323 | $lastModifiedIndex = 0; |
324 | if ( !isset( $dirNode['nodes'] ) ) { |
325 | throw new EtcdConfigParseError( |
326 | "Unexpected JSON response in dir '$dirName'; missing 'nodes' list." ); |
327 | } |
328 | if ( !is_array( $dirNode['nodes'] ) ) { |
329 | throw new EtcdConfigParseError( |
330 | "Unexpected JSON response in dir '$dirName'; 'nodes' is not an array." ); |
331 | } |
332 | |
333 | foreach ( $dirNode['nodes'] as $node ) { |
334 | '@phan-var array $node'; |
335 | $baseName = basename( $node['key'] ); |
336 | $fullName = $dirName === '' ? $baseName : "$dirName/$baseName"; |
337 | if ( !empty( $node['dir'] ) ) { |
338 | $lastModifiedIndex = max( |
339 | $this->parseDirectory( $fullName, $node, $config ), |
340 | $lastModifiedIndex ); |
341 | } else { |
342 | $value = $this->unserialize( $node['value'] ); |
343 | if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) { |
344 | throw new EtcdConfigParseError( "Failed to parse value for '$fullName'." ); |
345 | } |
346 | $lastModifiedIndex = max( $node['modifiedIndex'], $lastModifiedIndex ); |
347 | $config[$fullName] = $value['val']; |
348 | } |
349 | } |
350 | return $lastModifiedIndex; |
351 | } |
352 | |
353 | /** |
354 | * @param string $string |
355 | * @return mixed |
356 | */ |
357 | private function unserialize( $string ) { |
358 | return json_decode( $string, true ); |
359 | } |
360 | } |
361 | |
362 | /** @deprecated class alias since 1.41 */ |
363 | class_alias( EtcdConfig::class, 'EtcdConfig' ); |