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( encode_base64 );
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',
43 'tree_id' => { label => 'Graph Tree ID (optional)',
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/)',
49 'import_freq' => { label => 'Minimum minutes between graph imports',
51 'max_graph_size' => { label => 'Maximum size per graph (MB)',
53 'delete_graphs' => { label => 'Delete associated graphs and data sources when unprovisioning',
56 'cacti_graph_template_id' => {
57 'label' => 'Graph Template',
61 'cacti_snmp_query_id' => {
62 'label' => 'SNMP Query ID',
66 'cacti_snmp_query_type_id' => {
67 'label' => 'SNMP Query Type ID',
71 'cacti_snmp_field' => {
72 'label' => 'SNMP Field',
76 'cacti_snmp_value' => {
77 'label' => 'SNMP Value',
84 'svc' => 'svc_broadband',
85 'desc' => 'Export service to cacti server, for svc_broadband services',
86 'post_config_element' => '/edit/elements/part_export/cacti.html',
87 'options' => \%options,
89 Add service to cacti upon provisioning, for broadband services.<BR>
90 See <A HREF="http://www.freeside.biz/mediawiki/index.php/Freeside:4:Documentation:Cacti#Connecting_Cacti_To_Freeside">documentation</A> for details.
94 # standard hooks for provisioning/unprovisioning service
97 my ($self, $svc_broadband) = @_;
98 my ($q,$error) = _insert_queue($self, $svc_broadband);
103 my ($self, $svc_broadband) = @_;
104 my $oldAutoCommit = $FS::UID::AutoCommit;
105 local $FS::UID::AutoCommit = 0;
107 foreach my $page (qsearch('cacti_page',{ svcnum => $svc_broadband->svcnum })) {
108 my $error = $page->delete;
110 $dbh->rollback if $oldAutoCommit;
114 my ($q,$error) = _delete_queue($self, $svc_broadband);
116 $dbh->rollback if $oldAutoCommit;
119 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
123 sub _export_replace {
124 my($self, $new, $old) = @_;
125 return '' if $new->ip_addr eq $old->ip_addr; #important part didn't change
126 #delete old then insert new, with second job dependant on the first
127 my $oldAutoCommit = $FS::UID::AutoCommit;
128 local $FS::UID::AutoCommit = 0;
130 my ($dq, $iq, $error);
131 ($dq,$error) = _delete_queue($self,$old);
133 $dbh->rollback if $oldAutoCommit;
136 ($iq,$error) = _insert_queue($self,$new);
138 $dbh->rollback if $oldAutoCommit;
141 $error = $iq->depend_insert($dq->jobnum);
143 $dbh->rollback if $oldAutoCommit;
146 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
150 sub _export_suspend {
154 sub _export_unsuspend {
161 my ($self, $svc_broadband) = @_;
162 my $queue = new FS::queue {
163 'svcnum' => $svc_broadband->svcnum,
164 'job' => "FS::part_export::cacti::ssh_insert",
166 my $error = $queue->insert(
167 'host' => $self->machine,
168 'user' => $self->option('user'),
169 'hostname' => $svc_broadband->ip_addr,
170 'script_path' => $self->option('script_path'),
171 'template_id' => $self->option('template_id'),
172 'tree_id' => $self->option('tree_id'),
173 'description' => $self->option('description'),
174 'svc_desc' => $svc_broadband->description,
175 'contact' => $svc_broadband->cust_main->contact,
176 'svcnum' => $svc_broadband->svcnum,
179 return ($queue,$error);
183 my ($self, $svc_broadband) = @_;
184 my $queue = new FS::queue {
185 'svcnum' => $svc_broadband->svcnum,
186 'job' => "FS::part_export::cacti::ssh_delete",
188 my $error = $queue->insert(
189 'host' => $self->machine,
190 'user' => $self->option('user'),
191 'hostname' => $svc_broadband->ip_addr,
192 'script_path' => $self->option('script_path'),
193 'delete_graphs' => $self->option('delete_graphs'),
195 return ($queue,$error);
198 # routines run by queued jobs
202 my $self = $opt{'self'};
205 die "Non-numerical Host Template ID, check export configuration\n"
206 unless $opt{'template_id'} =~ /^\d+$/;
207 die "Non-numerical Graph Tree ID, check export configuration\n"
208 unless $opt{'tree_id'} =~ /^\d*$/;
211 my $desc = $opt{'description'};
212 $desc =~ s/\$ip_addr/$opt{'hostname'}/g;
213 $desc =~ s/\$description/$opt{'svc_desc'}/g;
214 $desc =~ s/\$contact/$opt{'contact'}/g;
215 #for some reason, device names with apostrophes fail to export graphs in Cacti
216 #just removing them for now, someday maybe dig to figure out why
217 # $desc =~ s/'/'\\''/g;
220 . $opt{'script_path'}
221 . q(add_device.php --description=')
226 . $opt{'template_id'};
227 my $response = ssh_cmd(%opt, 'command' => $cmd);
228 unless ( $response =~ /Success - new device-id: \((\d+)\)/ ) {
229 die "Error adding device: $response";
234 if ($opt{'tree_id'}) {
236 . $opt{'script_path'}
237 . q(add_tree.php --type=node --node-type=host --tree-id=)
241 $response = ssh_cmd(%opt, 'command' => $cmd);
242 unless ( $response =~ /Added Node node-id: \((\d+)\)/ ) {
243 die "Host added, but error adding host to tree: $response";
247 # Get list of graph templates for new id
249 . $opt{'script_path'}
250 . q(freeside_cacti.php --get-graph-templates --host-template=)
251 . $opt{'template_id'};
252 my $ginfo = { map { $_ ? ($_ => undef) : () } split(/\n/,ssh_cmd(%opt, 'command' => $cmd)) };
254 # Add extra config info
255 my @xtragid = split("\n", $self->option('cacti_graph_template_id'));
256 my @query_id = split("\n", $self->option('cacti_snmp_query_id'));
257 my @query_type_id = split("\n", $self->option('cacti_snmp_query_type_id'));
258 my @snmp_field = split("\n", $self->option('cacti_snmp_field'));
259 my @snmp_value = split("\n", $self->option('cacti_snmp_value'));
260 for (my $i = 0; $i < @xtragid; $i++) {
261 my $gtid = $xtragid[$i];
262 $ginfo->{$gtid} ||= [];
263 push(@{$ginfo->{$gtid}},{
265 'query_id' => $query_id[$i],
266 'query_type_id' => $query_type_id[$i],
267 'snmp_field' => $snmp_field[$i],
268 'snmp_value' => $snmp_value[$i],
273 ref($ginfo->{$_}) ? @{$ginfo->{$_}} : {'gtid' => $_}
275 warn "Host ".$opt{'hostname'}." exported to cacti, but no graphs configured"
280 foreach my $gdef (@gdefs) {
281 # validate graph info
282 my $gtid = $gdef->{'gtid'};
284 $gerror .= " Bad graph template: $gtid"
285 unless $gtid =~ /^\d+$/;
286 my $isds = $gdef->{'query_id'}
287 || $gdef->{'query_type_id'}
288 || $gdef->{'snmp_field'}
289 || $gdef->{'snmp_value'};
291 $gerror .= " Bad SNMP Query Id: " . $gdef->{'query_id'}
292 unless $gdef->{'query_id'} =~ /^\d+$/;
293 $gerror .= " Bad SNMP Query Type Id: " . $gdef->{'query_type_id'}
294 unless $gdef->{'query_type_id'} =~ /^\d+$/;
295 $gerror .= " SNMP Field cannot contain apostrophe"
296 if $gdef->{'snmp_field'} =~ /'/;
297 $gerror .= " SNMP Value cannot contain apostrophe"
298 if $gdef->{'snmp_value'} =~ /'/;
304 . $opt{'script_path'}
305 . q(add_graphs.php --graph-type=)
306 . ($isds ? 'ds' : 'cg')
307 . q( --graph-template-id=)
312 $cmd .= q( --snmp-query-id=)
313 . $gdef->{'query_id'}
314 . q( --snmp-query-type-id=)
315 . $gdef->{'query_type_id'}
317 . $gdef->{'snmp_field'}
318 . q(' --snmp-value=')
319 . $gdef->{'snmp_value'}
322 $response = ssh_cmd(%opt, 'command' => $cmd);
323 #might be more than one graph added, just testing success
324 $gerror .= "Error creating graph $gtid: $response"
325 unless $response =~ /Graph Added - graph-id: \((\d+)\)/;
329 # job fails, but partial export may have occurred
330 die $gerror . " Partial export occurred\n" if $gerror;
338 . $opt{'script_path'}
339 . q(freeside_cacti.php --drop-device --ip=')
342 $cmd .= q( --delete-graphs)
343 if $opt{'delete_graphs'};
344 my $response = ssh_cmd(%opt, 'command' => $cmd);
345 die "Error removing from cacti: " . $response
354 =item process_graphs JOB PARAM
356 Intended to be run as an FS::queue job.
358 Copies graphs for a single service from Cacti export directory to FS cache,
359 generates basic html pages for this service with base64-encoded graphs embedded,
360 and stores the generated pages in the database.
367 my ($job,$param) = @_;
369 $job->update_statustext(10);
370 my $cachedir = $FS::UID::cache_dir . '/cacti-graphs/';
373 my $svcnum = $param->{'svcnum'} || die "No svcnum specified";
375 'table' => 'svc_broadband',
376 'hashref' => { 'svcnum' => $svcnum },
377 }) || die "Could not load svcnum $svcnum";
379 # load relevant FS::part_export::cacti object
380 my ($self) = $svc->cust_svc->part_svc->part_export('cacti');
382 $job->update_statustext(20);
384 my $oldAutoCommit = $FS::UID::AutoCommit;
385 local $FS::UID::AutoCommit = 0;
388 # check for existing pages
390 my @oldpages = qsearch({
391 'table' => 'cacti_page',
392 'hashref' => { 'svcnum' => $svcnum, 'exportnum' => $self->exportnum },
393 'select' => 'cacti_pagenum, exportnum, svcnum, graphnum, imported', #no need to load old content
394 'order_by' => 'ORDER BY graphnum',
397 #if pages are recent enough, do nothing and return
398 if ($oldpages[0]->imported > $self->exptime($now)) {
399 $job->update_statustext(100);
403 foreach my $oldpage (@oldpages) {
404 my $error = $oldpage->delete;
406 $dbh->rollback if $oldAutoCommit;
412 $job->update_statustext(30);
414 # get list of graphs for this svc from cacti server
416 . $self->option('script_path')
417 . q(freeside_cacti.php --get-graphs --ip=')
420 my @graphs = map { [ split(/\t/,$_) ] }
422 'host' => $self->machine,
423 'user' => $self->option('user'),
427 $job->update_statustext(40);
429 # copy graphs from cacti server to cache
430 # requires version 2.6.4 of rsync, released March 2005
431 my $rsync = File::Rsync->new({
435 'source' => $self->option('graphs_path'),
438 (map { q('**graph_).${$_}[0].q(*.png') } @graphs),
439 (map { q('**thumb_).${$_}[0].q(.png') } @graphs),
444 #don't know why a regular $rsync->exec isn't doing includes right, but this does
445 my $error = system(join(' ',@{$rsync->getcmd()}));
446 die "rsync failed with exit status $error" if $error;
448 $job->update_statustext(50);
450 # create html file contents
451 my $svchead = q(<!-- UPDATED ) . $now . qq( -->)
452 . '<H2 STYLE="margin-top: 0;">Service #' . $svcnum . '</H2>'
453 . q(<P>Last updated ) . scalar(localtime($now)) . q(</P>);
454 my $svchtml = $svchead;
455 my $maxgraph = 1024 * 1024 * ($self->options('max_graph_size') || 5);
457 for (my $i = 0; $i <= $#graphs; $i++) {
458 my $graph = $graphs[$i];
459 my $thumbfile = $cachedir . 'graphs/thumb_' . $$graph[0] . '.png';
461 if ( stat($thumbfile)->size() < $maxgraph ) {
463 # add graph to main file
464 my $graphhead = q(<H3>) . $$graph[1] . q(</H3>);
465 $svchtml .= $graphhead;
466 $svchtml .= anchor_tag( $svcnum, $$graph[0], img_tag($thumbfile) );
467 # create graph details file
468 my $graphhtml = $svchead . $graphhead;
471 while (-e (my $graphfile = $cachedir.'graphs/graph_'.$$graph[0].'_'.$j.'.png')) {
472 if ( stat($graphfile)->size() < $maxgraph ) {
474 $graphhtml .= img_tag($graphfile);
479 $graphhtml .= '<P>No detail graphs to display for this graph</P>'
481 my $newobj = new FS::cacti_page {
482 'exportnum' => $self->exportnum,
484 'graphnum' => $$graph[0],
486 'content' => $graphhtml,
488 $error = $newobj->insert;
490 $dbh->rollback if $oldAutoCommit;
496 $job->update_statustext(49 + int($i / @graphs) * 50);
498 $svchtml .= '<P>No graphs to display for this service</P>'
500 my $newobj = new FS::cacti_page {
501 'exportnum' => $self->exportnum,
505 'content' => $svchtml,
507 $error = $newobj->insert;
509 $dbh->rollback if $oldAutoCommit;
513 $dbh->commit or die $dbh->errstr if $oldAutoCommit;
515 $job->update_statustext(100);
520 my $somefile = shift;
521 return q(<IMG SRC="data:image/png;base64,)
522 . encode_base64(slurp($somefile,binmode=>':raw'),'')
523 . qq(" STYLE="margin-bottom: 1em;"><BR>);
527 my ($svcnum, $graphnum, $contents) = @_;
528 return q(<A HREF="?svcnum=)
537 #this gets used by everything else
538 #fake false laziness, other ssh_cmds handle error/output differently
542 my $ssh = Net::OpenSSH->new($opt->{'user'}.'@'.$opt->{'host'});
543 die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
544 my ($output, $errput) = $ssh->capture2($opt->{'command'});
545 die "Error running SSH command: ". $opt->{'command'}. ' ERROR: ' . $ssh->error if $ssh->error;
546 die $errput if $errput;
556 Removes all expired graphs for this export from the database.
562 my $oldAutoCommit = $FS::UID::AutoCommit;
563 local $FS::UID::AutoCommit = 0;
565 my $sth = $dbh->prepare('DELETE FROM cacti_page WHERE exportnum = ? and imported <= ?')
567 $dbh->rollback if $oldAutoCommit;
570 $sth->execute($self->exportnum,$self->exptime)
572 $dbh->rollback if $oldAutoCommit;
575 $dbh->commit or return $dbh->errstr if $oldAutoCommit;
579 =item exptime [ TIME ]
581 Accepts optional current time, defaults to actual current time.
583 Returns timestamp for the oldest possible non-expired graph import,
584 based on the import_freq option.
590 my $now = shift || time;
591 return $now - 60 * ($self->option('import_freq') || 5);
599 jonathan@freeside.biz
601 =head1 LICENSE AND COPYRIGHT
603 Copyright 2015 Freeside Internet Services
605 This program is free software; you can redistribute it and/or
606 modify it under the terms of the GNU General Public License
607 as published by the Free Software Foundation.