File Coverage

blib/lib/Beam/Make.pm
Criterion Covered Total %
statement 108 110 98.1
branch 26 32 81.2
condition 6 7 85.7
subroutine 15 15 100.0
pod 0 1 0.0
total 155 165 93.9


line stmt bran cond sub pod time code
1             package Beam::Make;
2             our $VERSION = '0.002';
3             # ABSTRACT: Recipes to declare and resolve dependencies between things
4              
5             #pod =head1 SYNOPSIS
6             #pod
7             #pod ### container.yml
8             #pod # This Beam::Wire container stores shared objects for our recipes
9             #pod dbh:
10             #pod $class: DBI
11             #pod $method: connect
12             #pod $args:
13             #pod - dbi:SQLite:RECENT.db
14             #pod
15             #pod ### Beamfile
16             #pod # This file contains our recipes
17             #pod # Download a list of recent changes to CPAN
18             #pod RECENT-6h.json:
19             #pod commands:
20             #pod - curl -O https://www.cpan.org/RECENT-6h.json
21             #pod
22             #pod # Parse that JSON file into a CSV using an external program
23             #pod RECENT-6h.csv:
24             #pod requires:
25             #pod - RECENT-6h.json
26             #pod commands:
27             #pod - yfrom json RECENT-6h.json | yq '.recent.[]' | yto csv > RECENT-6h.csv
28             #pod
29             #pod # Build a SQLite database to hold the recent data
30             #pod RECENT.db:
31             #pod $class: Beam::Make::DBI::Schema
32             #pod dbh: { $ref: 'container.yml:dbh' }
33             #pod schema:
34             #pod - table: recent
35             #pod columns:
36             #pod - path: VARCHAR(255)
37             #pod - epoch: DOUBLE
38             #pod - type: VARCHAR(10)
39             #pod
40             #pod # Load the recent data CSV into the SQLite database
41             #pod cpan-recent:
42             #pod $class: Beam::Make::DBI::CSV
43             #pod requires:
44             #pod - RECENT.db
45             #pod - RECENT-6h.csv
46             #pod dbh: { $ref: 'container.yml:dbh' }
47             #pod table: recent
48             #pod file: RECENT-6h.csv
49             #pod
50             #pod ### Load the recent data into our database
51             #pod $ beam make cpan-recent
52             #pod
53             #pod =head1 DESCRIPTION
54             #pod
55             #pod C<Beam::Make> allows an author to describe how to build some thing (a
56             #pod file, some data in a database, an image, a container, etc...) and the
57             #pod relationships between things. This is similar to the classic C<make>
58             #pod program used to build some software packages.
59             #pod
60             #pod Each thing is a C<recipe> and can depend on other recipes. A user runs
61             #pod the C<beam make> command to build the recipes they want, and
62             #pod C<Beam::Make> ensures that the recipe's dependencies are satisfied
63             #pod before building the recipe.
64             #pod
65             #pod This class is a L<Beam::Runnable> object and can be embedded in other
66             #pod L<Beam::Wire> containers.
67             #pod
68             #pod =head2 Recipe Classes
69             #pod
70             #pod Unlike C<make>, C<Beam::Make> recipes can do more than just execute
71             #pod a series of shell scripts. Each recipe is a Perl class that describes
72             #pod how to build the desired thing and how to determine if that thing needs
73             #pod to be rebuilt.
74             #pod
75             #pod These recipe classes come with C<Beam::Make>:
76             #pod
77             #pod =over
78             #pod
79             #pod =item * L<File|Beam::Make::File> - The default recipe class that creates
80             #pod a file using one or more shell commands (a la C<make>)
81             #pod
82             #pod =item * L<DBI|Beam::Make::DBI> - Write data to a database
83             #pod
84             #pod =item * L<DBI::Schema|Beam::Make::DBI::Schema> - Create a database
85             #pod schema
86             #pod
87             #pod =item * L<DBI::CSV|Beam::Make::DBI::CSV> - Load data from a CSV into
88             #pod a database table
89             #pod
90             #pod =item * L<Docker::Image|Beam::Make::Docker::Image> - Build or pull a Docker image
91             #pod
92             #pod =item * L<Docker::Container|Beam::Make::Docker::Container> - Build a Docker container
93             #pod
94             #pod =back
95             #pod
96             #pod Future recipe class ideas are:
97             #pod
98             #pod =over
99             #pod
100             #pod =item *
101             #pod
102             #pod B<Template rendering>: Files could be generated from a configuration
103             #pod file or database and a template.
104             #pod
105             #pod =item *
106             #pod
107             #pod B<Docker compose>: An entire docker-compose network could be rebuilt.
108             #pod
109             #pod =item *
110             #pod
111             #pod B<System services (init daemon, systemd service, etc...)>: Services
112             #pod could depend on their configuration files (built with a template) and be
113             #pod restarted when their configuration file is updated.
114             #pod
115             #pod =back
116             #pod
117             #pod =head2 Beamfile
118             #pod
119             #pod The C<Beamfile> defines the recipes. To avoid the pitfalls of C<Makefile>, this is
120             #pod a YAML file containing a mapping of recipe names to recipe configuration. Each
121             #pod recipe configuration is a mapping containing the attributes for the recipe class.
122             #pod The C<$class> special configuration key declares the recipe class to use. If no
123             #pod C<$class> is specified, the default L<Beam::Wire::File> recipe class is used.
124             #pod All recipe classes inherit from L<Beam::Class::Recipe> and have the L<name|Beam::Class::Recipe/name>
125             #pod and L<requires|Beam::Class::Recipe/requires> attributes.
126             #pod
127             #pod For examples, see the L<Beam::Wire examples directory on
128             #pod Github|https://github.com/preaction/Beam-Make/tree/master/eg>.
129             #pod
130             #pod =head2 Object Containers
131             #pod
132             #pod For additional configuration, create a L<Beam::Wire> container and
133             #pod reference the objects inside using C<< $ref: "<container>:<service>" >>
134             #pod as the value for a recipe attribute.
135             #pod
136             #pod =head1 TODO
137             #pod
138             #pod =over
139             #pod
140             #pod =item Target names in C<Beamfile> should be regular expressions
141             #pod
142             #pod This would work like Make's wildcard recipes, but with Perl regexp. The
143             #pod recipe object's name is the real name, but the recipe chosen is the one
144             #pod the matches the regexp.
145             #pod
146             #pod =item Environment variables should interpolate into all attributes
147             #pod
148             #pod Right now, the C<< NAME=VALUE >> arguments to C<beam make> only work in
149             #pod recipes that use shell scripts (like L<Beam::Make::File>). It would be
150             #pod nice if they were also interpolated into other recipe attributes.
151             #pod
152             #pod =item Recipes should be able to require wildcards and directories
153             #pod
154             #pod Recipe requirements should be able to depend on patterns, like all
155             #pod C<*.conf> files in a directory. It should also be able to depend on
156             #pod a directory, which would be the same as depending on every file,
157             #pod recursively, in that directory.
158             #pod
159             #pod This would allow rebuilding a ZIP file when something changes, or
160             #pod rebuilding a Docker image when needed.
161             #pod
162             #pod =item Beam::Wire should support the <container>:<service> syntax
163             #pod for references
164             #pod
165             #pod The L<Beam::Wire> class should handle the C<BEAM_PATH> environment
166             #pod variable directly and be able to resolve services from other files
167             #pod without building another C<Beam::Wire> object in the container.
168             #pod
169             #pod =item Beam::Wire should support resolving objects in arbitrary data
170             #pod structures
171             #pod
172             #pod L<Beam::Wire> should have a class method that one can pass in a hash and
173             #pod get back a hash with any C<Beam::Wire> object references resolved,
174             #pod including C<$ref> or C<$class> object.
175             #pod
176             #pod =back
177             #pod
178             #pod =head1 SEE ALSO
179             #pod
180             #pod L<Beam::Wire>
181             #pod
182             #pod =cut
183              
184 3     3   278910 use v5.20;
  3         44  
