File Coverage

blib/lib/Pb.pm
Criterion Covered Total %
statement 49 192 25.5
branch 0 76 0.0
condition 0 26 0.0
subroutine 15 42 35.7
pod 15 17 88.2
total 79 353 22.3


line stmt bran cond sub pod time code
1             package Pb;
2              
3 1     1   118621 use 5.14.0;
  1         13  
4 1     1   5 use warnings;
  1         2  
  1         27  
5 1     1   438 use autodie ':all';
  1         15497  
  1         4  
6              
7             our $VERSION = '0.01_04'; # TRIAL VERSION
8              
9 1     1   23300 use Exporter;
  1         3  
  1         77  
10             our @EXPORT =
11             (
12             qw< command base_command flow >, # base structure of the command itself
13             qw< arg opt must_be one_of also >, # for declaring command arguments and options
14             qw< log_to control_via >, # attributes of the command
15             qw< verify SH CODE RUN >, # keywords inside a flow
16             qw< $FLOW %OPT >, # variable containers that flows need access to
17             qw< pwd >, # pass-through from PerlX::bash
18             );
19              
20 1     1   521 use Moo;
  1         11220  
  1         8  
21 1     1   2080 use CLI::Osprey;
  1         24524  
  1         6  
22              
23 1     1   75773 use Safe::Isa;
  1         575  
  1         179  
24 1     1   556 use Type::Tiny;
  1         14897  
  1         44  
25 1     1   521 use PerlX::bash 0.05 qw< bash pwd >;
  1         20612  
  1         74  
26 1     1   440 use Import::Into;
  1         500  
  1         34  
27 1     1   9 use Sub::Install qw< install_sub >;
  1         2  
  1         10  
28 1     1   179 use File::Basename;
  1         2  
  1         85  
29              
30 1     1   518 use Pb::Command::Context;
  1         3  
  1         3302  
