]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blob - includes/config/EtcdConfig.php
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / includes / config / EtcdConfig.php
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 use Psr\Log\LoggerAwareInterface;
22 use Psr\Log\LoggerInterface;
23 use Wikimedia\WaitConditionLoop;
24
25 /**
26  * Interface for configuration instances
27  *
28  * @since 1.29
29  */
30 class EtcdConfig implements Config, LoggerAwareInterface {
31         /** @var MultiHttpClient */
32         private $http;
33         /** @var BagOStuff */
34         private $srvCache;
35         /** @var array */
36         private $procCache;
37         /** @var LoggerInterface */
38         private $logger;
39
40         /** @var string */
41         private $host;
42         /** @var string */
43         private $protocol;
44         /** @var string */
45         private $directory;
46         /** @var string */
47         private $encoding;
48         /** @var int */
49         private $baseCacheTTL;
50         /** @var int */
51         private $skewCacheTTL;
52         /** @var int */
53         private $timeout;
54
55         /**
56          * @param array $params Parameter map:
57          *   - host: the host address and port
58          *   - protocol: either http or https
59          *   - directory: the etc "directory" were MediaWiki specific variables are located
60          *   - encoding: one of ("JSON", "YAML"). Defaults to JSON. [optional]
61          *   - cache: BagOStuff instance or ObjectFactory spec thereof for a server cache.
62          *            The cache will also be used as a fallback if etcd is down. [optional]
63          *   - cacheTTL: logical cache TTL in seconds [optional]
64          *   - skewTTL: maximum seconds to randomly lower the assigned TTL on cache save [optional]
65          *   - timeout: seconds to wait for etcd before throwing an error [optional]
66          */
67         public function __construct( array $params ) {
68                 $params += [
69                         'protocol' => 'http',
70                         'encoding' => 'JSON',
71                         'cacheTTL' => 10,
72                         'skewTTL' => 1,
73                         'timeout' => 2
74                 ];
75
76                 $this->host = $params['host'];
77                 $this->protocol = $params['protocol'];
78                 $this->directory = trim( $params['directory'], '/' );
79                 $this->encoding = $params['encoding'];
80                 $this->skewCacheTTL = $params['skewTTL'];
81                 $this->baseCacheTTL = max( $params['cacheTTL'] - $this->skewCacheTTL, 0 );
82                 $this->timeout = $params['timeout'];
83
84                 if ( !isset( $params['cache'] ) ) {
85                         $this->srvCache = new HashBagOStuff();
86                 } elseif ( $params['cache'] instanceof BagOStuff ) {
87                         $this->srvCache = $params['cache'];
88                 } else {
89                         $this->srvCache = ObjectFactory::getObjectFromSpec( $params['cache'] );
90                 }
91
92                 $this->logger = new Psr\Log\NullLogger();
93                 $this->http = new MultiHttpClient( [
94                         'connTimeout' => $this->timeout,
95                         'reqTimeout' => $this->timeout,
96                         'logger' => $this->logger
97                 ] );
98         }
99
100         public function setLogger( LoggerInterface $logger ) {
101                 $this->logger = $logger;
102                 $this->http->setLogger( $logger );
103         }
104
105         public function has( $name ) {
106                 $this->load();
107
108                 return array_key_exists( $name, $this->procCache['config'] );
109         }
110
111         public function get( $name ) {
112                 $this->load();
113
114                 if ( !array_key_exists( $name, $this->procCache['config'] ) ) {
115                         throw new ConfigException( "No entry found for '$name'." );
116                 }
117
118                 return $this->procCache['config'][$name];
119         }
120
121         /**
122          * @throws ConfigException
123          */
124         private function load() {
125                 if ( $this->procCache !== null ) {
126                         return; // already loaded
127                 }
128
129                 $now = microtime( true );
130                 $key = $this->srvCache->makeGlobalKey(
131                         __CLASS__,
132                         $this->host,
133                         $this->directory
134                 );
135
136                 // Get the cached value or block until it is regenerated (by this or another thread)...
137                 $data = null; // latest config info
138                 $error = null; // last error message
139                 $loop = new WaitConditionLoop(
140                         function () use ( $key, $now, &$data, &$error ) {
141                                 // Check if the values are in cache yet...
142                                 $data = $this->srvCache->get( $key );
143                                 if ( is_array( $data ) && $data['expires'] > $now ) {
144                                         $this->logger->debug( "Found up-to-date etcd configuration cache." );
145
146                                         return WaitConditionLoop::CONDITION_REACHED;
147                                 }
148
149                                 // Cache is either empty or stale;
150                                 // refresh the cache from etcd, using a mutex to reduce stampedes...
151                                 if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) {
152                                         try {
153                                                 list( $config, $error, $retry ) = $this->fetchAllFromEtcd();
154                                                 if ( is_array( $config ) ) {
155                                                         // Avoid having all servers expire cache keys at the same time
156                                                         $expiry = microtime( true ) + $this->baseCacheTTL;
157                                                         $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
158
159                                                         $data = [ 'config' => $config, 'expires' => $expiry ];
160                                                         $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE );
161
162                                                         $this->logger->info( "Refreshed stale etcd configuration cache." );
163
164                                                         return WaitConditionLoop::CONDITION_REACHED;
165                                                 } else {
166                                                         $this->logger->error( "Failed to fetch configuration: $error" );
167                                                         if ( !$retry ) {
168                                                                 // Fail fast since the error is likely to keep happening
169                                                                 return WaitConditionLoop::CONDITION_FAILED;
170                                                         }
171                                                 }
172                                         } finally {
173                                                 $this->srvCache->unlock( $key ); // release mutex
174                                         }
175                                 }
176
177                                 if ( is_array( $data ) ) {
178                                         $this->logger->info( "Using stale etcd configuration cache." );
179
180                                         return WaitConditionLoop::CONDITION_REACHED;
181                                 }
182
183                                 return WaitConditionLoop::CONDITION_CONTINUE;
184                         },
185                         $this->timeout
186                 );
187
188                 if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
189                         // No cached value exists and etcd query failed; throw an error
190                         throw new ConfigException( "Failed to load configuration from etcd: $error" );
191                 }
192
193                 $this->procCache = $data;
194         }
195
196         /**
197          * @return array (config array or null, error string, allow retries)
198          */
199         public function fetchAllFromEtcd() {
200                 $dsd = new DnsSrvDiscoverer( $this->host );
201                 $servers = $dsd->getServers();
202                 if ( !$servers ) {
203                         return $this->fetchAllFromEtcdServer( $this->host );
204                 }
205
206                 do {
207                         // Pick a random etcd server from dns
208                         $server = $dsd->pickServer( $servers );
209                         $host = IP::combineHostAndPort( $server['target'], $server['port'] );
210                         // Try to load the config from this particular server
211                         list( $config, $error, $retry ) = $this->fetchAllFromEtcdServer( $host );
212                         if ( is_array( $config ) || !$retry ) {
213                                 break;
214                         }
215
216                         // Avoid the server next time if that failed
217                         $servers = $dsd->removeServer( $server, $servers );
218                 } while ( $servers );
219
220                 return [ $config, $error, $retry ];
221         }
222
223         /**
224          * @param string $address Host and port
225          * @return array (config array or null, error string, whether to allow retries)
226          */
227         protected function fetchAllFromEtcdServer( $address ) {
228                 // Retrieve all the values under the MediaWiki config directory
229                 list( $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ) = $this->http->run( [
230                         'method' => 'GET',
231                         'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/?recursive=true",
232                         'headers' => [ 'content-type' => 'application/json' ]
233                 ] );
234
235                 static $terminalCodes = [ 404 => true ];
236                 if ( $rcode < 200 || $rcode > 399 ) {
237                         return [
238                                 null,
239                                 strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)",
240                                 empty( $terminalCodes[$rcode] )
241                         ];
242                 }
243                 try {
244                         return [ $this->parseResponse( $rbody ), null, false ];
245                 } catch ( EtcdConfigParseError $e ) {
246                         return [ null, $e->getMessage(), false ];
247                 }
248         }
249
250         /**
251          * Parse a response body, throwing EtcdConfigParseError if there is a validation error
252          *
253          * @param string $rbody
254          * @return array
255          */
256         protected function parseResponse( $rbody ) {
257                 $info = json_decode( $rbody, true );
258                 if ( $info === null ) {
259                         throw new EtcdConfigParseError( "Error unserializing JSON response." );
260                 }
261                 if ( !isset( $info['node'] ) || !is_array( $info['node'] ) ) {
262                         throw new EtcdConfigParseError(
263                                 "Unexpected JSON response: Missing or invalid node at top level." );
264                 }
265                 $config = [];
266                 $this->parseDirectory( '', $info['node'], $config );
267                 return $config;
268         }
269
270         /**
271          * Recursively parse a directory node and populate the array passed by
272          * reference, throwing EtcdConfigParseError if there is a validation error
273          *
274          * @param string $dirName The relative directory name
275          * @param array $dirNode The decoded directory node
276          * @param array &$config The output array
277          */
278         protected function parseDirectory( $dirName, $dirNode, &$config ) {
279                 if ( !isset( $dirNode['nodes'] ) ) {
280                         throw new EtcdConfigParseError(
281                                 "Unexpected JSON response in dir '$dirName'; missing 'nodes' list." );
282                 }
283                 if ( !is_array( $dirNode['nodes'] ) ) {
284                         throw new EtcdConfigParseError(
285                                 "Unexpected JSON response in dir '$dirName'; 'nodes' is not an array." );
286                 }
287
288                 foreach ( $dirNode['nodes'] as $node ) {
289                         $baseName = basename( $node['key'] );
290                         $fullName = $dirName === '' ? $baseName : "$dirName/$baseName";
291                         if ( !empty( $node['dir'] ) ) {
292                                 $this->parseDirectory( $fullName, $node, $config );
293                         } else {
294                                 $value = $this->unserialize( $node['value'] );
295                                 if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) {
296                                         throw new EtcdConfigParseError( "Failed to parse value for '$fullName'." );
297                                 }
298
299                                 $config[$fullName] = $value['val'];
300                         }
301                 }
302         }
303
304         /**
305          * @param string $string
306          * @return mixed
307          */
308         private function unserialize( $string ) {
309                 if ( $this->encoding === 'YAML' ) {
310                         return yaml_parse( $string );
311                 } else { // JSON
312                         return json_decode( $string, true );
313                 }
314         }
315 }