App-Yabsm

 view release on metacpan or  search on metacpan

lib/App/Yabsm/Config/Parser.pm  view on Meta::CPAN

#  Author:  Nicholas Hubbard
#  WWW:     https://github.com/NicholasBHubbard/yabsm
#  License: MIT

#  Provides functionality for Yabsm configuration parsing using the
#  Parser::MGC library. Tests for the parser are located at
#  src/t/Config.t.
#
#  This parser produces a multi-dimensional hash data structure with
#  the following skeleton:
#
#  %config = ( yabsm_dir     => '/.snapshots/yabsm'
#
#              subvols       => { foo => { mountpoint=/foo_dir }
#                               , bar => { mountpoint=/bar_dir }
#                               , ...
#                               },
#              snaps         => { foo_snap => { key=val, ... }
#                               , bar_snap => { key=val, ... }
#                               , ...
#                               },
#              ssh_backups   => { foo_ssh_backup => { key=val, ... }
#                               , bar_ssh_backup => { key=val, ... }
#                               ,  ...
#                               },
#              local_backups => { foo_local_backup => { key=val, ... }
#                               , bar_local_backup => { key=val, ... }
#                               , ...
#                               }
#            );

use strict;
use warnings;
use v5.16.3;

package App::Yabsm::Config::Parser;

use App::Yabsm::Tools qw(arg_count_or_die);

use Carp qw(confess);
use Array::Utils qw(array_minus);
use Regexp::Common qw(net);
use Feature::Compat::Try;

use Parser::MGC;
use base 'Parser::MGC';

                 ####################################
                 #              EXPORTED            #
                 ####################################

use Exporter qw(import);
our @EXPORT_OK = qw(parse_config_or_die);

sub parse_config_or_die {

    # Attempt to parse $file into a yabsm configuration data structure.

    arg_count_or_die(0, 1, @_);

    my $file = shift // '/etc/yabsm.conf';

    -f $file or die "yabsm: config error: no such file '$file'\n";
    -r $file or die "yabsm: config error: can not read file '$file'\n";

    # Initialize the Parser::MGC parser object
    my $parser = __PACKAGE__->new( toplevel => 'config_parser'
                                 , patterns => { comment => &grammar->{comment}
                                               , ws      => &grammar->{whitespace}
                                               }
                                 );

    my $config_ref = do {
        try { $parser->from_file($file) }
        catch ($e) { $e =~ s/\s+$// ; die "yabsm: config error: $e\n" }
    };

    my ($config_valid, @error_msgs) = check_config($config_ref);

    if ($config_valid) {
        return wantarray ? %{ $config_ref} : $config_ref;
    }
    else {
        my $error_msg = join '', map { $_ = "$_\n" } @error_msgs;
        die $error_msg;
    }
}

lib/App/Yabsm/Config/Parser.pm  view on Meta::CPAN

        closing_brace  => q('}'),
        equals_sign    => q('='),
        comment        => 'comment',
        whitespace     => 'whitespace',
        #keep
        '5minute_keep' => 'positive integer',
        hourly_keep    => 'positive integer',
        daily_keep     => 'positive integer',
        weekly_keep    => 'positive integer',
        monthly_keep   => 'positive integer',
        #time
        daily_times    => q(comma seperated list of times in 'hh:mm' form'),
        weekly_time    => q(time in 'hh:mm' form),
        monthly_time   => q(time in 'hh:mm' form),
        #day
        weekly_day     => 'week day',
        monthly_day    => 'month day'
    );

    return wantarray ? %grammar_msg : \%grammar_msg;
}

sub subvol_settings_grammar {

    # Return a hash of a subvols key=val grammar.

    arg_count_or_die(0, 0, @_);

    my %grammar = grammar();

    my %subvol_settings_grammar = (
        mountpoint => $grammar{mountpoint}
    );

    return wantarray ? %subvol_settings_grammar : \%subvol_settings_grammar;
}

sub snap_settings_grammar {

    # Return a hash of a snaps key=val grammar. Optionally takes a false value
    # to exclude the timeframe subgrammar from the returned grammar.

    arg_count_or_die(0, 1, @_);

    my $include_tf = shift // 1;

    my %grammar = grammar();

    my %timeframe_sub_grammar =
      $include_tf ? %{ $grammar{timeframe_sub_grammar} } : ();

    my %snap_settings_grammar = (
        subvol     => $grammar{subvol},
        timeframes => $grammar{timeframes},
        %timeframe_sub_grammar
    );

    return wantarray ? %snap_settings_grammar : \%snap_settings_grammar;
}

