File Coverage

blib/lib/Test/Chimps/Smoker.pm
Criterion Covered Total %
statement 61 238 25.6
branch 2 70 2.8
condition 0 11 0.0
subroutine 17 30 56.6
pod 2 6 33.3
total 82 355 23.1


line stmt bran cond sub pod time code
1             package Test::Chimps::Smoker;
2              
3 4     4   115065 use warnings;
  4         11  
  4         171  
4 4     4   23 use strict;
  4         9  
  4         146  
5              
6 4     4   22 use Config;
  4         8  
  4         567  
7 4     4   28 use Cwd qw(abs_path);
  4         7  
  4         280  
8 4     4   24 use File::Path;
  4         9  
  4         282  
9 4     4   8056 use File::Temp qw/tempdir/;
  4         129580  
  4         276  
10 4     4   3696 use Params::Validate qw/:all/;
  4         49108  
  4         1063  
11 4     4   3389 use Test::Chimps::Smoker::Source;
  4         16  
  4         30  
12 4     4   2656 use Test::Chimps::Client;
  4         17  
  4         45  
13 4     4   4420 use TAP::Harness::Archive;
  4         968035  
  4         161  
14 4     4   2877 use YAML::Syck;
  4         7053  
  4         483  
15              
16             =head1 NAME
17              
18             Test::Chimps::Smoker - Poll a set of repositories and run tests when they change
19              
20             =head1 SYNOPSIS
21              
22             # command line tool
23             chimps-smoker.pl \
24             -c /path/to/configfile.yml \
25             -s http://www.example.com/cgi-bin/chimps-server.pl
26              
27             # API
28             use Test::Chimps::Smoker;
29              
30             my $poller = Test::Chimps::Smoker->new(
31             server => 'http://www.example.com/cgi-bin/chimps-server.pl',
32             config_file => '/path/to/configfile.yml',
33             );
34              
35             $poller->smoke;
36              
37             =head1 DESCRIPTION
38              
39             Chimps is the Collaborative Heterogeneous Infinite Monkey
40             Perfectionification Service. It is a framework for storing,
41             viewing, generating, and uploading smoke reports. This
42             distribution provides client-side modules and binaries for Chimps.
43              
44             This module gives you everything you need to make your own build
45             slave. You give it a configuration file describing all of your
46             projects and how to test them, and it will monitor the repositories,
47             check the projects out (and their dependencies), test them, and submit
48             the report to a server.
49              
50             =head1 METHODS
51              
52             =head2 new ARGS
53              
54             Creates a new smoker object. ARGS is a hash whose valid keys are:
55              
56             =over 4
57              
58             =item * config_file
59              
60             Mandatory. The configuration file describing which repositories to
61             monitor. The format of the configuration is described in
62             L. File is update after each run.
63              
64             =item * server
65              
66             Optional. The URI of the server script to upload the reports to.
67             Defaults to simulation mode when reports are sent.
68              
69             =item * sleep
70              
71             Optional. Number of seconds to sleep between repository checks.
72             Defaults to 60 seconds.
73              
74             =item * simulate [DEPRECATED]
75              
76             [DEPRECATED] Just don't provide server option to enable simulation.
77              
78             Don't actually submit the smoke reports, just run the tests. This
79             I, however, increment the revision numbers in the config
80             file.
81              
82             =back
83              
84             =cut
85              
86 4     4   31 use base qw/Class::Accessor/;
  4         8  
  4         12695  
