File Coverage

blib/lib/Mnet/Stanza.pm
Criterion Covered Total %
statement 135 141 95.7
branch 64 76 84.2
condition 26 35 74.2
subroutine 12 13 92.3
pod 4 4 100.0
total 241 269 89.5


line stmt bran cond sub pod time code
1             package Mnet::Stanza;
2              
3             =head1 NAME
4              
5             Mnet::Stanza - Manipulate stanza outline text such as ios configs
6              
7             =head1 SYNOPSIS
8              
9             # use this module
10             use Mnet::Stanza;
11              
12             # read current config from standard input, trim extra spaces
13             my $sh_run = undef;
14             $sh_run .= "$_\n" while ;
15             $sh_run = Mnet::Stanza::trim($sh_run);
16              
17             # remove and recreate acl if configured acl does not match below
18             Mnet::Stanza::ios("
19             =ip access-list DMZ
20             =permit 192.168.0.0 0.0.255.255
21             ", $sh_run);
22              
23             # print config applying acl to shutdown interfaces if not present
24             my @ints = Mnet::Stanza::parse($sh_run, qr/^interface/);
25             foreach my $int (@ints) {
26             next if $int !~ /^\s*shutdown/m;
27             next if $int =~ /^\s*ip access-group DMZ in/m;
28             die "error, $int" if $int !~ /^interface (\S+)/;
29             print "interface $1\n";
30             print " ip access-group DMZ in\n";
31             }
32              
33             =head1 DESCRIPTION
34              
35             Mnet::Stanza can be used on text arranged in stanzas of indented lines or text
36             in outline format, such as the following:
37              
38             line
39             stanza 1
40             indented line
41             stanza 2
42             sub-stanza 1
43             indented line 1
44             indented line 2
45             sub-sub-stanza 1
46             indented line 1
47             indented line 2
48             end
49              
50             In the above example the following would be true:
51              
52             stanza 1 contains a single indented line
53             stanza 2 contains sub-stanza 1 and everything under sub-stanza 1
54             sub-stanza 1 contains two indented lines and a sub-sub-stanza 1
55             sub-sub-stanza 1 contains two indented lines
56              
57             This can be used to parse cisco ios configs, amongst other similar formats.
58              
59             =head1 FUNCTIONS
60              
61             Mnet::Stanza implements the functions listed below.
62              
63             =cut
64              
65             # required modules
66 2     2   1069 use warnings;
  2         13  
  2         66  
67 2     2   10 use strict;
  2         3  
  2         49  
68 2     2   18 use Carp;
  2         5  
  2         242  
69 2     2   908 use Mnet;
  2         6  
  2         4563  
70              
71              
72              
73             sub trim {
74              
75             =head2 trim
76              
77             $output = Mnet::Stanza::trim($input)
78              
79             The Mnet::Stanza::trim function can be used to normalize stanza spacing and may
80             be useful before calling the diff function or otherwise processing the stanza.
81              
82             This function modifies the input string as following:
83              
84             - remove trailing spaces at the end of all lines of text
85             - remove blank lines before, after, and within text
86             - remove extra leading spaces while preserving indentation
87             - remove extra spaces inside non-description/remark lines
88             dosn't modify ios description and remark command lines
89              
90             A null value will be output if the input is undefined.
91              
92             Note that in some cases extra spaces in the input may be significant and it
93             may not be appropriate to use this trim function. This must be determined
94             by the developer. Also note that this function does not touch tabs.
95              
96             =cut
97              
98             # read input stanza text
99 47   100 47 1 1258 my $input = shift // "";
100              
101             # init output
102 47         63 my $output = "";
103              
104             # replace multiple spaces inside text with single spaces
105             # preserve description/remark double spaces
106 47         160 foreach my $line (split(/\n/, $input)) {
107 333 100       1267 $line =~ s/(\S) +/$1 /g if $line !~ /^\s*(description|remark)\s/;
108 333         610 $output .= "$line\n";
109             }
110              
111             # remove trailing spaces at the end of all lines of text
112 47         258 $output =~ s/\s+$//m;
113              
114             # remove blank lines before, after, and within text
115 47         98 $output =~ s/\n\n+/\n/g;
116 47         1235 $output =~ s/(^\n+|\n+$)//g;
117              
118             # determine smallest indent common to all lines
119 47         62 my $indent_init = 999999999999;
120 47         96 my $indent = $indent_init;
121 47         142 foreach my $line (split(/\n/, $output)) {
122 297 100 66     1111 if ($line =~ /^(\s*)\S/ and length($1) < $indent) {
123 45         80 $indent = length($1);
124             }
125             }
126              
127             # remove extra leading spaces while preserving indentation
128 47 100 100     558 $output =~ s/^ {$indent}//mg if $indent and $indent < $indent_init;
129              
130             # finished trim function, return trimmed output text
131 47         189 return $output;
132             }
133              
134              
135              
136             sub parse {
137              
138             =head2 parse
139              
140             @output = Mnet::Stanza::parse($input, qr/$match_re/)
141             $output = Mnet::Stanza::parse($input, qr/$match_re/)
142              
143             The Mnet::Stanza::parse function can be used to output one or more matching
144             stanza sections from the input text, either as a list of matching stanzas or
145             a single string.
146              
147             Here's some sample input text:
148              
149             hostname test
150             interface Ethernet1
151             no ip address
152             shutdown
153             interface Ethernet2
154             ip address 1.2.3.4 255.255.255.0
155              
156             Using an input match_re of qr/^interface/ the following two stanzas are output:
157              
158             interface Ethernet1
159             no ip address
160             shutdown
161             interface Ethernet2
162             ip address 1.2.3.4 255.255.255.0
163              
164             Note that blank lines are not considered to terminate stanzas.
165              
166             Refer also to the Mnet::Stanza::trim function in this module.
167              
168             =cut
169              
170             # read input stanza text and match regular expression
171 25     25 1 44 my $input = shift;
172 25   33     56 my $match_re = shift // croak("missing match_re arg");
173              
174             # init list of matched output stanzas
175             # each output stanza will include lines indented under matched line
176 25         44 my @output = ();
177              
178             # loop through lines, set matching output stanzas
179             # use indent var to track indent level of current matched stanza line
180             # if line matches current indent or is blank then append to output stanza
181             # elsif line matches input mathc_re then push to a new output stanza
182             # else reset current indet to undef, to wait for a new match_re line
183 25         30 my $indent = undef;
184 25         111 foreach my $line (split(/\n/, $input)) {
185 350 100 100     1232 if (defined $indent and $line =~ /^($indent|\s*$)/) {
    100          
186 34         79 $output[-1] .= "$line\n";
187             } elsif ($line =~ $match_re) {
188 51         122 push @output, "$line\n";
189 51 50       178 $indent = "$1 " if $line =~ /^(\s*)/;
190             } else {
191 265         371 $indent = undef;
192             }
193             }
194              
195             # remove last end of line from all output stanzas
196 25         68 chomp(@output);
197              
198             # finished parse function, return output stanzas as list or string
199 25 100       105 return wantarray ? @output : join("\n", @output);
200             }
201              
202              
203              
204             sub diff {
205              
206             =head2 diff
207              
208             $diff = Mnet::Stanza::diff($old, $new)
209              
210             The Mnet::Stanza::diff function checks to see if the input old and new stanza
211             strings are the same.
212              
213             The returned diff value will be set as follows:
214              
215             indicates old and new inputs match
216             indicates both inputs are undefined
217             undef:$a indicates either new or old input arg is undefined
218             line $n: $t indicates mismatch line number and line text
219             other indicates other mismatch such as extra eol chars
220              
221             Note that blank lines and all other spaces are significant. Consider using the
222             Mnet::Stanza::trim function to normalize both inputs before calling this
223             function.
224              
225             =cut
226              
227             # read input old and new stanzas
228 9     9 1 26 my ($old, $new) = (shift, shift);
229 9   100     40 my ($length_old, $length_new) = (length($old // ""), length($new // ""));
      100        
230              
231             # init output diff value
232 9         14 my $diff = undef;
233              
234             # set diff undef if old and new are both undefined
235 9 100 100     40 if (not defined $old and not defined $new) {
    100          
    100          
    100          
236 1         2 $diff = undef;
237              
238             # set diff if old stanza is undefined
239             } elsif (not defined $old) {
240 1         2 $diff = "undef: old";
241              
242             # set diff if new stanza is undefined
243             } elsif (not defined $new) {
244 1         3 $diff = "undef: new";
245              
246             # set diff to null if old and new stanzas match
247             } elsif ($old eq $new) {
248 1         3 $diff = "";
249              
250             # set diff to first old or new line that doesn't match
251             # loop through old lines, looking for equivalant new lines
252             # look for additional new lines that are not present in old
253             # set diff to other if we don't know why old is not equal to new
254             } else {
255 5         16 my @new = split(/\n/, $new);
256 5         9 my $num = 0;
257 5         10 foreach my $line (split(/\n/, $old)) {
258 7         11 $num++;
259 7 100 100     25 if (defined $new[0] and $new[0] eq $line) {
260 4         11 shift @new;
261             } else {
262 3         8 $diff = "line $num: $line";
263 3         5 last;
264             }
265             }
266 5         8 $num++;
267 5 100 100     18 $diff = "line $num: $new[0]" if defined $new[0] and not defined $diff;
268 5 100       12 $diff = "other" if not defined $diff;
269              
270             # finished setting output diff
271             }
272              
273             # finished diff function, return diff text
274 9         32 return $diff;
275             }
276              
277              
278              
279             sub ios {
280              
281             =head2 ios
282              
283             $output = Mnet::Stanza::ios($template, $config)
284              
285             The Mnet::Stanza::ios fucntion uses a template to check a config for needed
286             config changes, outputting a generated list of overlay config commands that can
287             be applied to the device to bring it into compliance.
288              
289             The script dies with an error if the template argument is missing. The second
290             config argument is optional and can be set to the current device config.
291              
292             The output from this function will be commands that need to be added and/or
293             removed from the input config, generated by comparing the input template to
294             the input config. It will be empty if the input config does not need to be
295             updated.
296              
297             This function is designed to work on templates and configs made up of indented
298             stanzas, as in the following ios config example, showing a global snmp command
299             and a named access-list stanza:
300              
301             ! global commands are not indented
302             snmp-server community global
303              
304             ! a stanza includes lines indented underneath
305             ip access-list stanza
306             permit ip any any
307             deny ip any any
308              
309             Template lines should start with one of the following characters:
310              
311             ! comment
312              
313             comments are ignored, they are not propagated to output
314              
315             + add line
316              
317             config line should be added if not already present
318             to add lines under a stanza refer to '>' below
319              
320             > find stanza
321              
322             use to find or create a stanza in the input config
323             found stanzas can have '+', '=', and/or '-' lines underneath
324             found stanzas are output if child lines generate output
325              
326             = match stanza
327              
328             output stanza if not already present and an exact match
329             indented lines undeneath to match must also start with '='
330             possible to '>' find a stanza and '=' match child sub-stanza
331              
332             - remove line or stanza if present
333              
334             remove global command, command under a stanza, or a stanza
335             wildcard '*!*' at end of line matches one or more characters
336             does not remove lines already checked in the current stanza
337             lines to be removed are prefixed by 'no' in the output
338             lines already starting with 'no' get the 'no' removed
339             to remove commands under a stanza refer above to '>'
340              
341             Following is an example of how this function can be used to remediate complex
342             ios feature configs:
343              
344             # use this module
345             use Mnet::Stanza;
346              
347             # read current config from standard input
348             my $sh_run = undef;
349             $sh_run .= "$_\n" while ;
350              
351             # define ios feature update template string
352             # can be programmatically generated from parsed config
353             my $update_remplate = "
354              
355             ! check numbered acl, ensure no extra lines
356             +access-list 1 permit ip any any
357             -access-list 1 *!*
358              
359             ! check that this stanza matches exactly
360             =ip access-list test
361             =permit ip any any
362              
363             ! find vlan stanza and ensure acls are applied
364             >interface Vlan1
365             +ip access-group 1 in
366             +ip access-group test out
367             ";
368              
369             # define ios feature remove template string
370             # used to remove any old config before applying update
371             my $remove_template = "
372              
373             ! acl automatically removed from interface
374             -access-list 1
375             -ip access-list test
376              
377             ";
378              
379             # output overlay config if update is needed
380             # overlay will remove old config before updating with new config
381             if (Mnet::Stanza::ios($update_template, $sh_run)) {
382             print Mnet::Stanza::ios($remove_template, $sh_run);
383             print Mnet::Stanza::ios($update_template);
384             }
385              
386             Note that extra spaces are removed from the template and config inputs using
387             the Mnet::Stanza::trim function. Refer to that function for more info.
388              
389             =cut
390              
391             # read input template and config args
392 12   33 12 1 115 my $template = shift // croak "missing template arg";
393 12   50     27 my $config = shift // "";
394              
395             # note: this function is called recursively for each find stanza '>'
396             # processing starts with input template and config supplied by caller
397             # template lines with the same indent level are processed, top to bottom
398             # find sub-stanzas causes recursive calls with stanza subcommands inputs
399              
400             # indent arg used by recursive ios find calls, abort if set by other caller
401             # setup some things when initially called by user script, indent is undef
402             # set indent null, trim input/template, everything starts on left margin
403             # set template errors sub now, so die always refers to original caller
404             # croak with an error if called by user script with indent arg set
405 12         15 my $indent = shift;
406 12 50       25 if (not defined $indent) {
  0 0       0  
407 12         19 $indent = "";
408 12         21 $config = Mnet::Stanza::trim($config);
409 12         27 $template = Mnet::Stanza::trim($template);
410             sub _ios_error_in_template {
411 0   0 0   0 my $template = shift // croak "undefined arg";
412 0         0 $template =~ /^(\s*\S.*)/m;
413 0         0 die "ios error in template line '$1'".Carp::shortmess()."\n";
414             }
415             } elsif (caller ne "Mnet::Stanza::_ios_find") {
416 0         0 croak("Mnet::Stanza::ios called with too many args")
417             }
418              
419             # init output config overlay
420 12         20 my $output = "";
421              
422             # track lines checked in current stanza
423             # key is set for each line checked with add/find/match/remove operation
424             # used by _ios_remove sub to ensure lines already checked are not removed
425             # example, adds test 1-2, removes all others: +test 1, +test 2, -test *!*
426 12         18 my $checked = {};
427              
428             # clear lines starting with the ios comment character from template
429 12         29 $template =~ s/^\s*!.*//mg;
430              
431             # parse list of lines/stanzas from input config at current indent level
432 12         56 my @template_stanzas = Mnet::Stanza::parse($template, qr/^$indent\S/);
433 12 50       34 croak("no text at indent ".length($indent)) if not $template_stanzas[0];
434              
435             # loop to process parsed template stanzas
436 12         23 foreach my $template_stanza (@template_stanzas) {
437              
438             # add line '+'
439 36 100       126 if ($template_stanza =~ /^\+/) {
    100          
    100          
    50          
440 6         13 $output .= _ios_add($template_stanza, $config, $checked, $indent);
441              
442             # find stanza '>'
443             # which recursively calls this function to process subcommands
444             } elsif ($template_stanza =~ /^\>/) {
445 9         26 $output .= _ios_find($template_stanza, $config, $checked, $indent);
446              
447             # match stanza '='
448             } elsif ($template_stanza =~ /^\=/) {
449 2         10 $output .= _ios_match($template_stanza, $config, $checked, $indent);
450              
451             # remove line or stanza '-'
452             } elsif ($template_stanza =~ /^\-/) {
453 19         39 $output .= _ios_remove($template_stanza, $config, $checked,$indent);
454              
455             # otherwise abort with template error
456             } else {
457 0         0 _ios_error_in_template($template_stanza);
458             }
459              
460             # continue processing parsed template stanzas
461             }
462              
463             # finished ios function, return output config overlay
464 12         65 return $output;
465             }
466              
467              
468              
469             sub _ios_add {
470              
471             # $output = _ios_add($template, $config, \%checked, $indent)
472             # purpose: ios template '+' add line if missing
473             # $output: missing config commands that need to be added
474             # $template: current template line to add, expected to be on left margin
475             # $config: ios config to work on, expected to be on left margin
476             # \%checked: keys for each add/find/match/remove in current stanza
477             # $indent: current template indent, zero or more spaces
478              
479             # read input args and initialize output config overlay
480 6     6   14 my ($template, $config, $checked, $indent) = (shift, shift, shift, shift);
481 6         10 my $output = "";
482              
483             # parse single line to add
484 6 50       20 _ios_error_in_template($template) if $template !~ /^\+(\S.*)$/;
485 6         14 my $add_line = $1;
486              
487             # append line to output if not already present with same indent
488 6 100       74 $output .= $indent.$add_line."\n" if $config !~ /^$indent\Q$add_line\E$/m;
489              
490             # note that this line was checked
491 6         22 $checked->{$indent.$add_line}++;
492              
493             # finished _ios_add, return output config overlay
494 6         16 return $output;
495             }
496              
497              
498              
499             sub _ios_find {
500              
501             # $output = _ios_find($template, $config, \%checked, $indent)
502             # purpose: ios template '>' find stanza
503             # $output: missing config commands that need to be added
504             # $template: current template line to find, expected to be on left margin
505             # $config: ios config to work on, expected to be on left margin
506             # \%checked: keys for each add/find/match/remove in current stanza
507             # $indent: current template indent, zero or more spaces
508              
509             # read input args and initialize output config overlay
510 9     9   28 my ($template, $config, $checked, $indent) = (shift, shift, shift, shift);
511 9         12 my $output = "";
512              
513             # parse template first line to find
514 9 50       31 _ios_error_in_template($template) if $template !~ /^\>(\S.*)$/m;
515 9         19 my $find_line = $1;
516              
517             # parse template subcommands under first line to find
518 9         14 my $find_subcommands = undef;
519 9         22 foreach my $line (split(/\n/, $template)) {
520 19 100       39 $find_subcommands .= "$line\n" if defined $find_subcommands;
521 19 100       41 $find_subcommands = "" if not defined $find_subcommands
522             }
523              
524             # process find template stanza, assuming subcommands under first line
525 9 100       35 if ($find_subcommands =~ /\S/) {
526              
527             # parse template indent level of subcommands
528 7         9 my $indent_subcommands = "";
529 7 50       43 $indent_subcommands = $1 if $template =~ /\n(\s*)/;
530              
531             # parse config looking for stanza that matched first line of template
532 7         85 my $config_stanza = Mnet::Stanza::parse($config, qr/^\Q$find_line\E$/);
533              
534             # look for matching stanza subcommands from config
535 7         17 my $config_subcommands = undef;
536 7         22 foreach my $line (split(/\n/, $config_stanza)) {
537 19 100       36 $config_subcommands .= "$line\n" if defined $config_subcommands;
538 19 100       36 $config_subcommands = "" if not defined $config_subcommands
539             }
540              
541             # resursive compare found template subcommands to config subcommands
542 7         16 my $output_subcommands = Mnet::Stanza::ios(
543             Mnet::Stanza::trim($find_subcommands),
544             Mnet::Stanza::trim($config_subcommands),
545             );
546              
547             # append found stanza with stanza output sub-commands
548 7 100       26 if ($output_subcommands =~ /\S/) {
549 6         15 $output .= $indent.$find_line."\n";
550 6         14 foreach my $line (split(/\n/, $output_subcommands)) {
551 8         20 $output .= $indent_subcommands.$line."\n";
552             }
553             }
554              
555             # finished processing find template stanza
556             }
557              
558             # note that template find stanza first line was checked
559 9         23 $checked->{$indent.$find_line}++;
560              
561             # finished _ios_find, return output config overlay
562 9         24 return $output;
563             }
564              
565              
566              
567             sub _ios_match {
568              
569             # $output = _ios_match($template, $config, \%checked, $indent)
570             # purpose: ios template '=' match stanza
571             # $output: missing config commands that need to be added
572             # $template: current template line to match, expected to be on left margin
573             # $config: ios config to work on, expected to be on left margin
574             # \%checked: keys for each add/find/match/remove in current stanza
575             # $indent: current template indent, zero or more spaces
576              
577             # read input args and initialize output config overlay
578 2     2   6 my ($template, $config, $checked, $indent) = (shift, shift, shift, shift);
579 2         3 my $output = "";
580              
581             # parse first line to match
582 2 50       10 _ios_error_in_template($template) if $template !~ /^\=(\S.*)$/m;
583 2         4 my $match_line = $1;
584              
585             # parse entire stanza to match
586 2         3 my $match_stanza = "";
587 2         6 foreach my $line (split(/\n/, $template)) {
588 5 50       18 _ios_error_in_template($line) if $line !~ /^(\s*)=(\S.*)$/;
589 5         19 $match_stanza .= $1.$2."\n";
590             }
591              
592             # look for matching stanza in config
593 2         25 my $config_stanza = Mnet::Stanza::parse($config,qr/^\Q$match_line\E$/)."\n";
594              
595             # append match stanza to output if not already present with same indent
596 2 100       9 $output .= $indent.$match_stanza if $config_stanza ne $match_stanza;
597              
598             # note that this line was checked
599 2         7 $checked->{$indent.$match_line}++;
600              
601             # finished _ios_stanza, return output config overlay
602 2         5 return $output;
603             }
604              
605              
606              
607             sub _ios_remove {
608              
609             # $output = _ios_remove($template, $config, \%checked, $indent)
610             # purpose: ios template '-' remove line or stanza
611             # $output: extra config commands that need to be removed
612             # $template: current template line to remove, expected to be on left margin
613             # $config: ios config to work on, expected to be on left margin
614             # \%checked: keys for each add/find/match/remove in current stanza
615             # $indent: current template indent, zero or more spaces
616              
617             # read input args and initialize output config overlay
618 19     19   41 my ($template, $config, $checked, $indent) = (shift, shift, shift, shift);
619 19         26 my $output = "";
620              
621             # parse single line to remove
622 19 50       57 _ios_error_in_template($template) if $template !~ /^\-(\S.*)$/;
623 19         40 my $remove_line = $1;
624              
625             # set regex to match line, with optional '*!*' wildcard at end
626 19         31 my ($regex_line, $regex_wildcard) = ($remove_line, "");
627 19 100       56 ($regex_line, $regex_wildcard) = ($1, ".*")
628             if $remove_line =~ /^(.*)\*\!\*$/;
629              
630             # check each config line for a match to remove line regex/wildcard
631             # ensure line was not already checked by add/find/match/remove operations
632             # skip line removal if it was already checked, otherwise note as checked
633             # lines matched in the config are output with a 'no' in front of them
634             # lines matched already starting with 'no' are output without the 'no'
635 19         113 foreach my $config_line (split(/\n/, $config)) {
636 535 100       1707 if ($config_line =~ /^$indent(\Q$regex_line\E$regex_wildcard)$/m) {
637 15         34 my $match_line = $1;
638 15 100       43 if (not $checked->{$indent.$match_line}) {
639 7         18 $checked->{$indent.$match_line}++;
640 7         14 my $no_line = "no $match_line";
641 7         12 $no_line =~ s/^no no //;
642 7         21 $output .= $indent.$no_line."\n";
643             }
644             }
645             }
646              
647             # finished _ios_remove, return output config overlay
648 19         76 return $output;
649             }
650              
651              
652              
653             =head1 SEE ALSO
654              
655             L
656              
657             L
658              
659             =cut
660              
661             # normal end of package
662             1;
663