File Coverage

blib/lib/Cluster/SSH/Helper.pm
Criterion Covered Total %
statement 20 180 11.1
branch 0 92 0.0
condition 0 30 0.0
subroutine 7 13 53.8
pod 5 6 83.3
total 32 321 9.9


line stmt bran cond sub pod time code
1             package Cluster::SSH::Helper;
2              
3 1     1   65946 use 5.006;
  1         3  
4 1     1   5 use strict;
  1         2  
  1         20  
5 1     1   6 use warnings;
  1         2  
  1         42  
6 1     1   465 use Config::Tiny;
  1         1050  
  1         33  
7 1     1   478 use String::ShellQuote;
  1         844  
  1         104  
8 1     1   461 use File::BaseDir qw/xdg_config_home/;
  1         1248  
  1         1101  
9              
10             =head1 NAME
11              
12             Cluster::SSH::Helper - Poll machines in a cluster via SNMP and determine which to run a command on.
13              
14             =head1 VERSION
15              
16             Version 0.1.1
17              
18             =cut
19              
20             our $VERSION = '0.1.1';
21              
22             =head1 SYNOPSIS
23              
24             use Cluster::SSH::Helper;
25              
26             my $csh;
27             eval({
28             # config using...
29             # ~/.config/cluster-ssh-helper/hosts.ini
30             # ~/.config/cluster-ssh-helper/config.ini
31             $csh= Cluster::SSH::Helper->new_from_ini();
32             });
33             if ( $@ ){
34             die( 'Cluster::SSH::Helper->new_from_ini failed... '.$@ );
35             }
36              
37             # Run the command on the machine with the lowest 1m load.
38             $csh->run({ command=>'uname -a' });
39              
40             =head1 METHODS
41              
42             =head2 new
43              
44             Any initially obvious errors in the passed config or hosts config will result in this method dieing.
45              
46             Tho hash references are required . The first is the general config and the second is the hosts config.
47              
48             my $csh;
49             eval({
50             $csh= Cluster::SSH::Helper->new( \%config, %hosts );
51             });
52             if ( $@ ){
53             die( 'Cluster::SSH::Helper->new failed... '.$@ );
54             }
55              
56             =cut
57              
58             sub new {
59 0     0 1   my $config = $_[1];
60 0           my $hosts = $_[2];
61              
62             # make sure we have a config and that it is a hash
63 0 0         if ( defined($config) ) {
64 0 0 0       if ( ( ref($config) ne 'HASH' )
65             && ( ref($config) ne 'Config::Tiny' ) )
66             {
67 0           die( 'The passed reference for the config is not a hash... ' . ref($config) );
68             }
69              
70 0 0         if ( !defined( $config->{'_'} ) ) {
71 0           $config->{'_'} = {};
72             }
73              
74 0 0         if ( defined( $config->{'_'}{'env'} ) ) {
75 0 0         if ( !defined( $config->{ $config->{'_'}{'env'} } ) ) {
76 0           die( '"' . $config->{'_'}{'env'} . '" is specified as for $config->{_}{env} but it is undefined' );
77             }
78             }
79             }
80             else {
81             # ALL DEFAULTS!
82 0           $config = { '_' => {} };
83             }
84              
85             # make sure we have a hosts and that it is a hash
86 0 0         if ( defined($hosts) ) {
87 0 0 0       if ( ( ref($hosts) ne 'HASH' ) && ( ref($config) ne 'Config::Tiny' ) ) {
88 0           die('The passed reference for the hosts is not a hash');
89             }
90             }
91             else {
92             # this module is useless with out it
93 0           die('No hash reference passed for the hosts config');
94             }
95              
96 0           my $self = {
97             config => $config,
98             hosts => $hosts,
99             };
100 0           bless $self;
101              
102             # set defaults
103 0 0         if ( !defined( $self->{config}{_}{warn_on_poll} ) ) {
104 0           $self->{config}{_}{warn_on_poll} = 1;
105             }
106 0 0         if ( !defined( $self->{config}{_}{method} ) ) {
107 0           $self->{config}{_}{method} = 'load_1m';
108             }
109 0 0         if ( !defined( $self->{config}{_}{ssh} ) ) {
110 0           $self->{config}{_}{ssh} = 'ssh';
111             }
112 0 0         if ( !defined( $self->{config}{_}{snmp} ) ) {
113 0           $self->{config}{_}{snmp} = '-v 2c -c public';
114             }
115              
116 0           return $self;
117             }
118              
119             =head1 new_from_ini
120              
121             Initiates the this object from a file contiaining the general config and host confg
122             from two INI files.
123              
124             There are two optional arguments. The first one is the path to the config INI and the
125             second is the path to the hosts INI.
126              
127             If not specifiedied, xdg_config_home.'/cluster-ssh-helper/config.ini'
128             and xdg_config_home.'/cluster-ssh-helper/hosts.ini' are used.
129              
130             my $csh;
131             eval({
132             $csh= Cluster::SSH::Helper->new_from_ini( $config_path, $hosts_path );
133             });
134             if ( $@ ){
135             die( 'Cluster::SSH::Helper->new_from_ini failed... '.$@ );
136             }
137              
138             =cut
139              
140             sub new_from_ini {
141 0     0 0   my $config_file = $_[1];
142 0           my $hosts_file = $_[2];
143              
144 0 0         if ( !defined($config_file) ) {
145 0           $config_file = xdg_config_home . '/cluster-ssh-helper/config.ini';
146             }
147 0 0         if ( !defined($hosts_file) ) {
148 0           $hosts_file = xdg_config_home . '/cluster-ssh-helper/hosts.ini';
149             }
150              
151 0           my $config = Config::Tiny->read($config_file);
152 0 0         if ( !defined($config) ) {
153 0           die( "Failed to read '" . $config_file . "'" );
154             }
155              
156 0           my $hosts = Config::Tiny->read($hosts_file);
157 0 0         if ( !defined($hosts) ) {
158 0           die( 'Failed to read"' . $hosts_file . '"' );
159             }
160              
161 0           my $self = Cluster::SSH::Helper->new( $config, $hosts );
162              
163 0           return $self;
164             }
165              
166             =head2 run
167              
168             =head3 command
169              
170             This is a array to use for building the command that will be run.
171             Basically thinking of it as @ARGV where $ARGV[0] will be the command
172             and the rest will the the various flags etc.
173              
174             =head3 env
175              
176             This is a alternative env value to use if not using the defaults.
177              
178             =head3 method
179              
180             This is the selector method to use if not using the default.
181              
182             =head3 debug
183              
184             If set to true, returns the command in question after figuring out what it is.
185              
186             =head3 print_cmd
187              
188             Prints the command before running it.
189              
190             eval({
191             $csh->run({
192             command=>'uname -a',
193             });
194             });
195              
196             =cut
197              
198             sub run {
199 0     0 1   my $self = $_[ 0 ];
200 0           my $opts = $_[ 1 ];
201              
202             # make sure we have the options
203 0 0         if ( !defined( $opts ) ) {
204 0           die( 'No options hash passed' );
205             }
206             else {
207 0 0         if ( ref( $opts ) ne 'HASH' ) {
208 0           die( 'The passed item was not a hash reference' );
209             }
210              
211 0 0         if ( !defined( $opts->{ command } ) ) {
212 0           die( '$opts->{command} is not defined' );
213             }
214             }
215              
216             # Gets the method to use
217 0 0         if (!defined( $opts->{method} )) {
218 0           $opts->{method}=$self->{config}{_}{method};
219             }
220              
221             # gets the env to use
222 0           my $default_env;
223 0 0         if ( !defined( $opts->{env} ) ) {
224 0 0         if ( defined( $self->{config}{_}{env} ) ) {
225 0           $opts->{env} = $self->{config}{_}{env};
226             }
227 0           $default_env=1;
228             }
229              
230             # _ is the root of the ini file... not a section, which are meant to be used as env
231 0 0 0       if ( ( defined( $opts->{env} ) && ( $opts->{env} eq '_' ) ) ) {
232 0           die('"_" is not a valid name for a enviroment section');
233             }
234              
235             # figure out what host to use
236 0           my $host;
237 0 0         if ( $opts->{method} eq 'load_1m' ) {
    0          
238 0           $host = $self->lowest_load_1n;
239             } elsif ( $opts->{method} eq 'ram_used_percent' ) {
240 0           $host = $self->lowest_used_ram_percent;
241             } else {
242 0           die( '"' . $opts->{method} . '" is not a valid method' );
243             }
244              
245             # if we don't have a host, no point in proceeding
246 0 0         if (!defined($host)) {
247 0           die('Unable to get a host. Host chooser method returned undef.');
248             }
249              
250             # real in host specific stuff
251 0           my $ssh_host = $host;
252 0 0         if ( defined( $self->{hosts}{$host}{host} ) ) {
253 0           $ssh_host = $self->{hosts}{$host}{host};
254             }
255 0 0 0       if ( defined( $self->{hosts}{$host}{env} ) && $default_env ) {
256 0           $opts->{env} = $self->{hosts}{$host}{env};
257             }
258              
259             # makes sure the section exists if one was specified
260 0 0 0       if ( defined( $opts->{env} )
261             && !defined( $self->{config}{ $opts->{env} } ) ) {
262             die( '"'
263             . $opts->{env}
264 0           . '" is not a defined section in the config' );
265             }
266              
267             # build the env section if needed.
268 0           my $env_command;
269 0 0         if ( defined( $opts->{env} ) ) {
270             # builds the env commant
271 0           $env_command = '/usr/bin/env';
272 0           foreach my $key ( keys( %{ $self->{config}{'test'} } ) ) {
  0            
273             $env_command
274             = $env_command . ' '
275             . shell_quote( shell_quote($key) ) . '='
276 0           . shell_quote( shell_quote( $self->{config}{ $opts->{env} }{$key} ) );
277             }
278             }
279              
280             # the initial command to use
281 0           my $command = $self->{config}{_}{ssh};
282 0 0         if ( defined( $self->{config}{_}{ssh_user} ) ) {
283 0           $command = $command . ' ' . shell_quote( $self->{config}{_}{ssh_user} ) . '@' . shell_quote($ssh_host);
284             }
285             else {
286 0           $command = $command . ' ' . $host;
287             }
288              
289             # add the env command if needed
290 0 0         if (defined($env_command)) {
291 0           $command=$command.' '.$env_command;
292             }
293              
294             # add on each argument
295 0           foreach my $part ( @{ $opts->{command} } ) {
  0            
296 0 0         if ( defined($env_command) ) {
297 0           $command = $command . ' '
298             . shell_quote( shell_quote( shell_quote($part) ) );
299             } else {
300 0           $command = $command . ' ' . shell_quote( shell_quote($part) );
301             }
302             }
303              
304 0 0         if ( $opts->{debug} ) {
305 0           return $command;
306             }
307              
308 0 0 0       if (
309             defined($opts->{print_cmd}) &&
310             $opts->{print_cmd}
311             ) {
312 0           print $command."\n";
313             }
314              
315 0           system($command);
316              
317 0           return $?;
318             }
319              
320             =head2 lowest_load_1n
321              
322             This returns the host with the lowest 1 minute load.
323              
324             my $host = $csh->lowest_load_1m;
325              
326             =cut
327              
328             sub lowest_load_1n {
329 0     0 1   my $self = $_[ 0 ];
330              
331 0           my @hosts = keys( %{ $self->{ hosts } } );
  0            
332              
333             # holds a list of the 1 minute laod values for each host
334 0           my %host_loads;
335 1     1   642 use Data::Dumper;
  1         6710  
  1         687  
336 0           foreach my $host (@hosts) {
337 0           my $snmp;
338             my $load;
339             # see if we can create it and fetch the OID
340 0           eval {
341 0           $snmp = $self->snmp_command($host).' 1.3.6.1.4.1.2021.10.1.6.1';
342 0           $load=`$snmp`;
343 0           chomp($load);
344 0           $load=~s/.*\:\ //;
345             };
346              
347 0           my $poll_failed;
348 0 0 0       if ( $@ && $self->{config}{_}{warn_on_poll} ) {
349 0           warn( 'Polling for "' . $host . '" failed with... ' . $@ );
350 0           $poll_failed = 1;
351             }
352              
353 0 0 0       if ( defined($load) ) {
    0          
354             # if we are here, then polling worked with out issue
355 0           $host_loads{$host} = $load;
356             } elsif ( defined($poll_failed)
357             && !defined($load) ) {
358             # If here, it means the polling did not die, but it also did not work
359 0           warn( 'Polling for "' . $host . '" returned undef' );
360             }
361             }
362              
363 0           my @sorted = sort { $host_loads{$a} <=> $host_loads{$b} } keys(%host_loads);
  0            
364              
365             # we did not manage to poll anything... or thre are zero hosts
366 0 0         if (!defined($sorted[0])) {
367 0           die('There are either zero defined hosts or polling failed for all hosts');
368             }
369              
370 0           return $sorted[0];
371             }
372              
373             =head2 lowest_used_ram_percent
374              
375             This returns the host with the lowest percent of used RAM.
376              
377             my $host = $csh->lowest_used_ram_percent;
378              
379             =cut
380              
381             sub lowest_used_ram_percent {
382 0     0 1   my $self = $_[ 0 ];
383              
384 0           my @hosts = keys( %{ $self->{ hosts } } );
  0            
385              
386             # holds a list of the 1 minute laod values for each host
387 0           my %hosts_polled;
388              
389 0           foreach my $host (@hosts) {
390 0           my $snmp;
391             my $used;
392 0           my $total;
393 0           my $used_value;
394 0           my $total_value;
395 0           my $percent;
396             # see if we can create it and fetch the OID
397 0           eval {
398 0           $snmp = $self->snmp_command($host).' 1.3.6.1.4.1.2021.10.1.6.1';
399 0           $used = $snmp.' 1.3.6.1.4.1.2021.4.6.0';
400 0           $total = $snmp.' 1.3.6.1.4.1.2021.4.5.0';
401              
402 0           $used_value=`$used`;
403 0           $total_value=`$total_value`;
404              
405 0           $used_value=~s/.*\:\ //;
406 0           $total_value=~s/.*\:\ //;
407              
408 0           $percent = $used_value / $total_value;
409             };
410              
411 0           my $poll_failed;
412 0 0 0       if ( $@ && $self->{config}{_}{warn_on_poll} ) {
413 0           warn( 'Polling for "' . $host . '" failed with... ' . $@ );
414 0           $poll_failed = 1;
415             }
416              
417 0 0 0       if ( defined($total) ) {
    0          
418             # if we are here, then polling worked with out issue
419 0           $hosts_polled{$host} = $percent;
420             } elsif ( defined($poll_failed)
421             && !defined($percent) ) {
422             # If here, it means the polling did not die, but it also did not work
423 0           warn( 'Polling for "' . $host . '" returned undef' );
424             }
425             }
426              
427 0           my @sorted = sort { $hosts_polled{$a} <=> $hosts_polled{$b} } keys(%hosts_polled);
  0            
428              
429             # we did not manage to poll anything... or thre are zero hosts
430 0 0         if (!defined($sorted[0])) {
431 0           die('There are either zero defined hosts or polling failed for all hosts');
432             }
433              
434 0           return $sorted[0];
435             }
436              
437             =head2 snmp_command
438              
439             Generates the full gammand to be used with snmpget minus that OID to fetch.
440              
441             One argument is taken and that is the host to generate it for.
442              
443             As long as the host exists, this command will work.
444              
445             my $cmd;
446             eval({
447             $cmd=$cshelper->snmp_command($host);
448             });
449              
450             =cut
451              
452             sub snmp_command{
453 0     0 1   my $self = $_[0];
454 0           my $host = $_[1];
455              
456 0 0         if (!defined($host)) {
457 0           die('No host defined');
458             }
459              
460 0 0         if (!defined($self->{hosts}{$host})) {
461 0           die('The host "'.$host.'" is not configured');
462             }
463              
464 0           my $snmp='';
465              
466 0 0         if (defined($self->{config}{_}{snmp})) {
467 0           $snmp=$self->{config}{_}{snmp};
468             }
469              
470 0 0         if (defined($self->{hosts}{snmp})) {
471 0           $snmp=$self->{hosts}{snmp};
472             }
473              
474 0           return 'snmpget -O vU '.$snmp.' '.$host;
475             }
476              
477             =head1 CONFIGURATOIN
478              
479             =head2 GENERAL
480              
481             The general configuration.
482              
483             This is written to be easily loadable via L, hence why '_' is used.
484              
485             =head3 _
486              
487             This contains the default settings to use. Each of these may be over ridden on a per host basis.
488              
489             If use SNMPv3, please see L for more information on the various session options.
490              
491             =head4 method
492              
493             This is default method to use for selecting the what host to run the command on. If not specified,
494             'load' is used.
495              
496             load_1m , checks 1 minute load and uses the lowest
497             ram_used_percent , uses the host with the lowest used percent of RAM
498              
499             =head4 ssh
500              
501             The default command to use for SSH.
502              
503             If not specified, just 'ssh' will be used.
504              
505             This should not include either user or host.
506              
507             =head4 ssh_user
508              
509             If specified, this will be the default user to use for SSH.
510              
511             If not specified, SSH is invoked with out specifying a user.
512              
513             =head4 snmp
514              
515             This is the default options to use with netsnmp. This should be like
516             '-v 2c -c public' or the like.
517              
518             If not specified, this defaults to '-v 2c -c public'.
519              
520             =head4 env
521              
522             If specified, '/usr/bin/env' will be inserted before the command to be ran.
523              
524             The name=values pairs for this will be built using the hash key specified by this.
525              
526             =head4 warn_on_poll
527              
528             Issue a warn() on SNMP timeouts or other polling issues. This won't be a automatic failure. Simply timing out
529             on SNMP means that host will be skipped and not considered.
530              
531             This defaults to 1, true.
532              
533             =head2 HOSTS
534              
535             This contains the hosts to connect to.
536              
537             Each key of this hash reference is name of the host in question. The value is a hash
538             containing any desired over rides to the general config.
539              
540             =head1 AUTHOR
541              
542             Zane C. Bowers-Hadley, C<< >>
543              
544             =head1 BUGS
545              
546             Please report any bugs or feature requests to C, or through
547             the web interface at L. I will be notified, and then you'll
548             automatically be notified of progress on your bug as I make changes.
549              
550             =head1 SUPPORT
551              
552             You can find documentation for this module with the perldoc command.
553              
554             perldoc Cluster::SSH::Helper
555              
556              
557             You can also look for information at:
558              
559             =over 4
560              
561             =item * RT: CPAN's request tracker (report bugs here)
562              
563             L
564              
565             =item * AnnoCPAN: Annotated CPAN documentation
566              
567             L
568              
569             =item * CPAN Ratings
570              
571             L
572              
573             =item * Search CPAN
574              
575             L
576              
577             =item * Repository
578              
579             L
580              
581             =back
582              
583              
584             =head1 ACKNOWLEDGEMENTS
585              
586              
587             =head1 LICENSE AND COPYRIGHT
588              
589             This software is Copyright (c) 2020 by Zane C. Bowers-Hadley.
590              
591             This is free software, licensed under:
592              
593             The Artistic License 2.0 (GPL Compatible)
594              
595              
596             =cut
597              
598             1; # End of Cluster::SSH::Helper