EtcdConfig.php 9.5 KB

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