1 package FS::part_export::cacti;
11 Cacti integration for Freeside
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.
22 use base qw( FS::part_export );
23 use FS::Record qw( qsearchs qsearch );
24 use FS::UID qw( dbh );
28 use File::Slurp qw( slurp );
30 use MIME::Base64 qw( decode_base64 encode_base64 );
31 use Storable qw(thaw);
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',
45 'tree_id' => { label => 'Graph Tree ID (optional)',
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/)',
51 'import_freq' => { label => 'Minimum minutes between graph imports',
53 'max_graph_size' => { label => 'Maximum size per graph (MB)',
55 'delete_graphs' => { label => 'Delete associated graphs and data sources when unprovisioning',
58 'include_path' => { label => 'Path to cacti include dir (relative to script_path)',
59 default => '../site/include/' },
60 'cacti_graph_template_id' => {
61 'label' => 'Graph Template',
65 'cacti_snmp_query_id' => {
66 'label' => 'SNMP Query ID',
70 'cacti_snmp_query_type_id' => {
71 'label' => 'SNMP Query Type ID',
75 'cacti_snmp_field' => {
76 'label' => 'SNMP Field',
80 'cacti_snmp_value' => {
81 'label' => 'SNMP Value',
88 'svc' => 'svc_broadband',
89 'desc' => 'Export service to cacti server, for svc_broadband services',
90 'post_config_element' => '/edit/elements/part_export/cacti.html',
91 'options' => \%options,
93 Add service to cacti upon provisioning, for broadband services.<BR>
94 See <A HREF="http://www.freeside.biz/mediawiki/index.php/Freeside:4:Documentation:Cacti#Connecting_Cacti_To_Freeside">documentation</A> for details.
98 # standard hooks for provisioning/unprovisioning service
101 my ($self, $svc_broadband) = @_;
102 my ($q,$error) = _insert_queue($self, $svc_broadband);
107 my ($self, $svc_broadband) = @_;
108 my $oldAutoCommit = $FS::UID::AutoCommit;
109 local $FS::UID::AutoCommit = 0;
111 foreach my $page (qsearch('cacti_page',{ svcnum => $svc_broadband->svcnum })) {
112 my $error = $page->delete;
114 $dbh->rollback if $oldAutoCommit;
118 my ($q,$error) = _delete_queue($self, $svc_broadband);
120 $dbh->rollback if $oldAutoCommit;
123 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
127 sub _export_replace {
128 my($self, $new, $old) = @_;
129 return '' if $new->ip_addr eq $old->ip_addr; #important part didn't change
130 #delete old then insert new, with second job dependant on the first
131 my $oldAutoCommit = $FS::UID::AutoCommit;
132 local $FS::UID::AutoCommit = 0;
134 my ($dq, $iq, $error);
135 ($dq,$error) = _delete_queue($self,$old);
137 $dbh->rollback if $oldAutoCommit;
140 ($iq,$error) = _insert_queue($self,$new);
142 $dbh->rollback if $oldAutoCommit;
145 $error = $iq->depend_insert($dq->jobnum);
147 $dbh->rollback if $oldAutoCommit;
150 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
154 sub _export_suspend {
158 sub _export_unsuspend {
165 my ($self, $svc_broadband) = @_;
166 my $queue = new FS::queue {
167 'svcnum' => $svc_broadband->svcnum,
168 'job' => "FS::part_export::cacti::ssh_insert",
170 my $error = $queue->insert(
171 'host' => $self->machine,
172 'user' => $self->option('user'),
173 'hostname' => $svc_broadband->ip_addr,
174 'script_path' => $self->option('script_path'),
175 'template_id' => $self->option('template_id'),
176 'tree_id' => $self->option('tree_id'),
177 'description' => $self->option('description'),
178 'svc_desc' => $svc_broadband->description,
179 'contact' => $svc_broadband->cust_main->contact,
180 'svcnum' => $svc_broadband->svcnum,
183 return ($queue,$error);
187 my ($self, $svc_broadband) = @_;
188 my $queue = new FS::queue {
189 'svcnum' => $svc_broadband->svcnum,
190 'job' => "FS::part_export::cacti::ssh_delete",
192 my $error = $queue->insert(
193 'host' => $self->machine,
194 'user' => $self->option('user'),
195 'hostname' => $svc_broadband->ip_addr,
196 'script_path' => $self->option('script_path'),
197 'delete_graphs' => $self->option('delete_graphs'),
198 'include_path' => $self->option('include_path'),
200 return ($queue,$error);
203 # routines run by queued jobs
207 my $self = $opt{'self'};
210 die "Non-numerical Host Template ID, check export configuration\n"
211 unless $opt{'template_id'} =~ /^\d+$/;
212 die "Non-numerical Graph Tree ID, check export configuration\n"
213 unless $opt{'tree_id'} =~ /^\d*$/;
216 my $desc = $opt{'description'};
217 $desc =~ s/\$ip_addr/$opt{'hostname'}/g;
218 $desc =~ s/\$description/$opt{'svc_desc'}/g;
219 $desc =~ s/\$contact/$opt{'contact'}/g;
220 #for some reason, device names with apostrophes fail to export graphs in Cacti
221 #just removing them for now, someday maybe dig to figure out why
222 # $desc =~ s/'/'\\''/g;
225 . trailslash($opt{'script_path'})
226 . q(add_device.php --description=')
231 . $opt{'template_id'};
232 my $response = ssh_cmd(%opt, 'command' => $cmd);
233 unless ( $response =~ /Success - new device-id: \((\d+)\)/ ) {
234 die "Error adding device: $response";
239 if ($opt{'tree_id'}) {
241 . trailslash($opt{'script_path'})
242 . q(add_tree.php --type=node --node-type=host --tree-id=)
246 $response = ssh_cmd(%opt, 'command' => $cmd);
247 unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
248 die "Host added, but error adding host to tree: $response";
252 # Get list of graph templates for new id
254 . trailslash($opt{'script_path'})
255 . q(freeside_cacti.php --get-graph-templates --host-template=)
256 . $opt{'template_id'};
257 $cmd .= q( --include-path=') . $self->option('include_path') . q(')
258 if $self->option('include_path');
259 my $ginfo = { map { $_ ? ($_ => undef) : () } split(/\n/,ssh_cmd(%opt, 'command' => $cmd)) };
261 # Add extra config info
262 my @xtragid = split("\n", $self->option('cacti_graph_template_id'));
263 my @query_id = split("\n", $self->option('cacti_snmp_query_id'));
264 my @query_type_id = split("\n", $self->option('cacti_snmp_query_type_id'));
265 my @snmp_field = split("\n", $self->option('cacti_snmp_field'));
266 my @snmp_value = split("\n", $self->option('cacti_snmp_value'));
267 for (my $i = 0; $i < @xtragid; $i++) {
268 my $gtid = $xtragid[$i];
269 $ginfo->{$gtid} ||= [];
270 push(@{$ginfo->{$gtid}},{
272 'query_id' => $query_id[$i],
273 'query_type_id' => $query_type_id[$i],
274 'snmp_field' => $snmp_field[$i],
275 'snmp_value' => $snmp_value[$i],
280 ref($ginfo->{$_}) ? @{$ginfo->{$_}} : {'gtid' => $_}
282 warn "Host ".$opt{'hostname'}." exported to cacti, but no graphs configured"
287 foreach my $gdef (@gdefs) {
288 # validate graph info
289 my $gtid = $gdef->{'gtid'};
291 $gerror .= " Bad graph template: $gtid"
292 unless $gtid =~ /^\d+$/;
293 my $isds = $gdef->{'query_id'}
294 || $gdef->{'query_type_id'}
295 || $gdef->{'snmp_field'}
296 || $gdef->{'snmp_value'};
298 $gerror .= " Bad SNMP Query Id: " . $gdef->{'query_id'}
299 unless $gdef->{'query_id'} =~ /^\d+$/;
300 $gerror .= " Bad SNMP Query Type Id: " . $gdef->{'query_type_id'}
301 unless $gdef->{'query_type_id'} =~ /^\d+$/;
302 $gerror .= " SNMP Field cannot contain apostrophe"
303 if $gdef->{'snmp_field'} =~ /'/;
304 $gerror .= " SNMP Value cannot contain apostrophe"
305 if $gdef->{'snmp_value'} =~ /'/;
311 . trailslash($opt{'script_path'})
312 . q(add_graphs.php --graph-type=)
313 . ($isds ? 'ds' : 'cg')
314 . q( --graph-template-id=)
319 $cmd .= q( --snmp-query-id=)
320 . $gdef->{'query_id'}
321 . q( --snmp-query-type-id=)
322 . $gdef->{'query_type_id'}
324 . $gdef->{'snmp_field'}
325 . q(' --snmp-value=')
326 . $gdef->{'snmp_value'}
329 $response = ssh_cmd(%opt, 'command' => $cmd);
330 #might be more than one graph added, just testing success
331 $gerror .= "Error creating graph $gtid: $response"
332 unless $response =~ /Graph Added - graph-id: \((\d+)\)/;
336 # job fails, but partial export may have occurred
337 die $gerror . " Partial export occurred\n" if $gerror;
345 . trailslash($opt{'script_path'})
346 . q(freeside_cacti.php --drop-device --ip=')
349 $cmd .= q( --delete-graphs)
350 if $opt{'delete_graphs'};
351 $cmd .= q( --include-path=') . $opt{'include_path'} . q(')
352 if $opt{'include_path'};
353 my $response = ssh_cmd(%opt, 'command' => $cmd);
354 die "Error removing from cacti: " . $response
363 =item process_graphs JOB PARAM
365 Intended to be run as an FS::queue job.
367 Copies graphs for a single service from Cacti export directory to FS cache,
368 generates basic html pages for this service with base64-encoded graphs embedded,
369 and stores the generated pages in the database.
377 my $param = thaw(decode_base64(shift));
379 $job->update_statustext(10);
380 my $cachedir = trailslash($FS::UID::cache_dir,'cache.'.$FS::UID::datasrc,'cacti-graphs');
383 my $svcnum = $param->{'svcnum'} || die "No svcnum specified";
385 'table' => 'svc_broadband',
386 'hashref' => { 'svcnum' => $svcnum },
387 }) || die "Could not load svcnum $svcnum";
389 # load relevant FS::part_export::cacti object
390 my ($self) = $svc->cust_svc->part_svc->part_export('cacti');
392 $job->update_statustext(20);
394 my $oldAutoCommit = $FS::UID::AutoCommit;
395 local $FS::UID::AutoCommit = 0;
398 # check for existing pages
400 my %oldpages = map { ($_->graphnum || 'MAIN') => $_ } qsearch({
401 'table' => 'cacti_page',
402 'hashref' => { 'svcnum' => $svcnum, 'exportnum' => $self->exportnum },
403 'select' => 'cacti_pagenum, exportnum, svcnum, graphnum, imported, thumbnail', #no need to load old content
404 'order_by' => 'ORDER BY graphnum',
407 # if all existing pages are recent enough, do nothing and return
408 # (won't detect newly introduced graphs, but they can wait for next run)
410 if (keys %oldpages) {
412 foreach my $oldpage (keys %oldpages) {
413 if ($oldpages{$oldpage}->imported <= $self->exptime($now)) {
420 $job->update_statustext(100);
424 $job->update_statustext(30);
426 # get list of graphs for this svc from cacti server
428 . trailslash($self->option('script_path'))
429 . q(freeside_cacti.php --get-graphs --ip=')
432 $cmd .= q( --include-path=') . $self->option('include_path') . q(')
433 if $self->option('include_path');
434 my @graphs = map { [ split(/\t/,$_) ] }
436 'host' => $self->machine,
437 'user' => $self->option('user'),
441 $job->update_statustext(40);
443 # copy graphs from cacti server to cache
444 # requires version 2.6.4 of rsync, released March 2005
445 my $rsync = File::Rsync->new({
451 'source' => trailslash($self->option('graphs_path')),
454 (map { q('**graph_).${$_}[0].q(*.png') } @graphs),
455 (map { q('**thumb_).${$_}[0].q(.png') } @graphs),
460 #don't know why a regular $rsync->exec isn't doing includes right, but this does
461 my $rscmd = join(' ',@{$rsync->getcmd()});
462 my $error = system($rscmd);
463 die "rsync ($rscmd) failed with exit status $error" if $error;
465 $job->update_statustext(50);
467 # create html file contents
468 my $svchead = q(<!-- UPDATED ) . $now . qq( -->)
469 . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>'
470 . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>);
471 my $svchtml = $svchead;
472 my $maxgraph = 1024 * 1024 * ($self->options('max_graph_size') || 5);
474 for (my $i = 0; $i <= $#graphs; $i++) {
475 my $graph = $graphs[$i];
476 my $thumbfile = $cachedir . 'graphs/thumb_' . $$graph[0] . '.png';
478 if ( stat($thumbfile)->size() < $maxgraph ) {
480 my $thumbnail = img_tag($thumbfile);
481 # add graph to main file
482 my $graphhead = q(<H3>) . $$graph[1] . q(</H3>);
483 $svchtml .= $graphhead;
484 $svchtml .= anchor_tag( $svcnum, $$graph[0], $thumbnail );
485 # create graph details file
486 my $graphhtml = $svchead . $graphhead;
489 # no easy way to tell what detail graphs should exist,
490 # and don't want detail graphs that are out of sync with thumbnail,
491 # so just use what we can find
492 while (-e (my $graphfile = $cachedir.'graphs/graph_'.$$graph[0].'_'.$j.'.png')) {
493 if ( stat($graphfile)->size() < $maxgraph ) {
495 $graphhtml .= img_tag($graphfile);
500 $graphhtml .= '<P>No detail graphs to display for this graph</P>'
502 #delete old detail page
503 if ($oldpages{$$graph[0]}) {
504 $error = $oldpages{$$graph[0]}->delete;
506 $dbh->rollback if $oldAutoCommit;
510 #insert new detail page
511 my $newobj = new FS::cacti_page {
512 'exportnum' => $self->exportnum,
514 'graphnum' => $$graph[0],
516 'content' => $graphhtml,
517 'thumbnail' => $thumbnail,
519 $error = $newobj->insert;
521 $dbh->rollback if $oldAutoCommit;
525 $svchtml .= qq(<P STYLE="color: #FF0000">File $thumbfile is too large, skipping</P>);
529 # try to use old page for this graph
530 if ($oldpages{$$graph[0]} && $oldpages{$$graph[0]}->thumbnail) {
532 # add old graph to main file
533 my $graphhead = q(<H3>) . $$graph[1] . q(</H3>);
534 $svchtml .= $graphhead;
535 $svchtml .= qq(<P STYLE="color: #FF0000">Current graphs unavailable; using previously imported data.</P>);
536 $svchtml .= anchor_tag( $svcnum, $$graph[0], $oldpages{$$graph[0]}->thumbnail );
538 $svchtml .= qq(<P STYLE="color: #FF0000">Error loading graph: $$graph[0]</P>);
541 # remove old page from hash even if it is being reused,
542 # remaining entries in hash will be deleted from database below
543 delete $oldpages{$$graph[0]} if $oldpages{$$graph[0]};
544 $job->update_statustext(49 + int($i / @graphs) * 50);
546 $svchtml .= '<P>No graphs to display for this service</P>'
548 # delete remaining old pages, including svc index
549 foreach my $oldpage (keys %oldpages) {
550 $error = $oldpages{$oldpage}->delete;
552 $dbh->rollback if $oldAutoCommit;
556 # insert new index page for svc
557 my $newobj = new FS::cacti_page {
558 'exportnum' => $self->exportnum,
562 'content' => $svchtml,
565 $error = $newobj->insert;
567 $dbh->rollback if $oldAutoCommit;
571 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
573 $job->update_statustext(100);
578 my $somefile = shift;
579 return q(<IMG SRC="data:image/png;base64,)
580 . encode_base64(slurp($somefile,binmode=>':raw'),'')
581 . qq(" STYLE="margin-bottom: 1em;"><BR>);
585 my ($svcnum, $graphnum, $contents) = @_;
586 return q(<A HREF="?svcnum=)
595 #this gets used by everything else
596 #fake false laziness, other ssh_cmds handle error/output differently
600 my $ssh = Net::OpenSSH->new($opt->{'user'}.'@'.$opt->{'host'});
601 die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
602 my ($output, $errput) = $ssh->capture2($opt->{'command'});
603 die "Error running SSH command: ". $opt->{'command'}. ' ERROR: ' . $ssh->error if $ssh->error;
604 die $errput if $errput;
608 #there's probably a better place to put this?
609 #makes sure there's a trailing slash between/after input
610 #doesn't add leading slashes
614 foreach my $path (@paths) {
616 $out .= '/' unless $out =~ /\/$/;
627 Removes all expired graphs for this export from the database.
633 my $oldAutoCommit = $FS::UID::AutoCommit;
634 local $FS::UID::AutoCommit = 0;
636 my $sth = $dbh->prepare('DELETE FROM cacti_page WHERE exportnum = ? and imported <= ?')
638 $dbh->rollback if $oldAutoCommit;
641 $sth->execute($self->exportnum,$self->exptime)
643 $dbh->rollback if $oldAutoCommit;
646 $dbh->commit or return $dbh->errstr if $oldAutoCommit;
650 =item exptime [ TIME ]
652 Accepts optional current time, defaults to actual current time.
654 Returns timestamp for the oldest possible non-expired graph import,
655 based on the import_freq option.
661 my $now = shift || time;
662 return $now - 60 * ($self->option('import_freq') || 5);
670 jonathan@freeside.biz
672 =head1 LICENSE AND COPYRIGHT
674 Copyright 2015 Freeside Internet Services
676 This program is free software; you can redistribute it and/or
677 modify it under the terms of the GNU General Public License
678 as published by the Free Software Foundation.