Added a EtcdSource to handle loading of settings from etcd. The implementation is based on EtcdConfig but is much simpler due to the reliance on the existing source caching layer. GuzzleHttp\Client is used over MultiHttpClient as the latter depends on MediaWikiServices and therefore should not be used during early initialization. A naive cache key is based on the etcd request URL, effectively representing the etcd API version and settings directory, and uses either the DNS SRV entry as the host name or the host name itself if discovery is disabled. The cache TTL is set to 10 seconds. The combination of this low TTL and the naive key should replicate the current caching pattern of EtcdConfig. Stale results are allowed for failover in case of temporary unavailability. At this time, the expiry weight was not changed from the suggested 1.0. However, verification of this as a suitable early expiration coefficient should be performed in a production like environment. Bug: T296771 Change-Id: I782f4ee567a986fd23df1a84aec629e648a29066
250 lines
5.4 KiB
PHP
250 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Tests\Unit\Settings\Source;
|
|
|
|
use GuzzleHttp\Client;
|
|
use GuzzleHttp\Exception\ClientException;
|
|
use GuzzleHttp\Exception\ConnectException;
|
|
use GuzzleHttp\Exception\GuzzleException;
|
|
use GuzzleHttp\Exception\ServerException;
|
|
use GuzzleHttp\Handler\MockHandler;
|
|
use GuzzleHttp\HandlerStack;
|
|
use GuzzleHttp\Psr7\Request;
|
|
use GuzzleHttp\Psr7\Response;
|
|
use MediaWiki\Settings\SettingsBuilderException;
|
|
use MediaWiki\Settings\Source\EtcdSource;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* @covers \MediaWiki\Settings\Source\EtcdSource
|
|
*/
|
|
class EtcdSourceTest extends TestCase {
|
|
public function testGetExpiryTtl() {
|
|
$source = new EtcdSource();
|
|
$this->assertSame( 10, $source->getExpiryTtl() );
|
|
}
|
|
|
|
public function testGetExpiryWeight() {
|
|
$source = new EtcdSource();
|
|
$this->assertSame( 1.0, $source->getExpiryWeight() );
|
|
}
|
|
|
|
public function testGetHashKey() {
|
|
$source = new EtcdSource( [ 'host' => 'an.example' ] );
|
|
|
|
$this->assertSame(
|
|
'https://_etcd-client-ssl._tcp.an.example/v2/keys/mediawiki/?recursive=true',
|
|
$source->getHashKey()
|
|
);
|
|
}
|
|
|
|
public function testGetHashKeyNoDiscovery() {
|
|
$source = new EtcdSource( [
|
|
'host' => 'an.example',
|
|
'discover' => false,
|
|
] );
|
|
|
|
$this->assertSame(
|
|
'https://an.example:2379/v2/keys/mediawiki/?recursive=true',
|
|
$source->getHashKey()
|
|
);
|
|
}
|
|
|
|
public function testToString() {
|
|
$source = new EtcdSource( [
|
|
'host' => 'an.example',
|
|
'discover' => false,
|
|
] );
|
|
|
|
$this->assertSame(
|
|
'https://an.example:2379/v2/keys/mediawiki/?recursive=true',
|
|
(string)$source
|
|
);
|
|
}
|
|
|
|
public function testLoad() {
|
|
$mapper = $this->mockCallable();
|
|
$client = $this->mockClientWithResponses( [ [ 200, 'valid.json' ] ] );
|
|
$resolver = $this->mockCallable();
|
|
$settings = [
|
|
'Fish/One' => 'fish',
|
|
'Fish/Two' => 'fish',
|
|
'MoreFish/Red' => 'fish',
|
|
'MoreFish/Blue' => 'fish',
|
|
];
|
|
|
|
$source = new EtcdSource( [], $mapper, $client, $resolver );
|
|
|
|
$mapper->expects( $this->once() )
|
|
->method( '__invoke' )
|
|
->with( $settings )
|
|
->willReturn( $settings );
|
|
|
|
$resolver->expects( $this->once() )
|
|
->method( '__invoke' )
|
|
->willReturn( [
|
|
[ 'an.example', 4001 ]
|
|
] );
|
|
|
|
$this->assertSame(
|
|
[
|
|
'Fish/One' => 'fish',
|
|
'Fish/Two' => 'fish',
|
|
'MoreFish/Red' => 'fish',
|
|
'MoreFish/Blue' => 'fish',
|
|
],
|
|
$source->load()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @dataProvider serverFailures
|
|
*/
|
|
public function testLoadAllServersFailed( GuzzleException $exception ) {
|
|
$client = $this->mockClientWithResponses( [
|
|
$exception,
|
|
$exception,
|
|
] );
|
|
$resolver = $this->mockCallable();
|
|
|
|
$source = new EtcdSource( [], null, $client, $resolver );
|
|
|
|
$resolver->expects( $this->once() )
|
|
->method( '__invoke' )
|
|
->willReturn( [
|
|
[ 'bad.example', 123 ],
|
|
[ 'bad.example', 321 ],
|
|
] );
|
|
|
|
$this->expectException( SettingsBuilderException::class );
|
|
|
|
$source->load();
|
|
}
|
|
|
|
/**
|
|
* @dataProvider serverFailures
|
|
*/
|
|
public function testLoadSomeServersFailed( GuzzleException $exception ) {
|
|
$client = $this->mockClientWithResponses( [
|
|
$exception,
|
|
[ 200, 'valid.json' ],
|
|
] );
|
|
$resolver = $this->mockCallable();
|
|
|
|
$source = new EtcdSource( [], null, $client, $resolver );
|
|
|
|
$resolver->expects( $this->once() )
|
|
->method( '__invoke' )
|
|
->willReturn( [
|
|
[ 'bad.example', 123 ],
|
|
[ 'ok.example', 321 ],
|
|
] );
|
|
|
|
$this->assertSame(
|
|
[
|
|
'Fish/One' => 'fish',
|
|
'Fish/Two' => 'fish',
|
|
'MoreFish/Red' => 'fish',
|
|
'MoreFish/Blue' => 'fish',
|
|
],
|
|
$source->load()
|
|
);
|
|
}
|
|
|
|
public function testLoadClientFailed() {
|
|
$client = $this->mockClientWithResponses( [
|
|
new ClientException(
|
|
'bad request',
|
|
new Request( 'GET', '/' ),
|
|
new Response( 400, [] )
|
|
)
|
|
] );
|
|
$resolver = $this->mockCallable();
|
|
|
|
$source = new EtcdSource( [], null, $client, $resolver );
|
|
|
|
$resolver->expects( $this->once() )
|
|
->method( '__invoke' )
|
|
->willReturn( [
|
|
[ 'ok.example', 123 ],
|
|
[ 'ok.example', 321 ],
|
|
] );
|
|
|
|
$this->expectException( SettingsBuilderException::class );
|
|
|
|
$source->load();
|
|
}
|
|
|
|
public function testLoadNotAnEtcdDirectory() {
|
|
$client = $this->mockClientWithResponses( [
|
|
[ 200, 'notadirectory.json' ],
|
|
] );
|
|
$resolver = $this->mockCallable();
|
|
|
|
$source = new EtcdSource( [], null, $client, $resolver );
|
|
|
|
$resolver->expects( $this->once() )
|
|
->method( '__invoke' )
|
|
->willReturn( [
|
|
[ 'an.example', 123 ],
|
|
] );
|
|
|
|
$this->expectException( SettingsBuilderException::class );
|
|
|
|
$source->load();
|
|
}
|
|
|
|
/**
|
|
* All possible server-side exceptions.
|
|
*
|
|
* @return array
|
|
*/
|
|
public function serverFailures(): array {
|
|
return [
|
|
[
|
|
new ConnectException(
|
|
'connection failure',
|
|
new Request( 'GET', '/foo' )
|
|
)
|
|
],
|
|
[
|
|
new ServerException(
|
|
'server side failure',
|
|
new Request( 'GET', '/foo' ),
|
|
new Response( 500, [], 'server error' )
|
|
),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array $responses
|
|
*
|
|
* @return Client
|
|
*/
|
|
private function mockClientWithResponses( array $responses ): Client {
|
|
return new Client( [
|
|
'handler' => HandlerStack::create(
|
|
new MockHandler( array_map( static function ( $response ) {
|
|
return is_array( $response )
|
|
? new Response(
|
|
$response[0],
|
|
[ 'content-type' => 'application/json' ],
|
|
file_get_contents( __DIR__ . '/fixtures/etcd/' . $response[1] )
|
|
)
|
|
: $response;
|
|
}, $responses ) )
|
|
),
|
|
] );
|
|
}
|
|
|
|
/**
|
|
* @return callable
|
|
*/
|
|
private function mockCallable() {
|
|
return $this
|
|
->getMockBuilder( __CLASS__ )
|
|
->addMethods( [ '__invoke' ] )
|
|
->getMock();
|
|
}
|
|
}
|