File Coverage

blib/lib/App/MrShell.pm
Criterion Covered Total %
statement 177 313 56.5
branch 46 124 37.1
condition 2 20 10.0
subroutine 26 41 63.4
pod 12 20 60.0
total 263 518 50.7


line stmt bran cond sub pod time code
1             package App::MrShell;
2              
3 7     7   54544 use strict;
  7         9813  
  7         442  
4 7     7   55 use warnings;
  7         16  
  7         245  
5              
6 7     7   54 use Carp;
  7         15  
  7         808  
7 7     7   8281 use POSIX;
  7         87117  
  7         65  
8 7     7   63626 use Config::Tiny;
  7         10771  
  7         340  
9 7     7   8632 use POE qw( Wheel::Run );
  7         456292  
  7         63  
10 7     7   1496780 use Term::ANSIColor qw(:constants);
  7         66491  
  7         7148  
11 7     7   10225 use Text::Balanced;
  7         99944  
  7         10346  
12              
13             our $VERSION = '2.0207';
14             our @DEFAULT_SHELL_COMMAND = (ssh => '-o', 'BatchMode yes', '-o', 'StrictHostKeyChecking no', '-o', 'ConnectTimeout 20', '[%u]-l', '[]%u', '%h');
15              
16             # new {{{
17             sub new {
18 5     5 0 23801 my $class = shift;
19 5         144 my $this = bless { hosts=>[], cmd=>[], _shell_cmd=>[@DEFAULT_SHELL_COMMAND] }, $class;
20              
21 5         36 return $this;
22             }
23             # }}}
24              
25             # _process_space_delimited {{{
26             sub _process_space_delimited {
27 0     0   0 my $this = shift;
28 0         0 my $that = shift;
29              
30 0         0 my @output;
31 0         0 while( $that ) {
32 0 0       0 if( $that =~ m/^\s*['"]/ ) {
33 0         0 my ($tok, $rem) = Text::Balanced::extract_delimited($that, qr(["']));
34              
35 0 0 0     0 ($tok =~ s/^(['"])// and $tok =~ s/$1$//) or die "internal error processing space delimited";
36              
37 0         0 push @output, $tok;
38 0         0 $that = $rem;
39              
40             } else {
41 0         0 my ($tok, $rem) = split ' ', $that, 2;
42              
43 0         0 push @output, $tok;
44 0         0 $that = $rem;
45             }
46             }
47              
48             return @output
49 0         0 }
50             # }}}
51             # _process_hosts {{{
52             sub _process_hosts {
53 3     3   6 my $this = shift;
54              
55 3         4 my @h = do {
56 3 0       14 my @tmp = map { my $k = $_; $k =~ s/^\@// ? @{$this->{groups}{$k} or die "couldn't find group: \@$k\n"} : $_ } @_;
  4 50       7  
  4         23  
  0         0  
57 3         5 my %h; @h{@tmp} = ();
  3         11  
58 3         11 for(keys %h) {
59 4 50       21 if(my ($k) = m/^\-(.+)/) {
60 0         0 delete $h{$_};
61 0         0 delete $h{$k};
62             }
63             }
64 3         16 sort keys %h;
65             };
66              
67 3   100     24 my $o = my $l = $this->{_host_width} || 0;
68 3         7 for( map { length $this->_host_route_to_nick($_) } @h ) {
  4         20  
69 4 100       22 $l = $_ if $_>$l
70             }
71              
72 3 100       15 $this->{_host_width} = $l if $l != $o;
73              
74 3         14 return @h;
75             }
76             # }}}
77             # _host_route_to_nick {{{
78             sub _host_route_to_nick {
79 16     16   27 my $this = shift;
80              
81 16         398 return join "", shift =~ m/(?:!|[^!]+$)/g
82             }
83             # }}}
84              
85             # set_shell_command_option {{{
86             sub set_shell_command_option {
87 2     2 1 4 my $this = shift;
88 2         5 my $arg = shift;
89              
90 2 50       12 if( ref($arg) eq "ARRAY" ) {
91 2         16 $this->{_shell_cmd} = [ @$arg ]; # make a real copy
92              
93             } else {
94 0   0     0 $this->{_shell_cmd} = [ $this->_process_space_delimited($arg||"") ];
95             }
96              
97 2         14 return $this;
98             }
99             # }}}
100             # set_group_option {{{
101             sub set_group_option {
102 0     0 1 0 my $this = shift;
103 0   0     0 my $groups = ($this->{groups} ||= {});
104              
105 0         0 my ($name, $value);
106 0   0     0 while( ($name, $value) = splice @_, 0, 2 and $name and $value ) {
      0        
107 0 0       0 if( ref($value) eq "ARRAY" ) {
108 0         0 $groups->{$name} = [ @$value ]; # make a real copy
109              
110             } else {
111 0         0 $groups->{$name} = [ $this->_process_space_delimited( $value ) ];
112             }
113             }
114              
115 0         0 my @groups = keys %{ $this->{groups} };
  0         0  
116 0         0 my $replace_limit = 30;
117 0         0 REPLACE_GROPUS: {
118 0         0 my $replaced = 0;
119              
120 0         0 for my $group (@groups) {
121 0         0 my $hosts = $groups->{$group};
122              
123 0         0 my $r = 0;
124 0         0 for(@$hosts) {
125 0 0       0 if( m/^@(.+)/ ) {
126 0 0       0 if( my $g = $groups->{$1} ) {
127 0         0 $_ = $g;
128              
129 0         0 $r ++;
130             }
131             }
132             }
133              
134 0 0       0 if( $r ) {
135 0         0 my %h;
136 0 0       0 @h{ map {ref $_ ? @$_ : $_} @$hosts } = ();
  0         0  
137 0         0 $groups->{$group} = [ keys %h ];
138 0         0 $replaced ++;
139             }
140             }
141              
142 0         0 $replace_limit --;
143 0 0       0 last if $replace_limit < 1;
144 0 0       0 redo if $replaced;
145             }
146              
147 0         0 return $this;
148             }
149             # }}}
150             # set_logfile_option {{{
151             sub set_logfile_option {
152 0     0 1 0 my $this = shift;
153 0         0 my $file = shift;
154 0         0 my $trunc = shift;
155              
156 0 0       0 unless( our $already_compiled++ ) {
157 0         0 my $load_ansi_filter_package = q {
158             package App::MrShell::ANSIFilter;
159             use Symbol;
160             use Tie::Handle;
161             use base 'Tie::StdHandle';
162              
163             my %orig;
164              
165             sub PRINT {
166             my $this = shift;
167             my @them = @_;
168             s/\e\[[\d;]+m//g for @them;
169             print {$orig{$this}} @them;
170             }
171              
172             sub filtered_handle {
173             my $pfft = gensym();
174             my $it = tie *{$pfft}, __PACKAGE__ or die $!;
175             $orig{$it} = shift;
176             $pfft;
177             }
178              
179             1};
180              
181 0 0       0 eval $load_ansi_filter_package or die $@; ## no critic -- sometimes this kind of eval is ok
182             # (This probably isn't one of them.)
183             }
184              
185 0 0       0 open my $log, ($trunc ? ">" : ">>"), $file or croak "couldn't open $file for write: $!"; ## no critic -- I mean to pass this around, shut up
    0          
186              
187 0         0 $this->{_log_fh} = App::MrShell::ANSIFilter::filtered_handle($log);
188              
189 0         0 return $this;
190             }
191             # }}}
192             # set_debug_option {{{
193             sub set_debug_option {
194 0     0 1 0 my $this = shift;
195 0         0 my $val = shift;
196              
197             # -d 0 and -d 1 are the same
198             # -d 2 is a level up, -d 4 is even more
199             # $val==undef clears the setting
200              
201 0 0       0 if( not defined $val ) {
202 0         0 delete $this->{debug};
203 0         0 return $this;
204             }
205              
206 0 0       0 $this->{debug} = $val ? $val : 1;
207              
208 0         0 return $this;
209             }
210             # }}}
211             # set_no_command_escapes_option {{{
212             sub set_no_command_escapes_option {
213 0     0 1 0 my $this = shift;
214              
215 0   0     0 $this->{no_command_escapes} = shift || 0;
216              
217 0         0 return $this;
218             }
219             # }}}
220              
221             # groups {{{
222             sub groups {
223 0     0 0 0 my $this = shift;
224              
225 0 0       0 return unless $this->{groups};
226 0 0       0 return wantarray ? %{$this->{groups}} : $this->{groups};
  0         0  
227             }
228             # }}}
229              
230             # set_usage_error($&) {{{
231             sub set_usage_error($&) { ## no critic -- prototypes are bad how again?
232 0     0 1 0 my $this = shift;
233 0         0 my $func = shift;
234 0         0 my $pack = caller;
235 0         0 my $name = $pack . "::$func";
236 0         0 my @args = @_;
237              
238             $this->{_usage_error} = sub {
239 7     7   81 no strict 'refs'; ## no critic -- how would you call this by name without this?
  7         17  
  7         37734  
240 0     0   0 $name->(@args)
241 0         0 };
242              
243 0         0 return $this;
244             }
245             # }}}
246             # read_config {{{
247             sub read_config {
248 0     0 1 0 my ($this, $that) = @_;
249              
250 0 0       0 $this->{_conf} = Config::Tiny->read($that) if -f $that;
251              
252 0         0 for my $group (keys %{ $this->{_conf}{groups} }) {
  0         0  
253 0         0 $this->set_group_option( $group => $this->{_conf}{groups}{$group} );
254             }
255              
256 0 0       0 if( my $c = $this->{_conf}{options}{'shell-command'} ) {
257 0         0 $this->set_shell_command_option( $c );
258             }
259              
260 0 0       0 if( my $c = $this->{_conf}{options}{'logfile'} ) {
261 0         0 my $t = $this->{_conf}{options}{'truncate-logfile'};
262 0 0       0 my $v = ($t ? 1:0);
263 0 0       0 $v = 0 if $t =~ m/(?:no|false)/i;
264              
265 0         0 $this->set_logfile_option($c, $v);
266             }
267              
268 0 0       0 if( my $c = $this->{_conf}{options}{'no-command-escapes'} ) {
269 0 0       0 my $v = ($c ? 1:0);
270 0 0       0 $v = 0 if $c =~ m/(?:no|false)/i;
271              
272 0         0 $this->set_no_command_escapes_option( $v );
273             }
274              
275 0         0 return $this;
276             }
277             # }}}
278             # set_hosts {{{
279             sub set_hosts {
280 3     3 1 6 my $this = shift;
281              
282 3         12 $this->{hosts} = [ $this->_process_hosts(@_) ];
283              
284 3         60 return $this;
285             }
286             # }}}
287             # queue_command {{{
288             sub queue_command {
289 4     4 1 8 my $this = shift;
290 4         6 my @hosts = @{$this->{hosts}};
  4         11  
291              
292 4 50       12 unless( @hosts ) {
293 0 0       0 if( my $h = $this->{_conf}{options}{'default-hosts'} ) {
294 0         0 @hosts = $this->_process_hosts( $this->_process_space_delimited($h) );
295              
296             } else {
297 0 0       0 if( my $e = $this->{_usage_error} ) {
298 0         0 warn "Error: no hosts specified\n";
299 0         0 $e->();
300              
301             } else {
302 0         0 croak "set_hosts before issuing queue_command";
303             }
304             }
305             }
306              
307 4         8 for my $h (@hosts) {
308 6         8 push @{$this->{_cmd_queue}{$h}}, [@_]; # make a real copy
  6         26  
309             }
310              
311 4         17 return $this;
312             }
313             # }}}
314             # run_queue {{{
315             sub run_queue {
316 2     2 1 5 my $this = shift;
317              
318             $this->{_session} = POE::Session->create( inline_states => {
319 2     2   551 _start => sub { $this->poe_start(@_) },
320 0     0   0 child_stdout => sub { $this->line(1, @_) },
321 0     0   0 child_stderr => sub { $this->line(2, @_) },
322 6     6   10876 child_signal => sub { $this->sigchld(@_) },
323 30     30   6013 stall_close => sub { $this->_close(@_) },
324 0     0   0 ErrorEvent => sub { $this->error_event },
325 2         89 });
326              
327 2         556 POE::Kernel->run();
328              
329 2         6153 return $this;
330             }
331             # }}}
332              
333             # std_msg {{{
334             sub std_msg {
335 11     11 0 2144 my $this = shift;
336 11         23 my $host = shift;
337 11         27 my $cmdno = shift;
338 11         15 my $fh = shift;
339 11         20 my $msg = shift;
340              
341 11 100       108 my $host_msg = $host ? $this->_host_route_to_nick($host) . ": " : "";
342 11         1144 my $time_str = strftime('%H:%M:%S', localtime);
343              
344 11 50       364 print $time_str,
345             sprintf(' %-*s', $this->{_host_width}+2, $host_msg),
346             ( $fh==2 ? ('[',BOLD,YELLOW,'stderr',RESET,'] ') : () ), $msg, RESET, "\n";
347              
348 11 50       1452 if( $this->{_log_fh} ) {
349 0         0 $time_str = strftime('%Y-%m-%d %H:%M:%S', localtime);
350              
351             # No point in printing colors, stripped anyway. Formatting columns is
352             # equally silly -- in append mode anyway.
353 0 0       0 $host_msg = $host ? "$host: " : "";
354 0 0       0 print {$this->{_log_fh}} "$time_str $host_msg", ($fh==2 ? "[stderr] " : ""), $msg, "\n";
  0         0  
355             }
356              
357 11         38 return $this;
358             }
359             # }}}
360              
361             # line {{{
362             sub line {
363 0     0 0 0 my $this = shift;
364 0         0 my $fh = shift;
365 0         0 my ($line, $wid) = @_[ ARG0, ARG1 ];
366 0         0 my ($kid, $host, $cmdno, $lineno) = @{$this->{_wid}{$wid}};
  0         0  
367              
368 0         0 $$lineno ++;
369 0         0 $this->std_msg($host, $cmdno, $fh, $line);
370              
371 0         0 return;
372             }
373             # }}}
374              
375             # sigchld {{{
376             sub _sigchld_exit_error {
377 0     0   0 my $this = shift;
378 0         0 my ($pid, $exit) = @_[ ARG1, ARG2 ];
379 0         0 $exit >>= 8;
380              
381 0         0 $this->std_msg("?", -1, 0, BOLD.RED."-- sigchld received for untracked pid($pid, $exit), probably a bug in Mr. Shell --");
382              
383 0         0 return;
384             }
385              
386             sub sigchld {
387 6     6 0 14 my $this = shift; # ARG0 is the signal name string
388 6 50       10 my ($kid, $host, $cmdno, @c) = @{ $this->{_pid}{ $_[ARG1] } || return $this->_sigchld_exit_error(@_) };
  6         47  
389              
390             # NOTE: this usually isn't an error, sometimes the sigchild will arrive
391             # before the handles are "closed" in the traditional sense. We get error
392             # eveents for errors.
393             #### # $this->std_msg($host, $cmdno, 0, RED.'-- error: unexpected child exit --');
394              
395             # NOTE: though, the exit value may indicate an actual error.
396 6 50       35 if( (my $exit = $_[ARG2]) != 0 ) {
397             # XXX: I'd like to do more here but I'm waiting to see what Paul
398             # Fenwick has to say about it.
399 0         0 $exit >>= 8;
400              
401 0         0 my $reset = RESET;
402 0         0 my $black = BOLD.BLACK;
403 0         0 my $red = RESET.RED;
404              
405 0         0 $this->std_msg($host, $cmdno, 0, "$black-- shell exited with nonzero status: $red$exit$black --");
406             }
407              
408 6         30 $_[KERNEL]->yield( stall_close => $kid->ID, 0 );
409              
410 6         508 return;
411             }
412             # }}}
413             # _close {{{
414             sub _close {
415 30     30   46 my $this = shift;
416 30         63 my ($wid, $count) = @_[ ARG0, ARG1 ];
417              
418 30 50       409 return unless $this->{_wid}{$wid}; # sometimes we'll get a sigchild *and* a close event
419              
420             # NOTE: I was getting erratic results with some fast running commands and
421             # guessed that I was sometimes getting the close event before the stdout
422             # event. Waiting through the kernel loop once is probably enough, but I
423             # used 3 because it does't hurt either.
424              
425 30 100       64 if( $count > 3 ) {
426 6         10 my ($kid, $host, $cmdno, $lineno, @c) = @{ delete $this->{_wid}{$wid} };
  6         35  
427              
428 6 50       387 $this->std_msg($host, $cmdno++, 0, BOLD.BLACK.'-- eof --') if $$lineno == 0;
429 6 100       24 if( @c ) {
430 2         14 $this->start_queue_on_host($_[KERNEL] => $host, $cmdno, @c);
431 2         311 $this->std_msg($host, $cmdno, 0, BOLD.BLACK."-- starting: @{$c[0]} --");
  2         189  
432             }
433              
434 6         41 delete $this->{_pid}{ $kid->PID };
435              
436             } else {
437 24         90 $_[KERNEL]->yield( stall_close => $wid, $count+1 );
438             }
439              
440 30         5005 return;
441             }
442             # }}}
443             # error_event {{{
444             sub error_event {
445 0     0 0 0 my $this = shift;
446 0         0 my ($operation, $errnum, $errstr, $wid) = @_[ARG0 .. ARG3];
447 0 0       0 my ($kid, $host, $cmdno, @c) = @{ delete $this->{_wid}{$wid} || return };
  0         0  
448 0         0 delete $this->{_pid}{ $kid->PID };
449              
450 0 0 0     0 $errstr = "remote end closed" if $operation eq "read" and not $errnum;
451 0         0 $this->std_msg($host, $cmdno, 0, RED."-- $operation error $errnum: $errstr --");
452              
453 0         0 return;
454             }
455             # }}}
456              
457             # set_subst_vars {{{
458             sub set_subst_vars {
459 17     17 1 1783 my $this = shift;
460              
461 17         89 while( my ($k,$v) = splice @_, 0, 2 ) {
462 23 50       205 $this->{_subst}{$k} = $v unless exists $this->{_subst}{$k};
463             }
464              
465 17         59 return $this;
466             }
467             # }}}
468             # subst_cmd_vars {{{
469             sub subst_cmd_vars {
470 17     17 1 25 my $this = shift;
471 17 50       37 my %h = %{ delete($this->{_subst}) || {} };
  17         131  
472 17         59 my $host = $h{'%h'};
473              
474 17         83 my @c = @_; # copy this so it doesn't get altered upstream
475             # (I'd swear I shoulnd't need to do this at all, but it's
476             # proovably true that I do.)
477              
478 17 100       103 if( $host =~ m/\b(?!<\\)!/ ) {
479 7         28 my @hosts = split '!', $host;
480              
481 7         14 my @indexes_of_replacements;
482 7         27 for(my $i=0; $i<@c; $i++) {
483 149 100       391 if( $c[$i] eq '%h' ) {
484 9         103 splice @c, $i, 1, $hosts[0];
485              
486 9         21 push @indexes_of_replacements, $i;
487              
488 9         29 for my $h (reverse @hosts[1 .. $#hosts]) {
489 15         73 splice @c, $i+1, 0, @c[0 .. $i-1] => $h;
490 15         28 push @indexes_of_replacements, $i+1 + $indexes_of_replacements[-1];
491              
492 15 50       39 unless( $this->{no_command_escapes} ) {
493 15         49 for my $arg (@c[$i+1 .. $#c]) {
494              
495             # NOTE: This escaping is going to be an utter pain to maintain...
496              
497 170         2546 $arg =~ s/([`\$])/\\$1/g;
498              
499 170 100       533 if( $arg =~ m/[\s()]/ ) {
500 25         105 $arg =~ s/([\\"])/\\$1/g;
501 25         58 $arg = "\"$arg\"";
502             }
503             }
504             }
505             }
506             }
507             }
508              
509 7         13 my $beg = 0;
510 7         18 for my $i (@indexes_of_replacements) {
511 24 100       75 if( $c[$i] =~ s/^([\w.\-_]+)@// ) {
512 5         12 my $u = $1;
513 5         21 for(@c[$beg .. $i-1]) {
514 24         49 s/^(\[\%u\]|\[\](?=\%u))//;
515 24 100       62 $_ = $u if $_ eq '%u';
516             }
517              
518             } else {
519             # NOTE: there's really no need to go through and remove [%u]
520             # conditional options, they'll automatically get nuked below
521 19         28 $c[$i] =~ s/\\@/@/g;
522             }
523 24         38 $beg = $i+1;
524             }
525              
526 7         28 delete $h{'%h'};
527              
528             } else {
529 10         31 $h{'%h'} =~ s/\\!/!/g;
530             }
531              
532 17 100       53 if( $h{'%h'} ) {
533 10 100       69 $h{'%u'} = $1 if $h{'%h'} =~ s/^([\w.\-_]+)@//;
534 10         33 $h{'%h'} =~ s/\\@/@/g;
535             }
536              
537 194 100       650 @c = map {exists $h{$_} ? $h{$_} : $_}
  4         77  
538 220 100       668 map { m/^\[([^\[\]]+)\]/ ? ($h{$1} ? do{s/^\[\Q$1\E\]//; $_} : ()) : ($_) } ## no critic: why on earth not?
  4 100       16  
  220         295  
539 17         52 map { s/\[\]\%(\w+)/[\%$1]\%$1/; $_ } ## no critic: why on earth not?
  220         316  
540             @c;
541              
542 17 50       109 if( $this->{debug} ) {
543 0         0 local $" = ")(";
544 0         0 $this->std_msg($host, $h{'%n'}, 0, BOLD.BLACK."DEBUG: exec(@c)");
545             }
546              
547 17         202 return @c;
548             }
549             # }}}
550             # start_queue_on_host {{{
551             sub start_queue_on_host {
552 6     6 0 22 my ($this, $kernel => $host, $cmdno, $cmd, @next) = @_;
553              
554             # NOTE: used (and deleted) by subst_cmd_vars
555 6         41 $this->set_subst_vars(
556             '%h' => $host,
557             '%n' => $cmdno,
558             );
559              
560 6         27 my $kid = POE::Wheel::Run->new(
561 6         13 Program => [ my @debug_rq = ($this->subst_cmd_vars(@{$this->{_shell_cmd}} => @$cmd)) ],
562             StdoutEvent => "child_stdout",
563             StderrEvent => "child_stderr",
564             CloseEvent => "child_close",
565             );
566              
567 6         47955 $kernel->sig_child( $kid->PID, "child_signal" );
568              
569 6         1036 my $lineno = 0;
570 6         82 my $info = [ $kid, $host, $cmdno, \$lineno, @next ];
571 6         48 $this->{_wid}{ $kid->ID } = $this->{_pid}{ $kid->PID } = $info;
572              
573 6         252 return;
574             }
575             # }}}
576              
577             # poe_start {{{
578             sub poe_start {
579 2     2 0 4 my $this = shift;
580              
581 2         4 my %starting;
582 2         4 my @hosts = keys %{ $this->{_cmd_queue} };
  2         10  
583 2         14 for my $host (@hosts) {
584 4         11 my @c = @{ $this->{_cmd_queue}{$host} };
  4         17  
585              
586 4         24 $this->start_queue_on_host($_[KERNEL] => $host, 1, @c);
587 4         5 push @{$starting{"@{$c[0]}"}}, $host;
  4         20  
  4         66  
588             }
589              
590 2         33 for my $message (keys %starting) {
591 3         12 my @hosts = @{ $starting{$message} };
  3         20  
592              
593 3 100       35 if( @hosts == 1 ) {
594 2         32 $this->std_msg($this->_host_route_to_nick($hosts[0]), 1, 0, BOLD.BLACK."-- starting: $message --");
595              
596             } else {
597 1         50 $this->std_msg("", 1, 0, BOLD.BLACK."-- starting: $message on @hosts --");
598             }
599             }
600              
601 2         44 delete $this->{_cmd_queue};
602              
603 2         80 return;
604             }
605             # }}}
606              
607             1;