1package Search::Elasticsearch::TestServer; 2$Search::Elasticsearch::TestServer::VERSION = '6.00'; 3use Moo; 4use Search::Elasticsearch(); 5use POSIX 'setsid'; 6use File::Temp(); 7use IO::Socket(); 8use HTTP::Tiny; 9 10use Search::Elasticsearch::Util qw(parse_params throw); 11use namespace::clean; 12 13has 'es_home' => ( is => 'ro', default => $ENV{ES_HOME} ); 14has 'es_version' => ( is => 'ro', default => $ENV{ES_VERSION} ); 15has 'instances' => ( is => 'ro', default => 1 ); 16has 'http_port' => ( is => 'ro', default => 9600 ); 17has 'es_port' => ( is => 'ro', default => 9700 ); 18has 'pids' => ( 19 is => 'ro', 20 default => sub { [] }, 21 clearer => 1, 22 predicate => 1 23); 24 25has 'dirs' => ( is => 'ro', default => sub { [] } ); 26has 'conf' => ( is => 'ro', default => sub { [] } ); 27has '_starter_pid' => ( is => 'rw', required => 0, predicate => 1 ); 28 29#=================================== 30sub start { 31#=================================== 32 my $self = shift; 33 34 my $home = $self->es_home 35 or throw( 'Param', "Missing required param <es_home>" ); 36 $self->es_version 37 or throw( 'Param', "Missing required param <es_version>" ); 38 39 my $instances = $self->instances; 40 my $port = $self->http_port; 41 my $es_port = $self->es_port; 42 my @http = map { $port++ } ( 1 .. $instances ); 43 my @transport = map { $es_port++ } ( 1 .. $instances ); 44 45 $self->_check_ports( @http, @transport ); 46 47 my $old_SIGINT = $SIG{INT}; 48 $SIG{INT} = sub { 49 $self->shutdown; 50 if ( ref $old_SIGINT eq 'CODE' ) { 51 return $old_SIGINT->(); 52 } 53 exit(1); 54 }; 55 56 for ( 0 .. $instances - 1 ) { 57 my $dir = File::Temp->newdir(); 58 push @{ $self->dirs }, $dir; 59 print "Starting node: http://127.0.0.1:$http[$_]\n"; 60 $self->_start_node( $dir, $transport[$_], $http[$_] ); 61 } 62 63 $self->_check_nodes(@http); 64 return [ map {"http://127.0.0.1:$_"} @http ]; 65} 66 67#=================================== 68sub _check_ports { 69#=================================== 70 my $self = shift; 71 for my $port (@_) { 72 next unless IO::Socket::INET->new("127.0.0.1:$port"); 73 throw( 'Param', 74 "There is already a service running on 127.0.0.1:$port. " 75 . "Please shut it down before starting the test server" ); 76 } 77} 78 79#=================================== 80sub _check_nodes { 81#=================================== 82 my $self = shift; 83 my $http = HTTP::Tiny->new; 84 for my $node (@_) { 85 print "Checking node: http://127.0.0.1:$node\n"; 86 my $i = 20; 87 while (1) { 88 last 89 if $http->head("http://127.0.0.1:$node/")->{status} == 200; 90 throw( 'Cxn', "Couldn't connect to http://127.0.0.1:$node" ) 91 unless $i--; 92 sleep 1; 93 } 94 95 } 96} 97 98#=================================== 99sub _start_node { 100#=================================== 101 my ( $self, $dir, $transport, $http ) = @_; 102 103 my $pid_file = File::Temp->new; 104 my @config = $self->_command_line( $pid_file, $dir, $transport, $http ); 105 106 my $int_caught = 0; 107 { 108 local $SIG{INT} = sub { $int_caught++; }; 109 defined( my $pid = fork ) 110 or throw( 'Internal', "Couldn't fork a new process: $!" ); 111 if ( $pid == 0 ) { 112 throw( 'Internal', "Can't start a new session: $!" ) 113 if setsid == -1; 114 exec(@config) or die "Couldn't execute @config: $!"; 115 } 116 else { 117 for ( 1 .. 5 ) { 118 last if -s $pid_file->filename(); 119 sleep 1; 120 } 121 open my $pid_fh, '<', $pid_file->filename; 122 my $pid = <$pid_fh>; 123 throw( 'Internal', "No PID file found for Elasticsearch" ) 124 unless $pid; 125 chomp $pid; 126 push @{ $self->{pids} }, $pid; 127 $self->_starter_pid($$); 128 } 129 } 130 $SIG{INT}->('INT') if $int_caught; 131} 132 133#=================================== 134sub guarded_shutdown { 135#=================================== 136 my $self = shift; 137 if ( $self->_has_starter_pid && $$ == $self->_starter_pid ) { 138 $self->shutdown(); 139 } 140} 141 142#=================================== 143sub shutdown { 144#=================================== 145 my $self = shift; 146 local $?; 147 148 return unless $self->has_pids; 149 150 my $pids = $self->pids; 151 $self->clear_pids; 152 return unless @$pids; 153 154 kill 9, @$pids; 155 $self->clear_dirs; 156} 157 158#=================================== 159sub _command_line { 160#=================================== 161 my ( $self, $pid_file, $dir, $transport, $http ) = @_; 162 163 my $version = $self->es_version; 164 my $class = "Search::Elasticsearch::Client::${version}::TestServer"; 165 eval "require $class" || die $@; 166 167 return $class->command_line(@_); 168} 169 170#=================================== 171sub clear_dirs { 172#=================================== 173 my $self = shift; 174 @{ $self->dirs() } = (); 175} 176 177#=================================== 178sub DEMOLISH { shift->guarded_shutdown } 179#=================================== 180 1811; 182 183# ABSTRACT: A helper class to launch Elasticsearch nodes 184 185__END__ 186 187=pod 188 189=encoding UTF-8 190 191=head1 NAME 192 193Search::Elasticsearch::TestServer - A helper class to launch Elasticsearch nodes 194 195=head1 VERSION 196 197version 6.00 198 199=head1 SYNOPSIS 200 201 use Search::Elasticsearch; 202 use Search::Elasticsearch::TestServer; 203 204 my $server = Search::Elasticsearch::TestServer->new( 205 es_home => '/path/to/elasticsearch', # defaults to $ENV{ES_HOME} 206 es_version => '6_0' # defaults to $ENV{ES_VERSION} 207 ); 208 209 my $nodes = $server->start; 210 my $es = Search::Elasticsearch->new( nodes => $nodes ); 211 # run tests 212 $server->shutdown; 213 214=head1 DESCRIPTION 215 216The L<Search::Elasticsearch::TestServer> class can be used to launch one or more 217instances of Elasticsearch for testing purposes. The nodes will 218be shutdown automatically. 219 220=head1 METHODS 221 222=head2 C<new()> 223 224 my $server = Search::Elasticsearch::TestServer->new( 225 es_home => '/path/to/elasticsearch', 226 es_version => '6_0', 227 instances => 1, 228 http_port => 9600, 229 es_port => 9700, 230 conf => ['attr.foo=bar'], 231 ); 232 233Params: 234 235=over 236 237=item * C<es_home> 238 239Required. Must point to the Elasticsearch home directory, which contains 240C<./bin/elasticsearch>. Defaults to C<$ENV{ES_HOME}> 241 242=item * C<es_version> 243 244Required. Accepts a version of the client, eg `6_0`, `5_0`, `2_0`, `1_0`, `0_90`. 245Defaults to C<$ENV{ES_VERSION}>. 246 247=item * C<instances> 248 249The number of nodes to start. Defaults to 1 250 251=item * C<http_port> 252 253The port to use for HTTP. If multiple instances are started, the C<http_port> 254will be incremented for each subsequent instance. Defaults to 9600. 255 256=item * C<es_port> 257 258The port to use for Elasticsearch's internal transport. If multiple instances 259are started, the C<es_port> will be incremented for each subsequent instance. 260Defaults to 9700 261 262=item * C<conf> 263 264An array containing any extra startup options that should be passed 265to Elasticsearch. 266 267=back 268 269=head1 C<start()> 270 271 $nodes = $server->start; 272 273Starts the required instances and returns an array ref containing the IP 274and port of each node, suitable for passing to L<Search::Elasticsearch/new()>: 275 276 $es = Search::Elasticsearch->new( nodes => $nodes ); 277 278=head1 C<shutdown()> 279 280 $server->shutdown; 281 282Kills the running instances. This will be called automatically when 283C<$server> goes out of scope or if the program receives a C<SIGINT>. 284 285=head1 AUTHOR 286 287Clinton Gormley <drtech@cpan.org> 288 289=head1 COPYRIGHT AND LICENSE 290 291This software is Copyright (c) 2017 by Elasticsearch BV. 292 293This is free software, licensed under: 294 295 The Apache License, Version 2.0, January 2004 296 297=cut 298