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