Merge "resourceloader: Add basic tests for getScript() and buildContent()"
[lhc/web/wiklou.git] / includes / config / EtcdConfig.php
1 <?php
2 /**
3 * Copyright 2017
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @author Aaron Schulz
22 */
23
24 use Psr\Log\LoggerAwareInterface;
25 use Psr\Log\LoggerInterface;
26 use Wikimedia\WaitConditionLoop;
27
28 /**
29 * Interface for configuration instances
30 *
31 * @since 1.29
32 */
33 class EtcdConfig implements Config, LoggerAwareInterface {
34 /** @var MultiHttpClient */
35 private $http;
36 /** @var BagOStuff */
37 private $srvCache;
38 /** @var array */
39 private $procCache;
40 /** @var LoggerInterface */
41 private $logger;
42
43 /** @var string */
44 private $host;
45 /** @var string */
46 private $protocol;
47 /** @var string */
48 private $directory;
49 /** @var string */
50 private $encoding;
51 /** @var integer */
52 private $baseCacheTTL;
53 /** @var integer */
54 private $skewCacheTTL;
55 /** @var integer */
56 private $timeout;
57
58 /**
59 * @param array $params Parameter map:
60 * - host: the host address and port
61 * - protocol: either http or https
62 * - directory: the etc "directory" were MediaWiki specific variables are located
63 * - encoding: one of ("JSON", "YAML"). Defaults to JSON. [optional]
64 * - cache: BagOStuff instance or ObjectFactory spec thereof for a server cache.
65 * The cache will also be used as a fallback if etcd is down. [optional]
66 * - cacheTTL: logical cache TTL in seconds [optional]
67 * - skewTTL: maximum seconds to randomly lower the assigned TTL on cache save [optional]
68 * - timeout: seconds to wait for etcd before throwing an error [optional]
69 */
70 public function __construct( array $params ) {
71 $params += [
72 'protocol' => 'http',
73 'encoding' => 'JSON',
74 'cacheTTL' => 10,
75 'skewTTL' => 1,
76 'timeout' => 2
77 ];
78
79 $this->host = $params['host'];
80 $this->protocol = $params['protocol'];
81 $this->directory = trim( $params['directory'], '/' );
82 $this->encoding = $params['encoding'];
83 $this->skewCacheTTL = $params['skewTTL'];
84 $this->baseCacheTTL = max( $params['cacheTTL'] - $this->skewCacheTTL, 0 );
85 $this->timeout = $params['timeout'];
86
87 if ( !isset( $params['cache'] ) ) {
88 $this->srvCache = new HashBagOStuff();
89 } elseif ( $params['cache'] instanceof BagOStuff ) {
90 $this->srvCache = $params['cache'];
91 } else {
92 $this->srvCache = ObjectFactory::getObjectFromSpec( $params['cache'] );
93 }
94
95 $this->logger = new Psr\Log\NullLogger();
96 $this->http = new MultiHttpClient( [
97 'connTimeout' => $this->timeout,
98 'reqTimeout' => $this->timeout,
99 'logger' => $this->logger
100 ] );
101 }
102
103 public function setLogger( LoggerInterface $logger ) {
104 $this->logger = $logger;
105 $this->http->setLogger( $logger );
106 }
107
108 public function has( $name ) {
109 $this->load();
110
111 return array_key_exists( $name, $this->procCache['config'] );
112 }
113
114 public function get( $name ) {
115 $this->load();
116
117 if ( !array_key_exists( $name, $this->procCache['config'] ) ) {
118 throw new ConfigException( "No entry found for '$name'." );
119 }
120
121 return $this->procCache['config'][$name];
122 }
123
124 /**
125 * @throws ConfigException
126 */
127 private function load() {
128 if ( $this->procCache !== null ) {
129 return; // already loaded
130 }
131
132 $now = microtime( true );
133 $key = $this->srvCache->makeGlobalKey(
134 __CLASS__,
135 $this->host,
136 $this->directory
137 );
138
139 // Get the cached value or block until it is regenerated (by this or another thread)...
140 $data = null; // latest config info
141 $error = null; // last error message
142 $loop = new WaitConditionLoop(
143 function () use ( $key, $now, &$data, &$error ) {
144 // Check if the values are in cache yet...
145 $data = $this->srvCache->get( $key );
146 if ( is_array( $data ) && $data['expires'] > $now ) {
147 $this->logger->debug( "Found up-to-date etcd configuration cache." );
148
149 return WaitConditionLoop::CONDITION_REACHED;
150 }
151
152 // Cache is either empty or stale;
153 // refresh the cache from etcd, using a mutex to reduce stampedes...
154 if ( $this->srvCache->lock( $key, 0, $this->baseCacheTTL ) ) {
155 try {
156 list( $config, $error, $retry ) = $this->fetchAllFromEtcd();
157 if ( is_array( $config ) ) {
158 // Avoid having all servers expire cache keys at the same time
159 $expiry = microtime( true ) + $this->baseCacheTTL;
160 $expiry += mt_rand( 0, 1e6 ) / 1e6 * $this->skewCacheTTL;
161
162 $data = [ 'config' => $config, 'expires' => $expiry ];
163 $this->srvCache->set( $key, $data, BagOStuff::TTL_INDEFINITE );
164
165 $this->logger->info( "Refreshed stale etcd configuration cache." );
166
167 return WaitConditionLoop::CONDITION_REACHED;
168 } else {
169 $this->logger->error( "Failed to fetch configuration: $error" );
170 if ( !$retry ) {
171 // Fail fast since the error is likely to keep happening
172 return WaitConditionLoop::CONDITION_FAILED;
173 }
174 }
175 } finally {
176 $this->srvCache->unlock( $key ); // release mutex
177 }
178 }
179
180 if ( is_array( $data ) ) {
181 $this->logger->info( "Using stale etcd configuration cache." );
182
183 return WaitConditionLoop::CONDITION_REACHED;
184 }
185
186 return WaitConditionLoop::CONDITION_CONTINUE;
187 },
188 $this->timeout
189 );
190
191 if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
192 // No cached value exists and etcd query failed; throw an error
193 throw new ConfigException( "Failed to load configuration from etcd: $error" );
194 }
195
196 $this->procCache = $data;
197 }
198
199 /**
200 * @return array (config array or null, error string, allow retries)
201 */
202 public function fetchAllFromEtcd() {
203 $dsd = new DnsSrvDiscoverer( $this->host );
204 $servers = $dsd->getServers();
205 if ( !$servers ) {
206 return $this->fetchAllFromEtcdServer( $this->host );
207 }
208
209 do {
210 // Pick a random etcd server from dns
211 $server = $dsd->pickServer( $servers );
212 $host = IP::combineHostAndPort( $server['target'], $server['port'] );
213 // Try to load the config from this particular server
214 list( $config, $error, $retry ) = $this->fetchAllFromEtcdServer( $host );
215 if ( is_array( $config ) || !$retry ) {
216 break;
217 }
218
219 // Avoid the server next time if that failed
220 $servers = $dsd->removeServer( $server, $servers );
221 } while ( $servers );
222
223 return [ $config, $error, $retry ];
224 }
225
226 /**
227 * @param string $address Host and port
228 * @return array (config array or null, error string, whether to allow retries)
229 */
230 protected function fetchAllFromEtcdServer( $address ) {
231 // Retrieve all the values under the MediaWiki config directory
232 list( $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ) = $this->http->run( [
233 'method' => 'GET',
234 'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/",
235 'headers' => [ 'content-type' => 'application/json' ]
236 ] );
237
238 static $terminalCodes = [ 404 => true ];
239 if ( $rcode < 200 || $rcode > 399 ) {
240 return [
241 null,
242 strlen( $rerr ) ? $rerr : "HTTP $rcode ($rdesc)",
243 empty( $terminalCodes[$rcode] )
244 ];
245 }
246
247 $info = json_decode( $rbody, true );
248 if ( $info === null || !isset( $info['node']['nodes'] ) ) {
249 return [ null, $rcode, "Unexpected JSON response; missing 'nodes' list.", false ];
250 }
251
252 $config = [];
253 foreach ( $info['node']['nodes'] as $node ) {
254 if ( !empty( $node['dir'] ) ) {
255 continue; // skip directories
256 }
257
258 $name = basename( $node['key'] );
259 $value = $this->unserialize( $node['value'] );
260 if ( !is_array( $value ) || !array_key_exists( 'val', $value ) ) {
261 return [ null, "Failed to parse value for '$name'.", false ];
262 }
263
264 $config[$name] = $value['val'];
265 }
266
267 return [ $config, null, false ];
268 }
269
270 /**
271 * @param string $string
272 * @return mixed
273 */
274 private function unserialize( $string ) {
275 if ( $this->encoding === 'YAML' ) {
276 return yaml_parse( $string );
277 } else { // JSON
278 return json_decode( $string, true );
279 }
280 }
281 }