31              
32              
33             sub import
34             {
35 1     1   9 my $caller = caller;
36 1         19 _setup_signal_handlers();
37 1         533 strict->import::into($caller);
38 1         221 warnings->import::into($caller);
39 1         177 feature->import::into($caller, ':5.14');
40 1         290 autodie->import::into({level=>1}, ':all'); # `autodie` requires a bit of magic ...
41 1         6092 goto \&Exporter::import;
42             }
43              
44              
45             # This is a global, sort of ... it has a global lifetime, certainly, but not global visibility.
46             # Think of it like a singleton. Most of our methods can either be called as object methods, in
47             # which case they operate on the object invocant, or just as straight functions, in which case they
48             # operate on this guy. `$CMD` is set by `Pb->go` (which is down at the very bottom of this file).
49             my $CMD;
50              
51             # And this is how we implement that optional invocant.
52 0 0   0   0 sub _pb_args { $_[0]->$_can('_osprey_config') ? @_ : ($CMD, @_) }
53              
54              
55             ###################
56             # CONTEXT OBJECTS #
57             ###################
58              
59             # This will be cloned and have command-specific values added to it when the flow executes.
60             our $FLOW = Pb::Command::Context->new;
61              
62             our %OPT; # key == option name, value == option value
63             our %CONTROL; # key == command name, value == control structure
64              
65              
66             ##################
67             # GLOBAL OPTIONS #
68             ##################
69              
70             option pretend =>
71             (
72             is => 'ro', doc => "don't run commands; just print them",
73             );
74              
75             option interactive =>
76             (
77             is => 'ro', doc => "only run commands if user approves each one",
78             );
79              
80              
81             ###############
82             # SCAFFOLDING #
83             ###############
84              
85             # this will hold all the different flows
86             my %FLOWS;
87              
88             # this is for the `base_command` (if there is one)
89             my $BASE_CMD;
90              
91              
92             # This takes an option def (i.e. a hashref built from the properties of an `opt` clause) and turns
93             # it into the arguments to an `option` call (`option` is defined by CLI::Osprey).
94             sub _option_args
95             {
96 0     0   0 my $def = shift;
97 0         0 my %props = ( is => 'ro' );
98 0 0       0 unless ( $def->{type}->is_a_type_of('Bool') )
99             {
100 0         0 $props{format} = 's';
101             }
102 0         0 return $def->{name} => %props;
103             }
104              
105             # This builds subcommands. If it weren't for the fact that we need our subcommands to be able to
106             # have their own options, we could simply do `subcommand $name => $cmd`. However, that creates an
107             # object of class CLI::Osprey::InlineSubcommand, and those can't have options. :-(
108             sub _install_subcommand
109             {
110 0     0   0 my ($name, $action, $optdefs) = @_;
111 0         0 my $pkg = $name =~ s/-/_/r;
112 0 0       0 fatal("illegal command name [$name]") if $pkg !~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/;
113 0         0 $pkg = "Pb::Subcommand::$pkg";
114 0         0 eval "package $pkg { use Moo; use CLI::Osprey; }";
115 0         0 install_sub({ code => $action, into => $pkg, as => 'run' });
116              
117             # handle options
118 0   0     0 my $option = $pkg->can('option') // die("Can't install options into subcommand package! [$name]");
119 0         0 $option->( _option_args($_) ) foreach @$optdefs;
120              
121             # NOTE: can pass a `desc =>` to the `subcommand` (useful for help?)
122 0         0 subcommand $name => $pkg;
123             }
124              
125             # This build the "base command," which is really just the default subcommand.
126             sub _install_base_command
127             {
128 0     0   0 my ($action, $optdefs) = @_;
129 0         0 option( _option_args($_) ) foreach @$optdefs;
130 0         0 $BASE_CMD = $action;
131             }
132              
133              
134             # This guarantees that `END` blocks are not only called when your program `exit`s or `die`s, but
135             # also when it's terminated due to a signal (where possible to catch). This is super-important for
136             # things like making sure pidfiles get cleaned up. I'm pretty sure that the only times your `END`
137             # blocks won't get called if your program exits after this runs is for uncatchable signals (i.e.
138             # `KILL`) and if you call `exec`. I'd worry more about that latter one, but it seems pretty
139             # unlikely in a Leadpipe context.
140             sub _setup_signal_handlers
141             {
142             # This list compiled via the following methodology:
143             # * Examine the signal(7) man page on a current (at the time) Linux version (this one just
144             # so happened to be Linux Mint 18.2, kernel 4.10.0-38-generic).
145             # * Find all signals which are labeled either "Term" or "Core" (i.e. all signals which will
146             # actually cause your process to exit).
147             # * Eliminate everything already in sigtrap.pm's "normal-signals" list.
148             # * Eliminate everything already in sigtrap.pm's "error-signals" list.
149             # * Eliminate "KILL," because you can't catch it anyway.
150             # * Eliminate "USR1" and "USR2" on the grounds that we shouldn't assume anything about
151             # "user-defined signals."
152             # * Whatever was leftover is the list below.
153 1     1   4 my @EXTRA_SIGNALS = qw< ALRM POLL PROF VTALRM XCPU XFSZ IOT STKFLT IO PWR LOST UNUSED >;
154 1         432 require sigtrap;
155             # Because of the `untrapped`, this won't bork any signals you've previously set yourself.
156             # Signals you _subsequently_ set yourself will of course override these.
157             sigtrap->import( handler => sub
158             {
159 0     0   0 my $signal = shift;
160             # Weirdly (or maybe not so much; I dunno), while `END` blocks don't get called if a
161             # `'DEFAULT'` signal handler leads to an exit, they _do_ for custom handlers. So this
162             # `sub` literally doesn't need to do _anything_. But, hey: while we're here, may as
163             # well alert the user as to what's going down.
164 0         0 $FLOW->raise_error("terminated due to signal $signal");
165 0         0 say STDERR "received signal: $signal";
166             },
167             untrapped => 'normal-signals', 'error-signals',
168 1         1166 grep { exists $SIG{$_} } @EXTRA_SIGNALS
  12         31  
169             );
170             }
171              
172              
173             #####################
174             # COMMAND STRUCTURE #
175             #####################
176              
177              
178             sub command
179             {
180 0     0 1   state $PASSTHRU_ARGS = { map { $_ => 1 } qw< log_to flow > };
  0            
181 0           state $CONTEXT_VAR_XLATE = { LOGFILE => 'log_to', };
182 0           my $name = shift;
183              
184             # these are all used in the closure below
185 0           my %args; # arguments to this command definition
186 0           my $argdefs = []; # definition of args to the command invocation
187 0           my $optdefs = []; # definition of opts to the command invocation
188             # process args: most are simple, some are trickier
189 0           while (@_)
190             {
191 0 0         if ($PASSTHRU_ARGS->{$_[0]})
    0          
    0          
    0          
192             {
193 0           my $arg = shift;
194 0           $args{$arg} = shift;
195             }
196             elsif ($_[0] eq 'arg')
197             {
198 0           shift; # just the 'arg' marker
199 0 0         fatal("base commands cannot take arguments (try an option instead)") if $name eq ':DEFAULT';
200 0           my $arg = {};
201 0           $arg->{name} = shift;
202 0           $arg->{type} = shift;
203             fatal("not a constraint [" . (ref $arg->{type} || $arg->{type}) . "]")
204 0 0 0       unless $arg->{type}->$_isa('Type::Tiny');
205 0           push @$argdefs, $arg;
206             }
207             elsif ($_[0] eq 'opt')
208             {
209 0           shift; # just the 'opt' marker
210 0           my $opt = {};
211 0           $opt->{name} = shift;
212 0 0         $opt->{type} = $_[0]->$_isa('Type::Tiny') ? shift : must_be('Bool');
213 0 0         if ($_[0] eq 'properties')
214             {
215 0           shift;
216 0           my $extra_props = shift;
217 0           $opt->{$_} = $extra_props->{$_} foreach keys %$extra_props;
218             }
219 0           push @$optdefs, $opt;
220             }
221             elsif ($_[0] eq 'control')
222             {
223 0           shift; # just the 'control' marker
224 0           my $control = shift;
225 0 0         fatal("`control_via' requires hashref") unless ref $control eq 'HASH';
226 0           $CONTROL{$name} = $control;
227             }
228             else
229             {
230 0           fatal("unknown command attribute [$_[0]]");
231             }
232             }
233              
234             # Save the flow (including processing any args) under our name. Doing args here rather than in
235             # the `$subcmd` below enables the `RUN` directive to pass args as well.
236             $FLOWS{$name} = sub
237             {
238 0     0     $FLOW->validate_args(@_, $argdefs);
239 0 0         fatal($FLOW->error) if $FLOW->error;
240 0           $args{flow}->();
241 0           };
242              
243             my $subcmd = sub
244             {
245 0     0     my ($osprey) = @_; # currently unused
246              
247             # Figure out what context vars we need to set based on our the `command` properties.
248 0           my $context_vars = {};
249 0           foreach ( keys %$CONTEXT_VAR_XLATE )
250             {
251 0           my $arg = $CONTEXT_VAR_XLATE->{$_};
252 0 0         $context_vars->{$_} = $args{$arg} if exists $args{$arg};
253             }
254              
255             # Build the context for this command based on the (skeletal) global one, doing 3 major
256             # things: adding in new context vars from our `command` definition, validing any
257             # command-specific opts, and processing the control structure (if any).
258 0           my $context = $FLOW->setup_context($context_vars, $optdefs, $CONTROL{$name});
259 0 0         if ($context->error) # either an opt didn't validate or the control structure had an error
260             {
261 0           fatal($context->error);
262             }
263             else # set global access vars for flows
264             {
265 0           $FLOW = $context;
266 0           %OPT = $FLOW->opts;
267             }
268              
269             # Script args are flow args (switches were already processed by Osprey and validated above).
270 0           $FLOWS{$name}->(@ARGV);
271 0           };
272 0 0         $name eq ':DEFAULT' ? _install_base_command($subcmd, $optdefs) : _install_subcommand($name => $subcmd, $optdefs);
273             }
274              
275              
276 0     0 1   sub base_command { unshift @_, ':DEFAULT'; &command }
  0            