sub ssh_backup_settings_grammar {

    # Return a hash of a ssh_backups key=val grammar. Optionally takes a false
    # value to exclude the timeframe subgrammar from the returned grammar.

    arg_count_or_die(0, 1, @_);

    my $include_tf = shift // 1;

    my %grammar = grammar();

    my %timeframe_sub_grammar =
      $include_tf ? %{ $grammar{timeframe_sub_grammar} } : ();

    my %ssh_backup_settings_grammar = (
        subvol     => $grammar{subvol},
        ssh_dest   => $grammar{ssh_dest},
        dir        => $grammar{dir},
        timeframes => $grammar{timeframes},
        %timeframe_sub_grammar
    );

    return wantarray ? %ssh_backup_settings_grammar : \%ssh_backup_settings_grammar;
}

sub local_backup_settings_grammar {

    # Return a hash of a local_backups key=val grammar. Optionally takes a false
    # value to exclude the timeframe subgrammar from the returned grammar.

    arg_count_or_die(0, 1, @_);

    my $include_tf = shift // 1;

    my %grammar = grammar();

    my %timeframe_sub_grammar =
      $include_tf ? %{ $grammar{timeframe_sub_grammar} } : ();

    my %local_backup_settings_grammar = (
        subvol     => $grammar{subvol},
        dir        => $grammar{dir},
        timeframes => $grammar{timeframes},
        %timeframe_sub_grammar
    );

    return wantarray ? %local_backup_settings_grammar : \%local_backup_settings_grammar;
}

                 ####################################
                 #              PARSER              #
                 ####################################

sub config_parser {

    # Top level parser

    arg_count_or_die(1, 1, @_);

    my $self = shift;

    # return this
    my %config;

    # Define the parser

    my %grammar = grammar();

    $self->sequence_of( sub {
        $self->commit;
        $self->any_of(
            sub {
                $self->expect( 'yabsm_dir' );
                $self->commit;
                exists $config{yabsm_dir} and $self->fail('yabsm_dir is already defined');
                $self->maybe_expect('=') // $self->fail(q(expected '='));
                my $dir = $self->maybe_expect($grammar{dir}) // $self->fail(grammar_msg->{dir});
                $config{yabsm_dir} = $dir;
            },
            sub {
                $self->expect( 'subvol' );
                $self->commit;
                my $name = $self->maybe_expect( $grammar{name} );
                $name // $self->fail('expected subvol name');
                exists $config{subvols}{$name}       and $self->fail("already have a subvol named '$name'");
                exists $config{snaps}{$name}         and $self->fail("already have a snap named '$name'");
                exists $config{ssh_backups}{$name}   and $self->fail("already have a ssh_backup named '$name'");
                exists $config{local_backups}{$name} and $self->fail("already have a local_backup named '$name'");
                my $kvs = $self->scope_of('{', 'subvol_settings_parser' ,'}');
                $config{subvols}{$name} = $kvs;
            },
            sub {
                $self->expect( 'snap' );
                $self->commit;
                my $name = $self->maybe_expect( $grammar{name} );
                $name // $self->fail('expected snap name');
                exists $config{subvols}{$name}       and $self->fail("already have a subvol named '$name'");
                exists $config{snaps}{$name}         and $self->fail("already have a snap named '$name'");
                exists $config{ssh_backups}{$name}   and $self->fail("already have a ssh_backup named '$name'");
                exists $config{local_backups}{$name} and $self->fail("already have a local_backup named '$name'");
                my $kvs = $self->scope_of('{', 'snap_settings_parser', '}');
                $config{snaps}{$name} = $kvs;
            },
            sub {
                $self->expect( 'ssh_backup' );
                $self->commit;
                my $name = $self->maybe_expect( $grammar{name} );
                $name // $self->fail('expected ssh_backup name');
                exists $config{subvols}{$name}       and $self->fail("already have a subvol named '$name'");
                exists $config{snaps}{$name}         and $self->fail("already have a snap named '$name'");
                exists $config{ssh_backups}{$name}   and $self->fail("already have a ssh_backup named '$name'");
                exists $config{local_backups}{$name} and $self->fail("already have a local_backup named '$name'");
                my $kvs = $self->scope_of('{', 'ssh_backup_settings_parser', '}');
                $config{ssh_backups}{$name} = $kvs;
            },
            sub {
                $self->expect( 'local_backup' );
                $self->commit;
                my $name = $self->maybe_expect( $grammar{name} );
                $name // $self->fail('expected local_backup name');
                exists $config{subvols}{$name}       and $self->fail("already have a subvol named '$name'");
                exists $config{snaps}{$name}         and $self->fail("already have a snap named '$name'");
                exists $config{ssh_backups}{$name}   and $self->fail("already have a ssh_backup named '$name'");
                exists $config{local_backups}{$name} and $self->fail("already have a local_backup named '$name'");
                my $kvs = $self->scope_of('{', 'local_backup_settings_parser', '}');
                $config{local_backups}{$name} = $kvs;
            },
            sub {
                $self->commit;
                $self->skip_ws; # skip_ws also skips comments
                $self->fail(q(expected one of 'subvol', 'snap', 'ssh_backup', or 'local_backup'));
            }
        );
    });

    return wantarray ? %config : \%config;
}