87             __PACKAGE__->mk_ro_accessors(qw/server config_file simulate sleep/);
88             __PACKAGE__->mk_accessors(
89             qw/_env_stack meta config projects iterations/);
90              
91             # add a signal handler so destructor gets run
92             $SIG{INT} = sub {print "caught sigint. cleaning up...\n"; exit(1)};
93             $ENV{PERL5LIB} = "" unless defined $ENV{PERL5LIB}; # Warnings avoidance
94              
95             sub new {
96 1     1 1 14 my $class = shift;
97 1         4 my $obj = bless {}, $class;
98 1         6 $obj->_init(@_);
99 1         4 return $obj;
100             }
101              
102             sub _init {
103 1     1   4 my $self = shift;
104 1         96 my %args = validate_with(
105             params => \@_,
106             spec => {
107             config_file => 1,
108             server => 0,
109             simulate => 0,
110             iterations => {
111             optional => 1,
112             default => 'inf'
113             },
114             projects => {
115             optional => 1,
116             default => 'all'
117             },
118             jobs => {
119             optional => 1,
120             type => SCALAR,
121             regex => qr/^\d+$/,
122             default => 1,
123             },
124             sleep => {
125             optional => 1,
126             type => SCALAR,
127             regex => qr/^\d+$/,
128             default => 60,
129             },
130             },
131             called => 'The Test::Chimps::Smoker constructor'
132             );
133              
134 1         16 foreach my $key (keys %args) {
135 6         24 $self->{$key} = $args{$key};
136             }
137              
138             # support simulate for a while
139 1 50       6 delete $self->{'server'} if $args{'simulate'};
140              
141             # make it absolute so we can update it later from any dir we're in
142 1         54 $self->{'config_file'} = abs_path($self->{'config_file'});
143              
144 1         8 $self->_env_stack([]);
145 1         27 $self->meta({});
146              
147 1         15 $self->load_config;
148             }
149              
150             =head2 smoke PARAMS
151              
152             Calling smoke will cause the C object to continually poll
153             repositories for changes in revision numbers. If an (actual)
154             change is detected, the repository will be checked out (with
155             dependencies), built, and tested, and the resulting report will be
156             submitted to the server. This method may not return. Valid
157             options to smoke are:
158              
159             =over 4
160              
161             =item * iterations
162              
163             Specifies the number of iterations to run. This is the number of
164             smoke reports to generate per project. A value of 'inf' means to
165             continue smoking forever. Defaults to 'inf'.
166              
167             =item * projects
168              
169             An array reference specifying which projects to smoke. If the
170             string 'all' is provided instead of an array reference, all
171             projects will be smoked. Defaults to 'all'.
172              
173             =back
174              
175             =cut
176              
177             sub smoke {
178 0     0 1 0 my $self = shift;
179 0         0 my $config = $self->config;
180              
181 0         0 my %args = validate_with(
182             params => \@_,
183             spec => {
184             iterations => {
185             optional => 1,
186             type => SCALAR,
187             regex => qr/^(inf|\d+)$/,
188             default => 'inf'
189             },
190             projects => {
191             optional => 1,
192             type => ARRAYREF | SCALAR,
193             default => 'all'
194             }
195             },
196             called => 'Test::Chimps::Smoker->smoke'
197             );
198              
199 0         0 my $projects = $args{projects};
200 0         0 my $iterations = $args{iterations};
201 0         0 $self->_validate_projects_opt($projects);
202              
203 0 0       0 if ($projects eq 'all') {
204 0         0 $projects = [keys %$config];
205             }
206              
207 0         0 $self->_smoke_n_times($iterations, $projects);
208             }
209              
210             sub _validate_projects_opt {
211 0     0   0 my ($self, $projects) = @_;
212 0 0       0 return if $projects eq 'all';
213              
214 0         0 foreach my $project (@$projects) {
215 0 0       0 die "no such project: '$project'"
216             unless exists $self->config->{$project};
217             }
218             }
219              
220             sub _smoke_n_times {
221 0     0   0 my $self = shift;
222 0         0 my $n = shift;
223 0         0 my $projects = shift;
224              
225 0 0       0 if ($n <= 0) {
    0          
226 0         0 die "Can not smoke projects a negative number of times";
227             } elsif ($n eq 'inf') {
228 0         0 while (1) {
229 0         0 $self->_smoke_projects($projects);
230 0 0       0 CORE::sleep $self->sleep if $self->sleep;
231             }
232             } else {
233 0         0 for (my $i = 0; $i < $n; $i++) {
234 0         0 $self->_smoke_projects($projects);
235 0 0 0     0 CORE::sleep $self->sleep if $i+1 < $n && $self->sleep;
236             }
237             }
238             }
239              
240             sub _smoke_projects {
241 0     0   0 my $self = shift;
242 0         0 my $projects = shift;
243              
244 0         0 foreach my $project (@$projects) {
245 0         0 local $@;
246 0         0 eval { $self->_smoke_once($project) };
  0         0  
247 0 0       0 warn "Couldn't smoke project '$project': $@"
248             if $@;
249             }
250             }
251              
252             sub _smoke_once {
253 0     0   0 my $self = shift;
254 0         0 my $project = shift;
255              
256 0         0 my $config = $self->config->{$project};
257 0 0       0 return 1 if $config->{dependency_only};
258              
259 0         0 $self->_clone_project( $config );
260              
261 0         0 my %next = $self->source($project)->next( $config->{revision} );
262 0 0       0 return 0 unless keys %next;
263              
264 0         0 my $revision = $next{'revision'};
265              
266 0         0 my @libs = $self->_checkout_project($config, $revision);
267 0 0       0 unless (@libs) {
268 0         0 print "Skipping report for $project revision $revision due to build failure\n";
269 0         0 $self->update_revision_in_config( $project => $revision );
270 0         0 return 0;
271             }
272              
273 0         0 print "running tests for $project\n";
274 0   0     0 my $test_glob = $config->{test_glob} || 't/*.t t/*/t/*.t';
275 0         0 my $tmpfile = File::Temp->new( SUFFIX => ".tar.gz" );
276 0   0     0 my $harness = TAP::Harness::Archive->new( {
277             archive => $tmpfile,
278             extra_properties => {
279             project => $project,
280             revision => $revision,
281             committer => $next{'committer'},
282             osname => $Config{osname},
283             osvers => $Config{osvers},
284             archname => $Config{archname},
285             },
286             jobs => ($config->{jobs} || $self->{jobs}),
287             lib => \@libs,
288             } );
289             {
290             # Runtests apparently grows PERL5LIB -- local it so it doesn't
291             # grow without bound
292 0         0 local $ENV{PERL5LIB} = $ENV{PERL5LIB};
  0         0  
293 0         0 $harness->runtests(glob($test_glob));
294             }
295              
296 0         0 $self->_clean_project( $config );
297              
298 0         0 $self->_unroll_env_stack;
299              
300 0 0       0 if ( my $server = $self->server ) {
301 0         0 my $client = Test::Chimps::Client->new(
302             archive => $tmpfile, server => $server,
303             );
304              
305 0         0 print "Sending smoke report for $server\n";
306 0         0 my ($status, $msg) = $client->send;
307 0 0       0 unless ( $status ) {
308 0         0 print "Error: the server responded: $msg\n";
309 0         0 return 0;
310             }
311             }
312             else {
313 0         0 print "Server is not specified, don't send the report\n";
314             }
315              
316 0         0 print "Done smoking revision $revision of $project\n";
317 0         0 $self->update_revision_in_config( $project => $revision );
318 0         0 return 1;
319             }
320              
321             sub load_config {
322 1     1 0 2 my $self = shift;
323              
324 1         7 my $cfg = $self->config(LoadFile($self->config_file));
325              
326             # update old style config file
327             {
328 1         333 my $found_old_style = 0;
  1         4  
329 1         5 foreach ( grep $_->{svn_uri}, values %$cfg ) {
330 0         0 $found_old_style = 1;
331              
332 0         0 $_->{'repository'} = {
333             type => 'SVN',
334             uri => delete $_->{svn_uri},
335             };
336             }
337 1 50       8 DumpFile($self->config_file, $cfg) if $found_old_style;
338             }
339            
340             # store project name in its hash
341 1         8 $cfg->{$_}->{'name'} = $_ foreach keys %$cfg;
342             }
343              
344             sub update_revision_in_config {
345 0     0 0 0 my $self = shift;
346 0         0 my ($project, $revision) = @_;
347              
348 0         0 my $tmp = LoadFile($self->config_file);
349 0         0 $tmp->{$project}->{revision} = $self->config->{$project}->{revision} = $revision;
350 0         0 DumpFile($self->config_file, $tmp);
351             }
352              
353             sub source {
354 0     0 0 0 my $self = shift;
355 0         0 my $project = shift;
356 0         0 $self->meta->{$project}{'source'} ||= Test::Chimps::Smoker::Source->new(
357 0   0     0 %{ $self->config->{$project}{'repository'} },
358             config => $self->config->{$project},
359             smoker => $self,
360             );
361 0         0 return $self->meta->{$project}{'source'};
362             }
363              
364             sub _clone_project {
365 0     0   0 my $self = shift;
366 0         0 my $project = shift;
367              
368 0         0 my $source = $self->source( $project->{'name'} );
369 0 0       0 if ( $source->cloned ) {
370 0 0       0 chdir $source->directory
371             or die "Couldn't change dir to ". $source->directory .": $!";
372 0         0 return 1;
373             }
374              
375 0         0 my $tmpdir = tempdir("chimps-XXXXXXX", TMPDIR => 1);
376 0         0 $source->directory( $tmpdir );
377 0 0       0 chdir $source->directory
378             or die "Couldn't change dir to ". $source->directory .": $!";
379 0         0 $source->clone;
380              
381 0         0 $source->cloned(1);
382              
383 0         0 return 1;
384             }
385              
386             sub _checkout_project {
387 0     0   0 my $self = shift;
388 0         0 my $project = shift;
389 0         0 my $revision = shift;
390              
391 0         0 my $source = $self->source( $project->{'name'} );
392 0         0 my $co_dir = $source->directory;
393 0 0       0 chdir $co_dir or die "Couldn't change dir to $co_dir: $!";
394 0         0 $source->checkout( revision => $revision );
395              
396 0         0 my $projectdir = File::Spec->catdir($co_dir, $project->{root_dir});
397              
398 0 0       0 my @libs = map File::Spec->catdir($projectdir, $_),
399 0         0 'blib/lib', @{ $project->{libs} || [] };
400 0         0 $self->meta->{ $project->{'name'} }{'libs'} = [@libs];
401              
402 0         0 $self->_push_onto_env_stack({
403 0 0       0 $project->{env}? (%{$project->{env}}) : (),
404             'CHIMPS_'. uc($project->{'name'}) .'_ROOT' => $projectdir,
405             });
406              
407 0         0 my @otherlibs;
408 0 0       0 if (defined $project->{dependencies}) {
409 0         0 foreach my $dep (@{$project->{dependencies}}) {
  0         0  
410 0         0 print "processing dependency $dep\n";
411 0         0 my $config = $self->config->{ $dep };
412 0         0 $self->_clone_project( $config );
413 0         0 my @deplibs = $self->_checkout_project( $config );
414 0 0       0 if (@deplibs) {
415 0         0 push @otherlibs, @deplibs;
416             } else {
417 0         0 print "Dependency $dep failed; aborting";
418 0         0 return ();
419             }
420             }
421             }
422              
423 0         0 my %seen;
424 0         0 @libs = grep {not $seen{$_}++} @libs, @otherlibs;
  0         0  
425              
426 0 0       0 unless (chdir($projectdir)) {
427 0         0 print "chdir to $projectdir failed -- check value of root_dir?\n";
428 0         0 return ();
429             }
430              
431 0         0 local $ENV{PERL5LIB} = join(":",@libs,$ENV{PERL5LIB});
432              
433 0 0       0 if (defined( my $cmd = $project->{'configure_cmd'} )) {
434 0         0 my $ret = system($cmd);
435 0 0       0 if ($ret) {
436 0 0       0 print STDERR "Return value of $cmd from $projectdir = $ret\n"
437             if $ret;
438 0         0 return ();
439             }
440             }
441              
442 0 0       0 if (defined( my $cmd = $project->{'clean_cmd'} )) {
443 0         0 print "Going to run project cleaner '$cmd'\n";
444 0         0 my @args = (
445             '--project', $project->{'name'},
446             '--config', $self->config_file,
447             );
448 0 0       0 open my $fh, '-|', join(' ', $cmd, @args)
449             or die "Couldn't run `". join(' ', $cmd, @args) ."`: $!";
450 0         0 $self->meta->{ $project->{'name'} }->{'cleaner'} = do { local $/; <$fh> };
  0         0  
  0         0  
451 0         0 close $fh;
452             }
453 0         0 return @libs;
454             }
455              
456             sub _clean_project {
457 0     0   0 my $self = shift;
458 0         0 my $project = shift;
459              
460 0 0       0 if (defined( my $cmd = $project->{'clean_cmd'} )) {
461 0         0 my @args = (
462             '--project', $project->{'name'},
463             '--config', $self->config_file,
464             '--clean',
465             );
466 0 0       0 open my $fh, '|-', join(' ', $cmd, @args)
467             or die "Couldn't run `". join(' ', $cmd, @args) ."`: $!";
468 0         0 print $fh $self->meta->{ $project->{'name'} }->{'cleaner'};
469 0         0 close $fh;
470             }
471              
472 0         0 $self->source( $project->{'name'} )->clean;
473              
474 0 0       0 if (defined $project->{dependencies}) {
475 0         0 foreach my $dep (@{$project->{dependencies}}) {
  0         0  
476 0         0 $self->_clean_project( $self->config->{ $dep } );
477             }
478             }
479             }
480              
481             sub _push_onto_env_stack {
482 0     0   0 my $self = shift;
483 0         0 my $vars = shift;
484              
485 0         0 my $frame = {};
486 0         0 foreach my $var (keys %$vars) {
487 0 0       0 if (exists $ENV{$var}) {
488 0         0 $frame->{$var} = $ENV{$var};
489             } else {
490 0         0 $frame->{$var} = undef;
491             }
492 0         0 my $value = $vars->{$var};
493              
494             # old value substitution
495 0         0 $value =~ s/\$$var/$ENV{$var}/g;
496              
497 0         0 print "setting environment variable $var to $value\n";
498 0         0 $ENV{$var} = $value;
499             }
500 0         0 push @{$self->_env_stack}, $frame;
  0         0  
501             }
502              
503             sub _unroll_env_stack {
504 0     0   0 my $self = shift;
505              
506 0         0 while (scalar @{$self->_env_stack}) {
  0         0  
507 0         0 my $frame = pop @{$self->_env_stack};
  0         0  
508 0         0 foreach my $var (keys %$frame) {
509 0 0       0 if (defined $frame->{$var}) {
510 0         0 print "reverting environment variable $var to $frame->{$var}\n";
511 0         0 $ENV{$var} = $frame->{$var};
512             } else {
513 0         0 print "unsetting environment variable $var\n";
514 0         0 delete $ENV{$var};
515             }
516             }
517             }
518             }
519              
520             sub DESTROY {
521 1     1   1494 my $self = shift;
522 1         6 $self->remove_checkouts;
523             }
524              
525             sub remove_checkouts {
526 1     1 0 2 my $self = shift;
527              
528 1         6 my $meta = $self->meta;
529 1         131 foreach my $source ( grep $_, map $_->{'source'}, values %$meta ) {
530 0 0         next unless my $dir = $source->directory;
531              
532 0           _remove_tmpdir($dir);
533 0           $source->directory(undef);
534 0           $source->cloned(0);
535             }
536             }
537              
538             sub _remove_tmpdir {
539 0     0     my $tmpdir = shift;
540 0           print "removing temporary directory $tmpdir\n";
541 0           rmtree($tmpdir, 0, 0);
542             }
543              
544             =head1 ACCESSORS
545              
546             There are read-only accessors for server and config_file.
547              
548             =head1 CONFIGURATION FILE
549              
550             The configuration file is YAML dump of a hash. The keys at the top
551             level of the hash are project names. Their values are hashes that
552             comprise the configuration options for that project.
553              
554             Perhaps an example is best. A typical configuration file might
555             look like this:
556              
557             ---
558             Some-jifty-project:
559             configure_cmd: perl Makefile.PL --skipdeps && make
560             dependencies:
561             - Jifty
562             revision: 555
563             root_dir: trunk/foo
564             repository:
565             type: SVN
566             uri: svn+ssh://svn.example.com/svn/foo
567             test_glob: t/*.t t/*/*.t
568             Jifty:
569             configure_cmd: perl Makefile.PL --skipdeps && make
570             dependencies:
571             - Jifty-DBI
572             revision: 1332
573             root_dir: trunk
574             repository:
575             type: SVN
576             uri: svn+ssh://svn.jifty.org/svn/jifty.org/jifty
577             Jifty-DBI:
578             configure_cmd: perl Makefile.PL --skipdeps && make
579             env:
580             JDBI_TEST_MYSQL: jiftydbitestdb
581             JDBI_TEST_MYSQL_PASS: ''
582             JDBI_TEST_MYSQL_USER: jiftydbitest
583             JDBI_TEST_PG: jiftydbitestdb
584             JDBI_TEST_PG_USER: jiftydbitest
585             revision: 1358
586             root_dir: trunk
587             repository:
588             type: SVN
589             uri: svn+ssh://svn.jifty.org/svn/jifty.org/Jifty-DBI
590              
591             The supported project options are as follows:
592              
593             =over 4
594              
595             =item * configure_cmd
596              
597             The command to configure the project after checkout, but before
598             running tests.
599              
600             =item * revision
601              
602             This is the last revision known for a given project. When started,
603             the poller will attempt to checkout and test all revisions (besides
604             ones on which the directory did not change) between this one and
605             HEAD. When a test has been successfully uploaded, the revision
606             number is updated and the configuration file is re-written.
607              
608             =item * root_dir
609              
610             The subdirectory inside the repository where configuration and
611             testing commands should be run.
612              
613             =item * repository
614              
615             A hash describing repository of the project. Mandatory key is
616             type which must match a source class name, for example SVN or
617             Git. Particular source class may have more options, but at this
618             moment Git and SVN have only 'uri' option.
619              
620             =item * env
621              
622             A hash of environment variable names and values that are set before
623             configuration, and reverted to their previous values after the
624             tests have been run. In addition, if environment variable FOO's
625             new value contains the string "$FOO", then the old value of FOO
626             will be substituted in when setting the environment variable.
627              
628             Special environment variables are set in addition to described
629             above. For each project CHIMPS__ROOT is set pointing
630             to the current checkout of the project.
631              
632             =item * dependencies
633              
634             A list of project names that are dependencies for the given
635             project. All dependencies are checked out at HEAD, have their
636             configuration commands run, and all dependencys' $root_dir/blib/lib
637             directories are added to @INC before the configuration command for
638             the project is run.
639              
640             =item * dependency_only
641              
642             Indicates that this project should not be tested. It is only
643             present to serve as a dependency for another project.
644              
645             =item * test_glob
646              
647             How to find all your tests, defaults to
648             t/*.t t/*/t/*.t
649              
650             =item * libs
651              
652             A list of paths, relative to the project root, which should be added
653             to @INC. C is automatically added, but you may need to
654             include C here, for instance.
655              
656             =item * clean_cmd
657              
658             The command to clean before or after each iteration of the project testing.
659             Called B before running tests and after with --config, --project
660             arguments and --clean argument when called for the second time after testing.
661              
662             When called before testing (without --clean), state information can be printed
663             to STDOUT. Later when called after testing (with --clean), the state info can
664             be read from STDIN.
665              
666             An example you can find in a tarball of this distribution - F.
667              
668             =back
669              
670             =head1 REPORT VARIABLES
671              
672             This module assumes the use of the following report variables:
673              
674             project
675             revision
676             committer
677             duration
678             osname
679             osvers
680             archname
681              
682             =head1 AUTHOR
683              
684             Zev Benjamin, C<< >>
685              
686             =head1 BUGS
687              
688             Please report any bugs or feature requests to
689             C, or through the web interface at
690             L.
691             I will be notified, and then you'll automatically be notified of progress on
692             your bug as I make changes.
693              
694             =head1 SUPPORT
695              
696             You can find documentation for this module with the perldoc command.
697              
698             perldoc Test::Chimps::Smoker
699              
700             You can also look for information at:
701              
702             =over 4
703              
704             =item * Mailing list
705              
706             Chimps has a mailman mailing list at
707             L. You can subscribe via the web
708             interface at
709             L.
710              
711             =item * AnnoCPAN: Annotated CPAN documentation
712              
713             L
714              
715             =item * CPAN Ratings
716              
717             L
718              
719             =item * RT: CPAN's request tracker
720              
721             L
722              
723             =item * Search CPAN
724              
725             L
726              
727             =back
728              
729             =head1 COPYRIGHT & LICENSE
730              
731             Copyright 2006-2009 Best Practical Solutions.
732              
733             This program is free software; you can redistribute it and/or modify it
734             under the same terms as Perl itself.
735              
736             =cut
737              
738             1;