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   54890 use strict;
  7         17  
  7         304  
4 7     7   40 use warnings;
  7         14  
  7         304  
5              
6 7     7   53 use Carp;
  7         19  
  7         763  
7 7     7   7253 use POSIX;
  7         60609  
  7         51  
8 7     7   42035 use Config::Tiny;
  7         9118  
  7         335  
9 7     7   7180 use POE qw( Wheel::Run );
  7         432442  
  7         51  
10 7     7   1095480 use Term::ANSIColor qw(:constants);
  7         64694  
  7         6773  
11 7     7   9447 use Text::Balanced;
  7         97389  
  7         10449  
12              
13             our $VERSION = '2.0207_1';
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 6420 my $class = shift;
19 5         73 my $this = bless { hosts=>[], cmd=>[], _shell_cmd=>[@DEFAULT_SHELL_COMMAND] }, $class;
20              
21 5         31 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   7 my $this = shift;
54              
55 3         7 my @h = do {
56 3 0       6 my @tmp = map { my $k = $_; $k =~ s/^\@// ? @{$this->{groups}{$k} or die "couldn't find group: \@$k\n"} : $_ } @_;
  4 50       9  
  4         21  
  0         0  
57 3         8 my %h; @h{@tmp} = ();
  3         11  
58 3         10 for(keys %h) {
59 4 50       51 if(my ($k) = m/^\-(.+)/) {
60 0         0 delete $h{$_};
61 0         0 delete $h{$k};
62             }
63             }
64 3         19 sort keys %h;
65             };
66              
67 3   100     21 my $o = my $l = $this->{_host_width} || 0;
68 3         7 for( map { length $this->_host_route_to_nick($_) } @h ) {
  4         15  
69 4 100       21 $l = $_ if $_>$l
70             }
71              
72 3 100       15 $this->{_host_width} = $l if $l != $o;
73              
74 3         13 return @h;
75             }
76             # }}}
77             # _host_route_to_nick {{{
78             sub _host_route_to_nick {
79 16     16   31 my $this = shift;
80              
81 16         211 return join "", shift =~ m/(?:!|[^!]+$)/g
82             }
83             # }}}
84              
85             # set_shell_command_option {{{
86             sub set_shell_command_option {
87 2     2 1 6 my $this = shift;
88 2         4 my $arg = shift;
89              
90 2 50       12 if( ref($arg) eq "ARRAY" ) {
91 2         17 $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         15 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   87 no strict 'refs'; ## no critic -- how would you call this by name without this?
  7         18  
  7         37755  
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 8 my $this = shift;
281              
282 3         14 $this->{hosts} = [ $this->_process_hosts(@_) ];
283              
284 3         52 return $this;
285             }
286             # }}}
287             # queue_command {{{
288             sub queue_command {
289 4     4 1 6 my $this = shift;
290 4         7 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         9 for my $h (@hosts) {
308 6         7 push @{$this->{_cmd_queue}{$h}}, [@_]; # make a real copy
  6         42  
309             }
310              
311 4         18 return $this;
312             }
313             # }}}
314             # run_queue {{{
315             sub run_queue {
316 2     2 1 4 my $this = shift;
317              
318             $this->{_session} = POE::Session->create( inline_states => {
319 2     2   549 _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   9719 child_signal => sub { $this->sigchld(@_) },
323 30     30   5080 stall_close => sub { $this->_close(@_) },
324 0     0   0 ErrorEvent => sub { $this->error_event },
325 2         66 });
326              
327 2         1140 POE::Kernel->run();
328              
329 2         4050 return $this;
330             }
331             # }}}
332              
333             # std_msg {{{
334             sub std_msg {
335 11     11 0 2088 my $this = shift;
336 11         23 my $host = shift;
337 11         18 my $cmdno = shift;
338 11         15 my $fh = shift;
339 11         23 my $msg = shift;
340              
341 11 100       81 my $host_msg = $host ? $this->_host_route_to_nick($host) . ": " : "";
342 11         1623 my $time_str = strftime('%H:%M:%S', localtime);
343              
344 11 50       353 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       6090 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         55 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 12 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         41  
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       32 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         392 return;
411             }
412             # }}}
413             # _close {{{
414             sub _close {
415 30     30   38 my $this = shift;
416 30         57 my ($wid, $count) = @_[ ARG0, ARG1 ];
417              
418 30 50       111 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       62 if( $count > 3 ) {
426 6         16 my ($kid, $host, $cmdno, $lineno, @c) = @{ delete $this->{_wid}{$wid} };
  6         36  
427              
428 6 50       275 $this->std_msg($host, $cmdno++, 0, BOLD.BLACK.'-- eof --') if $$lineno == 0;
429 6 100       27 if( @c ) {
430 2         17 $this->start_queue_on_host($_[KERNEL] => $host, $cmdno, @c);
431 2         267 $this->std_msg($host, $cmdno, 0, BOLD.BLACK."-- starting: @{$c[0]} --");
  2         181  
432             }
433              
434 6         44 delete $this->{_pid}{ $kid->PID };
435              
436             } else {
437 24         278 $_[KERNEL]->yield( stall_close => $wid, $count+1 );
438             }
439              
440 30         4170 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 2025 my $this = shift;
460              
461 17         93 while( my ($k,$v) = splice @_, 0, 2 ) {
462 23 50       207 $this->{_subst}{$k} = $v unless exists $this->{_subst}{$k};
463             }
464              
465 17         73 return $this;
466             }
467             # }}}
468             # subst_cmd_vars {{{
469             sub subst_cmd_vars {
470 17     17 1 29 my $this = shift;
471 17 50       26 my %h = %{ delete($this->{_subst}) || {} };
  17         223  
472 17         63 my $host = $h{'%h'};
473              
474 17         73 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       102 if( $host =~ m/\b(?!<\\)!/ ) {
479 7         34 my @hosts = split '!', $host;
480              
481 7         13 my @indexes_of_replacements;
482 7         28 for(my $i=0; $i<@c; $i++) {
483 149 100       413 if( $c[$i] eq '%h' ) {
484 9         104 splice @c, $i, 1, $hosts[0];
485              
486 9         19 push @indexes_of_replacements, $i;
487              
488 9         29 for my $h (reverse @hosts[1 .. $#hosts]) {
489 15         74 splice @c, $i+1, 0, @c[0 .. $i-1] => $h;
490 15         31 push @indexes_of_replacements, $i+1 + $indexes_of_replacements[-1];
491              
492 15 50       42 unless( $this->{no_command_escapes} ) {
493 15         48 for my $arg (@c[$i+1 .. $#c]) {
494              
495             # NOTE: This escaping is going to be an utter pain to maintain...
496              
497 170         208 $arg =~ s/([`\$])/\\$1/g;
498              
499 170 100       475 if( $arg =~ m/[\s()]/ ) {
500 25         92 $arg =~ s/([\\"])/\\$1/g;
501 25         59 $arg = "\"$arg\"";
502             }
503             }
504             }
505             }
506             }
507             }
508              
509 7         13 my $beg = 0;
510 7         15 for my $i (@indexes_of_replacements) {
511 24 100       71 if( $c[$i] =~ s/^([\w.\-_]+)@// ) {
512 5         12 my $u = $1;
513 5         16 for(@c[$beg .. $i-1]) {
514 24         52 s/^(\[\%u\]|\[\](?=\%u))//;
515 24 100       74 $_ = $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         29 $c[$i] =~ s/\\@/@/g;
522             }
523 24         42 $beg = $i+1;
524             }
525              
526 7         23 delete $h{'%h'};
527              
528             } else {
529 10         41 $h{'%h'} =~ s/\\!/!/g;
530             }
531              
532 17 100       59 if( $h{'%h'} ) {
533 10 100       57 $h{'%u'} = $1 if $h{'%h'} =~ s/^([\w.\-_]+)@//;
534 10         24 $h{'%h'} =~ s/\\@/@/g;
535             }
536              
537 194 100       494 @c = map {exists $h{$_} ? $h{$_} : $_}
  4         51  
538 220 100       559 map { m/^\[([^\[\]]+)\]/ ? ($h{$1} ? do{s/^\[\Q$1\E\]//; $_} : ()) : ($_) } ## no critic: why on earth not?
  4 100       23  
  220         304  
539 17         47 map { s/\[\]\%(\w+)/[\%$1]\%$1/; $_ } ## no critic: why on earth not?
  220         339  
540             @c;
541              
542 17 50       102 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         206 return @c;
548             }
549             # }}}
550             # start_queue_on_host {{{
551             sub start_queue_on_host {
552 6     6 0 24 my ($this, $kernel => $host, $cmdno, $cmd, @next) = @_;
553              
554             # NOTE: used (and deleted) by subst_cmd_vars
555 6         35 $this->set_subst_vars(
556             '%h' => $host,
557             '%n' => $cmdno,
558             );
559              
560 6         33 my $kid = POE::Wheel::Run->new(
561 6         11 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         44974 $kernel->sig_child( $kid->PID, "child_signal" );
568              
569 6         1666 my $lineno = 0;
570 6         31 my $info = [ $kid, $host, $cmdno, \$lineno, @next ];
571 6         63 $this->{_wid}{ $kid->ID } = $this->{_pid}{ $kid->PID } = $info;
572              
573 6         273 return;
574             }
575             # }}}
576              
577             # poe_start {{{
578             sub poe_start {
579 2     2 0 4 my $this = shift;
580              
581 2         5 my %starting;
582 2         4 my @hosts = keys %{ $this->{_cmd_queue} };
  2         9  
583 2         6 for my $host (@hosts) {
584 4         7 my @c = @{ $this->{_cmd_queue}{$host} };
  4         18  
585              
586 4         21 $this->start_queue_on_host($_[KERNEL] => $host, 1, @c);
587 4         19 push @{$starting{"@{$c[0]}"}}, $host;
  4         15  
  4         63  
588             }
589              
590 2         19 for my $message (keys %starting) {
591 3         5 my @hosts = @{ $starting{$message} };
  3         20  
592              
593 3 100       23 if( @hosts == 1 ) {
594 2         19 $this->std_msg($this->_host_route_to_nick($hosts[0]), 1, 0, BOLD.BLACK."-- starting: $message --");
595              
596             } else {
597 1         23 $this->std_msg("", 1, 0, BOLD.BLACK."-- starting: $message on @hosts --");
598             }
599             }
600              
601 2         65 delete $this->{_cmd_queue};
602              
603 2         62 return;
604             }
605             # }}}
606              
607             1;