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