version up
[mrsh.git] / MrShell.pm
blob1c0e9eeaa9978150ce25a73e3f5e048cd301e1d0
1 package App::MrShell;
3 use strict;
4 use warnings;
6 use Carp;
7 use POSIX;
8 use Config::Tiny;
9 use POE qw( Wheel::Run );
10 use Term::ANSIColor qw(:constants);
11 use Text::Balanced;
13 our $VERSION = '2.0210';
14 our @DEFAULT_SHELL_COMMAND = (ssh => '-o', 'BatchMode yes', '-o', 'StrictHostKeyChecking no', '-o', 'ConnectTimeout 20', '[%u]-l', '[]%u', '%h');
16 # new {{{
17 sub new {
18 my $class = shift;
19 my $this = bless { hosts=>[], cmd=>[], _shell_cmd=>[@DEFAULT_SHELL_COMMAND] }, $class;
21 return $this;
23 # }}}
25 # _process_space_delimited {{{
26 sub _process_space_delimited {
27 my $this = shift;
28 my $that = shift;
30 my @output;
31 while( $that ) {
32 if( $that =~ m/^\s*['"]/ ) {
33 my ($tok, $rem) = Text::Balanced::extract_delimited($that, qr(["']));
35 ($tok =~ s/^(['"])// and $tok =~ s/$1$//) or die "internal error processing space delimited";
37 push @output, $tok;
38 $that = $rem;
40 } else {
41 my ($tok, $rem) = split ' ', $that, 2;
43 push @output, $tok;
44 $that = $rem;
48 return @output
50 # }}}
51 # _process_hosts {{{
52 sub _process_hosts {
53 my $this = shift;
55 my @h = do {
56 my @tmp = map { my $k = $_; $k =~ s/^\@// ? @{$this->{groups}{$k} or die "couldn't find group: \@$k\n"} : $_ } @_;
57 my %h; @h{@tmp} = ();
58 for(keys %h) {
59 if(my ($k) = m/^\-(.+)/) {
60 delete $h{$_};
61 delete $h{$k};
64 sort keys %h;
67 my $o = my $l = $this->{_host_width} || 0;
68 for( map { length $this->_host_route_to_nick($_) } @h ) {
69 $l = $_ if $_>$l
72 $this->{_host_width} = $l if $l != $o;
74 return @h;
76 # }}}
77 # _host_route_to_nick {{{
78 sub _host_route_to_nick {
79 my $this = shift;
81 return join "", shift =~ m/(?:!|[^!]+$)/g
83 # }}}
85 # set_shell_command_option {{{
86 sub set_shell_command_option {
87 my $this = shift;
88 my $arg = shift;
90 $arg = [ $this->_process_space_delimited($arg||"") ] unless ref $arg;
92 if( ref($arg) eq "ARRAY" ) {
93 if( not $arg->[0] ) {
94 $this->{_shell_cmd} = sub {
95 shift @$arg;
96 my $perl_program = shift @$arg;
98 local @ARGV = $this->subst_cmd_vars(@$arg, @_);
100 unless( defined( do $perl_program ) ) {
101 die $@ if $@;
102 die $! if $!;
103 die "execution failure for $perl_program";
106 return;
109 } else {
110 $this->{_shell_cmd} = [ @$arg ]; # make a real copy
113 } elsif( ref($arg) eq "CODE" ) {
114 $this->{_shell_cmd} = $arg;
117 return $this;
119 # }}}
120 # set_group_option {{{
121 sub set_group_option {
122 my $this = shift;
123 my $groups = ($this->{groups} ||= {});
125 my ($name, $value);
126 while( ($name, $value) = splice @_, 0, 2 and $name and $value ) {
127 if( ref($value) eq "ARRAY" ) {
128 $groups->{$name} = [ @$value ]; # make a real copy
130 } else {
131 $groups->{$name} = [ $this->_process_space_delimited( $value ) ];
135 my @groups = keys %{ $this->{groups} };
136 my $replace_limit = 30;
137 REPLACE_GROPUS: {
138 my $replaced = 0;
140 for my $group (@groups) {
141 my $hosts = $groups->{$group};
143 my $r = 0;
144 for(@$hosts) {
145 if( m/^@(.+)/ ) {
146 if( my $g = $groups->{$1} ) {
147 $_ = $g;
149 $r ++;
154 if( $r ) {
155 my %h;
156 @h{ map {ref $_ ? @$_ : $_} @$hosts } = ();
157 $groups->{$group} = [ keys %h ];
158 $replaced ++;
162 $replace_limit --;
163 last if $replace_limit < 1;
164 redo if $replaced;
167 return $this;
169 # }}}
170 # set_logfile_option {{{
171 sub set_logfile_option {
172 my $this = shift;
173 my $file = shift;
174 my $trunc = shift;
176 unless( our $already_compiled++ ) {
177 my $load_ansi_filter_package = q {
178 package App::MrShell::ANSIFilter;
179 use Symbol;
180 use Tie::Handle;
181 use base 'Tie::StdHandle';
183 my %orig;
185 sub PRINT {
186 my $this = shift;
187 my @them = @_;
188 s/\e\[[\d;]+m//g for @them;
189 print {$orig{$this}} @them;
192 sub filtered_handle {
193 my $pfft = gensym();
194 my $it = tie *{$pfft}, __PACKAGE__ or die $!;
195 $orig{$it} = shift;
196 $pfft;
201 eval $load_ansi_filter_package or die $@; ## no critic -- sometimes this kind of eval is ok
202 # (This probably isn't one of them.)
205 open my $log, ($trunc ? ">" : ">>"), $file or croak "couldn't open $file for write: $!"; ## no critic -- I mean to pass this around, shut up
207 $this->{_log_fh} = App::MrShell::ANSIFilter::filtered_handle($log);
209 return $this;
211 # }}}
212 # set_debug_option {{{
213 sub set_debug_option {
214 my $this = shift;
215 my $val = shift;
217 # -d 0 and -d 1 are the same
218 # -d 2 is a level up, -d 4 is even more
219 # $val==undef clears the setting
221 if( not defined $val ) {
222 delete $this->{debug};
223 return $this;
226 $this->{debug} = $val ? $val : 1;
228 return $this;
230 # }}}
231 # set_no_command_escapes_option {{{
232 sub set_no_command_escapes_option {
233 my $this = shift;
235 $this->{no_command_escapes} = shift || 0;
237 return $this;
239 # }}}
241 # groups {{{
242 sub groups {
243 my $this = shift;
245 return unless $this->{groups};
246 return wantarray ? %{$this->{groups}} : $this->{groups};
248 # }}}
250 # set_usage_error($&) {{{
251 sub set_usage_error($&) { ## no critic -- prototypes are bad how again?
252 my $this = shift;
253 my $func = shift;
254 my $pack = caller;
255 my $name = $pack . "::$func";
256 my @args = @_;
258 $this->{_usage_error} = sub {
259 no strict 'refs'; ## no critic -- how would you call this by name without this?
260 $name->(@args)
263 return $this;
265 # }}}
266 # read_config {{{
267 sub read_config {
268 my ($this, $that) = @_;
270 $this->{_conf} = Config::Tiny->read($that) if -f $that;
272 for my $group (keys %{ $this->{_conf}{groups} }) {
273 $this->set_group_option( $group => $this->{_conf}{groups}{$group} );
276 if( my $c = $this->{_conf}{options}{'shell-command'} ) {
277 $this->set_shell_command_option( $c );
280 if( my $c = $this->{_conf}{options}{'logfile'} ) {
281 my $t = $this->{_conf}{options}{'truncate-logfile'};
282 my $v = ($t ? 1:0);
283 $v = 0 if $t =~ m/(?:no|false)/i;
285 $this->set_logfile_option($c, $v);
288 if( my $c = $this->{_conf}{options}{'no-command-escapes'} ) {
289 my $v = ($c ? 1:0);
290 $v = 0 if $c =~ m/(?:no|false)/i;
292 $this->set_no_command_escapes_option( $v );
295 return $this;
297 # }}}
298 # set_hosts {{{
299 sub set_hosts {
300 my $this = shift;
302 $this->{hosts} = [ $this->_process_hosts(@_) ];
304 return $this;
306 # }}}
307 # queue_command {{{
308 sub queue_command {
309 my $this = shift;
310 my @hosts = @{$this->{hosts}};
312 unless( @hosts ) {
313 if( my $h = $this->{_conf}{options}{'default-hosts'} ) {
314 @hosts = $this->_process_hosts( $this->_process_space_delimited($h) );
316 } else {
317 if( my $e = $this->{_usage_error} ) {
318 warn "Error: no hosts specified\n";
319 $e->();
321 } else {
322 croak "set_hosts before issuing queue_command";
327 for my $h (@hosts) {
328 push @{$this->{_cmd_queue}{$h}}, [@_]; # make a real copy
331 return $this;
333 # }}}
334 # run_queue {{{
335 sub run_queue {
336 my $this = shift;
338 $this->{_session} = POE::Session->create( inline_states => {
339 _start => sub { $this->poe_start(@_) },
340 child_stdout => sub { $this->line(1, @_) },
341 child_stderr => sub { $this->line(2, @_) },
342 child_signal => sub { $this->sigchld(@_) },
343 stall_close => sub { $this->_close(@_) },
344 ErrorEvent => sub { $this->error_event },
347 POE::Kernel->run();
349 return $this;
351 # }}}
353 # std_msg {{{
354 sub std_msg {
355 my $this = shift;
356 my $host = shift;
357 my $cmdno = shift;
358 my $fh = shift;
359 my $msg = shift;
361 my $host_msg = $host ? $this->_host_route_to_nick($host) . ": " : "";
362 my $time_str = strftime('%H:%M:%S', localtime);
364 print $time_str,
365 sprintf(' %-*s', $this->{_host_width}+2, $host_msg),
366 ( $fh==2 ? ('[',BOLD,YELLOW,'stderr',RESET,'] ') : () ), $msg, RESET, "\n";
368 if( $this->{_log_fh} ) {
369 $time_str = strftime('%Y-%m-%d %H:%M:%S', localtime);
371 # No point in printing colors, stripped anyway. Formatting columns is
372 # equally silly -- in append mode anyway.
373 $host_msg = $host ? "$host: " : "";
374 print {$this->{_log_fh}} "$time_str $host_msg", ($fh==2 ? "[stderr] " : ""), $msg, "\n";
377 return $this;
379 # }}}
381 # line {{{
382 sub line {
383 my $this = shift;
384 my $fh = shift;
385 my ($line, $wid) = @_[ ARG0, ARG1 ];
386 my ($kid, $host, $cmdno, $lineno) = @{$this->{_wid}{$wid}};
388 $$lineno ++;
389 $this->std_msg($host, $cmdno, $fh, $line);
391 return;
393 # }}}
395 # sigchld {{{
396 sub _sigchld_exit_error {
397 my $this = shift;
398 my ($pid, $exit) = @_[ ARG1, ARG2 ];
399 $exit >>= 8;
401 $this->std_msg("?", -1, 0, BOLD.RED."-- sigchld received for untracked pid($pid, $exit), probably a bug in Mr. Shell --");
403 return;
406 sub sigchld {
407 my $this = shift; # ARG0 is the signal name string
408 my ($kid, $host, $cmdno, @c) = @{ $this->{_pid}{ $_[ARG1] } || return $this->_sigchld_exit_error(@_) };
410 # NOTE: this usually isn't an error, sometimes the sigchild will arrive
411 # before the handles are "closed" in the traditional sense. We get error
412 # eveents for errors.
413 #### # $this->std_msg($host, $cmdno, 0, RED.'-- error: unexpected child exit --');
415 # NOTE: though, the exit value may indicate an actual error.
416 if( (my $exit = $_[ARG2]) != 0 ) {
417 # XXX: I'd like to do more here but I'm waiting to see what Paul
418 # Fenwick has to say about it.
419 $exit >>= 8;
421 my $reset = RESET;
422 my $black = BOLD.BLACK;
423 my $red = RESET.RED;
425 $this->std_msg($host, $cmdno, 0, "$black-- shell exited with nonzero status: $red$exit$black --");
428 $_[KERNEL]->yield( stall_close => $kid->ID, 0 );
430 return;
432 # }}}
433 # _close {{{
434 sub _close {
435 my $this = shift;
436 my ($wid, $count) = @_[ ARG0, ARG1 ];
438 return unless $this->{_wid}{$wid}; # sometimes we'll get a sigchild *and* a close event
440 # NOTE: I was getting erratic results with some fast running commands and
441 # guessed that I was sometimes getting the close event before the stdout
442 # event. Waiting through the kernel loop once is probably enough, but I
443 # used 3 because it does't hurt either.
445 if( $count > 3 ) {
446 my ($kid, $host, $cmdno, $lineno, @c) = @{ delete $this->{_wid}{$wid} };
448 $this->std_msg($host, $cmdno, 0, BOLD.BLACK.'-- eof --') if $$lineno == 0;
450 if( @c ) {
452 $cmdno ++;
454 $this->start_queue_on_host($_[KERNEL] => $host, $cmdno, @c);
455 $this->std_msg($host, $cmdno, 0, BOLD.BLACK."-- starting: @{$c[0]} --");
458 delete $this->{_pid}{ $kid->PID };
460 } else {
461 $_[KERNEL]->yield( stall_close => $wid, $count+1 );
464 return;
466 # }}}
467 # error_event {{{
468 sub error_event {
469 my $this = shift;
470 my ($operation, $errnum, $errstr, $wid) = @_[ARG0 .. ARG3];
471 my ($kid, $host, $cmdno, @c) = @{ delete $this->{_wid}{$wid} || return };
472 delete $this->{_pid}{ $kid->PID };
474 $errstr = "remote end closed" if $operation eq "read" and not $errnum;
475 $this->std_msg($host, $cmdno, 0, RED."-- $operation error $errnum: $errstr --");
477 return;
479 # }}}
481 # set_subst_vars {{{
482 sub set_subst_vars {
483 my $this = shift;
485 while( my ($k,$v) = splice @_, 0, 2 ) {
486 $this->{_subst}{$k} = $v unless exists $this->{_subst}{$k};
489 return $this;
491 # }}}
492 # subst_cmd_vars {{{
493 sub subst_cmd_vars {
494 my $this = shift;
495 my %h = %{ delete($this->{_subst}) || {} };
496 my $host = $h{'%h'};
498 my @c = @_; # copy this so it doesn't get altered upstream
499 # (I'd swear I shoulnd't need to do this at all, but it's
500 # proovably true that I do.)
502 if( $host =~ m/\b(?!<\\)!/ ) {
503 my @hosts = split '!', $host;
505 my @indexes_of_replacements;
506 for(my $i=0; $i<@c; $i++) {
507 if( $c[$i] eq '%h' ) {
508 splice @c, $i, 1, $hosts[0];
510 push @indexes_of_replacements, $i;
512 for my $h (reverse @hosts[1 .. $#hosts]) {
513 splice @c, $i+1, 0, @c[0 .. $i-1] => $h;
514 push @indexes_of_replacements, $i+1 + $indexes_of_replacements[-1];
516 unless( $this->{no_command_escapes} ) {
517 for my $arg (@c[$i+1 .. $#c]) {
519 # NOTE: This escaping is going to be an utter pain to maintain...
521 $arg =~ s/([`\$])/\\$1/g;
523 if( $arg =~ m/[\s()]/ ) {
524 $arg =~ s/([\\"])/\\$1/g;
525 $arg = "\"$arg\"";
533 my $beg = 0;
534 for my $i (@indexes_of_replacements) {
535 if( $c[$i] =~ s/^([\w.\-_]+)@// ) {
536 my $u = $1;
537 for(@c[$beg .. $i-1]) {
538 s/^(\[\%u\]|\[\](?=\%u))//;
539 $_ = $u if $_ eq '%u';
542 } else {
543 # NOTE: there's really no need to go through and remove [%u]
544 # conditional options, they'll automatically get nuked below
545 $c[$i] =~ s/\\@/@/g;
547 $beg = $i+1;
550 delete $h{'%h'};
552 } else {
553 $h{'%h'} =~ s/\\!/!/g;
556 if( $h{'%h'} ) {
557 $h{'%u'} = $1 if $h{'%h'} =~ s/^([\w.\-_]+)@//;
558 $h{'%h'} =~ s/\\@/@/g;
561 @c = map {exists $h{$_} ? $h{$_} : $_}
562 map { m/^\[([^\[\]]+)\]/ ? ($h{$1} ? do{s/^\[\Q$1\E\]//; $_} : ()) : ($_) } ## no critic: why on earth not?
563 map { s/\[\]\%(\w+)/[\%$1]\%$1/; $_ } ## no critic: why on earth not?
566 if( $this->{debug} ) {
567 local $" = ")(";
568 $this->std_msg($host, $h{'%n'}, 0, BOLD.BLACK."DEBUG: exec(@c)");
571 return @c;
573 # }}}
574 # start_queue_on_host {{{
575 sub start_queue_on_host {
576 my ($this, $kernel => $host, $cmdno, $cmd, @next) = @_;
578 my $program;
579 if( ref($this->{_shell_cmd}) eq "CODE" ) {
580 $program = sub {
581 $this->set_subst_vars( '%h' => $host, '%n' => $cmdno );
582 $this->{_shell_cmd}->(@$cmd)
585 } else {
586 $this->set_subst_vars( '%h' => $host, '%n' => $cmdno );
587 $program = [ $this->subst_cmd_vars(@{$this->{_shell_cmd}} => @$cmd) ];
590 my $kid = POE::Wheel::Run->new(
591 Program => $program,
592 StdoutEvent => "child_stdout",
593 StderrEvent => "child_stderr",
594 CloseEvent => "child_close",
597 $kernel->sig_child( $kid->PID, "child_signal" );
599 my $lineno = 0;
600 my $info = [ $kid, $host, $cmdno, \$lineno, @next ];
601 $this->{_wid}{ $kid->ID } = $this->{_pid}{ $kid->PID } = $info;
603 return;
605 # }}}
607 # poe_start {{{
608 sub poe_start {
609 my $this = shift;
611 my %starting;
612 my @hosts = keys %{ $this->{_cmd_queue} };
613 for my $host (@hosts) {
614 my @c = @{ $this->{_cmd_queue}{$host} };
616 $this->start_queue_on_host($_[KERNEL] => $host, 1, @c);
617 push @{$starting{"@{$c[0]}"}}, $host;
620 for my $message (keys %starting) {
621 my @hosts = @{ $starting{$message} };
623 if( @hosts == 1 ) {
624 $this->std_msg($this->_host_route_to_nick($hosts[0]), 1, 0, BOLD.BLACK."-- starting: $message --");
626 } else {
627 $this->std_msg("", 1, 0, BOLD.BLACK."-- starting: $message on @hosts --");
631 delete $this->{_cmd_queue};
633 return;
635 # }}}