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