185 3     3   17 use warnings;
  3         7  
  3         102  
186 3     3   1397 use Log::Any qw( $LOG );
  3         25242  
  3         15  
187 3     3   8007 use Moo;
  3         29736  
  3         16  
188 3     3   6036 use experimental qw( signatures postderef );
  3         10823  
  3         17  
189 3     3   2367 use Time::Piece;
  3         29307  
  3         14  
190 3     3   1574 use YAML ();
  3         21043  
  3         76  
191 3     3   1978 use Beam::Wire;
  3         1924417  
  3         145  
192 3     3   30 use Scalar::Util qw( blessed );
  3         8  
  3         210  
193 3     3   21 use List::Util qw( max );
  3         8  
  3         183  
194 3     3   1524 use Beam::Make::Cache;
  3         10  
  3         137  
195 3     3   23 use File::stat;
  3         7  
  3         12  
196             with 'Beam::Runnable';
197              
198             has conf => ( is => 'ro', default => sub { YAML::LoadFile( 'Beamfile' ) } );
199             # Beam::Wire container objects
200             has _wire => ( is => 'ro', default => sub { {} } );
201              
202 7     7 0 44782 sub run( $self, @argv ) {
  7         19  
  7         30  
  7         14  
203 7         19 my ( @targets, %vars );
204              
205 7         38 for my $arg ( @argv ) {
206 9 100       56 if ( $arg =~ /^([^=]+)=([^=]+)$/ ) {
207 1         31 $vars{ $1 } = $2;
208             }
209             else {
210 8         26 push @targets, $arg;
211             }
212             }
213              
214 7         41 local @ENV{ keys %vars } = values %vars;
215 7         33 my $conf = $self->conf;
216 7         218 my $cache = Beam::Make::Cache->new;
217              
218             # Targets must be built in order
219             # Prereqs satisfied by original target remain satisfied
220 7         21 my %recipes; # Built recipes
221             my @target_stack;
222             # Build a target (if necessary) and return its last modified date.
223             # Each dependent will be checked against their depencencies' last
224             # modified date to see if they need to be updated
225 15     15   24 my $build = sub( $target ) {
  15         135  
  15         42  
226 15         113 $LOG->debug( "Want to build: $target" );
227 15 50       181 if ( grep { $_ eq $target } @target_stack ) {
  8         30  
228 0         0 die "Recursion at @target_stack";
229             }
230             # If we already have the recipe, it must already have been run
231 15 100       54 if ( $recipes{ $target } ) {
232 1         20 $LOG->debug( "Nothing to do: $target already built" );
233 1         10 return $recipes{ $target }->last_modified;
234             }
235              
236             # If there is no recipe for the target, it must be a source
237             # file. Source files cannot be built, but we do want to know
238             # when they were last modified
239 14 100       58 if ( !$conf->{ $target } ) {
240 1 50       41 $LOG->debug(
241             "$target has no recipe and "
242             . ( -e $target ? 'exists as a file' : 'does not exist as a file' )
243             );
244 1 50       15 return stat( $target )->mtime if -e $target;
245 1         20 die $LOG->errorf( q{No recipe for target "%s" and file does not exist}."\n", $target );
246             }
247              
248             # Resolve any references in the recipe object via Beam::Wire
249             # containers.
250 13         71 my $target_conf = $self->_resolve_ref( $conf->{ $target } );
251 13   100     102 my $class = delete( $target_conf->{ '$class' } ) || 'Beam::Make::File';
252 13         88 $LOG->debug( "Building recipe object $target ($class)" );
253 13         1262 eval "require $class";
254 13 50       128 if ( $@ ) {
255 0         0 die "Could not load $class: $@";
256             }
257 13         235 my $recipe = $recipes{ $target } = $class->new(
258             $target_conf->%*,
259             name => $target,
260             cache => $cache,
261             );
262              
263 13         1974 my $requires_modified = 0;
264 13 100       85 if ( my @requires = $recipe->requires->@* ) {
265 7         64 $LOG->debug( "Checking requirements for $target: @requires" );
266 7         68 push @target_stack, $target;
267 7         21 for my $require ( @requires ) {
268 7         50 $requires_modified = max $requires_modified, __SUB__->( $require );
269             }
270 7         1206 pop @target_stack;
271             }
272              
273             # Do we need to build this recipe?
274 13         59 my $result;
275 13 100 100     58 if ( $requires_modified > ( $recipe->last_modified || -1 ) ) {
276 9         522 $LOG->debug( "Building $target" );
277 9         114 $recipe->make( %vars );
278 9         92 $result = $LOG->info( "$target updated (modified: " . $recipe->last_modified . ")" );
279             }
280             else {
281 4         875 $result = $LOG->info( "$target up-to-date (modified: " . $recipe->last_modified . ")" );
282             }
283 13 100       2872 if ( !@target_stack ) {
284             # We were directly asked to build this, so let the user
285             # know about it
286 7         1061 say $result;
287             }
288 13         76 return $recipe->last_modified;
289 7         84 };
290 7         40 $build->( $_ ) for @targets;
291             }
292              
293             # Resolve any references via Beam::Wire container lookups
294 115     115   152 sub _resolve_ref( $self, $conf ) {
  115         145  
  115         159  
  115         131  
295 115 100 66     485 return $conf if !ref $conf || blessed $conf;
296 66 100       212 if ( ref $conf eq 'HASH' ) {
    50          
297 41 100       120 if ( grep { $_ !~ /^\$/ } keys %$conf ) {
  69         213  
298 34         54 my %resolved;
299 34         80 for my $key ( keys %$conf ) {
300 62         12627 $resolved{ $key } = $self->_resolve_ref( $conf->{ $key } );
301             }
302 34         132 return \%resolved;
303             }
304             else {
305             # All keys begin with '$', so this must be a reference
306             # XXX: We should add the 'file:path' syntax to
307             # Beam::Wire directly. We could even call it as a class
308             # method! We should also move BEAM_PATH resolution to
309             # Beam::Wire directly...
310             # A single Beam::Wire->resolve( $conf ) should recursively
311             # resolve the refs in a hash (like this entire subroutine
312             # does), but also allow defining inline objects (with
313             # $class)
314 7         38 my ( $file, $service ) = split /:/, $conf->{ '$ref' }, 2;
315 7         28 my $wire = $self->_wire->{ $file };
316 7 100       21 if ( !$wire ) {
317 1         5 for my $path ( split /:/, $ENV{BEAM_PATH} ) {
318 1 50       28 next unless -e join '/', $path, $file;
319 1         12 $wire = $self->_wire->{ $file } = Beam::Wire->new( file => join '/', $path, $file );
320             }
321             }
322 7         37011 return $wire->get( $service );
323             }
324             }
325             elsif ( ref $conf eq 'ARRAY' ) {
326 25         43 my @resolved;
327 25         99 for my $i ( 0..$#$conf ) {
328 40         114 $resolved[$i] = $self->_resolve_ref( $conf->[$i] );
329             }
330 25         88 return \@resolved;
331             }
332             }
333              
334             1;
335              
336             __END__
337              
338             =pod
339              
340             =head1 NAME
341              
342             Beam::Make - Recipes to declare and resolve dependencies between things
343              
344             =head1 VERSION
345              
346             version 0.002
347              
348             =head1 SYNOPSIS
349              
350             ### container.yml
351             # This Beam::Wire container stores shared objects for our recipes
352             dbh:
353             $class: DBI
354             $method: connect
355             $args:
356             - dbi:SQLite:RECENT.db
357              
358             ### Beamfile
359             # This file contains our recipes
360             # Download a list of recent changes to CPAN
361             RECENT-6h.json:
362             commands:
363             - curl -O https://www.cpan.org/RECENT-6h.json
364              
365             # Parse that JSON file into a CSV using an external program
366             RECENT-6h.csv:
367             requires:
368             - RECENT-6h.json
369             commands:
370             - yfrom json RECENT-6h.json | yq '.recent.[]' | yto csv > RECENT-6h.csv
371              
372             # Build a SQLite database to hold the recent data
373             RECENT.db:
374             $class: Beam::Make::DBI::Schema
375             dbh: { $ref: 'container.yml:dbh' }
376             schema:
377             - table: recent
378             columns:
379             - path: VARCHAR(255)
380             - epoch: DOUBLE
381             - type: VARCHAR(10)
382              
383             # Load the recent data CSV into the SQLite database
384             cpan-recent:
385             $class: Beam::Make::DBI::CSV
386             requires:
387             - RECENT.db
388             - RECENT-6h.csv
389             dbh: { $ref: 'container.yml:dbh' }
390             table: recent
391             file: RECENT-6h.csv
392              
393             ### Load the recent data into our database
394             $ beam make cpan-recent
395              
396             =head1 DESCRIPTION
397              
398             C<Beam::Make> allows an author to describe how to build some thing (a
399             file, some data in a database, an image, a container, etc...) and the
400             relationships between things. This is similar to the classic C<make>
401             program used to build some software packages.
402              
403             Each thing is a C<recipe> and can depend on other recipes. A user runs
404             the C<beam make> command to build the recipes they want, and
405             C<Beam::Make> ensures that the recipe's dependencies are satisfied
406             before building the recipe.
407              
408             This class is a L<Beam::Runnable> object and can be embedded in other
409             L<Beam::Wire> containers.
410              
411             =head2 Recipe Classes
412              
413             Unlike C<make>, C<Beam::Make> recipes can do more than just execute
414             a series of shell scripts. Each recipe is a Perl class that describes
415             how to build the desired thing and how to determine if that thing needs
416             to be rebuilt.
417              
418             These recipe classes come with C<Beam::Make>:
419              
420             =over
421              
422             =item * L<File|Beam::Make::File> - The default recipe class that creates
423             a file using one or more shell commands (a la C<make>)
424              
425             =item * L<DBI|Beam::Make::DBI> - Write data to a database
426              
427             =item * L<DBI::Schema|Beam::Make::DBI::Schema> - Create a database
428             schema
429              
430             =item * L<DBI::CSV|Beam::Make::DBI::CSV> - Load data from a CSV into
431             a database table
432              
433             =item * L<Docker::Image|Beam::Make::Docker::Image> - Build or pull a Docker image
434              
435             =item * L<Docker::Container|Beam::Make::Docker::Container> - Build a Docker container
436              
437             =back
438              
439             Future recipe class ideas are:
440              
441             =over
442              
443             =item *
444              
445             B<Template rendering>: Files could be generated from a configuration
446             file or database and a template.
447              
448             =item *
449              
450             B<Docker compose>: An entire docker-compose network could be rebuilt.
451              
452             =item *
453              
454             B<System services (init daemon, systemd service, etc...)>: Services
455             could depend on their configuration files (built with a template) and be
456             restarted when their configuration file is updated.
457              
458             =back
459              
460             =head2 Beamfile
461              
462             The C<Beamfile> defines the recipes. To avoid the pitfalls of C<Makefile>, this is
463             a YAML file containing a mapping of recipe names to recipe configuration. Each
464             recipe configuration is a mapping containing the attributes for the recipe class.
465             The C<$class> special configuration key declares the recipe class to use. If no
466             C<$class> is specified, the default L<Beam::Wire::File> recipe class is used.
467             All recipe classes inherit from L<Beam::Class::Recipe> and have the L<name|Beam::Class::Recipe/name>
468             and L<requires|Beam::Class::Recipe/requires> attributes.
469              
470             For examples, see the L<Beam::Wire examples directory on
471             Github|https://github.com/preaction/Beam-Make/tree/master/eg>.
472              
473             =head2 Object Containers
474              
475             For additional configuration, create a L<Beam::Wire> container and
476             reference the objects inside using C<< $ref: "<container>:<service>" >>
477             as the value for a recipe attribute.
478              
479             =head1 TODO
480              
481             =over
482              
483             =item Target names in C<Beamfile> should be regular expressions
484              
485             This would work like Make's wildcard recipes, but with Perl regexp. The
486             recipe object's name is the real name, but the recipe chosen is the one
487             the matches the regexp.
488              
489             =item Environment variables should interpolate into all attributes
490              
491             Right now, the C<< NAME=VALUE >> arguments to C<beam make> only work in
492             recipes that use shell scripts (like L<Beam::Make::File>). It would be
493             nice if they were also interpolated into other recipe attributes.
494              
495             =item Recipes should be able to require wildcards and directories
496              
497             Recipe requirements should be able to depend on patterns, like all
498             C<*.conf> files in a directory. It should also be able to depend on
499             a directory, which would be the same as depending on every file,
500             recursively, in that directory.
501              
502             This would allow rebuilding a ZIP file when something changes, or
503             rebuilding a Docker image when needed.
504              
505             =item Beam::Wire should support the <container>:<service> syntax
506             for references
507              
508             The L<Beam::Wire> class should handle the C<BEAM_PATH> environment
509             variable directly and be able to resolve services from other files
510             without building another C<Beam::Wire> object in the container.
511              
512             =item Beam::Wire should support resolving objects in arbitrary data
513             structures
514              
515             L<Beam::Wire> should have a class method that one can pass in a hash and
516             get back a hash with any C<Beam::Wire> object references resolved,
517             including C<$ref> or C<$class> object.
518              
519             =back
520              
521             =head1 SEE ALSO
522              
523             L<Beam::Wire>
524              
525             =head1 AUTHOR
526              
527             Doug Bell <preaction@cpan.org>
528              
529             =head1 COPYRIGHT AND LICENSE
530              
531             This software is copyright (c) 2020 by Doug Bell.
532              
533             This is free software; you can redistribute it and/or modify it under
534             the same terms as the Perl 5 programming language system itself.
535              
536             =cut