File Coverage

blib/lib/SVN/Deploy.pm
Criterion Covered Total %
statement 31 33 93.9
branch n/a
condition n/a
subroutine 11 11 100.0
pod n/a
total 42 44 95.4


line stmt bran cond sub pod time code
1             package SVN::Deploy;
2            
3 1     1   46681 use strict;
  1         2  
  1         40  
4 1     1   6 use warnings;
  1         2  
  1         41  
5            
6             our $VERSION = '0.11';
7 1     1   6 use Carp;
  1         5  
  1         72  
8            
9 1     1   5 use Cwd;
  1         1  
  1         59  
10 1     1   61585 use File::Temp qw/tempdir/;
  1         32215  
  1         91  
11 1     1   10 use File::Spec::Functions qw/:ALL/;
  1         3  
  1         256  
12 1     1   1042 use File::Copy::Recursive;
  1         7478  
  1         55  
13 1     1   11 use File::Path;
  1         1  
  1         53  
14 1     1   1254 use Storable qw/dclone nfreeze thaw/;
  1         4155  
  1         102  
15 1     1   838 use MIME::Base64;
  1         648  
  1         58  
16            
17 1     1   579 use SVN::Deploy::Utils;
  0            
  0            
18             $SVN::Error::handler = undef;
19            
20             use Data::Dumper;
21             $Data::Dumper::Indent=1;
22            
23             =head1 NAME
24            
25             SVN::Deploy - audit conform building/deploying releases to/from an SVN deploy repository
26            
27             =head1 SYNOPSIS
28            
29             use SVN::Deploy;
30            
31             # creating a SVN::Deploy object
32             my $obj = SVN::Deploy->new(
33             repo => 'svn:://deploy_srv/deploy_repo',
34             cleanup_tmp => 1,
35             );
36            
37             # adding a category
38             $obj->category_add(category => 'Cat1')
39            
40             # defining a product
41             my %cfg = (
42             build => [
43             '[os]perl build1.pl',
44             '[os]perl build2.pl',
45             ],
46             source => [
47             'svn://source_srv/source_repo/trunk/mypath1',
48             'svn://source_srv/source_repo/trunk/mypath2',
49             ],
50             qa => {
51             dest => [
52             '[none]',
53             '/mypath/to/qa/environment',
54             ],
55             pre => ['[os]perl pre.pl'],
56             post => ['[os]perl post.pl'],
57             },
58             prod => {
59             dest => [
60             '[none]',
61             '/mypath/to/prod/environment',
62             ],
63             pre => ['[os]perl pre.pl'],
64             post => ['[os]perl post.pl'],
65             },
66             );
67            
68             $obj->product_add(
69             category => 'Cat1',
70             product => 'Prod1',
71             [cfg => \%cfg,]
72             );
73            
74            
75             # exporting data from source repos
76             # and importing into deploy repo
77             my $rev = $obj->build_version(
78             category => 'Cat1',
79             product => 'Prod1',
80             versions => {
81             "svn://source_srv/source_repo/trunk/mypath1" => 42,
82             "svn://source_srv/source_repo/trunk/mypath2" => 42,
83             },
84             comment => 'some log message',
85             );
86            
87             print "Built version has revision $rev in deploy repo\n";
88            
89             # deploying the newly created release
90             # to the specified target
91             $obj->deploy_version(
92             category => 'Cat1',
93             product => 'Prod1',
94             version => $rev,
95             target => 'qa',
96             reference_id => 'Version 1.02',
97             reference_data => {
98             requested_from => 'Bill',
99             tested_by => 'Bob',
100             pumpking => 'Beth',
101             },
102             comment => "Lets hope it'll work :-)",
103             );
104            
105             =head1 DESCRIPTION
106            
107             SVN::Deploy implements an interface to handle release data held within
108             a separate SVN repository. You can define categorized products where
109             each product consists of multiple sources (SVN repositories and
110             directories or files from a filesystem) and multiple destinations
111             (filesystem directories).
112            
113             It was designed for situations where the build and deploy steps should
114             not be performed by the developers of a product but by operators with
115             only read access to the developers repository, while the developers
116             have no access to the deploy repository.
117            
118             The overall outline looks like this:
119            
120             (dev:developers, op:operating, usr:users/testers):
121            
122             - (dev) define a product
123             (location of sources from the devel repo and/or files,
124             providing build procedures, etc)
125             - (op) define the product in the deploy repository
126             - (dev) order a new release (give source revision numbers to op)
127             - (op) build the release
128             (resulting in a new revision in the deploy repo)
129             - (op) deploy the new release to QA environment giving
130             release revision info to testers
131             - (usr) approve/reject the release
132             - (op) on approval deploy the new release to
133             production environment
134            
135             All information and the build/deploy history is held in the deploy
136             repository and can be easily exported for auditing purposes.
137            
138             The deploy repository will look like this:
139            
140             repo_root
141             \-- Category1
142             \-- Category2
143             \-- Product1
144             \-- 0
145             \-- subdir1
146             \-- file1
147             \-- file2
148             \--file1
149             \-- Product2
150             \-- 0
151             \-- 1
152             \-- 2
153             ...
154            
155             All product information is saved as properties of the product nodes.
156             So an:
157            
158             svn proplist -v /Category2/Product1
159            
160             will show the product properties. If the latest commit was a result
161             of a deployment task, deployment information will be visible (properties
162             with a leading 'D:').
163            
164             To get full deployment information you have to retrieve the properties
165             for all revisions of the product.
166            
167             There are of course history methods provided to automate the process.
168            
169             =cut
170            
171            
172             our $Debug = 0;
173             sub _log (@) { print @_, "\n" if $Debug }
174            
175             # hash for checking subroutine arguments
176             # m mandatory, o optional
177             my %arg_check = (
178             _init => {qw/repo m cleanup_tmp o debug o pwd_sub o/},
179             build_version => {qw/category m product m versions o comment o/},
180             category_add => {qw/category m/},
181             category_delete => {qw/category m/},
182             category_history => {qw/
183             category m from m
184             to m csv o
185             build o
186             /},
187             category_list => {qw/category o/},
188             category_update => {qw/category m new_name m/},
189             deploy_version => {qw/
190             category m product m version m target m
191             reference_id o reference_data o comment o
192             /},
193             product_add => {qw/category m product m cfg o/},
194             product_delete => {qw/category m product m/},
195             product_history => {qw/category m product m from m to m csv o build o/},
196             product_list => {qw/category m product o/},
197             product_update => {qw/category m product m cfg o new_name o/},
198             );
199            
200            
201             # check named arguments against %arg_check
202             sub _getargs {
203             my $self = shift;
204            
205             $self->{lasterr} = '';
206            
207             my $caller = (caller(1))[3];
208            
209             unless ( @_ % 2 == 0 ) {
210             $self->{lasterr} = "odd number of arguments for $caller()";
211             return;
212             }
213            
214             $caller =~ s/.*::(\w+)$/$1/;
215             my %tmp = @_;
216            
217             for my $arg ( keys( %{ $arg_check{$caller} } ) ) {
218             next if $arg_check{$caller}{$arg} ne 'm';
219             unless ( $tmp{$arg} ) {
220             $self->{lasterr}
221             = "$caller: mandatory parameter '$arg' missing or empty";
222             return;
223             }
224             }
225            
226             for my $arg ( keys( %tmp ) ) {
227             unless ( exists($arg_check{$caller}{$arg}) ) {
228             $self->{lasterr} = "$caller: unknown parameter '$arg'";
229             return;
230             }
231             }
232            
233             return(@_);
234             }
235            
236            
237             =head2 Constructor new
238            
239             my $obj = SVN::Deploy->new(
240             repo => ,
241             [cleanup_tmp => <0|1>,]
242             [debug => <0|1>,]
243             [pwd_sub => ,]
244             );
245            
246             C<'repo'>, C<'cleanup_tmp'> and C<'debug'> should be obvious. C<'pwd_sub'> can
247             point to a reference to a subroutine returning username and password
248             for the repository. It will only be called when credentials for a user
249             cannot be obtained from the svn cache. A successful logon will be
250             cached.
251            
252             Returns the created object.
253            
254             =cut
255            
256             sub new {
257             my($class, %args) = @_;
258            
259             my $self = bless({}, $class);
260             $self->_init(%args);
261             return($self);
262             }
263            
264            
265             # initialise object with svn client context
266             sub _init {
267             my $self = shift;
268             my %args = $self->_getargs(@_)
269             or croak "init failed, ", $self->{lasterr};
270            
271             my $cleanup = defined($args{cleanup_tmp})
272             ? $args{cleanup_tmp}
273             : 1;
274            
275             $self->{tempdir} = tempdir(
276             'SVN-Deploy-XXXXXX',
277             CLEANUP => $cleanup,
278             TMPDIR => 1,
279             );
280            
281             $SVN::Deploy::Utils::Cleanup = $cleanup;
282             $SVN::Deploy::Utils::Verbose = $args{debug};
283            
284             $self->{ctx} = SVN::Deploy::Utils::connect_cached(
285             map { $_ => $args{$_} } qw/username password pwd_sub/
286             );
287             $self->{repo} = $args{repo};
288             $Debug = $args{debug};
289             }
290            
291            
292             =head2 METHODS
293            
294             All methods will return undef on errors. They will return 1 on
295             success unless another return value is documented.
296             Calling the C method will return a printable error description.
297            
298            
299             =cut
300            
301             # wrapper for SVN::Client methods
302             # hook for debugging, sets lasterr, unifies return values
303             sub _svn {
304             my $self = shift;
305             my $call = shift;
306            
307             _log "calling $call(", join(', ', @_), ')';
308            
309             my @ret = $self->{ctx}->$call(@_);
310            
311             _log "return:", Dumper(\@ret);
312            
313             if ( ref($ret[0]) eq '_p_svn_error_t' ) {
314             $self->{lasterr} = "svn call $call(" . join(', ', @_) . ') failed, '
315             . $ret[0]->expanded_message();
316             return;
317             }
318            
319             return(wantarray ? @ret : ($ret[0] || 1) );
320             };
321            
322            
323             # running actions
324             # implemented:
325             # - [os] -> run with system()/backticks
326             sub _run_scripts {
327             my($self, $act_ref, $work_dir, $add_args_ref) = @_;
328            
329             my $add_args = '';
330             if ( ref($add_args_ref) eq 'ARRAY' ) {
331             $add_args .= qq("$_" ) for @$add_args_ref;
332             }
333            
334             my $ret_sum = 0;
335             my $output = '';
336             for my $act ( @$act_ref ) {
337             my($type, $action) = $act =~ /^\[(\w+)\](.*)$/;
338             unless ( $action ) {
339             _log "no action given in >>$act<<";
340             $output .= "no action given in >>$act<<, should be []\n";
341             next;
342             }
343             if ( $type eq 'os' ) {
344             _log "running >>$action $add_args<<";
345             my $dir_save = getcwd();
346             chdir($work_dir);
347             $output .= `$action $add_args 2>&1`;
348             my $ret = $? >> 8;
349             _log ">>$action $add_args<< finished, exit code:", $ret;
350             $ret_sum += $ret;
351             chdir($dir_save);
352             } else {
353             _log "unknown type >>$type<< in >>$act<<";
354             $output .= "unknown type >>$type<< in >>$act<<, should be [os]\n";
355             }
356             }
357             return($ret_sum, $output);
358             }
359            
360            
361             # getting data from filesystem
362             # creating dir for single files
363             sub _export_fs {
364             my $self = shift;
365             my(%args) = @_;
366            
367             if ( -d $args{source} ) {
368             File::Copy::Recursive::dircopy($args{source}, $args{dir})
369             or do {
370             $self->{lasterr}
371             = "dircopy($args{source}, $args{dir}) failed, $!";
372             return;
373             }
374             } else {
375             my $file = (splitpath($args{source}))[-1];
376             unless ( -d $args{dir} ) {
377             unless ( mkdir($args{dir}) ) {
378             $self->{lasterr} = "mkdir($args{dir}) failed, $!";
379             return;
380             }
381             }
382             my $dest = catdir($args{dir}, $file);
383             _log "copy >$args{source}< to >$dest<";
384             unless ( File::Copy::copy($args{source}, $dest) ) {
385             $self->{lasterr} = "copy($args{source}, $dest) failed, $!";
386             return;
387             }
388             }
389            
390             return($args{source});
391             }
392            
393            
394             # exporting data from source SVN
395             # creating dir for single files first
396             sub _export_svn {
397             my $self = shift;
398             my(%args) = @_;
399            
400             my $kind;
401             $self->_svn(
402             'info', $args{source}, $args{version}, $args{version},
403             sub { $kind = $_[1]->kind }, 0,
404             ) or return;
405            
406             my $dest;
407             if ( $kind == $SVN::Node::file ) {
408             my $file = (split('/', $args{source}))[-1];
409             unless ( mkdir($args{dir}) ) {
410             $self->{lasterr} = "mkdir($args{dir}) failed, $!";
411             return;
412             }
413             $dest = catdir($args{dir}, $file);
414             } else {
415             $dest = $args{dir};
416             }
417            
418             $self->_svn('export', $args{source}, $dest, $args{version}, 0)
419             or return;
420            
421             return("$args{source}\[$args{version}\]");
422             }
423            
424            
425             =head3 build_version
426            
427             $obj->build_version(
428             category => ,
429             product => ,
430             [versions => {
431             [ => ,]
432             [...,]
433             },]
434             [comment => ,]
435             );
436            
437             Export the sources defined by a product to a temporary directory,
438             run optional build scripts and import everything as new
439             version for the product in the deploy repository. Each defined
440             source will result in a numbered subdirectory (starting at 0) of the
441             product node.
442            
443             Build scripts can create additional numbered directories in the
444             temporary directory (e.g. for putting created binaries into).
445             The build script will be run with the temporary directory as
446             working directory.
447            
448             For sources from SVN repositories (beginning with 'svn://' or
449             'file://'), providing the revision number is mandatory.
450            
451             Returns the revision number of the last commit to the deploy
452             repository (every subdirectory is committed separately).
453            
454             =cut
455            
456             sub build_version {
457             my $self = shift;
458             my %args = $self->_getargs(@_) or return;
459            
460             # check parameters
461             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
462             or return;
463            
464             unless ( exists($root_href->{$args{category}}) ) {
465             $self->{lasterr} = "Category $args{category} does not exist";
466             return;
467             }
468            
469             my $cat_url = join('/', $self->{repo}, $args{category});
470             my $cat_href = $self->_svn('ls', $cat_url, 'HEAD', 0)
471             or return;
472            
473             unless ( exists($cat_href->{$args{product}}) ) {
474             $self->{lasterr} = "Product $args{product} does not exist";
475             return;
476             }
477            
478             # check that version numbers exist for svn sources
479             my $plist = $self->product_list(
480             category => $args{category},
481             product => $args{product},
482             )->{$args{product}};
483             foreach my $entry ( @{ $plist->{source} } ) {
484             if ( $entry =~ m!^svn://! ) {
485             unless (
486             ref($args{versions}) eq 'HASH'
487             and $args{versions}{$entry}
488             ) {
489             $self->{lasterr}
490             = "no version specified for source '$entry'";
491             return;
492             }
493             }
494             }
495            
496             # create dir in tmpdir
497             my $prod_tmp = catdir(
498             $self->{tempdir},
499             join('-', $args{category}, $args{product}),
500             );
501            
502             unless ( -d $prod_tmp ) {
503             unless ( mkdir($prod_tmp) ) {
504             $self->{lasterr} = "mkdir($prod_tmp) failed, $!";
505             return;
506             }
507             }
508            
509             # get files to subdirs 0, 1 ,...
510             my $i = 0;
511             my @exported;
512             foreach my $entry ( @{ $plist->{source} } ) {
513             my $dir = catdir($prod_tmp, $i);
514             if ( $entry =~ m!^(?:svn|file)://! ) {
515             my $ex_str = $self->_export_svn(
516             source => $entry,
517             version => $args{versions}{$entry},
518             dir => $dir,
519             ) or return;
520             push @exported, $ex_str;
521             } else {
522             my $ex_str = $self->_export_fs(
523             source => $entry,
524             dir => $dir,
525             ) or return;
526             push @exported, $ex_str;
527             }
528             $i++;
529             }
530            
531             # run build scripts
532             $ENV{DEPLOY_CATEGORY} = $args{category};
533             $ENV{DEPLOY_PRODUCT} = $args{product};
534             my($ret, $output)
535             = $self->_run_scripts($plist->{build}, $prod_tmp);
536             if ( $ret ) {
537             $self->{lasterr} = "build had errors, output:$output";
538             return;
539             }
540             $self->{output} = "BUILD_OUTPUT:\n" . $output;
541            
542             # import into deploy repo
543             my $prod_url = join('/', $cat_url, $args{product});
544             my $last_revnum = SVN::Deploy::Utils::import_synch(
545             dir => $prod_tmp,
546             url => $prod_url,
547             log => join("\n", 'build:', @exported, $args{comment} || ''),
548             ) or do {
549             $self->{lasterr}
550             = "import_synch failed, $SVN::Deploy::Utils::LastErr";
551             return;
552             };
553            
554             return($last_revnum);
555             }
556            
557            
558             =head3 category_add
559            
560             $obj->category_add(
561             category => ,
562             );
563            
564             Trying to add an already existing category will result in an error.
565            
566             =cut
567            
568             sub category_add {
569             my $self = shift;
570             my %args = $self->_getargs(@_) or return;
571            
572             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
573             or return;
574            
575             for my $cat ( keys(%$root_href) ) {
576             if ( uc($cat) eq uc($args{category}) ) {
577             $self->{lasterr} = "Category $args{category} already exists";
578             return;
579             }
580             }
581            
582             my $url = join('/', $self->{repo}, $args{category});
583            
584             _log "creating >>$url<<";
585             $self->_svn('mkdir', $url) or return;
586            
587             return(1);
588             }
589            
590            
591             =head3 category_delete
592            
593             $obj->category_delete(
594             category => ,
595             );
596            
597             Trying to delete a non existing category or deleting a category
598             with defined products will result in an error.
599            
600             =cut
601            
602             sub category_delete {
603             my $self = shift;
604             my %args = $self->_getargs(@_) or return;
605            
606             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
607             or return;
608            
609             unless ( exists($root_href->{$args{category}}) ) {
610             $self->{lasterr} = "Category $args{category} does not exist";
611             return;
612             }
613            
614             my $cat_url = join('/', $self->{repo}, $args{category});
615             my $cat_href = $self->_svn('ls', $cat_url, 'HEAD', 0)
616             or return;
617            
618             if ( keys(%$cat_href) ) {
619             $self->{lasterr} = "Category $args{category} is not empty";
620             return;
621             }
622            
623             _log "deleting >>$cat_url<<";
624             $self->_svn('delete', $cat_url, 1)
625             or return;
626            
627             return(1);
628             }
629            
630            
631             =head3 category_history
632            
633             $obj->category_history(
634             category => ,
635             from => ,
636             to => ,
637             [csv => ,]
638             [build => <0|1>,]
639             );
640            
641             Returns a reference to an array with history data. If the paramter
642             C<'csv'> evaluates to false the elemets of the array will be hash
643             references looking like this:
644            
645             {
646             'props' => {
647             'source' => 'svn://source_srv/source_repo/trunk/mypath1',
648             'prod_post' => '[os]perl post.pl',
649             'qa_dest' => '/mypath/to/qa/environment',
650             'qa_pre' => '[os]perl pre.pl',
651             'D:version' => '11',
652             'D:target' => 'qa',
653             'prod_pre' => '[os]perl pre.pl',
654             'D:action' => 'deploy start',
655             'prod_dest' => '/mypath/to/prod/environment',
656             'build' => '[os]perl build.pl',
657             'qa_post' => '[os]perl post.pl',
658             'D:reference_id' => '08/15',
659             'D:reference_data' => {
660             'requested_from' => 'Bill',
661             'tested_by' => 'Bob',
662             'pumpking' => 'Beth',
663             },
664             },
665             'time' => '11:06:33',
666             'date' => '2008-05-06',
667             'rev' => 12,
668             'log' => 'first qa rollout',
669             'category' => 'Cat1',
670             'product' => 'Product1',
671             }
672            
673             When C<'csv'> is specified the array will contain strings with
674             concatenated data (with the value of C<'csv'> as concatenator).
675            
676             The first string will contain concatenated header
677             names.
678            
679             The C<'from'> and C<'to'> parameters will acept all the formats the
680             commandline svn client accepts.
681            
682             When C<'build'> is set the build instead of the deploy history will be
683             returned.
684            
685             =cut
686            
687             sub category_history {
688             my $self = shift;
689             my %args = @_;
690            
691             $self->{lasterr} = '';
692            
693             my $catlist = $self->category_list(category => $args{category})
694             or return;
695            
696             my @hist;
697             for my $p ( sort @{ $catlist->{$args{category}} } ) {
698             $args{product} = $p;
699             my $hist_ref = $self->product_history(%args) or return;
700             push @hist, @$hist_ref;
701             }
702            
703             return(\@hist);
704             }
705            
706            
707             =head3 category_list
708            
709             $obj->category_list(
710             [category => ,]
711             );
712            
713             Returns a hashref with category names as keys and a reference to an
714             array of products as values. Specifying a category will return
715             information for this category only.
716            
717             =cut
718            
719            
720             sub category_list {
721             my $self = shift;
722             my %args = $self->_getargs(@_);
723            
724             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
725             or do {
726             $self->{lasterr} = "couldn't get categories from repo";
727             return;
728             };
729            
730             if ( $args{category} and !exists($root_href->{$args{category}}) ) {
731             $self->{lasterr} = "Category $args{category} does not exist";
732             return;
733             }
734            
735             my @cat_list = $args{category} ? ($args{category}) : keys(%$root_href);
736            
737             my %cat_hash;
738             foreach my $cat ( @cat_list ) {
739             my $cat_url = join('/', $self->{repo}, $cat);
740             my $cat_href = $self->_svn('ls', $cat_url, 'HEAD', 0)
741             or return;
742             $cat_hash{$cat} = [keys(%$cat_href)];
743             }
744            
745             return(\%cat_hash);
746             }
747            
748            
749             =head3 category_update
750            
751             $obj->category_update(
752             category => ,
753             new_name => ,
754             );
755            
756             Rename a category. Defined products will not be touched.
757            
758             =cut
759            
760             sub category_update {
761             my $self = shift;
762             my %args = $self->_getargs(@_) or return;
763            
764             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
765             or return;
766            
767             unless ( exists($root_href->{$args{category}}) ) {
768             $self->{lasterr} = "Category $args{category} does not exist";
769             return;
770             }
771            
772             my $old = join('/', $self->{repo}, $args{category});
773             my $new = join('/', $self->{repo}, $args{new_name});
774            
775             _log "renaming >>$old<< to >>$new<<";
776             $self->_svn('move', $old, 'HEAD', $new, 1)
777             or return;
778            
779             return(1);
780             }
781            
782            
783             # add entry to history log
784             # an entry consists of a set of properties:
785             my @hist_values = qw/
786             target version reference_id reference_data action
787             /;
788             sub _hist_add {
789             my($self, %args) = @_;
790            
791             my $cat_url = join('/', $self->{repo}, $args{category});
792             my $prod_url = join('/', $cat_url, $args{product});
793            
794             my $prod_tmp = catdir(
795             $self->{tempdir},
796             join('-', $args{category}, $args{product}, 'props'),
797             );
798            
799             if ( -e $prod_tmp ) {
800             _log "updating $prod_tmp";
801             $self->_svn('update', $prod_tmp, 'HEAD', 0);
802             } else {
803             _log "checking out '$prod_url' to $prod_tmp";
804             $self->_svn('checkout', $prod_url, $prod_tmp, 'HEAD', 0)
805             or return;
806             }
807            
808             my $dir_save = getcwd();
809             chdir($prod_tmp);
810            
811             $args{reference_id} ||= '';
812            
813             # serialize arbitrary external data
814             if ( ref($args{reference_data}) ) {
815             $args{reference_data}
816             = encode_base64(nfreeze($args{reference_data}));
817             }
818            
819             # setting svn properties
820             for my $hv ( @hist_values ) {
821             _log "setting property for $hv";
822             $self->_svn('propset', "D:$hv", $args{$hv}, $prod_tmp, 0)
823             or return;
824             }
825            
826             _log "committing property changes";
827            
828             $self->_svn('log_msg', sub { ${$_[0]} = $args{comment} } )
829             if $args{comment};
830            
831             $self->_svn('commit', $prod_tmp, 0)
832             or return;
833            
834             chdir($dir_save);
835            
836             return(1);
837             }
838            
839            
840             =head3 deploy_version
841            
842             $obj->deploy_version(
843             category => ,
844             product => ,
845             version => ,
846             target => 'qa'|'prod',
847             [reference_id => ,]
848             [reference_data => ,]
849             [comment => ,]
850             );
851            
852             Deploy a previously build revision of a product to the specified
853             target.
854            
855             Defined pre and post scripts (see L) are run before
856             respectively after deploy.
857            
858             The reference parameters exist for storing external references
859             that can later be retrieved by the history functions for auditing
860             purposes. Typicaly this would be information on who did what on
861             whose request.
862            
863             =cut
864            
865             sub deploy_version {
866             my $self = shift;
867             my %args = $self->_getargs(@_) or return;
868            
869             # get release props
870             my $props = $self->product_list(
871             category => $args{category},
872             product => $args{product},
873             )->{$args{product}};
874            
875             my $cat_url = join('/', $self->{repo}, $args{category});
876             my $prod_url = join('/', $cat_url, $args{product});
877            
878             unless ( exists($props->{$args{target}}) ) {
879             $self->{lasterr} = "unknown target '$args{target}'";
880             return;
881             }
882            
883             $self->_hist_add(%args, action => "deploy start")
884             or return;
885            
886             $ENV{DEPLOY_CATEGORY} = $args{category};
887             $ENV{DEPLOY_PRODUCT} = $args{product};
888            
889             # running pre actions
890             my($ret, $output)
891             = $self->_run_scripts(
892             $props->{$args{target}}{pre},
893             $self->{tempdir},
894             );
895             if ( $ret ) {
896             $self->{lasterr} = "pre had errors, output:$output";
897             return;
898             }
899             $self->{output} = "PRE_OUTPUT:\n" . $output;
900            
901             # exporting data
902             my $i = 0;
903             for my $node ( @{ $props->{$args{target}}{dest} } ) {
904            
905             next if $node =~ /^\[none\]$/i;
906            
907             if ( -e $node ) {
908             unless ( -d $node ) {
909             $self->{lasterr}
910             = ">>$node<< exists and is not a directory";
911             return;
912             }
913             } else {
914             eval { mkpath($node) };
915             if ( $@ ) {
916             $self->{lasterr} = "mkpath($node) failed, $@";
917             return;
918             };
919             }
920            
921             my $url = join('/', $prod_url, $i);
922             $self->_svn('export', $url, $node, $args{version}, 1)
923             or return;
924             } continue {
925             ++$i;
926             }
927            
928             # running post actions
929             ($ret, $output)
930             = $self->_run_scripts(
931             $props->{$args{target}}{post},
932             $self->{tempdir},
933             $props->{$args{target}}{dest},
934             );
935             if ( $ret ) {
936             $self->{lasterr} = "post had errors, output:$output";
937             return;
938             }
939            
940             $self->{output} .= "POST_OUTPUT:\n" . $output;
941            
942             $self->_hist_add(%args, action => 'deploy end')
943             or return;
944            
945             return(1);
946             }
947            
948            
949             =head3 get_methods
950            
951             $obj->get_methods();
952            
953             Returns a reference to a hash with all available method names as keys
954             and a hashref for the parameters as values. The parameter hashes have
955             the parameters as keys and the value will consist of 'm' for mandatory
956             and 'o' for optional parameters.
957            
958             =cut
959            
960             sub get_methods { return(dclone(\%arg_check)) }
961            
962            
963             =head3 lasterr
964            
965             $obj->lasterr();
966            
967             Returns the text error message for the last encountered error.
968            
969             =cut
970            
971             sub lasterr { return($_[0]->{lasterr} || '') }
972            
973            
974             =head3 output
975            
976             $obj->output();
977            
978             Returns the output from external scripts after a call to
979             $obj->build_version() or $obj->deploy_version.
980            
981             =cut
982            
983             sub output { return($_[0]->{output} || '') }
984            
985            
986             # relocated check for product_* methods
987             sub _product_args_check {
988             my $self = shift;
989             my %args = @_;
990            
991             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
992             or return;
993            
994             unless ( exists($root_href->{$args{category}}) ) {
995             $self->{lasterr} = "Category $args{category} does not exist";
996             return;
997             }
998            
999             return(1) unless $args{cfg};
1000            
1001             # source is mandatory
1002             unless (
1003             $args{cfg}{source} and ref($args{cfg}{source}) eq 'ARRAY'
1004             and @{ $args{cfg}{source} }
1005             ) {
1006             $self->{lasterr} = "no source specified";
1007             return;
1008             }
1009            
1010             # optional build scripts
1011             if (
1012             exists($args{cfg}{build})
1013             and ref($args{cfg}{build}) ne 'ARRAY'
1014             ) {
1015             $self->{lasterr}
1016             = "parameter 'build' must contain an array ref";
1017             return;
1018             }
1019            
1020             for my $env (qw/qa prod/) {
1021            
1022             for my $key (qw/dest pre post/) {
1023            
1024             if (
1025             exists($args{cfg}{$env}{$key})
1026             and ref($args{cfg}{$env}{$key}) ne 'ARRAY'
1027             ) {
1028             $self->{lasterr}
1029             = "$env: parameter '$key' must contain an array ref";
1030             return;
1031             }
1032             }
1033            
1034             if (
1035             exists($args{cfg}{$env}{dest})
1036             and @{ $args{cfg}{$env}{dest} } )
1037             {
1038             if ( @{ $args{cfg}{$env}{dest} } < @{ $args{cfg}{source} } ) {
1039             $self->{lasterr}
1040             = "$env: destination for one ore more sources missing";
1041             return;
1042             }
1043             }
1044             }
1045            
1046             return(1);
1047             }
1048            
1049            
1050             # relocated set function for product_* methods
1051             sub _product_set_params {
1052             my $self = shift;
1053             my %args = @_;
1054            
1055             my $prod_tmp = catdir(
1056             $self->{tempdir},
1057             join('-', $args{category}, $args{product}, 'props'),
1058             );
1059            
1060             if ( -e $prod_tmp ) {
1061             _log "updating $prod_tmp";
1062             $self->_svn('update', $prod_tmp, 'HEAD', 0)
1063             or return;
1064             } else {
1065             _log "checking out '$args{prod_url}' to $prod_tmp";
1066             $self->_svn('checkout', $args{prod_url}, $prod_tmp, 'HEAD', 0)
1067             or return;
1068             }
1069            
1070             my $dir_save = getcwd();
1071             chdir($prod_tmp);
1072            
1073             for my $param ( qw/build source/ ) {
1074             next unless $args{cfg}{$param};
1075             $self->_svn(
1076             'propset',
1077             $param,
1078             join("\n", @{ $args{cfg}{$param} }),
1079             $prod_tmp,
1080             0,
1081             ) or return;
1082             }
1083            
1084             for my $env (qw/qa prod/) {
1085             for my $key (qw/dest pre post/) {
1086             if ( $args{cfg}{$env}{$key} ) {
1087             $self->_svn(
1088             'propset',
1089             "${env}_$key",
1090             join("\n", @{ $args{cfg}{$env}{$key} }),
1091             $prod_tmp,
1092             0,
1093             ) or return;
1094             }
1095             }
1096             }
1097            
1098             _log "committing property changes";
1099             $self->_svn('commit', $prod_tmp, 0) or return;
1100            
1101             chdir($dir_save);
1102            
1103             return(1);
1104             }
1105            
1106            
1107             =head3 product_add
1108            
1109             my %cfg = (
1110             build => [
1111             '[os]perl build1.pl',
1112             '[os]perl build2.pl',
1113             ],
1114             source => [
1115             'svn://source_srv/source_repo/trunk/mypath1',
1116             'svn://source_srv/source_repo/trunk/mypath2',
1117             ],
1118             qa => {
1119             dest => [
1120             '[none]',
1121             '/mypath/to/qa/environment',
1122             ],
1123             pre => ['[os]perl pre.pl'],
1124             post => ['[os]perl post.pl'],
1125             },
1126             prod => {
1127             dest => [
1128             '[none]',
1129             '/mypath/to/prod/environment',
1130             ],
1131             pre => ['[os]perl pre.pl'],
1132             post => ['[os]perl post.pl'],
1133             },
1134             );
1135            
1136             $obj->product_add(
1137             category => ,
1138             product => ,
1139             [cfg => \%cfg,]
1140             );
1141            
1142             Add a new product to a category. When specifying a destination, you
1143             have to provide a destination for each specified source. '[none]' is a
1144             valid destination, meaning the corresponding path of the deploy
1145             repository will not be exported when calling $obj->deploy_version. You
1146             can have more destinations than sources, e.g. when the build scripts
1147             create additional directories.
1148            
1149             You can create a product without a configuration, but you have to call
1150             $obj->product_update with a valid configuration before calling build
1151             or deploy methods.
1152            
1153             The C<'pre'>, C<'post'> and C<'build'> parameters have to be references to arrays with commands.
1154             The commands must be prefixed by C<'[os]'> and will be run with C (backticks).
1155             This is to be able to add other types of commands in later versions.
1156            
1157             =cut
1158            
1159             sub product_add {
1160             my $self = shift;
1161             my %args = $self->_getargs(@_) or return;
1162            
1163             $self->_product_args_check(%args) or return;
1164            
1165             my $cat_url = join('/', $self->{repo}, $args{category});
1166             my $cat_href = $self->_svn('ls', $cat_url, 'HEAD', 0)
1167             or return;
1168             my $prod_url = join('/', $cat_url, $args{product});
1169            
1170             for my $prod ( keys(%$cat_href) ) {
1171             if ( uc($prod) eq uc($args{product}) ) {
1172             $self->{lasterr} = "Product $args{product} already exists";
1173             return;
1174             }
1175             }
1176            
1177             _log "creating >>$prod_url<<";
1178             $self->_svn('mkdir', $prod_url) or return;
1179            
1180             $args{prod_url} = $prod_url;
1181            
1182             if ( $args{cfg} ) {
1183             $self->_product_set_params(%args) or return;
1184             }
1185            
1186             return(1);
1187             }
1188            
1189            
1190             =head3 product_delete
1191            
1192             $obj->product_add(
1193             category => ,
1194             product => ,
1195             );
1196            
1197             Deletes an existing product.
1198            
1199             =cut
1200            
1201             sub product_delete {
1202             my $self = shift;
1203             my %args = $self->_getargs(@_) or return;
1204            
1205             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
1206             or return;
1207            
1208             unless ( exists($root_href->{$args{category}}) ) {
1209             $self->{lasterr} = "Category $args{category} does not exist";
1210             return;
1211             }
1212            
1213             my $cat_url = join('/', $self->{repo}, $args{category});
1214             my $cat_href = $self->_svn('ls', $cat_url, 'HEAD', 0)
1215             or return;
1216            
1217             unless ( exists($cat_href->{$args{product}}) ) {
1218             $self->{lasterr} = "Product $args{product} does not exist";
1219             return;
1220             }
1221            
1222             my $prod_url = join('/', $cat_url, $args{product});
1223            
1224             _log "deleting >>$prod_url<<";
1225             $self->_svn('delete', $prod_url, 0)
1226             or return;
1227            
1228             return(1);
1229             }
1230            
1231            
1232             =head3 product_history
1233            
1234             $obj->product_history(
1235             category => ,
1236             product => ,
1237             from => ,
1238             to => ,
1239             [csv => ,]
1240             [build => <0|1>,]
1241             );
1242            
1243             See L for a description. product_history just returns
1244             the history for one product.
1245            
1246             =cut
1247            
1248             my @base_headers = qw/Date Time Category Product Revision/;
1249             my @deploy_headers = qw/Action ReferenceID ReferenceData Comment/;
1250             my @build_headers = qw/Built_From/;
1251             sub product_history {
1252             my $self = shift;
1253             my %args = $self->_getargs(@_) or return;
1254            
1255             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
1256             or return;
1257            
1258             unless ( exists($root_href->{$args{category}}) ) {
1259             $self->{lasterr} = "Category $args{category} does not exist";
1260             return;
1261             }
1262            
1263             my $cat_url = join('/', $self->{repo}, $args{category});
1264             my $cat_href = $self->_svn('ls', $cat_url, 'HEAD', 0);
1265            
1266             unless ( exists($cat_href->{$args{product}}) ) {
1267             $self->{lasterr} = "Product $args{product} does not exist";
1268             return;
1269             }
1270            
1271             my $prod_url = join('/', $cat_url, $args{product});
1272            
1273             # get all selected revisions for $prod_url
1274             my @revisions;
1275             $self->_svn(
1276             'log', [$prod_url], $args{from}, $args{to}, 0, 0,
1277             sub {
1278             my($date, $time) =
1279             $_[3] =~ /(\d\d\d\d-\d\d-\d\d)T(\d\d:\d\d:\d\d)/;
1280             $_[4] =~ s/\n/ /g;
1281             push @revisions, {
1282             category => $args{category},
1283             product => $args{product},
1284             rev => $_[1],
1285             date => $date,
1286             time => $time,
1287             log => $_[4]
1288             };
1289             },
1290             ) or return;
1291            
1292             my @out_revs;
1293             if ( $args{build} ) {
1294            
1295             # filter for log messages beginning with "build:\n"
1296             my %seen;
1297             @out_revs = map {
1298             $seen{$_->{log}} ? () : do { $seen{$_->{log}} = 1; $_ }
1299             } sort {
1300             $b->{rev} <=> $a->{rev}
1301             } grep {
1302             $_->{log} =~ /^build:/
1303             } @revisions;
1304            
1305             } else {
1306            
1307             # filter for deploy information properties
1308             for my $r ( @revisions ) {
1309             # get properties for the revision
1310             my $props = $self->_svn('proplist', $prod_url, $r->{rev}, 0)
1311             or return;
1312             next unless @$props;
1313             $r->{props} = $props->[0]->prop_hash;
1314            
1315             if ( $r->{props}{'D:reference_data'} ) {
1316             $r->{props}{'D:reference_data'}
1317             = thaw(decode_base64($r->{props}{'D:reference_data'}));
1318             }
1319             }
1320            
1321             @out_revs = grep {
1322             $_->{props}{'D:version'}
1323             } @revisions;
1324            
1325             }
1326            
1327             return(\@out_revs) unless $args{csv};
1328            
1329             # csv output
1330            
1331             my @headers = ( @base_headers, $args{build} ? @build_headers : @deploy_headers );
1332            
1333             push(my @csv, join($args{csv}, @headers));
1334            
1335             push(
1336             @csv,
1337             join(
1338             $args{csv},
1339             @$_{qw/date time/},
1340             $_->{category},
1341             $_->{product},
1342             $args{build}
1343             ? ()
1344             : @{$_->{props}}{qw/
1345             D:version D:action
1346             D:reference_id D:reference_data
1347             /},
1348             $_->{log},
1349             )
1350             ) for @out_revs;
1351            
1352             return(\@csv);
1353             }
1354            
1355            
1356             =head3 product_list
1357            
1358             $obj->product_list(
1359             category => ,
1360             [product => ,]
1361             );
1362            
1363             Returns a reference to a hash with product names as keys and a
1364             reference to the product's configuration hash as values. The structure
1365             is the same as the one specified for the parameter cfg in
1366             $obj->product_add or $obj->product_update.
1367            
1368             =cut
1369            
1370             sub product_list {
1371             my $self = shift;
1372             my %args = $self->_getargs(@_) or return;
1373            
1374             my $root_href = $self->_svn('ls', $self->{repo}, 'HEAD', 0)
1375             or return;
1376            
1377             unless ( exists($root_href->{$args{category}}) ) {
1378             $self->{lasterr} = "Category $args{category} does not exist";
1379             return;
1380             }
1381            
1382             my $cat_url = join('/', $self->{repo}, $args{category});
1383             my $cat_href = $self->_svn('ls', $cat_url, 'HEAD', 0);
1384            
1385             if ( $args{product} and !exists($cat_href->{$args{product}}) ) {
1386             $self->{lasterr} = "Product $args{product} does not exist";
1387             return;
1388             }
1389            
1390             my @prod_list = $args{product} ? ($args{product}) : keys(%$cat_href);
1391            
1392             my %prod_hash;
1393             foreach my $prod ( @prod_list ) {
1394             my $prod_url = join('/', $cat_url, $prod);
1395             my $prop_ref = $self->_svn(
1396             'proplist', $prod_url, 'HEAD', 0
1397             ) or return;
1398             my $props = $prop_ref->[0]
1399             ? $prop_ref->[0]->prop_hash
1400             : {};
1401            
1402             for my $prop ( qw/build source/ ) {
1403             $props->{$prop}
1404             = [split(/\n/, $props->{$prop} || '')];
1405             }
1406            
1407             for my $env (qw/qa prod/) {
1408             for my $key (qw/dest pre post/) {
1409             $props->{$env}{$key}
1410             = [split(/\n/, $props->{"${env}_$key"} || '')];
1411             delete($props->{"${env}_$key"});
1412             }
1413             }
1414            
1415             $prod_hash{$prod} = $props;
1416             }
1417            
1418             return(\%prod_hash);
1419             }
1420            
1421            
1422             =head3 product_update
1423            
1424             $obj->product_update(
1425             category => ,
1426             product => ,
1427             [cfg => \%cfg,]
1428             [new_name => ,]
1429             );
1430            
1431             Rename an existing Product and/or change its configuration. See
1432             $obj->product_add for the description of the configuration hash.
1433            
1434             =cut
1435            
1436             sub product_update {
1437             my $self = shift;
1438             my %args = $self->_getargs(@_) or return;
1439            
1440             $self->_product_args_check(%args) or return;
1441            
1442             my $cat_url = join('/', $self->{repo}, $args{category});
1443             my $cat_href = $self->_svn('ls', $cat_url, 'HEAD', 0)
1444             or return;
1445             my $prod_url = join('/', $cat_url, $args{product});
1446            
1447             unless ( exists($cat_href->{$args{product}}) ) {
1448             $self->{lasterr} = "Product $args{product} does not exist";
1449             return;
1450             }
1451            
1452             if ( $args{new_name} ) {
1453            
1454             my $old = join('/', $self->{repo}, $args{category}, $args{product});
1455             my $new = join('/', $self->{repo}, $args{category}, $args{new_name});
1456            
1457             _log "renaming >>$old<< to >>$new<<";
1458             $self->_svn('move', $old, 'HEAD', $new, 1)
1459             or return;
1460            
1461             $args{product} = $args{new_name};
1462             delete($args{new_name});
1463             }
1464            
1465             $args{prod_url} = $prod_url;
1466            
1467             if ( $args{cfg} ) {
1468             $self->_product_set_params(%args) or return;
1469             }
1470            
1471             return(1);
1472             }
1473            
1474            
1475             1;
1476            
1477            
1478             =head1 AUTHOR
1479            
1480             Thomas Kratz Etomk@cpan.orgE
1481            
1482             Copyright (c) 2008 Thomas Kratz. All rights reserved.
1483            
1484             This library is free software; you can redistribute it and/or modify
1485             it under the same terms as Perl itself, either Perl version 5.8.8 or,
1486             at your option, any later version of Perl 5 you may have available.
1487            
1488             =cut