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