1# -*- cperl -*-
2#
3# Copyright (C) 2009 Magnus Hagander, Redpill Linpro AB
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; version 2 dated June,
8# 1991.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
18# 02110-1301 USA.
19
20# This Module is user documented inline, interspersed with code with
21# perlpod.
22
23
24=head1 NAME
25
26Munin::Plugin::Pgsql - Base module for PostgreSQL plugins for Munin
27
28=head1 SYNOPSIS
29
30The Munin::Plugin::Pgsql module provides base functionality for all
31PostgreSQL Munin plugins, including common configuration parameters.
32
33=head1 CONFIGURATION
34
35All configuration is done through environment variables.
36
37=head1 ENVIRONMENT VARIABLES
38
39All plugins based on Munin::Plugin::Pgsql accepts all the environment
40variables that libpq does. The most common ones used are:
41
42 PGHOST      hostname to connect to, or path to Unix socket
43 PGPORT      port number to connect to
44 PGUSER      username to connect as
45 PGPASSWORD  password to connect with, if a password is required
46
47The plugins will by default connect to the 'template1' database, except for
48wildcard per-database plugins. This can be overridden using the PGDATABASE
49variable, but this is usually a bad idea.
50
51=head2 Example
52
53 [postgres_*]
54    user postgres
55    env.PGUSER postgres
56    env.PGPORT 5433
57
58=head1 WILDCARD MATCHING
59
60Wildcard plugins based on this module will match on whatever type of object
61specifies for a filter, usually a database. If the object name ALL is used
62(for example, a symlink to postgres_connections_ALL), the filter will not be
63applied, and the plugin behaves like a non-wildcard one.
64
65=head1 REQUIREMENTS
66
67The module requires DBD::Pg to work.
68
69=head1 TODO
70
71Support for using psql instead of DBD::Pg, to remove dependency.
72
73=head1 BUGS
74
75No known bugs at this point.
76
77=head1 SEE ALSO
78
79L<DBD::Pg>
80
81=head1 AUTHOR
82
83Magnus Hagander <magnus@hagander.net>, Redpill Linpro AB
84
85=head1 COPYRIGHT/License.
86
87Copyright (c) 2009 Magnus Hagander, Redpill Linpro AB
88
89All rights reserved. This program is free software; you can
90redistribute it and/or modify it under the terms of the GNU General
91Public License as published by the Free Software Foundation; version 2
92dated June, 1991.
93
94=head1 API DOCUMENTATION
95
96The following functions are available to plugins using this module.
97
98=cut
99
100package Munin::Plugin::Pgsql;
101
102use strict;
103use warnings;
104
105use Munin::Plugin;
106
107=head2 Initialization
108
109 use Munin::Plugin::Pgsql;
110 my $pg = Munin::Plugin::Pgsql->new(
111    parameter=>value,
112    parameter=>value
113 );
114
115=head3 Parameters
116
117 minversion     Minimum PostgreSQL version required, formatted like 8.2. If the
118                database is an older version than this, the plugin will exit
119                with an error.
120 category       The category for this plugin. Copied directly to the config
121                output. Default 'PostgreSQL'.
122 title          The title for this plugin. Copied directly to the config output.
123 info           The info for this plugin. Copied directly to the config output.
124 vlabel         The vertical label for the graph. Copied directly to the config
125                output.
126 basename       For wildcard plugins, this is the base name of the plugin,
127                including the trailing underscore.
128 basequery      SQL query run to get the plugin values. The query should return
129                two columns, one being the name of the counter and the second
130                being the current value for the counter.
131 pivotquery     Set to 1 to indicate that the query in basequery returns a single
132                row, with one field for each counter. The name of the counter is
133                taken from the returned column name, and the value from the
134                first row in the result.
135 configquery    SQL query run to generate the configuration information for the
136                plugin. The query should return at least two columns, which are
137                the name of the counter and the label of the counter. If
138                a third column is present, it will be used as the info
139                parameter.
140 suggestquery   SQL query to run to generate the list of suggestions for a
141                wildcard plugin. Don't forget to include ALL if the plugin
142                supports aggregate statistics.
143 autoconfquery  SQL query to run as the last step of "autoconf", to determine
144                if the plugin should be run on this machine. Must return a single
145                row, two columns columns. The first one is a boolean field
146                representing yes or no, the second one a reason for "no".
147 graphdraw      The draw parameter for the graph. The default is LINE1.
148 graphtype      The type parameter for the graph. The default is GAUGE.
149 graphperiod    The period for the graph. Copied directly to the config output.
150 graphmin       The min parameter for the graph. The default is no minimum.
151 graphmax       The max parameter for the graph. The default is no maximum.
152 stack          If set to 1, all counters except the first one will be written
153                with a draw type of STACK.
154 base           Used for graph_args --base. Default is 1000, set to 1024 when
155                returning sizes in Kb for example.
156 wildcardfilter The SQL to substitute for when a wildcard plugin is run against
157                a specific entity, for example a database. All occurrences of
158                the string %%FILTER%% will be replaced with this string, and
159                for each occurrence a parameter with the value of the filtering
160                condition will be added to the DBI statement.
161 paramdatabase  Makes the plugin connect to the database in the first parameter
162                (wildcard plugins only) instead of 'template1'.
163 defaultdb      Makes the plugin connect to the database specified in this
164                parameter instead of 'template1'.
165 extraconfig    This string is copied directly into the configuration output
166                when the plugin is run in config mode, allowing low-level
167                customization.
168 postprocess    A function that's called with the result of the base query,
169                and can post-process the result and return a new resultset.
170 postconfig     A function that's called with the result of the config query,
171                and can post-process the result and return a new resultset.
172 postautoconf   A function that's called with the result of the autoconf query,
173                and can post-process the result and return a new resultset.
174 postsuggest    A function that's called with the result of the suggest query,
175                and can post-process the result and return a new resultset.
176
177=head3 Specifying queries
178
179Queries specified in one of the parameters above can take one of two forms.
180The easiest one is a simple string, which will then always be executed,
181regardless of server version. The other form is an array, looking like this:
182 [
183  "SELECT 'default',... FROM ...",
184  [
185    "8.3", "SELECT 'query for 8.3 or earlier',... FROM ...",
186    "8.1", "SELECT 'query for 8.1 or earlier',... FROM ..."
187  ]
188 ]
189This array is parsed from top to bottom, so the entries must be in order of
190version number. The *last* value found where the version specified is higher
191than or equal to the version of the server will be used (yes, it counts
192backwards).
193
194=cut
195
196sub new {
197    my ($class) = shift;
198    my (%args)  = @_;
199
200    my %defaults = (
201        base      => 1000,
202        category  => 'PostgreSQL',
203        graphdraw => 'LINE1',
204        graphtype => 'GAUGE'
205    );
206
207    my $self = {
208        minversion     => $args{minversion},
209        basename       => $args{basename},
210        basequery      => $args{basequery},
211        category       => $args{category},
212        title          => $args{title},
213        info           => $args{info},
214        vlabel         => $args{vlabel},
215        graphdraw      => $args{graphdraw},
216        graphtype      => $args{graphtype},
217        graphperiod    => $args{graphperiod},
218        graphmin       => $args{graphmin},
219        graphmax       => $args{graphmax},
220        stack          => $args{stack},
221        configquery    => $args{configquery},
222        autoconfquery  => $args{autoconfquery},
223        base           => $args{base},
224        wildcardfilter => $args{wildcardfilter},
225        suggestquery   => $args{suggestquery},
226        pivotquery     => $args{pivotquery},
227        paramdatabase  => $args{paramdatabase},
228        defaultdb      => $args{defaultdb},
229        extraconfig    => $args{extraconfig},
230        postprocess    => $args{postprocess},
231        postconfig     => $args{postconfig},
232        postautoconf   => $args{postautoconf},
233        postsuggest    => $args{postsuggest},
234    };
235
236    foreach my $k (keys %defaults) {
237        unless (defined $self->{$k}) {
238            $self->{$k} = $defaults{$k};
239        }
240    }
241    return bless $self, $class;
242}
243
244sub Config {
245    my ($self) = @_;
246
247    $self->ensure_version();
248
249    my $w = $self->wildcard_parameter();
250    if ($w) {
251      print "graph_title $self->{title} ($w)\n";
252    }
253    else {
254      print "graph_title $self->{title}\n";
255    }
256    print "graph_vlabel $self->{vlabel}\n";
257    print "graph_category $self->{category}\n";
258    print "graph_info $self->{info}\n";
259    print "graph_args --base $self->{base}";
260    print " -l $self->{graphmin}" if (defined $self->{graphmin});
261    print "\n";
262    print "graph_period $self->{graphperiod}\n" if ($self->{graphperiod});
263    print "$self->{extraconfig}\n"              if ($self->{extraconfig});
264
265    my $firstrow = 1;
266    my ($q, @p)
267        = $self->replace_wildcard_parameters(
268        $self->get_versioned_query($self->{configquery}));
269    my $r = $self->runquery($q, \@p);
270    if ($self->{postconfig}) {
271        $r = $self->{postconfig}->($r);
272    }
273
274    foreach my $row (@$r) {
275        my $l = Munin::Plugin::clean_fieldname($row->[0]);
276        print "$l.label $row->[1]\n";
277        print "$l.info $row->[2]\n" if (defined $row->[2]);
278        print "$l.type $self->{graphtype}\n";
279        if ($self->{stack} && !$firstrow) {
280            print "$l.draw STACK\n";
281        }
282        else {
283            print "$l.draw $self->{graphdraw}\n";
284        }
285        print "$l.min $self->{graphmin}\n" if (defined $self->{graphmin});
286        print "$l.max $self->{graphmax}\n" if (defined $self->{graphmax});
287        $firstrow = 0;
288    }
289}
290
291sub Autoconf {
292    my ($self) = @_;
293
294    if (!$self->connect(1, 1)) {
295        print "no ($self->{connecterror})\n";
296        return 1;
297    }
298
299    # Check minimum version, if it applies
300    if ($self->{minversion}) {
301        $self->get_version();
302        if ($self->{detected_version} < $self->{minversion}) {
303            print
304                "no (version $self->{detected_version} is less than the required $self->{minversion})\n";
305            return 1;
306        }
307    }
308
309    # If the module has defined a query, run it and check the results. If it's
310    # not defined, assume we will now work.
311    if ($self->{autoconfquery}) {
312        my $r = $self->runquery($self->{autoconfquery});
313        if ($self->{postautoconf}) {
314            $r = $self->{postautoconf}->($r);
315        }
316        if (!$r->[0]->[0]) {
317            print "no (" . $r->[0]->[1] . ")\n";
318            return 1;
319        }
320    }
321
322    print "yes\n";
323    return 0;
324}
325
326sub Suggest {
327    my ($self) = @_;
328
329    if (!$self->connect(1, 1)) {
330        return 0;
331    }
332
333    $self->ensure_version();
334    if ($self->{suggestquery}) {
335        my $r = $self->runquery($self->{suggestquery});
336        if ($self->{postsuggest}) {
337            $r = $self->{postsuggest}->($r);
338        }
339        foreach my $row (@$r) {
340            print $row->[0] . "\n";
341        }
342        return 0;
343    }
344    die "Plugin can't do suggest, why did you try?\n";
345}
346
347sub GetData {
348    my ($self) = @_;
349    $self->ensure_version();
350    if ($self->{basequery}) {
351        my ($q, @p)
352            = $self->replace_wildcard_parameters(
353            $self->get_versioned_query($self->{basequery}));
354        my $r = $self->runquery($q, \@p, $self->{pivotquery});
355        if ($self->{postprocess}) {
356            $r = $self->{postprocess}->($r);
357        }
358        foreach my $row (@$r) {
359            my $l = Munin::Plugin::clean_fieldname($row->[0]);
360            print $l . ".value " . $row->[1] . "\n";
361        }
362        return;
363    }
364    die "No query configured!";
365}
366
367=head2 Processing
368
369 $pg->Process();
370
371 This command executes the plugin. It will automatically parse the ARGV array
372 for commands given by Munin.
373
374=cut
375
376sub Process {
377    my ($self) = @_;
378
379    if (defined $ARGV[0] && $ARGV[0] ne '') {
380        if ($ARGV[0] eq 'autoconf') {
381            return $self->Autoconf();
382        }
383        elsif ($ARGV[0] eq 'config') {
384            return $self->Config();
385        }
386        elsif ($ARGV[0] eq 'suggest') {
387            return $self->Suggest();
388        }
389        else {
390            print "Unknown command: '$ARGV[0]'\n";
391            return 1;
392        }
393    }
394
395    return $self->GetData();
396}
397
398# Internal useful functions
399sub connect() {
400    my ($self, $noexit, $nowildcard) = @_;
401
402    my $r = $self->_connect($nowildcard);
403    return 1 if ($r);         # connect successful
404    return 0 if ($noexit);    # indicate failure but don't exit
405    print "Failed to connect to database: $self->{connecterror}\n";
406    exit(1);
407}
408
409sub _connect() {
410    my ($self, $nowildcard) = @_;
411
412    return 1 if ($self->{dbh});
413
414    if (eval "require DBI; require DBD::Pg;") {
415
416        # By default, connect to database template1, because it exists on both old
417        # and new versions of PostgreSQL, unless the database should be controlled
418        # by the first parameter. Using the defaultdb parameter will override
419        # this. Finally, specifying the database name in the environment will
420        # override everything.
421        #
422        # All other connection parameters are controlled by the libpq environment
423        # variables.
424        my $dbname = "template1";
425        $dbname = $self->{defaultdb}           if ($self->{defaultdb});
426        $dbname = $self->wildcard_parameter(0) if ($self->{paramdatabase} && !defined($nowildcard) && $self->wildcard_parameter(0));
427        $dbname = $ENV{"PGDATABASE"}           if ($ENV{"PGDATABASE"});
428        $self->{dbh} = DBI->connect("DBI:Pg:dbname=$dbname", '', '', {pg_server_prepare => 0, PrintError => 0});
429        unless ($self->{dbh}) {
430            my $err_str = "$DBI::errstr";
431            $err_str =~ s/[\r\n\t]/ /g;
432            $err_str =~ s/\h+/ /g;
433            $err_str =~ s/ $//;
434            $self->{connecterror} = $err_str;
435            return 0;
436        }
437    }
438    else {
439        $self->{connecterror} = "DBD::Pg not found, and cannot do psql yet";
440        return 0;
441    }
442    return 1;
443}
444
445sub runquery {
446    my ($self, $query, $params, $pivot) = @_;
447    $self->connect();
448    if ($self->{dbh}) {
449
450        # Run query on DBI
451        my $s = $self->{dbh}->prepare($query);
452        my $r = $s->execute(@$params);
453        unless ($r) {
454            print "Query failed!\n";
455            exit(1);
456        }
457        if ($pivot) {
458
459            # Query returning a single row with one column for each counter
460            # Turn this into a regular resultset
461            my $r     = [];
462            my @dbrow = $s->fetchrow_array();
463            for (my $i = 0; $i < scalar(@dbrow); $i++) {
464                push @$r, [$s->{NAME}->[$i], $dbrow[$i]];
465            }
466            return $r;
467        }
468        else {
469            return $s->fetchall_arrayref();
470        }
471    }
472    die "Don't know how to run without DBI yet!\n";
473}
474
475sub get_version {
476    my ($self) = @_;
477
478    return if (defined $self->{detected_version});
479
480    my $r = $self->runquery("SHOW server_version");
481    my $v = $r->[0]->[0];
482    die "Unable to detect PostgreSQL version\n"
483        unless ($v =~ /^(\d+)\.(\d+).*\b/);
484    # from PostgreSQL 10 on only the major version is needed
485    # see https://www.postgresql.org/support/versioning/
486    $self->{detected_version} = ($1 >= 10) ? "$1" : "$1.$2";
487}
488
489sub get_versioned_query {
490    my ($self, $query) = @_;
491    if (ref($query) eq "ARRAY") {
492        my $rq = undef;
493        $self->get_version();
494        foreach my $entry (@$query) {
495            if (!defined($rq)) {
496
497                # First row must always be a scalar
498                die "First available query must be unconditional"
499                    unless (ref($entry) eq "");
500                $rq = $entry;
501                next;
502            }
503            die "Non-first available queries must be version conditional!"
504                unless (ref($entry) eq "ARRAY");
505            if ($self->{detected_version} <= @$entry[0]) {
506
507                # We are running against a server that's this version or older, so change
508                # to using this query.
509                $rq = @$entry[1];
510            }
511        }
512        return $rq;
513    }
514    else {
515        return $query;
516    }
517}
518
519sub ensure_version {
520    my ($self) = @_;
521
522    if ($self->{minversion}) {
523        $self->get_version();
524        if ($self->{detected_version} < $self->{minversion}) {
525            die
526                "This plugin requires PostgreSQL $self->{minversion} or newer!\n";
527        }
528    }
529}
530
531sub replace_wildcard_parameters {
532    my ($self, $q) = @_;
533    my @p = ();
534
535    my $w = $self->wildcard_parameter();
536    if ($w) {
537        while ($q =~ s/%%FILTER%%/$self->{wildcardfilter}/) {
538            push @p, $self->wildcard_parameter();
539        }
540    }
541    else {
542
543        # Not called as a wildcard, or called with "all" - remove filter spec
544        $q =~ s/%%FILTER%%//g;
545    }
546
547    # PARAM replacements are done without placeholders, so they can modify
548    # the query itself.
549    if ($self->wildcard_parameter(-1)) {
550        my @pieces = split /:/, $self->wildcard_parameter(-1);
551        for (my $i = 0; $i <= $#pieces; $i++) {
552            while ($q =~ s/%%PARAM$i%%/$pieces[$i]/) {
553            }
554        }
555    }
556    return ($q, @p);
557}
558
559sub wildcard_parameter {
560    my ($self, $paramnum) = @_;
561
562    return undef unless (defined $self->{basename});
563
564    $paramnum = 0 unless (defined $paramnum);
565    if ($0 =~ /$self->{basename}(.*)$/) {
566
567        # If asking for first parameter, and there's no filter on it,
568        # return undef.
569        return undef if ($1 eq "ALL" && $paramnum == 0);
570
571        # If asking for unsplit, return that (internal use only, really)
572        return $1 if ($paramnum == -1);
573
574        # Otherwise, split the string again on colon, and return the
575        # selected piece.
576        my @pieces = split /:/, $1;
577        if (scalar(@pieces) < $paramnum + 1) {
578            die "Piece $paramnum not found in wildcard parameter.\n";
579        }
580        return $pieces[$paramnum];
581    }
582    die "Wildcard base not found in called filename!\n";
583}
584
5851;
586