38413: Cacti integration
[freeside.git] / FS / FS / part_export / cacti.pm
1 package FS::part_export::cacti;
2
3 =pod
4
5 =head1 NAME
6
7 FS::part_export::cacti
8
9 =head1 SYNOPSIS
10
11 Cacti integration for Freeside
12
13 =head1 DESCRIPTION
14
15 This module in particular handles FS::part_export object creation for Cacti integration;
16 consult any existing L<FS::part_export> documentation for details on how that works.
17
18 =cut
19
20 use strict;
21
22 use base qw( FS::part_export );
23 use FS::Record qw( qsearchs qsearch );
24 use FS::UID qw( dbh );
25 use FS::cacti_page;
26
27 use File::Rsync;
28 use File::Slurp qw( slurp );
29 use File::stat;
30 use MIME::Base64 qw( decode_base64 encode_base64 );
31 use Storable qw(thaw);
32
33
34 use vars qw( %info );
35
36 my $php = 'php -q ';
37
38 tie my %options, 'Tie::IxHash',
39   'user'              => { label   => 'User Name',
40                            default => 'freeside' },
41   'script_path'       => { label   => 'Script Path',
42                            default => '/usr/share/cacti/cli/' },
43   'template_id'       => { label   => 'Host Template ID',
44                            default => '' },
45   'tree_id'           => { label   => 'Graph Tree ID (optional)',
46                            default => '' },
47   'description'       => { label   => 'Description (can use tokens $contact, $ip_addr and $description)',
48                            default => 'Freeside $contact $description $ip_addr' },
49   'graphs_path'       => { label   => 'Graph Export Directory (user@host:/path/to/graphs/)',
50                            default => '' },
51   'import_freq'       => { label   => 'Minimum minutes between graph imports',
52                            default => '5' },
53   'max_graph_size'    => { label   => 'Maximum size per graph (MB)',
54                            default => '5' },
55   'delete_graphs'     => { label   => 'Delete associated graphs and data sources when unprovisioning', 
56                            type    => 'checkbox',
57                          },
58   'include_path'      => { label   => 'Path to cacti include dir (relative to script_path)',
59                            default => '../site/include/' },
60   'cacti_graph_template_id'  => { 
61     'label'    => 'Graph Template',
62     'type'     => 'custom',
63     'multiple' => 1,
64   },
65   'cacti_snmp_query_id'      => { 
66     'label'    => 'SNMP Query ID',
67     'type'     => 'custom',
68     'multiple' => 1,
69   },
70   'cacti_snmp_query_type_id' => { 
71     'label'    => 'SNMP Query Type ID',
72     'type'     => 'custom',
73     'multiple' => 1,
74   },
75   'cacti_snmp_field'         => { 
76     'label'    => 'SNMP Field',
77     'type'     => 'custom',
78     'multiple' => 1,
79   },
80   'cacti_snmp_value'         => { 
81     'label'    => 'SNMP Value',
82     'type'     => 'custom',
83     'multiple' => 1,
84   },
85 ;
86
87 %info = (
88   'svc'                  => 'svc_broadband',
89   'desc'                 => 'Export service to cacti server, for svc_broadband services',
90   'post_config_element'  => '/edit/elements/part_export/cacti.html',
91   'options'              => \%options,
92   'notes'                => <<'END',
93 Add service to cacti upon provisioning, for broadband services.<BR>
94 See <A HREF="http://www.freeside.biz/mediawiki/index.php/Freeside:4:Documentation:Cacti#Connecting_Cacti_To_Freeside">documentation</A> for details.
95 END
96 );
97
98 # standard hooks for provisioning/unprovisioning service
99
100 sub _export_insert {
101   my ($self, $svc_broadband) = @_;
102   my ($q,$error) = _insert_queue($self, $svc_broadband);
103   return $error;
104 }
105
106 sub _export_delete {
107   my ($self, $svc_broadband) = @_;
108   my $oldAutoCommit = $FS::UID::AutoCommit;
109   local $FS::UID::AutoCommit = 0;
110   my $dbh = dbh;
111   foreach my $page (qsearch('cacti_page',{ svcnum => $svc_broadband->svcnum })) {
112     my $error = $page->delete;
113     if ($error) {
114       $dbh->rollback if $oldAutoCommit;
115       return $error;
116     }
117   }
118   my ($q,$error) = _delete_queue($self, $svc_broadband);
119   if ($error) {
120     $dbh->rollback if $oldAutoCommit;
121     return $error;
122   }
123   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
124   return '';
125 }
126
127 sub _export_replace {
128   my($self, $new, $old) = @_;
129   return '' if $new->ip_addr eq $old->ip_addr; #important part didn't change
130   #delete old then insert new, with second job dependant on the first
131   my $oldAutoCommit = $FS::UID::AutoCommit;
132   local $FS::UID::AutoCommit = 0;
133   my $dbh = dbh;
134   my ($dq, $iq, $error);
135   ($dq,$error) = _delete_queue($self,$old);
136   if ($error) {
137     $dbh->rollback if $oldAutoCommit;
138     return $error;
139   }
140   ($iq,$error) = _insert_queue($self,$new);
141   if ($error) {
142     $dbh->rollback if $oldAutoCommit;
143     return $error;
144   }
145   $error = $iq->depend_insert($dq->jobnum);
146   if ($error) {
147     $dbh->rollback if $oldAutoCommit;
148     return $error;
149   }
150   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
151   return '';
152 }
153
154 sub _export_suspend {
155   return '';
156 }
157
158 sub _export_unsuspend {
159   return '';
160 }
161
162 # create queued jobs
163
164 sub _insert_queue {
165   my ($self, $svc_broadband) = @_;
166   my $queue = new FS::queue {
167     'svcnum' => $svc_broadband->svcnum,
168     'job'    => "FS::part_export::cacti::ssh_insert",
169   };
170   my $error = $queue->insert(
171     'host'        => $self->machine,
172     'user'        => $self->option('user'),
173     'hostname'    => $svc_broadband->ip_addr,
174     'script_path' => $self->option('script_path'),
175     'template_id' => $self->option('template_id'),
176     'tree_id'     => $self->option('tree_id'),
177     'description' => $self->option('description'),
178         'svc_desc'    => $svc_broadband->description,
179     'contact'     => $svc_broadband->cust_main->contact,
180     'svcnum'      => $svc_broadband->svcnum,
181     'self'        => $self
182   );
183   return ($queue,$error);
184 }
185
186 sub _delete_queue {
187   my ($self, $svc_broadband) = @_;
188   my $queue = new FS::queue {
189     'svcnum' => $svc_broadband->svcnum,
190     'job'    => "FS::part_export::cacti::ssh_delete",
191   };
192   my $error = $queue->insert(
193     'host'          => $self->machine,
194     'user'          => $self->option('user'),
195     'hostname'      => $svc_broadband->ip_addr,
196     'script_path'   => $self->option('script_path'),
197     'delete_graphs' => $self->option('delete_graphs'),
198     'include_path'  => $self->option('include_path'),
199   );
200   return ($queue,$error);
201 }
202
203 # routines run by queued jobs
204
205 sub ssh_insert {
206   my %opt = @_;
207   my $self = $opt{'self'};
208
209   # Option validation
210   die "Non-numerical Host Template ID, check export configuration\n"
211     unless $opt{'template_id'} =~ /^\d+$/;
212   die "Non-numerical Graph Tree ID, check export configuration\n"
213     unless $opt{'tree_id'} =~ /^\d*$/;
214
215   # Add host to cacti
216   my $desc = $opt{'description'};
217   $desc =~ s/\$ip_addr/$opt{'hostname'}/g;
218   $desc =~ s/\$description/$opt{'svc_desc'}/g;
219   $desc =~ s/\$contact/$opt{'contact'}/g;
220 #for some reason, device names with apostrophes fail to export graphs in Cacti
221 #just removing them for now, someday maybe dig to figure out why
222 #  $desc =~ s/'/'\\''/g;
223   $desc =~ s/'//g;
224   my $cmd = $php
225           . trailslash($opt{'script_path'})
226           . q(add_device.php --description=')
227           . $desc
228           . q(' --ip=')
229           . $opt{'hostname'}
230           . q(' --template=)
231           . $opt{'template_id'};
232   my $response = ssh_cmd(%opt, 'command' => $cmd);
233   unless ( $response =~ /Success - new device-id: \((\d+)\)/ ) {
234     die "Error adding device: $response";
235   }
236   my $id = $1;
237
238   # Add host to tree
239   if ($opt{'tree_id'}) {
240     $cmd = $php
241          . trailslash($opt{'script_path'})
242          . q(add_tree.php --type=node --node-type=host --tree-id=)
243          . $opt{'tree_id'}
244          . q( --host-id=)
245          . $id;
246     $response = ssh_cmd(%opt, 'command' => $cmd);
247     unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
248       die "Host added, but error adding host to tree: $response";
249     }
250   }
251
252   # Get list of graph templates for new id
253   $cmd = $php
254        . trailslash($opt{'script_path'}) 
255        . q(freeside_cacti.php --get-graph-templates --host-template=)
256        . $opt{'template_id'};
257   $cmd .= q( --include-path=') . $self->option('include_path') . q(')
258     if $self->option('include_path');
259   my $ginfo = { map { $_ ? ($_ => undef) : () } split(/\n/,ssh_cmd(%opt, 'command' => $cmd)) };
260
261   # Add extra config info
262   my @xtragid = split("\n", $self->option('cacti_graph_template_id'));
263   my @query_id = split("\n", $self->option('cacti_snmp_query_id'));
264   my @query_type_id = split("\n", $self->option('cacti_snmp_query_type_id'));
265   my @snmp_field = split("\n", $self->option('cacti_snmp_field'));
266   my @snmp_value = split("\n", $self->option('cacti_snmp_value'));
267   for (my $i = 0; $i < @xtragid; $i++) {
268     my $gtid = $xtragid[$i];
269     $ginfo->{$gtid} ||= [];
270     push(@{$ginfo->{$gtid}},{
271       'gtid'          => $gtid,
272       'query_id'      => $query_id[$i],
273       'query_type_id' => $query_type_id[$i],
274       'snmp_field'    => $snmp_field[$i],
275       'snmp_value'    => $snmp_value[$i],
276     });
277   }
278
279   my @gdefs = map {
280     ref($ginfo->{$_}) ? @{$ginfo->{$_}} : {'gtid' => $_}
281   } keys %$ginfo;
282   warn "Host ".$opt{'hostname'}." exported to cacti, but no graphs configured"
283     unless @gdefs;
284
285   # Create graphs
286   my $gerror = '';
287   foreach my $gdef (@gdefs) {
288     # validate graph info
289     my $gtid = $gdef->{'gtid'};
290     next unless $gtid;
291     $gerror .= " Bad graph template: $gtid"
292       unless $gtid =~ /^\d+$/;
293     my $isds = $gdef->{'query_id'} 
294             || $gdef->{'query_type_id'} 
295             || $gdef->{'snmp_field'} 
296             || $gdef->{'snmp_value'};
297     if ($isds) {
298       $gerror .= " Bad SNMP Query Id: " . $gdef->{'query_id'}
299         unless $gdef->{'query_id'} =~ /^\d+$/;
300       $gerror .= " Bad SNMP Query Type Id: " . $gdef->{'query_type_id'}
301         unless $gdef->{'query_type_id'} =~ /^\d+$/;
302       $gerror .= " SNMP Field cannot contain apostrophe"
303         if $gdef->{'snmp_field'} =~ /'/;
304       $gerror .= " SNMP Value cannot contain apostrophe"
305         if $gdef->{'snmp_value'} =~ /'/;
306     }
307     next if $gerror;
308
309     # create the graph
310     $cmd = $php
311          . trailslash($opt{'script_path'})
312          . q(add_graphs.php --graph-type=)
313          . ($isds ? 'ds' : 'cg')
314          . q( --graph-template-id=)
315          . $gtid
316          . q( --host-id=)
317          . $id;
318     if ($isds) {
319       $cmd .= q( --snmp-query-id=)
320            .  $gdef->{'query_id'}
321            .  q( --snmp-query-type-id=)
322            .  $gdef->{'query_type_id'}
323            .  q( --snmp-field=')
324            .  $gdef->{'snmp_field'}
325            .  q(' --snmp-value=')
326            .  $gdef->{'snmp_value'}
327            .  q(');
328     }
329     $response = ssh_cmd(%opt, 'command' => $cmd);
330     #might be more than one graph added, just testing success
331     $gerror .= "Error creating graph $gtid: $response"
332       unless $response =~ /Graph Added - graph-id: \((\d+)\)/;
333
334   } #foreach $gtid
335
336   # job fails, but partial export may have occurred
337   die $gerror . " Partial export occurred\n" if $gerror;
338
339   return '';
340 }
341
342 sub ssh_delete {
343   my %opt = @_;
344   my $cmd = $php
345           . trailslash($opt{'script_path'}) 
346           . q(freeside_cacti.php --drop-device --ip=')
347           . $opt{'hostname'}
348           . q(');
349   $cmd .= q( --delete-graphs)
350     if $opt{'delete_graphs'};
351   $cmd .= q( --include-path=') . $opt{'include_path'} . q(')
352     if $opt{'include_path'};
353   my $response = ssh_cmd(%opt, 'command' => $cmd);
354   die "Error removing from cacti: " . $response
355     if $response;
356   return '';
357 }
358
359 =head1 SUBROUTINES
360
361 =over 4
362
363 =item process_graphs JOB PARAM
364
365 Intended to be run as an FS::queue job.
366
367 Copies graphs for a single service from Cacti export directory to FS cache,
368 generates basic html pages for this service with base64-encoded graphs embedded, 
369 and stores the generated pages in the database.
370
371 =back
372
373 =cut
374
375 sub process_graphs {
376   my $job = shift;
377   my $param = thaw(decode_base64(shift));
378
379   $job->update_statustext(10);
380   my $cachedir = trailslash($FS::UID::cache_dir,'cache.'.$FS::UID::datasrc,'cacti-graphs');
381
382   # load the service
383   my $svcnum = $param->{'svcnum'} || die "No svcnum specified";
384   my $svc = qsearchs({
385    'table'   => 'svc_broadband',
386    'hashref' => { 'svcnum' => $svcnum },
387   }) || die "Could not load svcnum $svcnum";
388
389   # load relevant FS::part_export::cacti object
390   my ($self) = $svc->cust_svc->part_svc->part_export('cacti');
391
392   $job->update_statustext(20);
393
394   my $oldAutoCommit = $FS::UID::AutoCommit;
395   local $FS::UID::AutoCommit = 0;
396   my $dbh = dbh;
397
398   # check for existing pages
399   my $now = time;
400   my %oldpages = map { ($_->graphnum || 'MAIN') => $_ } qsearch({
401     'table'    => 'cacti_page',
402     'hashref'  => { 'svcnum' => $svcnum, 'exportnum' => $self->exportnum },
403     'select'   => 'cacti_pagenum, exportnum, svcnum, graphnum, imported, thumbnail', #no need to load old content
404     'order_by' => 'ORDER BY graphnum',
405   });
406
407   # if all existing pages are recent enough, do nothing and return
408   # (won't detect newly introduced graphs, but they can wait for next run)
409   my $uptodate = 0;
410   if (keys %oldpages) {
411     $uptodate = 1;
412     foreach my $oldpage (keys %oldpages) {
413       if ($oldpages{$oldpage}->imported <= $self->exptime($now)) {
414         $uptodate = 0;
415         last;
416       }
417     }
418   }
419   if ($uptodate) {
420     $job->update_statustext(100);
421     return '';
422   }
423
424   $job->update_statustext(30);
425
426   # get list of graphs for this svc from cacti server
427   my $cmd = $php
428           . trailslash($self->option('script_path'))
429           . q(freeside_cacti.php --get-graphs --ip=')
430           . $svc->ip_addr
431           . q(');
432   $cmd .= q( --include-path=') . $self->option('include_path') . q(')
433     if $self->option('include_path');
434   my @graphs = map { [ split(/\t/,$_) ] } 
435                  split(/\n/, ssh_cmd(
436                    'host'          => $self->machine,
437                    'user'          => $self->option('user'),
438                    'command'       => $cmd
439                  ));
440
441   $job->update_statustext(40);
442
443   # copy graphs from cacti server to cache
444   # requires version 2.6.4 of rsync, released March 2005
445   my $rsync = File::Rsync->new({
446     'rsh'       => 'ssh',
447     'verbose'   => 1,
448     'recursive' => 1,
449     'quote-src' => 1,
450     'quote-dst' => 1,
451     'source'    => trailslash($self->option('graphs_path')),
452     'dest'      => $cachedir,
453     'include'   => [
454       (map { q('**graph_).${$_}[0].q(*.png') } @graphs),
455       (map { q('**thumb_).${$_}[0].q(.png') } @graphs),
456       q('*/'),
457       q('- *'),
458     ],
459   });
460   #don't know why a regular $rsync->exec isn't doing includes right, but this does
461   my $rscmd = join(' ',@{$rsync->getcmd()});
462   my $error = system($rscmd);
463   die "rsync ($rscmd) failed with exit status $error" if $error;
464
465   $job->update_statustext(50);
466
467   # create html file contents
468   my $svchead = q(<!-- UPDATED ) . $now . qq( -->)
469               . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>'
470               . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>);
471   my $svchtml = $svchead;
472   my $maxgraph = 1024 * 1024 * ($self->options('max_graph_size') || 5);
473   my $nographs = 1;
474   for (my $i = 0; $i <= $#graphs; $i++) {
475     my $graph = $graphs[$i];
476     my $thumbfile = $cachedir . 'graphs/thumb_' . $$graph[0] . '.png';
477     if (-e $thumbfile) {
478       if ( stat($thumbfile)->size() < $maxgraph ) {
479         $nographs = 0;
480         my $thumbnail = img_tag($thumbfile);
481         # add graph to main file
482         my $graphhead = q(<H3>) . $$graph[1] . q(</H3>);
483         $svchtml .= $graphhead;
484         $svchtml .= anchor_tag( $svcnum, $$graph[0], $thumbnail );
485         # create graph details file
486         my $graphhtml = $svchead . $graphhead;
487         my $nodetail = 1;
488         my $j = 1;
489         # no easy way to tell what detail graphs should exist,
490         # and don't want detail graphs that are out of sync with thumbnail,
491         # so just use what we can find
492         while (-e (my $graphfile = $cachedir.'graphs/graph_'.$$graph[0].'_'.$j.'.png')) {
493           if ( stat($graphfile)->size() < $maxgraph ) {
494             $nodetail = 0;
495             $graphhtml .= img_tag($graphfile);
496           }
497           unlink($graphfile);
498           $j++;
499         }
500         $graphhtml .= '<P>No detail graphs to display for this graph</P>'
501           if $nodetail;
502         #delete old detail page
503         if ($oldpages{$$graph[0]}) {
504           $error = $oldpages{$$graph[0]}->delete;
505           if ($error) {
506             $dbh->rollback if $oldAutoCommit;
507             die $error;
508           }
509         }
510         #insert new detail page
511         my $newobj = new FS::cacti_page {
512           'exportnum' => $self->exportnum,
513           'svcnum'    => $svcnum,
514           'graphnum'  => $$graph[0],
515           'imported'  => $now,
516           'content'   => $graphhtml,
517           'thumbnail' => $thumbnail,
518         };
519         $error = $newobj->insert;
520         if ($error) {
521           $dbh->rollback if $oldAutoCommit;
522           die $error;
523         }
524       } else {
525         $svchtml .= qq(<P STYLE="color: #FF0000">File $thumbfile is too large, skipping</P>);
526       }
527       unlink($thumbfile);
528     } else {
529       # try to use old page for this graph
530       if ($oldpages{$$graph[0]} && $oldpages{$$graph[0]}->thumbnail) {
531         $nographs = 0;
532         # add old graph to main file
533         my $graphhead = q(<H3>) . $$graph[1] . q(</H3>);
534         $svchtml .= $graphhead;
535         $svchtml .= qq(<P STYLE="color: #FF0000">Current graphs unavailable; using previously imported data.</P>);
536         $svchtml .= anchor_tag( $svcnum, $$graph[0], $oldpages{$$graph[0]}->thumbnail );
537       } else {
538         $svchtml .= qq(<P STYLE="color: #FF0000">Error loading graph: $$graph[0]</P>);
539       }
540     }
541     # remove old page from hash even if it is being reused,
542     # remaining entries in hash will be deleted from database below
543     delete $oldpages{$$graph[0]} if $oldpages{$$graph[0]};
544     $job->update_statustext(49 + int($i / @graphs) * 50);
545   }
546   $svchtml .= '<P>No graphs to display for this service</P>'
547     if $nographs;
548   # delete remaining old pages, including svc index
549   foreach my $oldpage (keys %oldpages) {
550     $error = $oldpages{$oldpage}->delete;
551     if ($error) {
552       $dbh->rollback if $oldAutoCommit;
553       die $error;
554     }
555   }
556   # insert new index page for svc
557   my $newobj = new FS::cacti_page {
558     'exportnum' => $self->exportnum,
559     'svcnum'    => $svcnum,
560     'graphnum'  => '',
561     'imported'  => $now,
562     'content'   => $svchtml,
563     'thumbnail' => '',
564   };
565   $error  = $newobj->insert;
566   if ($error) {
567     $dbh->rollback if $oldAutoCommit;
568     die $error;
569   }
570
571   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
572
573   $job->update_statustext(100);
574   return '';
575 }
576
577 sub img_tag {
578   my $somefile = shift;
579   return q(<IMG SRC="data:image/png;base64,)
580        . encode_base64(slurp($somefile,binmode=>':raw'),'')
581        . qq(" STYLE="margin-bottom: 1em;"><BR>);
582 }
583
584 sub anchor_tag {
585   my ($svcnum, $graphnum, $contents) = @_;
586   return q(<A HREF="?svcnum=)
587        . $svcnum
588        . q(&graphnum=)
589        . $graphnum
590        . q(">)
591        . $contents
592        . q(</A>);
593 }
594
595 #this gets used by everything else
596 #fake false laziness, other ssh_cmds handle error/output differently
597 sub ssh_cmd {
598   use Net::OpenSSH;
599   my $opt = { @_ };
600   my $ssh = Net::OpenSSH->new($opt->{'user'}.'@'.$opt->{'host'});
601   die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
602   my ($output, $errput) = $ssh->capture2($opt->{'command'});
603   die "Error running SSH command: ". $opt->{'command'}. ' ERROR: ' . $ssh->error if $ssh->error;
604   die $errput if $errput;
605   return $output;
606 }
607
608 #there's probably a better place to put this?
609 #makes sure there's a trailing slash between/after input
610 #doesn't add leading slashes
611 sub trailslash {
612   my @paths = @_;
613   my $out = '';
614   foreach my $path (@paths) {
615     $out .= $path;
616     $out .= '/' unless $out =~ /\/$/;
617   }
618   return $out;
619 }
620
621 =head1 METHODS
622
623 =over 4
624
625 =item cleanup
626
627 Removes all expired graphs for this export from the database.
628
629 =cut
630
631 sub cleanup {
632   my $self = shift;
633   my $oldAutoCommit = $FS::UID::AutoCommit;
634   local $FS::UID::AutoCommit = 0;
635   my $dbh = dbh;
636   my $sth = $dbh->prepare('DELETE FROM cacti_page WHERE exportnum = ? and imported <= ?') 
637     or do {
638       $dbh->rollback if $oldAutoCommit;
639       return $dbh->errstr;
640     };
641   $sth->execute($self->exportnum,$self->exptime)
642     or do {
643       $dbh->rollback if $oldAutoCommit;
644       return $dbh->errstr;
645     };
646   $dbh->commit or return $dbh->errstr if $oldAutoCommit;
647   return '';
648 }
649
650 =item exptime [ TIME ]
651
652 Accepts optional current time, defaults to actual current time.
653
654 Returns timestamp for the oldest possible non-expired graph import,
655 based on the import_freq option.
656
657 =cut
658
659 sub exptime {
660   my $self = shift;
661   my $now = shift || time;
662   return $now - 60 * ($self->option('import_freq') || 5);
663 }
664
665 =back
666
667 =head1 AUTHOR
668
669 Jonathan Prykop 
670 jonathan@freeside.biz
671
672 =head1 LICENSE AND COPYRIGHT
673
674 Copyright 2015 Freeside Internet Services      
675
676 This program is free software; you can redistribute it and/or 
677 modify it under the terms of the GNU General Public License 
678 as published by the Free Software Foundation.
679
680 =cut
681
682 1;
683
684