EtcdConfig.php 10 KB

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