sub settings_parser {

    # Abstract method that parses a sequence of key=val pairs based off of the
    # input grammar %grammar. The arg $type is simply a string that is either
    # 'subvol', 'snap', 'ssh_backup', or 'local_backup' and is only used for
    # error message generation. This method should be called from a wrapper
    # method.

    arg_count_or_die(3, 3, @_);

    my $self    = shift;
    my $type    = shift;
    my $grammar = shift;

    my @settings = keys %{ $grammar };
    my $setting_regex = join '|', @settings;

    # return this
    my %kvs;

    $self->sequence_of( sub {
        $self->commit;

        my $setting = $self->maybe_expect( qr/$setting_regex/ )
          // $self->fail("expected a $type setting");

        $self->maybe_expect('=') // $self->fail('expected "="');

        my $value = $self->maybe_expect($grammar->{$setting})
          // $self->fail('expected ' . grammar_msg->{$setting});

        $kvs{$setting} = $value;
    });

    return wantarray ? %kvs : \%kvs;
}

sub subvol_settings_parser {
    arg_count_or_die(1, 1, @_);
    my $self = shift;
    my $subvol_settings_grammar = subvol_settings_grammar();
    $self->settings_parser('subvol', $subvol_settings_grammar);
}

sub snap_settings_parser {
    arg_count_or_die(1, 1, @_);
    my $self = shift;
    my $snap_settings_grammar = snap_settings_grammar();
    $self->settings_parser('snap', $snap_settings_grammar);
}

sub ssh_backup_settings_parser {
    arg_count_or_die(1, 1, @_);
    my $self = shift;
    my $ssh_backup_settings_grammar = ssh_backup_settings_grammar();
    $self->settings_parser('ssh_backup', $ssh_backup_settings_grammar);
}

sub local_backup_settings_parser {
    arg_count_or_die(1, 1, @_);
    my $self = shift;
    my $local_backup_settings_grammar = local_backup_settings_grammar();
    $self->settings_parser('local_backup', $local_backup_settings_grammar);
}

                 ####################################
                 #          ERROR ANALYSIS          #
                 ####################################

sub check_config {

    # Ensure that $config_ref references a valid yabsm configuration.  If the
    # config is valid return a list containing only the value 1, otherwise
    # return multiple values where the first value is 0 and the rest of the
    # values are the corresponding error messages.

    arg_count_or_die(1, 1, @_);

    my $config_ref = shift;

    my @error_msgs;

    unless ($config_ref->{yabsm_dir}) {
        push @error_msgs, q(yabsm: config error: missing required setting 'yabsm_dir');
    }

    unless ($config_ref->{snaps} || $config_ref->{ssh_backups} || $config_ref->{local_backups}) {
        push @error_msgs, 'yabsm: config error: no defined snaps, ssh_backups, or local_backups';
    }

    push @error_msgs, snap_errors($config_ref);
    push @error_msgs, ssh_backup_errors($config_ref);
    push @error_msgs, local_backup_errors($config_ref);

    if (@error_msgs) {
        return (0, @error_msgs);
    }
    else {
        return (1);
    }
}

sub snap_errors {

    # Ensure that all the snaps defined in the config referenced by $config_ref
    # are not missing required snap settings and are snapshotting a defined
    # subvol.

    arg_count_or_die(1, 1, @_);

    my $config_ref = shift;

    # return this
    my @error_msgs;

    # Base required settings. Passing 0 to snap_settings_grammar excludes
    # timeframe settings from the returned hash.
    my @base_required_settings = keys %{ snap_settings_grammar(0) };

    foreach my $snap (keys %{ $config_ref->{snaps} }) {

        # Make sure that the subvol being snapped exists
        my $subvol = $config_ref->{snaps}{$snap}{subvol};
        if (defined $subvol) {
            unless (grep { $subvol eq $_ } keys %{ $config_ref->{subvols} }) {
                push @error_msgs, "yabsm: config error: snap '$snap' is snapshotting up a non-existent subvol '$subvol'";
            }
        }

        # Make sure all required settings are defined
        my @required_settings = @base_required_settings;
        my $timeframes = $config_ref->{snaps}{$snap}{timeframes};
        if (defined $timeframes) {
            push @required_settings, required_timeframe_settings($timeframes);
        }
        my @defined_settings = keys %{ $config_ref->{snaps}{$snap} };
        my @missing_settings = array_minus(@required_settings, @defined_settings);
        foreach my $missing (@missing_settings) {
            push @error_msgs, "yabsm: config error: snap '$snap' missing required setting '$missing'";
        }
    }

    return wantarray ? @error_msgs : \@error_msgs;
}

