RT#18834: Cacti integration [added thaw/decode]
[freeside.git] / FS / FS / part_export / cacti.pm
index 6877c8f..740be25 100644 (file)
@@ -1,9 +1,35 @@
 package FS::part_export::cacti;
 
+=pod
+
+=head1 NAME
+
+FS::part_export::cacti
+
+=head1 SYNOPSIS
+
+Cacti integration for Freeside
+
+=head1 DESCRIPTION
+
+This module in particular handles FS::part_export object creation for Cacti integration;
+consult any existing L<FS::part_export> documentation for details on how that works.
+
+=cut
+
 use strict;
+
 use base qw( FS::part_export );
-use FS::Record qw( qsearchs );
+use FS::Record qw( qsearchs qsearch );
 use FS::UID qw( dbh );
+use FS::cacti_page;
+
+use File::Rsync;
+use File::Slurp qw( slurp );
+use File::stat;
+use MIME::Base64 qw( decode_base64 encode_base64 );
+use Storable qw(thaw);
+
 
 use vars qw( %info );
 
@@ -14,14 +40,18 @@ tie my %options, 'Tie::IxHash',
                            default => 'freeside' },
   'script_path'       => { label   => 'Script Path',
                            default => '/usr/share/cacti/cli/' },
-  'base_url'          => { label   => 'Base Cacti URL',
-                           default => '' },
   'template_id'       => { label   => 'Host Template ID',
                            default => '' },
-  'tree_id'           => { label   => 'Graph Tree ID',
+  'tree_id'           => { label   => 'Graph Tree ID (optional)',
                            default => '' },
-  'description'       => { label   => 'Description (can use $ip_addr and $description tokens)',
-                           default => 'Freeside $description $ip_addr' },
+  'description'       => { label   => 'Description (can use tokens $contact, $ip_addr and $description)',
+                           default => 'Freeside $contact $description $ip_addr' },
+  'graphs_path'       => { label   => 'Graph Export Directory (user@host:/path/to/graphs/)',
+                           default => '' },
+  'import_freq'       => { label   => 'Minimum minutes between graph imports',
+                           default => '5' },
+  'max_graph_size'    => { label   => 'Maximum size per graph (MB)',
+                           default => '5' },
 #  'delete_graphs'     => { label   => 'Delete associated graphs and data sources when unprovisioning', 
 #                           type    => 'checkbox',
 #                         },
@@ -33,7 +63,7 @@ tie my %options, 'Tie::IxHash',
   'options'         => \%options,
   'notes'           => <<'END',
 Add service to cacti upon provisioning, for broadband services.<BR>
-See FS::part_export::cacti documentation for details.
+See <A HREF="http://www.freeside.biz/mediawiki/index.php/Freeside:4:Documentation:Cacti#Connecting_Cacti_To_Freeside">documentation</A> for details.
 END
 );
 
@@ -103,6 +133,7 @@ sub _insert_queue {
     'tree_id'     => $self->option('tree_id'),
     'description' => $self->option('description'),
        'svc_desc'    => $svc_broadband->description,
+    'contact'     => $svc_broadband->cust_main->contact,
     'svcnum'      => $svc_broadband->svcnum,
   );
   return ($queue,$error);
