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