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.
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.
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
21 * @author Aaron Schulz
24 use Psr\Log\LoggerAwareInterface
;
25 use Psr\Log\LoggerInterface
;
26 use Wikimedia\WaitConditionLoop
;
29 * Interface for configuration instances
33 class EtcdConfig
implements Config
, LoggerAwareInterface
{
34 /** @var MultiHttpClient */
40 /** @var LoggerInterface */
52 private $baseCacheTTL;
54 private $skewCacheTTL;
58 private $directoryHash;
61 * @param array $params Parameter map:
62 * - host: the host address and port
63 * - protocol: either http or https
64 * - directory: the etc "directory" were MediaWiki specific variables are located
65 * - encoding: one of ("JSON", "YAML"). Defaults to JSON. [optional]
66 * - cache: BagOStuff instance or ObjectFactory spec thereof for a server cache.
67 * The cache will also be used as a fallback if etcd is down. [optional]
68 * - cacheTTL: logical cache TTL in seconds [optional]
69 * - skewTTL: maximum seconds to randomly lower the assigned TTL on cache save [optional]
70 * - timeout: seconds to wait for etcd before throwing an error [optional]
72 public function __construct( array $params ) {
81 $this->host
= $params['host'];
82 $this->protocol
= $params['protocol'];
83 $this->directory
= trim( $params['directory'], '/' );
84 $this->directoryHash
= sha1( $this->directory
);
85 $this->encoding
= $params['encoding'];
86 $this->skewCacheTTL
= $params['skewTTL'];
87 $this->baseCacheTTL
= max( $params['cacheTTL'] - $this->skewCacheTTL
, 0 );
88 $this->timeout
= $params['timeout'];
90 if ( !isset( $params['cache'] ) ) {
91 $this->srvCache
= new HashBagOStuff( [] );
92 } elseif ( $params['cache'] instanceof BagOStuff
) {
93 $this->srvCache
= $params['cache'];
95 $this->srvCache
= ObjectFactory
::getObjectFromSpec( $params['cache'] );
98 $this->logger
= new Psr\Log\
NullLogger();
99 $this->http
= new MultiHttpClient( [
100 'connTimeout' => $this->timeout
,
101 'reqTimeout' => $this->timeout
105 public function setLogger( LoggerInterface
$logger ) {
106 $this->logger
= $logger;
109 public function has( $name ) {
112 return array_key_exists( $name, $this->procCache
['config'] );
115 public function get( $name ) {
118 if ( !array_key_exists( $name, $this->procCache
['config'] ) ) {
119 throw new ConfigException( "No entry found for '$name'." );
122 return $this->procCache
['config'][$name];
125 private function load() {
126 if ( $this->procCache
!== null ) {
127 return; // already loaded
130 $now = microtime( true );
131 $key = $this->srvCache
->makeKey( 'variable', $this->directoryHash
);
133 // Get the cached value or block until it is regenerated (by this or another thread)...
134 $data = null; // latest config info
135 $error = null; // last error message
136 $loop = new WaitConditionLoop(
137 function () use ( $key, $now, &$data, &$error ) {
138 // Check if the values are in cache yet...
139 $data = $this->srvCache
->get( $key );
140 if ( is_array( $data ) && $data['expires'] > $now ) {
141 $this->logger
->debug( "Found up-to-date etcd configuration cache." );
143 return WaitConditionLoop
::CONDITION_REACHED
;
146 // Cache is either empty or stale;
147 // refresh the cache from etcd, using a mutex to reduce stampedes...
148 if ( $this->srvCache
->lock( $key, 0, $this->baseCacheTTL
) ) {
150 list( $config, $error, $retry ) = $this->fetchAllFromEtcd();
151 if ( $config === null ) {
152 $this->logger
->error( "Failed to fetch configuration: $error" );
153 // Fail fast if the error is likely to just keep happening
155 ? WaitConditionLoop
::CONDITION_CONTINUE
156 : WaitConditionLoop
::CONDITION_FAILED
;
159 // Avoid having all servers expire cache keys at the same time
160 $expiry = microtime( true ) +
$this->baseCacheTTL
;
161 $expiry +
= mt_rand( 0, 1e6
) / 1e6
* $this->skewCacheTTL
;
163 $data = [ 'config' => $config, 'expires' => $expiry ];
164 $this->srvCache
->set( $key, $data, BagOStuff
::TTL_INDEFINITE
);
166 $this->logger
->info( "Refreshed stale etcd configuration cache." );
168 return WaitConditionLoop
::CONDITION_REACHED
;
170 $this->srvCache
->unlock( $key ); // release mutex
174 if ( is_array( $data ) ) {
175 $this->logger
->info( "Using stale etcd configuration cache." );
177 return WaitConditionLoop
::CONDITION_REACHED
;
180 return WaitConditionLoop
::CONDITION_CONTINUE
;
185 if ( $loop->invoke() !== WaitConditionLoop
::CONDITION_REACHED
) {
186 // No cached value exists and etcd query failed; throw an error
187 throw new ConfigException( "Failed to load configuration from etcd: $error" );
190 $this->procCache
= $data;
194 * @return array (config array or null, error string, allow retries)
196 public function fetchAllFromEtcd() {
197 $dsd = new DnsSrvDiscoverer( $this->host
);
198 $servers = $dsd->getServers();
200 return $this->fetchAllFromEtcdServer( $this->host
);
204 // Pick a random etcd server from dns
205 $server = $dsd->pickServer( $servers );
206 $host = IP
::combineHostAndPort( $server['target'], $server['port'] );
207 // Try to load the config from this particular server
208 list( $config, $error, $retry ) = $this->fetchAllFromEtcdServer( $host );
209 if ( is_array( $config ) ||
!$retry ) {
213 // Avoid the server next time if that failed
214 $dsd->removeServer( $server, $servers );
215 } while ( $servers );
217 return [ $config, $error, $retry ];
221 * @param string $address Host and port
222 * @return array (config array or null, error string, whether to allow retries)
224 protected function fetchAllFromEtcdServer( $address ) {
225 // Retrieve all the values under the MediaWiki config directory
226 list( $rcode, $rdesc, /* $rhdrs */, $rbody, $rerr ) = $this->http
->run( [
228 'url' => "{$this->protocol}://{$address}/v2/keys/{$this->directory}/",
229 'headers' => [ 'content-type' => 'application/json' ]
232 static $terminalCodes = [ 404 => true ];
233 if ( $rcode < 200 ||
$rcode > 399 ) {
236 strlen( $rerr ) ?
$rerr : "HTTP $rcode ($rdesc)",
237 empty( $terminalCodes[$rcode] )
241 $info = json_decode( $rbody, true );
242 if ( $info === null ||
!isset( $info['node']['nodes'] ) ) {
243 return [ null, $rcode, "Unexpected JSON response; missing 'nodes' list.", false ];
247 foreach ( $info['node']['nodes'] as $node ) {
248 if ( !empty( $node['dir'] ) ) {
249 continue; // skip directories
252 $name = basename( $node['key'] );
253 $value = $this->unserialize( $node['value'] );
254 if ( !is_array( $value ) ||
!isset( $value['val'] ) ) {
255 return [ null, "Failed to parse value for '$name'.", false ];
258 $config[$name] = $value['val'];
261 return [ $config, null, false ];
265 * @param string $string
268 private function unserialize( $string ) {
269 if ( $this->encoding
=== 'YAML' ) {
270 return yaml_parse( $string );
272 return json_decode( $string, true );