File Coverage

blib/lib/Test/Chimps/Smoker.pm
Criterion Covered Total %
statement 48 196 24.4
branch 0 32 0.0
condition n/a
subroutine 14 25 56.0
pod 2 2 100.0
total 64 255 25.1


line stmt bran cond sub pod time code
1             package Test::Chimps::Smoker;
2              
3 1     1   30650 use warnings;
  1         3  
  1         38  
4 1     1   5 use strict;
  1         3  
  1         34  
5              
6 1     1   5 use Config;
  1         3  
  1         39  
7 1     1   6 use File::Basename;
  1         2  
  1         96  
8 1     1   5 use File::Path;
  1         3  
  1         66  
9 1     1   1451 use File::Temp qw/tempdir/;
  1         32739  
  1         84  
10 1     1   972 use Params::Validate qw/:all/;
  1         12542  
  1         270  
11 1     1   600 use Test::Chimps::Client;
  1         4  
  1         10  
12 1     1   1177 use Test::TAP::Model::Visual;
  1         42934  
  1         36  
13 1     1   2080 use YAML::Syck;
  1         2728  
  1         183  
14              
15             =head1 NAME
16              
17             Test::Chimps::Smoker - Poll a set of SVN repositories and run tests when they change
18              
19             =head1 SYNOPSIS
20              
21             This module gives you everything you need to make your own build
22             slave. You give it a configuration file describing all of your
23             projects and how to test them, and it will monitor the SVN
24             repositories, check the projects out (and their dependencies), test
25             them, and submit the report to a server.
26              
27             use Test::Chimps::Smoker;
28              
29             my $poller = Test::Chimps::Smoker->new(
30             server => 'http://www.example.com/cgi-bin/chimps-server.pl',
31             config_file => '/path/to/configfile.yml'
32              
33              
34             $poller->poll();
35              
36             =head1 METHODS
37              
38             =head2 new ARGS
39              
40             Creates a new Client object. ARGS is a hash whose valid keys are:
41              
42             =over 4
43              
44             =item * config_file
45              
46             Mandatory. The configuration file describing which repositories to
47             monitor. The format of the configuration is described in
48             L.
49              
50             =item * server
51              
52             Mandatory. The URI of the server script to upload the reports to.
53              
54             =item * simulate
55              
56             Don't actually submit the smoke reports, just run the tests. This
57             I, however, increment the revision numbers in the config
58             file.
59              
60             =back
61              
62             =cut
63              
64 1     1   12 use base qw/Class::Accessor/;
  1         3  
  1         2761  
65             __PACKAGE__->mk_ro_accessors(qw/server config_file simulate/);
66             __PACKAGE__->mk_accessors(
67             qw/_added_to_inc _env_stack _checkout_paths _config projects iterations/);
68              
69             # add a signal handler so destructor gets run
70             $SIG{INT} = sub {print "caught sigint. cleaning up...\n"; exit(1)};
71              
72             sub new {
73 1     1 1 84 my $class = shift;
74 1         5 my $obj = bless {}, $class;
75 1         9 $obj->_init(@_);
76 1         372 return $obj;
77             }
78              
79             sub _init {
80 1     1   3 my $self = shift;
81 1         78 my %args = validate_with(
82             params => \@_,
83             spec => {
84             server => 1,
85             config_file => 1,
86             simulate => 0,
87             iterations => {
88             optional => 1,
89             default => 'inf'
90             },
91             projects => {
92             optional => 1,
93             default => 'all'
94             }
95             },
96             called => 'The Test::Chimps::Smoker constructor'
97             );
98              
99 1         13 foreach my $key (keys %args) {
100 4         23 $self->{$key} = $args{$key};
101             }
102 1         10 $self->_added_to_inc([]);
103 1         26 $self->_env_stack([]);
104 1         14 $self->_checkout_paths([]);
105              
106 1         14 $self->_config(LoadFile($self->config_file));
107             }
108              
109             sub DESTROY {
110 1     1   1989 my $self = shift;
111 1         4 foreach my $tmpdir (@{$self->_checkout_paths}) {
  1         8  
112 0           _remove_tmpdir($tmpdir);
113             }
114             }
115              
116             sub _smoke_once {
117 0     0     my $self = shift;
118 0           my $project = shift;
119 0           my $config = $self->_config;
120              
121 0 0         return 1 if $config->{$project}->{dependency_only};
122              
123 0           my $info_out = `svn info $config->{$project}->{svn_uri}`;
124 0           $info_out =~ m/^Revision: (\d+)/m;
125 0           my $latest_revision = $1;
126 0           $info_out =~ m/^Last Changed Rev: (\d+)/m;
127 0           my $last_changed_revision = $1;
128              
129 0           my $old_revision = $config->{$project}->{revision};
130              
131 0 0         return 0 unless $last_changed_revision > $old_revision;
132              
133 0           my @revisions = (($old_revision + 1) .. $latest_revision);
134 0           my $revision;
135 0           while (@revisions) {
136 0           $revision = shift @revisions;
137             # only actually do the check out if the revision and last changed revision match for
138             # a particular revision
139 0 0         last if _change_on_revision($config->{$project}->{svn_uri}, $revision);
140             }
141              
142 0           $info_out = `svn info -r $revision $config->{$project}->{svn_uri}`;
143 0           $info_out =~ m/^Last Changed Author: (\w+)/m;
144 0           my $committer = $1;
145              
146 0           $config->{$project}->{revision} = $revision;
147              
148 0           $self->_checkout_project($config->{$project}, $revision);
149              
150 0           my $model;
151             {
152 0     0     local $SIG{ALRM} = sub { die "10 minute timeout exceeded" };
  0            
  0            
153 0           alarm 600;
154 0           print "running tests for $project\n";
155 0           eval {
156 0           $model = Test::TAP::Model::Visual->new_with_tests(glob("t/*.t t/*/t/*.t"));
157             };
158 0           alarm 0; # cancel alarm
159             }
160              
161 0 0         if ($@) {
162 0           print "Tests aborted: $@\n";
163             }
164              
165 0           my $duration = $model->structure->{end_time} - $model->structure->{start_time};
166              
167 0           $self->_unroll_env_stack;
168              
169 0           foreach my $libdir (@{$self->_added_to_inc}) {
  0            
170 0           print "removing $libdir from \@INC\n";
171 0           shift @INC;
172             }
173 0           $self->_added_to_inc([]);
174              
175 0           chdir(File::Spec->rootdir);
176              
177 0           foreach my $tmpdir (@{$self->_checkout_paths}) {
  0            
178 0           _remove_tmpdir($tmpdir);
179             }
180 0           $self->_checkout_paths([]);
181              
182 0           my $client = Test::Chimps::Client->new(
183             model => $model,
184             report_variables => {
185             project => $project,
186             revision => $revision,
187             committer => $committer,
188             duration => $duration,
189             osname => $Config{osname},
190             osvers => $Config{osvers},
191             archname => $Config{archname}
192             },
193             server => $self->server
194             );
195              
196 0           my ($status, $msg);
197 0 0         if ($self->simulate) {
198 0           $status = 1;
199             } else {
200 0           ($status, $msg) = $client->send;
201             }
202              
203 0 0         if ($status) {
204 0           print "Sumbitted smoke report for $project revision $revision\n";
205 0           DumpFile($self->config_file, $config);
206 0           return 1;
207             } else {
208 0           print "Error: the server responded: $msg\n";
209 0           return 0;
210             }
211             }
212              
213             sub _smoke_n_times {
214 0     0     my $self = shift;
215 0           my $n = shift;
216 0           my $projects = shift;
217              
218 0 0         if ($n <= 0) {
    0          
219 0           die "Can not smoke projects a negative number of times";
220             } elsif ($n eq 'inf') {
221 0           while (1) {
222 0           $self->_smoke_projects($projects);
223 0           sleep 60;
224             }
225             } else {
226 0           for (my $i = 0; $i < $n;) {
227 0 0         $i++ if $self->_smoke_projects($projects);
228 0           sleep 60;
229             }
230             }
231             }
232              
233             sub _smoke_projects {
234 0     0     my $self = shift;
235 0           my $projects = shift;
236 0           my $config = $self->_config;
237              
238 0           foreach my $project (@$projects) {
239 0           $self->_smoke_once($project);
240             }
241             }
242              
243             =head2 smoke PARAMS
244              
245             Calling smoke will cause the C object to continually poll
246             repositories for changes in revision numbers. If an (actual)
247             change is detected, the repository will be checked out (with
248             dependencies), built, and tested, and the resulting report will be
249             submitted to the server. This method may not return. Valid
250             options to smoke are:
251              
252             =over 4
253              
254             =item * iterations
255              
256             Specifies the number of iterations to run. This is the number of
257             smoke reports to generate per project. A value of 'inf' means to
258             continue smoking forever. Defaults to 'inf'.
259              
260             =item * projects
261              
262             An array reference specifying which projects to smoke. If the
263             string 'all' is provided instead of an array reference, all
264             projects will be smoked. Defaults to 'all'.
265              
266             =back
267              
268             =cut
269              
270             sub smoke {
271 0     0 1   my $self = shift;
272 0           my $config = $self->_config;
273              
274 0           my %args = validate_with(
275             params => \@_,
276             spec => {
277             iterations => {
278             optional => 1,
279             type => SCALAR,
280             regex => qr/^(inf|\d+)$/,
281             default => 'inf'
282             },
283             projects => {
284             optional => 1,
285             type => ARRAYREF | SCALAR,
286             default => 'all'
287             }
288             },
289             called => 'Test::Chimps::Smoker->smoke'
290             );
291              
292 0           my $projects = $args{projects};
293 0           my $iterations = $args{iterations};
294 0           $self->_validate_projects_opt($projects);
295              
296 0 0         if ($projects eq 'all') {
297 0           $projects = [keys %$config];
298             }
299              
300 0           $self->_smoke_n_times($iterations, $projects);
301              
302             }
303              
304             sub _validate_projects_opt {
305 0     0     my ($self, $projects) = @_;
306 0 0         return if $projects eq 'all';
307              
308 0           foreach my $project (@$projects) {
309 0 0         die "no such project: '$project'"
310             unless exists $self->_config->{$project};
311             }
312             }
313              
314             sub _checkout_project {
315 0     0     my $self = shift;
316 0           my $project = shift;
317 0           my $revision = shift;
318              
319 0           my $tmpdir = tempdir("chimps-svn-XXXXXXX", TMPDIR => 1);
320 0           unshift @{$self->_checkout_paths}, $tmpdir;
  0            
321              
322 0           system("svn", "co", "-r", $revision, $project->{svn_uri}, $tmpdir);
323              
324 0           $self->_push_onto_env_stack($project->{env});
325              
326 0           my $projectdir = File::Spec->catdir($tmpdir, $project->{root_dir});
327              
328 0 0         if (defined $project->{dependencies}) {
329 0           foreach my $dep (@{$project->{dependencies}}) {
  0            
330 0           print "processing dependency $dep\n";
331 0           $self->_checkout_project($self->_config->{$dep}, 'HEAD');
332             }
333             }
334              
335 0           chdir($projectdir);
336              
337 0           my $old_perl5lib = $ENV{PERL5LIB};
338 0           $ENV{PERL5LIB} = join($Config{path_sep}, @{$self->_added_to_inc}) .
  0            
339             ':' . $ENV{PERL5LIB};
340 0 0         if (defined $project->{configure_cmd}) {
341 0           system($project->{configure_cmd});
342             }
343 0           $ENV{PERL5LIB} = $old_perl5lib;
344              
345 0           for my $libloc (qw{blib/lib}) {
346 0           my $libdir = File::Spec->catdir($tmpdir,
347             $project->{root_dir},
348             $libloc);
349 0           print "adding $libdir to \@INC\n";
350 0           unshift @{$self->_added_to_inc}, $libdir;
  0            
351 0           unshift @INC, $libdir;
352             }
353              
354              
355 0           return $projectdir;
356             }
357              
358             sub _remove_tmpdir {
359 0     0     my $tmpdir = shift;
360 0           print "removing temporary directory $tmpdir\n";
361 0           rmtree($tmpdir, 0, 0);
362             }
363              
364             sub _change_on_revision {
365 0     0     my $uri = shift;
366 0           my $revision = shift;
367              
368 0           my $info_out = `svn info -r $revision $uri`;
369 0           $info_out =~ m/^Revision: (\d+)/m;
370 0           my $latest_revision = $1;
371 0           $info_out =~ m/^Last Changed Rev: (\d+)/m;
372 0           my $last_changed_revision = $1;
373              
374 0           return $latest_revision == $last_changed_revision;
375             }
376              
377             sub _push_onto_env_stack {
378 0     0     my $self = shift;
379 0           my $vars = shift;
380              
381 0           my $frame = {};
382 0           foreach my $var (keys %$vars) {
383 0 0         if (exists $ENV{$var}) {
384 0           $frame->{$var} = $ENV{$var};
385             } else {
386 0           $frame->{$var} = undef;
387             }
388 0           my $value = $vars->{$var};
389             # old value substitution
390 0           $value =~ s/\$$var/$ENV{$var}/g;
391              
392 0           print "setting environment variable $var to $value\n";
393 0           $ENV{$var} = $value;
394             }
395 0           push @{$self->_env_stack}, $frame;
  0            
396             }
397              
398             sub _unroll_env_stack {
399 0     0     my $self = shift;
400              
401 0           while (scalar @{$self->_env_stack}) {
  0            
402 0           my $frame = pop @{$self->_env_stack};
  0            
403 0           foreach my $var (keys %$frame) {
404 0 0         if (defined $frame->{$var}) {
405 0           print "reverting environment variable $var to $frame->{$var}\n";
406 0           $ENV{$var} = $frame->{$var};
407             } else {
408 0           print "unsetting environment variable $var\n";
409 0           delete $ENV{$var};
410             }
411             }
412             }
413             }
414              
415             =head1 ACCESSORS
416              
417             There are read-only accessors for server, config_file, and simulate.
418              
419             =head1 CONFIGURATION FILE
420              
421             The configuration file is YAML dump of a hash. The keys at the top
422             level of the hash are project names. Their values are hashes that
423             comprise the configuration options for that project.
424              
425             Perhaps an example is best. A typical configuration file might
426             look like this:
427              
428             ---
429             Some-jifty-project:
430             configure_cmd: perl Makefile.PL --skipdeps && make
431             dependencies:
432             - Jifty
433             revision: 555
434             root_dir: trunk/foo
435             svn_uri: svn+ssh://svn.example.com/svn/foo
436             Jifty:
437             configure_cmd: perl Makefile.PL --skipdeps && make
438             dependencies:
439             - Jifty-DBI
440             revision: 1332
441             root_dir: trunk
442             svn_uri: svn+ssh://svn.jifty.org/svn/jifty.org/jifty
443             Jifty-DBI:
444             configure_cmd: perl Makefile.PL --skipdeps && make
445             env:
446             JDBI_TEST_MYSQL: jiftydbitestdb
447             JDBI_TEST_MYSQL_PASS: ''
448             JDBI_TEST_MYSQL_USER: jiftydbitest
449             JDBI_TEST_PG: jiftydbitestdb
450             JDBI_TEST_PG_USER: jiftydbitest
451             revision: 1358
452             root_dir: trunk
453             svn_uri: svn+ssh://svn.jifty.org/svn/jifty.org/Jifty-DBI
454              
455             The supported project options are as follows:
456              
457             =over 4
458              
459             =item * configure_cmd
460              
461             The command to configure the project after checkout, but before
462             running tests.
463              
464             =item * revision
465              
466             This is the last revision known for a given project. When started,
467             the poller will attempt to checkout and test all revisions (besides
468             ones on which the directory did not change) between this one and
469             HEAD. When a test has been successfully uploaded, the revision
470             number is updated and the configuration file is re-written.
471              
472             =item * root_dir
473              
474             The subdirectory inside the repository where configuration and
475             testing commands should be run.
476              
477             =item * svn_uri
478              
479             The subversion URI of the project.
480              
481             =item * env
482              
483             A hash of environment variable names and values that are set before
484             configuration, and reverted to their previous values after the
485             tests have been run. In addition, if environment variable FOO's
486             new value contains the string "$FOO", then the old value of FOO
487             will be substituted in when setting the environment variable.
488              
489             =item * dependencies
490              
491             A list of project names that are dependencies for the given
492             project. All dependencies are checked out at HEAD, have their
493             configuration commands run, and all dependencys' $root_dir/blib/lib
494             directories are added to @INC before the configuration command for
495             the project is run.
496              
497             =item * dependency_only
498              
499             Indicates that this project should not be tested. It is only
500             present to serve as a dependency for another project.
501              
502             =back
503              
504             =head1 REPORT VARIABLES
505              
506             This module assumes the use of the following report variables:
507              
508             project
509             revision
510             committer
511             duration
512             osname
513             osvers
514             archname
515              
516             =head1 AUTHOR
517              
518             Zev Benjamin, C<< >>
519              
520             =head1 BUGS
521              
522             Please report any bugs or feature requests to
523             C, or through the web interface at
524             L.
525             I will be notified, and then you'll automatically be notified of progress on
526             your bug as I make changes.
527              
528             =head1 SUPPORT
529              
530             You can find documentation for this module with the perldoc command.
531              
532             perldoc Test::Chimps::Smoker
533              
534             You can also look for information at:
535              
536             =over 4
537              
538             =item * Mailing list
539              
540             Chimps has a mailman mailing list at
541             L. You can subscribe via the web
542             interface at
543             L.
544              
545             =item * AnnoCPAN: Annotated CPAN documentation
546              
547             L
548              
549             =item * CPAN Ratings
550              
551             L
552              
553             =item * RT: CPAN's request tracker
554              
555             L
556              
557             =item * Search CPAN
558              
559             L
560              
561             =back
562              
563             =head1 COPYRIGHT & LICENSE
564              
565             Copyright 2006 Best Practical Solutions.
566              
567             This program is free software; you can redistribute it and/or modify it
568             under the same terms as Perl itself.
569              
570             =cut
571              
572             1;