@@ -133,12 +164,13 @@ sub ssh_insert {
   die "Non-numerical Host Template ID, check export configuration\n"
     unless $opt{'template_id'} =~ /^\d+$/;
   die "Non-numerical Graph Tree ID, check export configuration\n"
-    unless $opt{'tree_id'} =~ /^\d+$/;
+    unless $opt{'tree_id'} =~ /^\d*$/;
 
   # Add host to cacti
   my $desc = $opt{'description'};
   $desc =~ s/\$ip_addr/$opt{'hostname'}/g;
   $desc =~ s/\$description/$opt{'svc_desc'}/g;
+  $desc =~ s/\$contact/$opt{'contact'}/g;
   $desc =~ s/'/'\\''/g;
   my $cmd = $php
           . $opt{'script_path'} 
@@ -155,27 +187,18 @@ sub ssh_insert {
   my $id = $1;
 
   # Add host to tree
-  $cmd = $php
-       . $opt{'script_path'}
-       . q(add_tree.php --type=node --node-type=host --tree-id=)
-       . $opt{'tree_id'}
-       . q( --host-id=)
-       . $id;
-  $response = ssh_cmd(%opt, 'command' => $cmd);
-  unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
+  if ($opt{'tree_id'}) {
+    $cmd = $php
+         . $opt{'script_path'}
+         . q(add_tree.php --type=node --node-type=host --tree-id=)
+         . $opt{'tree_id'}
+         . q( --host-id=)
+         . $id;
+    $response = ssh_cmd(%opt, 'command' => $cmd);
+    unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
       die "Error adding host to tree: $response";
+    }
   }
-  my $leaf_id = $1;
-
-  # Store id for generating graph urls
-  my $svc_broadband = qsearchs({
-    'table'   => 'svc_broadband',
-    'hashref' => { 'svcnum' => $opt{'svcnum'} },
-  });
-  die "Could not reload broadband service" unless $svc_broadband;
-  $svc_broadband->set('cacti_leaf_id',$leaf_id);
-  my $error = $svc_broadband->replace;
-  return $error if $error;
 
 #  # Get list of graph templates for new id
 #  $cmd = $php
@@ -237,6 +260,194 @@ sub ssh_delete {
   return '';
 }
 
+=head1 SUBROUTINES
+
+=over 4
+
+=item process_graphs JOB PARAM
+
+Intended to be run as an FS::queue job.
+
+Copies graphs for a single service from Cacti export directory to FS cache,
+generates basic html pages for this service with base64-encoded graphs embedded, 
+and stores the generated pages in the database.
+
+=back
+
+=cut
+
+sub process_graphs {
+  my $job = shift;
+  my $param = thaw(decode_base64(shift));
+
+  $job->update_statustext(10);
+  my $cachedir = $FS::UID::cache_dir . '/cacti-graphs/';
+
+  # load the service
+  my $svcnum = $param->{'svcnum'} || die "No svcnum specified";
+  my $svc = qsearchs({
+   'table'   => 'svc_broadband',
+   'hashref' => { 'svcnum' => $svcnum },
+  }) || die "Could not load svcnum $svcnum";
+
+  # load relevant FS::part_export::cacti object
+  my ($self) = $svc->cust_svc->part_svc->part_export('cacti');
+
+  $job->update_statustext(20);
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  # check for existing pages
+  my $now = time;
+  my @oldpages = qsearch({
+    'table'    => 'cacti_page',
+    'hashref'  => { 'svcnum' => $svcnum, 'exportnum' => $self->exportnum },
+    'select'   => 'cacti_pagenum, exportnum, svcnum, graphnum, imported', #no need to load old content
+    'order_by' => 'ORDER BY graphnum',
+  });
+  if (@oldpages) {
+    #if pages are recent enough, do nothing and return
+    if ($oldpages[0]->imported > $self->exptime($now)) {
+      $job->update_statustext(100);
+      return '';
+    }
+    #delete old pages
+    foreach my $oldpage (@oldpages) {
+      my $error = $oldpage->delete;
+      if ($error) {
+        $dbh->rollback if $oldAutoCommit;
+        die $error;
+      }
+    }
+  }
+
+  $job->update_statustext(30);
+
+  # get list of graphs for this svc from cacti server
+  my $cmd = $php
+          . $self->option('script_path')
+          . q(freeside_cacti.php --get-graphs --ip=')
+          . $svc->ip_addr
+          . q(');
+  my @graphs = map { [ split(/\t/,$_) ] } 
+                 split(/\n/, ssh_cmd(
+                   'host'          => $self->machine,
+                   'user'          => $self->option('user'),
+                   'command'       => $cmd
+                 ));
+
+  $job->update_statustext(40);
+
+  # copy graphs from cacti server to cache
+  # requires version 2.6.4 of rsync, released March 2005
+  my $rsync = File::Rsync->new({
+    'rsh'       => 'ssh',
+    'verbose'   => 1,
+    'recursive' => 1,
+    'source'    => $self->option('graphs_path'),
+    'dest'      => $cachedir,
+    'include'   => [
+      (map { q('**graph_).${$_}[0].q(*.png') } @graphs),
+      (map { q('**thumb_).${$_}[0].q(.png') } @graphs),
+      q('*/'),
+      q('- *'),
+    ],
+  });
+  #don't know why a regular $rsync->exec isn't doing includes right, but this does
+  my $error = system(join(' ',@{$rsync->getcmd()}));
+  die "rsync failed with exit status $error" if $error;
+
+  $job->update_statustext(50);
+
+  # create html file contents
+  my $svchead = q(<!-- UPDATED ) . $now . qq( -->)
+              . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>'
+              . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>);
+  my $svchtml = $svchead;
+  my $maxgraph = 1024 * 1024 * ($self->options('max_graph_size') || 5);
+  my $nographs = 1;
+  for (my $i = 0; $i <= $#graphs; $i++) {
+    my $graph = $graphs[$i];
+    my $thumbfile = $cachedir . 'graphs/thumb_' . $$graph[0] . '.png';
+    if (
+      (-e $thumbfile) && 
+      ( stat($thumbfile)->size() < $maxgraph )
+    ) {
+      $nographs = 0;
+      # add graph to main file
+      my $graphhead = q(<H3>) . $$graph[1] . q(</H3>);
+      $svchtml .= $graphhead;
+      $svchtml .= anchor_tag( $svcnum, $$graph[0], img_tag($thumbfile) );
+      # create graph details file
+      my $graphhtml = $svchead . $graphhead;
+      my $nodetail = 1;
+      my $j = 1;
+      while (-e (my $graphfile = $cachedir.'graphs/graph_'.$$graph[0].'_'.$j.'.png')) {
+        if ( stat($graphfile)->size() < $maxgraph ) {
+          $nodetail = 0;
+          $graphhtml .= img_tag($graphfile);
+        }
+        $j++;
+      }
+      $graphhtml .= '<P>No detail graphs to display for this graph</P>'
+        if $nodetail;
+      my $newobj = new FS::cacti_page {
+        'exportnum' => $self->exportnum,
+        'svcnum'    => $svcnum,
+        'graphnum'  => $$graph[0],
+        'imported'  => $now,
+        'content'   => $graphhtml,
+      };
+      $error = $newobj->insert;
+      if ($error) {
+        $dbh->rollback if $oldAutoCommit;
+        die $error;
+      }
+    }
+    $job->update_statustext(49 + int($i / $#graphs) * 50);
+  }
+  $svchtml .= '<P>No graphs to display for this service</P>'
+    if $nographs;
+  my $newobj = new FS::cacti_page {
+    'exportnum' => $self->exportnum,
+    'svcnum'    => $svcnum,
+    'graphnum'  => '',
+    'imported'  => $now,
+    'content'   => $svchtml,
+  };
+  $error  = $newobj->insert;
+  if ($error) {
+    $dbh->rollback if $oldAutoCommit;
+    die $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  $job->update_statustext(100);
+  return '';
+}
+
+sub img_tag {
+  my $somefile = shift;
+  return q(<IMG SRC="data:image/png;base64,)
+       . encode_base64(slurp($somefile,binmode=>':raw'),'')
+       . qq(" STYLE="margin-bottom: 1em;"><BR>);
+}
+
+sub anchor_tag {
+  my ($svcnum, $graphnum, $contents) = @_;
+  return q(<A HREF="?svcnum=)
+       . $svcnum
+       . q(&graphnum=)
+       . $graphnum
+       . q(">)
+       . $contents
+       . q(</A>);
+}
+
+#this gets used by everything else
 #fake false laziness, other ssh_cmds handle error/output differently
 sub ssh_cmd {
   use Net::OpenSSH;
@@ -249,67 +460,51 @@ sub ssh_cmd {
   return $output;
 }
 
-=pod
-
-=head1 NAME
-
-FS::part_export::cacti
-
-=head1 SYNOPSIS
-
-Cacti integration for Freeside
-
-=head1 DESCRIPTION
-
-This module in particular handles FS::part_export object creation for Cacti integration;
-consult any existing L<FS::part_export> documentation for details on how that works.
-What follows is more general instructions for connecting your Cacti installation
-to your Freeside installation.
-
-=head2 Connecting Cacti To Freeside
+=head1 METHODS
 
-Copy the freeside_cacti.php script from the bin directory of your Freeside
-installation to the cli directory of your Cacti installation.  Give this file 
-the same permissions as the other files in that directory, and create 
-(or choose an existing) user with sufficient permission to read these scripts.
+=over 4
 
-In the regular Cacti interface, create a Host Template to be used by 
-devices exported by Freeside, and note the template's id number.
+=item cleanup
 
-In Freeside, go to Configuration->Services->Provisioning exports to
-add a new export.  From the Add Export page, select cacti for Export then enter...
+Removes all expired graphs for this export from the database.
 
-* the User Name with permission to run scripts in the cli directory
-
-* enter the full Script Path to that directory (eg /usr/share/cacti/cli/)
-
-* enter the Base Cacti URL for your cacti server (eg https://example.com/cacti/)
-
-* the Host Template ID for adding new devices
+=cut
 
-* the Graph Tree ID for adding new devices
+sub cleanup {
+  my $self = shift;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+  my $sth = $dbh->prepare('DELETE FROM cacti_page WHERE exportnum = ? and imported <= ?') 
+    or do {
+      $dbh->rollback if $oldAutoCommit;
+      return $dbh->errstr;
+    };
+  $sth->execute($self->exportnum,$self->exptime)
+    or do {
+      $dbh->rollback if $oldAutoCommit;
+      return $dbh->errstr;
+    };
+  $dbh->commit or return $dbh->errstr if $oldAutoCommit;
+  return '';
+}
 
-* the Description for new devices;  you can use the tokens
-  $ip_addr and $description to include the equivalent fields
-  from the broadband service definition
+=item exptime [ TIME ]
 
-After adding the export, go to Configuration->Services->Service definitions.
-The export you just created will be available for selection when adding or
-editing broadband service definitions.
+Accepts optional current time, defaults to actual current time.
 
-When properly configured broadband services are provisioned, they should now
-be added to Cacti using the Host Template you specified, and the created device
-will also be added to the specified Graph Tree.
+Returns timestamp for the oldest possible non-expired graph import,
+based on the import_freq option.
 
-Once added, a link to the graphs for this host will be available when viewing 
-the details of the provisioned service in Freeside (you will need to authenticate 
-into Cacti to view them.)
+=cut
 
-Devices will be deleted from Cacti when the service is unprovisioned in Freeside, 
-and they will be deleted and re-added if the ip address changes.
+sub exptime {
+  my $self = shift;
+  my $now = shift || time;
+  return $now - 60 * ($self->option('import_freq') || 5);
+}
 
-Currently, graphs themselves must still be added in cacti by hand or some
-other form of automation tailored to your specific graph inputs and data sources.
+=back
 
 =head1 AUTHOR
 
@@ -320,8 +515,8 @@ jonathan@freeside.biz
 
 Copyright 2015 Freeside Internet Services      
 
-This program is free software; you can redistribute it and/or           |
-modify it under the terms of the GNU General Public License             |
+This program is free software; you can redistribute it and/or 
+modify it under the terms of the GNU General Public License 
 as published by the Free Software Foundation.
 
 =cut