sub ssh_backup_errors {

    # Ensure that all the ssh_backups defined in the config referenced by
    # $config_ref are not missing required ssh_backup settings and are backing
    # up a defined subvol.

    arg_count_or_die(1, 1, @_);

    my $config_ref = shift;

    # return this
    my @error_msgs;

    # Base required settings. Passing 0 to ssh_backup_settings_grammar excludes
    # timeframe settings from the returned hash.
    my @base_required_settings = keys %{ ssh_backup_settings_grammar(0) };

    foreach my $ssh_backup (keys %{ $config_ref->{ssh_backups} }) {

        # Make sure that the subvol being backed up exists
        my $subvol = $config_ref->{ssh_backups}{$ssh_backup}{subvol};
        if (defined $subvol) {
            unless (grep { $subvol eq $_ } keys %{ $config_ref->{subvols} }) {
                push @error_msgs, "yabsm: config error: ssh_backup '$ssh_backup' is backing up a non-existent subvol '$subvol'";
            }
        }

        # Make sure all required settings are defined
        my @required_settings = @base_required_settings;
        my $timeframes = $config_ref->{ssh_backups}{$ssh_backup}{timeframes};
        if (defined $timeframes) {
            push @required_settings, required_timeframe_settings($timeframes);
        }
        my @defined_settings = keys %{ $config_ref->{ssh_backups}{$ssh_backup} };
        my @missing_settings = array_minus(@required_settings, @defined_settings);
        foreach my $missing (@missing_settings) {
            push @error_msgs, "yabsm: config error: ssh_backup '$ssh_backup' missing required setting '$missing'";
        }
    }

    return wantarray ? @error_msgs : \@error_msgs;
}

sub local_backup_errors {

    # Ensure that all the local_backups defined in the config referenced by
    # $config_ref are not missing required local_backup settings and are backing
    # up a defined subvol

    arg_count_or_die(1, 1, @_);

    my $config_ref = shift;

    # return this
    my @error_msgs;

    # Base required settings. Passing 0 to local_backup_settings_grammar
    # excludes timeframe settings from the returned hash.
    my @base_required_settings = keys %{ local_backup_settings_grammar(0) };

    foreach my $local_backup (keys %{ $config_ref->{local_backups} }) {

        # Make sure that the subvol being backed up exists
        my $subvol = $config_ref->{local_backups}{$local_backup}{subvol};
        if (defined $subvol) {
            unless (grep { $subvol eq $_ } keys %{ $config_ref->{subvols} }) {
                push @error_msgs, "yabsm: config error: local_backup '$local_backup' is backing up a non-existent subvol '$subvol'";
            }
        }

        # Make sure all required settings are defined
        my @required_settings = @base_required_settings;
        my $timeframes = $config_ref->{local_backups}{$local_backup}{timeframes};
        if (defined $timeframes) {
            push @required_settings, required_timeframe_settings($timeframes);
        }
        my @defined_settings = keys %{ $config_ref->{local_backups}{$local_backup} };
        my @missing_settings = array_minus(@required_settings, @defined_settings);
        foreach my $missing (@missing_settings) {
            push @error_msgs, "yabsm: config error: local_backup '$local_backup' missing required setting '$missing'";
        }
    }

    return wantarray ? @error_msgs : \@error_msgs;
}

sub required_timeframe_settings {

    # Given a timeframes value like 'hourly,daily,monthly' returns a list of
    # required settings. This subroutine is used to dynamically determine what
    # settings are required for certain config entities.

    arg_count_or_die(1, 1, @_);

    my $tframes = shift;

    my @timeframes = split ',', $tframes;

    # return this
    my @required;

    foreach my $tframe (@timeframes) {
        if    ($tframe eq '5minute') { push @required, qw(5minute_keep) }
        elsif ($tframe eq 'hourly')  { push @required, qw(hourly_keep) }
        elsif ($tframe eq 'daily')   { push @required, qw(daily_keep daily_times) }
        elsif ($tframe eq 'weekly')  { push @required, qw(weekly_keep weekly_time weekly_day) }
        elsif ($tframe eq 'monthly') { push @required, qw(monthly_keep monthly_time monthly_day) }
        else {
            confess("yabsm: internal error: no such timeframe '$tframe'");
        }
    }

    return @required;
}

1;



( run in 1.180 second using v1.01-cache-2.11-cpan-ceb78f64989 )