RT#18834: Cacti integration [real graph import]
[freeside.git] / FS / FS / part_export / cacti.pm
1 package FS::part_export::cacti;
2
3 use strict;
4
5 use base qw( FS::part_export );
6 use FS::Record qw( qsearchs );
7 use FS::UID qw( dbh );
8
9 use File::Rsync;
10 use File::Slurp qw( append_file slurp write_file );
11 use File::stat;
12 use MIME::Base64 qw( encode_base64 );
13
14 use vars qw( %info );
15
16 my $php = 'php -q ';
17
18 tie my %options, 'Tie::IxHash',
19   'user'              => { label   => 'User Name',
20                            default => 'freeside' },
21   'script_path'       => { label   => 'Script Path',
22                            default => '/usr/share/cacti/cli/' },
23   'template_id'       => { label   => 'Host Template ID',
24                            default => '' },
25   'tree_id'           => { label   => 'Graph Tree ID (optional)',
26                            default => '' },
27   'description'       => { label   => 'Description (can use $ip_addr and $description tokens)',
28                            default => 'Freeside $description $ip_addr' },
29   'graphs_path'       => { label   => 'Graph Export Directory (user@host:/path/to/graphs/)',
30                            default => '' },
31   'import_freq'       => { label   => 'Minimum minutes between graph imports',
32                            default => '5' },
33   'max_graph_size'    => { label   => 'Maximum size per graph (MB)',
34                            default => '5' },
35 #  'delete_graphs'     => { label   => 'Delete associated graphs and data sources when unprovisioning', 
36 #                           type    => 'checkbox',
37 #                         },
38 ;
39
40 %info = (
41   'svc'             => 'svc_broadband',
42   'desc'            => 'Export service to cacti server, for svc_broadband services',
43   'options'         => \%options,
44   'notes'           => <<'END',
45 Add service to cacti upon provisioning, for broadband services.<BR>
46 See FS::part_export::cacti documentation for details.
47 END
48 );
49
50 # standard hooks for provisioning/unprovisioning service
51
52 sub _export_insert {
53   my ($self, $svc_broadband) = @_;
54   my ($q,$error) = _insert_queue($self, $svc_broadband);
55   return $error;
56 }
57
58 sub _export_delete {
59   my ($self, $svc_broadband) = @_;
60   my ($q,$error) = _delete_queue($self, $svc_broadband);
61   return $error;
62 }
63
64 sub _export_replace {
65   my($self, $new, $old) = @_;
66   return '' if $new->ip_addr eq $old->ip_addr; #important part didn't change
67   #delete old then insert new, with second job dependant on the first
68   my $oldAutoCommit = $FS::UID::AutoCommit;
69   local $FS::UID::AutoCommit = 0;
70   my $dbh = dbh;
71   my ($dq, $iq, $error);
72   ($dq,$error) = _delete_queue($self,$old);
73   if ($error) {
74     $dbh->rollback if $oldAutoCommit;
75     return $error;
76   }
77   ($iq,$error) = _insert_queue($self,$new);
78   if ($error) {
79     $dbh->rollback if $oldAutoCommit;
80     return $error;
81   }
82   $error = $iq->depend_insert($dq->jobnum);
83   if ($error) {
84     $dbh->rollback if $oldAutoCommit;
85     return $error;
86   }
87   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
88   return '';
89 }
90
91 sub _export_suspend {
92   return '';
93 }
94
95 sub _export_unsuspend {
96   return '';
97 }
98
99 # create queued jobs
100
101 sub _insert_queue {
102   my ($self, $svc_broadband) = @_;
103   my $queue = new FS::queue {
104     'svcnum' => $svc_broadband->svcnum,
105     'job'    => "FS::part_export::cacti::ssh_insert",
106   };
107   my $error = $queue->insert(
108     'host'        => $self->machine,
109     'user'        => $self->option('user'),
110     'hostname'    => $svc_broadband->ip_addr,
111     'script_path' => $self->option('script_path'),
112     'template_id' => $self->option('template_id'),
113     'tree_id'     => $self->option('tree_id'),
114     'description' => $self->option('description'),
115         'svc_desc'    => $svc_broadband->description,
116     'svcnum'      => $svc_broadband->svcnum,
117   );
118   return ($queue,$error);
119 }
120
121 sub _delete_queue {
122   my ($self, $svc_broadband) = @_;
123   my $queue = new FS::queue {
124     'svcnum' => $svc_broadband->svcnum,
125     'job'    => "FS::part_export::cacti::ssh_delete",
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 #    'delete_graphs' => $self->option('delete_graphs'),
133   );
134   return ($queue,$error);
135 }
136
137 # routines run by queued jobs
138
139 sub ssh_insert {
140   my %opt = @_;
141
142   # Option validation
143   die "Non-numerical Host Template ID, check export configuration\n"
144     unless $opt{'template_id'} =~ /^\d+$/;
145   die "Non-numerical Graph Tree ID, check export configuration\n"
146     unless $opt{'tree_id'} =~ /^\d+$/;
147
148   # Add host to cacti
149   my $desc = $opt{'description'};
150   $desc =~ s/\$ip_addr/$opt{'hostname'}/g;
151   $desc =~ s/\$description/$opt{'svc_desc'}/g;
152   $desc =~ s/'/'\\''/g;
153   my $cmd = $php
154           . $opt{'script_path'} 
155           . q(add_device.php --description=')
156           . $desc
157           . q(' --ip=')
158           . $opt{'hostname'}
159           . q(' --template=)
160           . $opt{'template_id'};
161   my $response = ssh_cmd(%opt, 'command' => $cmd);
162   unless ( $response =~ /Success - new device-id: \((\d+)\)/ ) {
163     die "Error adding device: $response";
164   }
165   my $id = $1;
166
167   # Add host to tree
168   if ($opt{'tree_id'}) {
169     $cmd = $php
170          . $opt{'script_path'}
171          . q(add_tree.php --type=node --node-type=host --tree-id=)
172          . $opt{'tree_id'}
173          . q( --host-id=)
174          . $id;
175     $response = ssh_cmd(%opt, 'command' => $cmd);
176     unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
177       die "Error adding host to tree: $response";
178     }
179   }
180
181 #  # Get list of graph templates for new id
182 #  $cmd = $php
183 #       . $opt{'script_path'} 
184 #       . q(freeside_cacti.php --get-graph-templates --host-template=)
185 #       . $opt{'template_id'};
186 #  my @gtids = split(/\n/,ssh_cmd(%opt, 'command' => $cmd));
187 #  die "No graphs configured for host template"
188 #    unless @gtids;
189 #
190 #  # Create graphs
191 #  foreach my $gtid (@gtids) {
192 #
193 #    # sanity checks, should never happen
194 #    next unless $gtid;
195 #    die "Bad graph template: $gtid"
196 #      unless $gtid =~ /^\d+$/;
197 #
198 #    # create the graph
199 #    $cmd = $php
200 #         . $opt{'script_path'}
201 #         . q(add_graphs.php --graph-type=cg --graph-template-id=)
202 #         . $gtid
203 #         . q( --host-id=)
204 #         . $id;
205 #    $response = ssh_cmd(%opt, 'command' => $cmd);
206 #    die "Error creating graph $gtid: $response"
207 #      unless $response =~ /Graph Added - graph-id: \((\d+)\)/;
208 #    my $gid = $1;
209 #
210 #    # add the graph to the tree
211 #    $cmd = $php
212 #         . $opt{'script_path'}
213 #         . q(add_tree.php --type=node --node-type=graph --tree-id=)
214 #         . $opt{'tree_id'}
215 #         . q( --graph-id=)
216 #         . $gid;
217 #    $response = ssh_cmd(%opt, 'command' => $cmd);
218 #    die "Error adding graph $gid to tree: $response"
219 #      unless $response =~ /Added Node/;
220 #
221 #  } #foreach $gtid
222
223   return '';
224 }
225
226 sub ssh_delete {
227   my %opt = @_;
228   my $cmd = $php
229           . $opt{'script_path'} 
230           . q(freeside_cacti.php --drop-device --ip=')
231           . $opt{'hostname'}
232           . q(');
233 #  $cmd .= q( --delete-graphs)
234 #    if $opt{'delete_graphs'};
235   my $response = ssh_cmd(%opt, 'command' => $cmd);
236   die "Error removing from cacti: " . $response
237     if $response;
238   return '';
239 }
240
241 # NOT A METHOD, run as an FS::queue job
242 # copies graphs for a single service from Cacti export directory to FS cache
243 # generates basic html pages for this service's graphs, and stores them in FS cache
244 sub process_graphs {
245   my ($job,$param) = @_; #
246
247   $job->update_statustext(10);
248   my $cachedir = $FS::UID::cache_dir . '/cacti-graphs/';
249
250   # load the service
251   my $svcnum = $param->{'svcnum'} || die "No svcnum specified";
252   my $svc = qsearchs({
253    'table'   => 'svc_broadband',
254    'hashref' => { 'svcnum' => $svcnum },
255   }) || die "Could not load svcnum $svcnum";
256
257   # load relevant FS::part_export::cacti object
258   my ($self) = $svc->cust_svc->part_svc->part_export('cacti');
259
260   $job->update_statustext(20);
261
262   # check for recent uploads, avoid doing this too often
263   my $svchtml = $cachedir.'svc_'.$svcnum.'.html';
264   if (-e $svchtml) {
265     open(my $fh, "<$svchtml");
266     my $firstline = <$fh>;
267     close($fh);
268     if ($firstline =~ /UPDATED (\d+)/) {
269       if ($1 > time - 60 * ($self->option('import_freq') || 5)) {
270         $job->update_statustext(100);
271         return '';
272       }
273     }
274   }
275
276   $job->update_statustext(30);
277
278   # get list of graphs for this svc
279   my $cmd = $php
280           . $self->option('script_path')
281           . q(freeside_cacti.php --get-graphs --ip=')
282           . $svc->ip_addr
283           . q(');
284   my @graphs = map { [ split(/\t/,$_) ] } 
285                  split(/\n/, ssh_cmd(
286                    'host'          => $self->machine,
287                    'user'          => $self->option('user'),
288                    'command'       => $cmd
289                  ));
290
291   $job->update_statustext(40);
292
293   # copy graphs to cache
294   # requires version 2.6.4 of rsync, released March 2005
295   my $rsync = File::Rsync->new({
296     'rsh'       => 'ssh',
297     'verbose'   => 1,
298     'recursive' => 1,
299     'source'    => $self->option('graphs_path'),
300     'dest'      => $cachedir,
301     'include'   => [
302       (map { q('**graph_).${$_}[0].q(*.png') } @graphs),
303       (map { q('**thumb_).${$_}[0].q(.png') } @graphs),
304       q('*/'),
305       q('- *'),
306     ],
307   });
308   #don't know why a regular $rsync->exec isn't doing includes right, but this does
309   my $error = system(join(' ',@{$rsync->getcmd()}));
310   die "rsync failed with exit status $error" if $error;
311
312   $job->update_statustext(50);
313
314   # create html files in cache
315   my $now = time;
316   my $svchead = q(<!-- UPDATED ) . $now . qq( -->\n)
317               . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>' . "\n"
318               . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>) . "\n";
319   write_file($svchtml,$svchead);
320   my $maxgraph = 1024 * 1024 * ($self->options('max_graph_size') || 5);
321   my $nographs = 1;
322   for (my $i = 0; $i <= $#graphs; $i++) {
323     my $graph = $graphs[$i];
324     my $thumbfile = $cachedir . 'graphs/thumb_' . $$graph[0] . '.png';
325     if (
326       (-e $thumbfile) && 
327       ( stat($thumbfile)->size() < $maxgraph )
328     ) {
329       $nographs = 0;
330       # add graph to main file
331       my $graphhead = q(<H3>) . $$graph[1] . q(</H3>) . "\n";
332       append_file( $svchtml, $graphhead,
333         anchor_tag( 
334           $svcnum, $$graph[0], img_tag($thumbfile)
335         )
336       );
337       # create graph details file
338       my $graphhtml = $cachedir . 'svc_' . $svcnum . '_graph_' . $$graph[0] . '.html';
339       write_file($graphhtml,$svchead,$graphhead);
340       my $nodetail = 1;
341       my $j = 1;
342       while (-e (my $graphfile = $cachedir.'graphs/graph_'.$$graph[0].'_'.$j.'.png')) {
343         if ( stat($graphfile)->size() < $maxgraph ) {
344           $nodetail = 0;
345           append_file( $graphhtml, img_tag($graphfile) );
346         }
347         $j++;
348       }
349       append_file($graphhtml, '<P>No detail graphs to display for this graph</P>')
350         if $nodetail;
351     }
352     $job->update_statustext(50 + ($i / $#graphs) * 50);
353   }
354   append_file($svchtml,'<P>No graphs to display for this service</P>')
355     if $nographs;
356
357   $job->update_statustext(100);
358   return '';
359 }
360
361 sub img_tag {
362   my $somefile = shift;
363   return q(<IMG SRC="data:image/png;base64,)
364        . encode_base64(slurp($somefile,binmode=>':raw'))
365        . qq(" STYLE="margin-bottom: 1em;"><BR>\n);
366 }
367
368 sub anchor_tag {
369   my ($svcnum, $graphnum, $contents) = @_;
370   return q(<A HREF="?svcnum=)
371        . $svcnum
372        . q(&graphnum=)
373        . $graphnum
374        . q(">)
375        . $contents
376        . q(</A>);
377 }
378
379 #this gets used by everything else
380 #fake false laziness, other ssh_cmds handle error/output differently
381 sub ssh_cmd {
382   use Net::OpenSSH;
383   my $opt = { @_ };
384   my $ssh = Net::OpenSSH->new($opt->{'user'}.'@'.$opt->{'host'});
385   die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
386   my ($output, $errput) = $ssh->capture2($opt->{'command'});
387   die "Error running SSH command: ". $ssh->error if $ssh->error;
388   die $errput if $errput;
389   return $output;
390 }
391
392 =pod
393
394 =head1 NAME
395
396 FS::part_export::cacti
397
398 =head1 SYNOPSIS
399
400 Cacti integration for Freeside
401
402 =head1 DESCRIPTION
403
404 This module in particular handles FS::part_export object creation for Cacti integration;
405 consult any existing L<FS::part_export> documentation for details on how that works.
406 What follows is more general instructions for connecting your Cacti installation
407 to your Freeside installation.
408
409 =head2 Connecting Cacti To Freeside
410
411 Copy the freeside_cacti.php script from the bin directory of your Freeside
412 installation to the cli directory of your Cacti installation.  Give this file 
413 the same permissions as the other files in that directory, and create 
414 (or choose an existing) user with sufficient permission to read these scripts.
415
416 In the regular Cacti interface, create a Host Template to be used by 
417 devices exported by Freeside, and note the template's id number.  Optionally,
418 create a Graph Tree for these devices to be automatically added to, and note
419 the tree's id number.  Configure a Graph Export (under Settings) and note 
420 the Export Directory.
421
422 In Freeside, go to Configuration->Services->Provisioning exports to
423 add a new export.  From the Add Export page, select cacti for Export then enter...
424
425 * the Hostname or IP address of your Cacti server
426
427 * the User Name with permission to run scripts in the cli directory
428
429 * the full Script Path to that directory (eg /usr/share/cacti/cli/)
430
431 * the Host Template ID for adding new devices
432
433 * the Graph Tree ID for adding new devices (optional)
434
435 * the Description for new devices;  you can use the tokens
436   $ip_addr and $description to include the equivalent fields
437   from the broadband service definition
438
439 * the Graph Export Directory, including connection information
440   if necessary (user@host:/path/to/graphs/)
441
442 * the minimum minutes between graph imports to Freeside (graphs will
443   otherwise be imported into Freeside as needed.)  This should be at least
444   as long as the minumum time between graph exports configured in Cacti.
445   Defaults to 5 if unspecified.
446
447 * the maximum size per graph, in MB;  individual graphs that exceed this size
448   will be quietly ignored by Freeside.  Defaults to 5 if unspecified.
449
450 After adding the export, go to Configuration->Services->Service definitions.
451 The export you just created will be available for selection when adding or
452 editing broadband service definitions; check the box to activate it for 
453 a given service.  Note that you should only have one cacti export per
454 broadband service definition.
455
456 When properly configured broadband services are provisioned, they will now
457 be added to Cacti using the Host Template you specified.  If you also specified
458 a Graph Tree, the created device will also be added to that.
459
460 Once added, a link to the graphs for this host will be available when viewing 
461 the details of the provisioned service in Freeside.
462
463 Devices will be deleted from Cacti when the service is unprovisioned in Freeside, 
464 and they will be deleted and re-added if the ip address changes.
465
466 Currently, graphs themselves must still be added in Cacti by hand or some
467 other form of automation tailored to your specific graph inputs and data sources.
468
469 =head1 AUTHOR
470
471 Jonathan Prykop 
472 jonathan@freeside.biz
473
474 =head1 LICENSE AND COPYRIGHT
475
476 Copyright 2015 Freeside Internet Services      
477
478 This program is free software; you can redistribute it and/or 
479 modify it under the terms of the GNU General Public License 
480 as published by the Free Software Foundation.
481
482 =cut
483
484 1;
485
486