277              
278              
279              
280 0     0 1   sub arg ($) { arg => shift }
281              
282 0     0 1   sub opt (@) { opt => @_ }
283              
284             sub must_be ($)
285             {
286 0     0 1   my $type = shift;
287             # slightly cheating, but this private method handles the widest range of things that might be a
288             # type (including if it's already a Type::Tiny to start with)
289 0           my ($t) = eval { Type::Tiny::_loose_to_TypeTiny($type) };
  0            
290 0 0         fatal("not a valid type [$type]") unless defined $t;
291 0   0 0     $t->create_child_type(message => sub { ($_ // '<>') . " is not a " . $t->name });
  0            
292             }
293              
294             sub one_of ($)
295             {
296 0     0 1   require Type::Tiny::Enum;
297 0           my $v = shift;
298 0   0 0     Type::Tiny::Enum->new( values => $v, message => sub { ($_ // '<>') . " must be one of: " . join(', ', @$v) });
  0            
299             }
300              
301 0 0   0 1   sub also { properties => { map { s/^-// ? ($_ => 1) : $_ } @_ } }
  0            
302              
303              
304              
305 0     0 1   sub log_to ($) { log_to => shift }
306              
307 0     0 1   sub control_via ($) { control => shift }
308              
309              
310              
311 0     0 1   sub flow (&) { flow => shift }
312              
313              
314             ##############
315             # DIRECTIVES #
316             ##############
317              
318              
319             sub verify (&$)
320             {
321 0     0 1   my ($check, $fail_msg) = @_;
322              
323             # we need to ensure verify code gets executed no matter what
324 0           my $save_runmode = $FLOW->runmode;
325 0           $FLOW->_set_runmode('VERIFY');
326 0 0         unless ( $check->() )
327             {
328             # Doing the error this way is a bit roundabout, but it guarantees failure here won't create
329             # a statusfile that might keep our next run from happening due to `unless_clean_exit`.
330 0           $FLOW->start_conditions_not_met("pre-flow check failed [$fail_msg]");
331 0           fatal($FLOW->error);
332             }
333 0           $FLOW->_set_runmode($save_runmode);
334             }
335              
336              
337             # figure out whether a directive should be executed, based on runmode
338             sub _should_doit
339             {
340 0     0     my ($dtype, $action) = @_;
341              
342 0 0         if ( $FLOW->runmode eq 'NOACTION' )
    0          
343             {
344 0           my $msg = "would run";
345 0 0         $msg .= $dtype eq 'shell command' ? ':' : " $dtype";
346 0 0         $msg .= " $action" if $action;
347 0           say $msg;
348 0           return 0;
349             }
350             elsif ( $FLOW->runmode eq 'ASKACTION' )
351             {
352 0           my $prompt = "run $dtype?";
353 0 0         $prompt .= " $action" if $action;
354 0           $prompt .= " [y/N] ";
355 0           print $prompt;
356 0           return =~ /^y/i;
357             }
358             # other run modes mean just do it
359 0           return 1;
360             }
361              
362              
363              
364             sub SH (@)
365             {
366 0     0 1   my @cmd = @_;
367              
368 0 0         return unless _should_doit('shell command', "@cmd");
369              
370             # In the rare case where `--pretend` is set but `runmode` is *not* "NOACTION," don't send our
371             # output to the logfile.
372 0 0 0       push @cmd, ">>$FLOW->{LOGFILE}", "2>&1" if exists $FLOW->{LOGFILE} and not $OPT{pretend};
373              
374 0           my $exitval = bash @cmd;
375 0 0         if (defined wantarray) # someone cares about our exit value
376             {
377 0           return $exitval;
378             }
379             else # just a straight `SH` directive; die unless clean exit
380             {
381 0 0         fatal("command [@_] exited non-zero [$exitval]") unless $exitval == 0;
382             }
383             }
384              
385              
386              
387             sub CODE (@)
388             {
389 0     0 1   my $block = pop;
390 0           my ($name) = @_;
391              
392 0 0         return unless _should_doit('code block', $name ? "[$name]" : '');
    0          
393              
394             # If we have a logfile, better make sure our code block is printing to it rather than STDOUT, if
395             # it prints anything.
396 0           my $log;
397 0 0         if ( my $logfile = $FLOW->logfile )
398             {
399 0           open($log, '>>', $logfile);
400             }
401              
402 0           my $retval;
403             eval
404 0           {
405             # Note that you can't do an `if` block here, because
406             # that would make a separate scope for the `local`.
407 0 0         local *STDOUT = $log if $log;
408 0 0         local *STDERR = $log if $log;
409 0           $retval = $block->();
410             };
411 0 0 0       if (not $retval or $@)
412             {
413 0 0         my $msg = "code block" . ($name ? " [$name]" : '');
414 0 0 0       $msg .= $@
415             ? " died [" . $@ =~ s/( at \S+ line \d+\.?)\n.*\Z//rs . "]"
416             : " returned false value [" . ($retval // 'undef') . "]";
417 0           fatal($msg);
418             }
419             }
420              
421              
422              
423             sub RUN (@)
424             {
425 0     0 1   my ($flow, @args) = @_;
426 0           $FLOWS{$flow}->(@args);
427             }
428              
429              
430             ####################
431             # SUPPORT ROUTINES #
432             ####################
433              
434              
435             sub fatal
436             {
437 0     0 1   my ($self, $msg) = &_pb_args;
438 0   0       my $me = $FLOW->{ME} // basename($0);
439 0           say STDERR "$me: $msg";
440 0           $FLOW->raise_error($msg);
441 0           exit 1;
442             }
443              
444              
445             ####################
446             # DEFAULT COMMANDS #
447             ####################
448              
449             subcommand help => sub { shift->osprey_help };
450             subcommand commands => sub
451             {
452             my $class = shift;
453             my %sc = $class->_osprey_subcommands;
454             say foreach sort keys %sc;
455             };
456              
457             subcommand info => sub
458             {
459             my $self = shift;
460             foreach (@_)
461             {
462             $self->fatal("no such setting [$_]") unless $FLOW->has_var($_);
463             say $FLOW->{$_};
464             }
465             };
466              
467              
468             ##############
469             # GO GO GO!! #
470             ##############
471              
472              
473             # This is only used when there's a base command (but Osprey needs it regardless).
474             sub run
475             {
476 0 0   0 0   $BASE_CMD->(@_) if $BASE_CMD;
477             }
478              
479             sub go
480             {
481 0 0 0 0 0   shift @ARGV and $FLOW->set_debug($1) if @ARGV and $ARGV[0] =~ /^DEBUG=(\d+)$/;
      0        
482              
483 0           $CMD = shift->new_with_options;
484 0           $FLOW->connect_to($CMD); # this connects the context to the command
485              
486 0           $CMD->run;
487             }
488              
489              
490             1;
491              
492              
493              
494             # ABSTRACT: a workflow system made from Perl and bash
495             # COPYRIGHT
496              
497             __END__