sshotgun 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. data/README.rdoc +523 -0
  2. data/lib/sshotgun.rb +475 -0
  3. metadata +76 -0
data/README.rdoc ADDED
@@ -0,0 +1,523 @@
1
+ == SSHotgun - Utility library for writing scripts to administer and provision multiple servers or server farms.
2
+
3
+ The SSHotgun project homepage is http://sshotgun.rubyforge.org
4
+
5
+ To get help, post your questions on forums on RubyForge at http://rubyforge.org/forum/?group_id=6867
6
+
7
+ The author of SSHotgun is Vick Perry (vick.perry @nospam@ gmail.com)
8
+
9
+ == DESCRIPTION:
10
+
11
+ SShotgun is a utility library for writing Unix/Linux server management and
12
+ provisioning scripts in Ruby. I use it for remotely managing machines in server
13
+ clusters/farms. SShotgun uses your locally installed SSH client and expects
14
+ that you have your SSH public key installed on the servers.
15
+
16
+ == FEATURES:
17
+
18
+ * Uses OpenSSH compatible SSH client and keys already installed on your local machine
19
+ * Optional use of a gateway machine for access to servers behind a firewall
20
+ * Logs in to remote machines via your public key already installed on all remote machines
21
+ * Automatically logs to the console and to a timestamped log file
22
+ * stdout and stderr from each remote machine is displayed and logged
23
+ * Can run sudo commands (your sudo password must be same on all remote machines)
24
+ * Multithreaded with configurable thread pool to service multiple machines simultaneously
25
+ * At end of processing run, a status report is displayed indicating successes, failures, timeouts, incompletes, etc.
26
+
27
+ == EXAMPLE:
28
+
29
+ You don't need to know much about Ruby to write powerful SSHotgun scripts. Just
30
+ copy the sample script below and edit the hostnames and the doProcessing method
31
+ as necessary.
32
+
33
+ The steps for writing a SSHotgun Ruby script are:
34
+
35
+ 1. Create an array of fully qualified hostnames (fqdn).
36
+ 2. Write your own custom processing class and doProcess method that will be
37
+ invoked on all remote servers. You can copy the example for this.
38
+ 3. Instantiate a SSHotgun object in the script.
39
+ 4. Run the script. A Ruby script can be invoked two different ways:
40
+
41
+ Run Ruby and specify the script
42
+
43
+ ruby ./myrubyscript.rb
44
+
45
+ or
46
+
47
+ include this line as the first line of the script
48
+
49
+ #!/usr/bin/env ruby
50
+
51
+ then make the script executable
52
+
53
+ chmod 755 myrubyscript.rb
54
+
55
+ and run it directly from the command line
56
+
57
+ ./myrubyscript.rb
58
+
59
+ This is a simple example of a SSHotgun Ruby script:
60
+
61
+ #!/usr/bin/env ruby
62
+
63
+ # Required libraries
64
+ require "highline/import"
65
+ require 'sshotgun'
66
+
67
+ # Create an array to contain hosts for processing
68
+ hostlist = Array::new()
69
+
70
+ # Create a host definition
71
+ host = HostDefinition.new("localhost")
72
+
73
+ # Add the host definition to the array of hosts
74
+ hostlist << host
75
+
76
+ # Create and add more hosts...
77
+ host = HostDefinition.new("another.host.junk")
78
+ hostlist << host
79
+
80
+ # Create your own custom processing class that subclasses
81
+ # BaseProcessingClass. In this class, define your own doProcessing method
82
+ # where you write commands to execute on each remote server.
83
+ class MyProcessingClass < BaseProcessingClass
84
+ def doProcessing
85
+ # Execute a command on all remote servers. The run command has a return
86
+ # object from which you can obtain the output and exit status. See the
87
+ # user guide for more details.
88
+ run "ls -aF"
89
+
90
+ # There are other sshotgun commands available for copying files, running
91
+ # sudo, invoking local commands, etc.
92
+ end
93
+ end
94
+
95
+ # Create an instance of SSHotgun and pass it the list of hosts and your
96
+ # custom processing class.
97
+ sshotgun = SSHotgun.new(hostlist, MyProcessingClass)
98
+
99
+ # Start processing. The start call should be the very last line in a SSHotgun
100
+ # script.
101
+ sshotgun.start
102
+
103
+ == REQUIREMENTS:
104
+
105
+ * You must have an OpenSSH-compatible SSH client installed on your local machine.
106
+ * You must have correctly installed your SSH public keys on all remote servers that you access.
107
+ * For most advanced administrative tasks, you'll also need sudo privleges on the remote servers.
108
+ * You'll need write permissions in your present working directory (pwd) since SSHotgun will need to write its logs.
109
+
110
+ == INSTALL:
111
+
112
+ Use gem to install SSHotgun. Note that you probably need to run this command with sudo or as root.
113
+
114
+ gem install sshotgun
115
+
116
+ == USER GUIDE:
117
+
118
+ Here are snippets of SSHotgun Ruby code to illustrate SSHotgun's capabilities.
119
+
120
+ At a minimum, a host definition contains the fully qualified domain name (fqdn)
121
+ of a machine. As a best practice, DNS aliases are not generally referenced -
122
+ only the fqdn. There are also several optional fields in a host definition that
123
+ can be used for more selectivity when running a script. For example, you may
124
+ wish to do some additional configuration for those machines categorized as
125
+ "ldap" or "proxy"
126
+
127
+ A host definition has the following fields: hostname, category, os, desc
128
+
129
+ * hostname is the primary hostname or fqdn and NOT an alias.
130
+ * category is a freeform identifier for the type of the host. e.g. "wiki", "ldap" (optional)
131
+ * os is a freeform string indicating os name + server/workstation + version. e.g. ubuntu_server_7.10 (optional)
132
+ * desc is a one sentence string describing main purpose of the host (optional)
133
+
134
+ An example of a complete host definition is:
135
+
136
+ host = HostDefinition.new("localhost", "test", "ubuntu_server_7.10", "test vm")
137
+ hostlist << host
138
+
139
+ You can share a list of hosts among several SSHotgun scripts. To do this,
140
+ create a Ruby module (not a class) in a separate file. The module will contain
141
+ a method that returns an array of host definitions. The module is referenced by
142
+ any script that needs it.
143
+
144
+ # When you create your own modules, name the filename and module name with a similar name.
145
+ # Note: This module filename is 'listofhosts.rb' - this is the reference in the require statement in the calling script.
146
+ module ListOfHosts
147
+
148
+ # Create a method named <modulename>.getHostlist
149
+ def ListOfHosts.getHostlist
150
+ # Define, load and return the hostlist array
151
+ hostlist = []
152
+
153
+ host = HostDefinition.new("localhost", "test", "ubuntu_server_7.10", "test vm")
154
+ hostlist << host
155
+
156
+ host = HostDefinition.new("other.host.junk", "test", "ubuntu_server_7.10", "test vm")
157
+ hostlist << host
158
+
159
+ # return the array
160
+ return hostlist
161
+ end
162
+
163
+ end
164
+
165
+
166
+ It is a good practice to name the module file name similar or identical to the
167
+ module name.
168
+
169
+ Here is a SSHotgun Ruby script that uses the list of hosts:
170
+
171
+ #!/usr/bin/env ruby
172
+
173
+ # Required libraries
174
+ require "highline/import"
175
+ require 'sshotgun'
176
+
177
+ # Create array to contain hosts for processing
178
+ hostlist = Array::new()
179
+
180
+ # Read hosts from an external Ruby module.
181
+ require 'listofhosts'
182
+
183
+ # In the listofhosts.rb file, the ListOfHosts getHostlist method returns an
184
+ # array of hosts. Concatenate all of the multiple arrays into a single array of
185
+ # hosts.
186
+ hostlist = hostlist + ListOfHosts.getHostlist
187
+
188
+ class MyProcessingClass < BaseProcessingClass
189
+ def doProcessing
190
+ run "ls -aF"
191
+ end
192
+ end
193
+
194
+ sshotgun = SSHotgun.new(hostlist, MyProcessingClass)
195
+ sshotgun.start
196
+
197
+ Here's how to prompt for the sudo password (Don't hardcode a password into your
198
+ script). See the advanced example for details.
199
+
200
+ sshotgun.sudopassword = ask(">>> Enter your sudo password: ") { |q| q.echo = "*" } # or q.echo = false
201
+
202
+ Below is an advanced example that illustrates additional capabilities.
203
+
204
+ #!/usr/bin/env ruby
205
+
206
+ # Required libraries
207
+ require "highline/import"
208
+ require 'sshotgun'
209
+
210
+ # Create array to contain hosts for processing
211
+ hostlist = Array::new()
212
+ host = HostDefinition.new("someserver.junk", "test", "ubuntu_server_7.10", "test vm")
213
+ hostlist << host
214
+
215
+ # Import a list of host definitions
216
+ require 'listofhosts'
217
+ hostlist = hostlist + ListOfHosts.getHostlist
218
+
219
+ class MyProcessingClass < BaseProcessingClass
220
+
221
+ # You can add your own Ruby instance variables. These are useful if you
222
+ # want to share data across your own custom methods.
223
+ attr_accessor :myInstanceVariable
224
+
225
+ # You MUST define a doProcessing method. Note that to make the doProcessing
226
+ # method less cluttered, you can also create your own methods that are
227
+ # called from within the doProcessing method. You can use your own accessor
228
+ # variables (see above) for data sharing between methods. The doProcess method
229
+ # is the only method called by the SSHotgun framework for each host.
230
+ def doProcessing
231
+ # Log the beginning of this method. This marker is useful for debugging problems.
232
+ log "[" + @hostdef.hostname + "] info: " + "Processing started"
233
+
234
+ # Execute a command on all remote servers. Remote output is always written
235
+ # to this script's stdout.
236
+ # run <command>
237
+ run "ls -aF | grep -i .bash"
238
+
239
+ # The runstatus object is returned from all run and runSudo calls.
240
+ # runstatus contains information about exit status, stdout and stderr from
241
+ # the remote server.
242
+ runstatus = run "ls -aF"
243
+
244
+ # Use the log method to write to the log (the console). Don't use the Ruby
245
+ # 'puts' method to write to the console because it isn't threadsafe and may
246
+ # mix and garble simultaneous output by multiple threads.
247
+ log "[" + @hostdef.hostname + "] info: " + "The exit status of the run command is " + runstatus.exitstatus.to_s
248
+
249
+ # Remote stderr and stdout are captured and stored in the stdoutstr and
250
+ # stderrstr variables. Learn about Ruby's regular expressions for examining
251
+ # the output from a remote command.
252
+ runstatus = run "id rumplestiltskin"
253
+
254
+ # Check for "No such user" contained in stdoutstr. That's what is displayed
255
+ # when the unix "id" command can't find the specified user. Note that the
256
+ # the exit status of a failed 'id' command is not equal to zero either (see
257
+ # below). This is also a Ruby regex comparison.
258
+ if /No such user/ =~ runstatus.stdoutstr
259
+ log "I failed to find user rumplestiltskin on this server"
260
+ end
261
+
262
+ # In some cases a remote command fails and you must stop processing for
263
+ # that one server. Stop the execution of the doProcessing method by calling
264
+ # Ruby's "return" statement. Don't call Ruby's "exit" statement because
265
+ # that will halt all running threads.
266
+ # runstatus = run "id rumplestiltskin"
267
+ # if runstatus.exitstatus > 0
268
+ # log "The exit code was not 0, therefore I failed to find user rumplestiltskin"
269
+ # return # stop processing for this host
270
+ # end
271
+ # log "You'll never get here...unless you have a user named rumplestiltskin"
272
+
273
+ # The @hostdef instance variable contains the host definition for the
274
+ # current host being processed. If you set category, os and desc to
275
+ # meaningful information then you can do fancier branching logic in your
276
+ # script. The @hostdef.hostname is very useful in log statements. The other
277
+ # @hostdef fields are useful for treating a category or os differently than
278
+ # the others.
279
+ log "The current host is: " + @hostdef.hostname
280
+ log "The current host's category is: " + @hostdef.category
281
+ log "The current host's os is: " + @hostdef.os
282
+ log "The current host's description is: " + @hostdef.desc
283
+
284
+ # Target a specified host. This is a Ruby string comparison.
285
+ if @hostdef.hostname == "192.168.1.224"
286
+ log "[" + @hostdef.hostname + "] info: " + "Special processing for this host"
287
+ end
288
+
289
+ # If necessary, escape any quotes within the command. that must be passed
290
+ # through to the remote server. This isn't a great example but you'll need
291
+ # to do this for tricky shell scripting.
292
+ run "ls -aF | grep -i \".bash\""
293
+
294
+ # If you are running any runSudo commands then the SSHotgun.sudoPassword
295
+ # must be set - uncomment the user prompt and setting of the sudoPassword
296
+ # at the bottom of this script. Don't set up your servers to permit a
297
+ # password-less sudo - it is a security risk. Watch out, the "runSudo"
298
+ # command and Ruby in general, are case sensitive.
299
+ # runSudo <command>
300
+ # runSudo "touch /usr/local/bin/mytest.txt"
301
+ # run "ls -alF /usr/local/bin/"
302
+ # runSudo "rm /usr/local/bin/mytest.txt"
303
+
304
+ # Run a command locally (on your local machine). The runstatus variable is
305
+ # returned if you need the data - same as run or runSudo.
306
+ # runLocal <command>
307
+ # runLocal "ls -alF"
308
+
309
+ # Run a sudo command locally. The runstatus variable is returned if you
310
+ # need data from it. Set a sudoPassword if you are calling runLocalSudo.
311
+ # runLocalSudo <command>
312
+ # runLocalSudo "ls -alF"
313
+
314
+ # Create a remote file from a string parameter.
315
+ # createRemoteFile <content string>, <remotefile>
316
+ # createRemoteFile "this is your content here", "/home/vickp/testfile.txt"
317
+
318
+ # Create a remote file and set mode
319
+ # createRemoteFile <content string>, <remotefile>, <mode string>
320
+ # createRemoteFile "this is your content here", "/home/vickp/testfile.sh", "0755"
321
+
322
+ # Copy a remote file to the local machine. Note that you should vary the
323
+ # name of the incoming local file else it will be overwritten when each
324
+ # host's file is copied.
325
+ # copyRemoteFileToLocal <remotefile>, <localfile>
326
+ # copyRemoteFileToLocal "/home/vickp/testfile.txt", "/home/vickp/temp/" + @hostdef.hostname + "_testfile.txt"
327
+
328
+ # Copy a remote file to the local machine and set the mode of local file.
329
+ # copyRemoteFileToLocal <remotefile>, <localfile>, <mode string>
330
+ # copyRemoteFileToLocal "/home/vickp/testfile.txt", "/home/vickp/temp/" + @hostdef.hostname + "_testfile.txt", "0644"
331
+
332
+ # Copy a local file to the remote machine.
333
+ # copyRemoteFileToLocal <localfile>, <remotefile>
334
+ # copyLocalFileToRemote "/home/vickp/temp/testfile.txt", "/home/vickp/testfile2.txt"
335
+
336
+ # Copy a local file to the remote machine and set the mode of remote file.
337
+ # copyRemoteFileToLocal <localfile>, <remotefile>, <mode string>
338
+ # copyLocalFileToRemote "/home/vickp/temp/testfile.txt", "/home/vickp/testfile2.txt", "0644"
339
+
340
+ # Other helpful SSHotgun and Ruby tips below
341
+ # ===========================================
342
+
343
+ # Ruby allows you to concatenate multiple command lines
344
+ # Note use of shell command separator ';'
345
+ # cmd = "ls -alF;"
346
+ # cmd << "ls -alF | grep -i .bash;"
347
+ # cmd << "ls -alF | grep -i .profile"
348
+ # run cmd
349
+
350
+ # Delete a file on the remote server.
351
+ # Note use of -f with rm command
352
+ # run "rm -f /path/to/remote/file"
353
+
354
+ # Call your own methods to unclutter and better organize your doProcess
355
+ # method code. In most of my SSHotgun scripts the doProcess method is
356
+ # fairly sparse - most of the work is done is various custom methods that
357
+ # are called within the doProcess method
358
+ # myOwnMethod
359
+
360
+ # Sometimes I either install unsigned packages of my own (shell and ruby
361
+ # scripts) or need to force the installation of files (overwrite) that
362
+ # belong to another package. Aptitude doesn't yet support forcing yes for
363
+ # this sort of thing but apt-get does...
364
+ # runSudo "apt-get install -y --force-yes myUntrustedPackage -o DPkg::options::='--force-overwrite'"
365
+
366
+ # Ruby's ENV contains the current user (you)
367
+ currentUser = ENV["USER"].to_s
368
+
369
+ # For double quoted strings, Ruby will do inline substitution for a
370
+ # variable enbedded within "#{myvariablehere}". This is useful when you
371
+ # want a variable substituted inside of double quotes
372
+ # createRemoteFile "deb http://debrepo/global ./", "/home/#{currentUser}/sources.list", "0644"
373
+
374
+ # Create a global variable if you need to prompt for additional information
375
+ # (such as userid, date range, etc) and want to use that information in
376
+ # your doProcessing method. Global variables begin with '$'. See bottom of
377
+ # script where the variable is created. Note that "id" returns with an exit
378
+ # status of 1 if the user does not exist.
379
+ runstatus = run "id #{$userid}"
380
+ if runstatus.exitstatus == 0
381
+ log "[" + @hostdef.hostname + "] info: " + "Found user " + $userid
382
+ else
383
+ log "[" + @hostdef.hostname + "] info: " + "DID NOT find user " + $userid
384
+ end
385
+
386
+ # To pass escaped characters in strings, tell Ruby not to interpolate them
387
+ # by using single quotes or the %q() delimiter to enclose the escaped
388
+ # string. This means that Ruby will not attempt to resolve the escaped
389
+ # string but merely pass it on to the ssh client.
390
+ # run %q("mycommand \"some\ string\ with\ escapes\" more")
391
+
392
+ # Example of how to update a debian/ubuntu sources.list package repository file
393
+ # Also see use of case statement in the method body.
394
+ # installSourcesListAndUpdate
395
+
396
+ # Log the end of this method.
397
+ log "[" + @hostdef.hostname + "] info: " + "Processing completed"
398
+ end
399
+ end
400
+
401
+ # Your own custom methods can be called from within the doProcess method
402
+ def myOwnMethod
403
+ log "[" + @hostdef.hostname + "] info: " + "Calling my own method"
404
+ end
405
+
406
+ # Update a debian/ubuntu /etc/apt/sources.list
407
+ def installSourcesListAndUpdate
408
+ # WARNING: This only works if you fill in the category field in your hostdefs.
409
+ #
410
+ # Copy production sources.list into your home directory, then copy it into
411
+ # place. Note this is a two step operation because you can't create the
412
+ # remote file in a place where you don't have perms. The second step is to
413
+ # use sudo and copy the file into place.
414
+ s = ""
415
+ case @hostdef.category
416
+ when "production"
417
+ f = File.new("/usr/local/bin/adminscripts_templates/production_ubuntuserver710i386.sources.list")
418
+ when "devint"
419
+ f = File.new("/usr/local/bin/adminscripts_templates/devint_ubuntuserver710i386.sources.list")
420
+ when "qaint"
421
+ f = File.new("/usr/local/bin/adminscripts_templates/qaint_ubuntuserver710i386.sources.list")
422
+ when "staging"
423
+ f = File.new("/usr/local/bin/adminscripts_templates/staging_ubuntuserver710i386.sources.list")
424
+ else
425
+ log "[" + @hostdef.hostname + "] ERROR: " + "Unknown hostdef category = " + @hostdef.category
426
+ return
427
+ end
428
+ # read the file and concatenate it into a single string
429
+ f.each_line do |line|
430
+ s << line
431
+ end
432
+ createRemoteFile s, "/home/#{$currentUser}/sources.list", "0644"
433
+ runSudo "cp /home/#{$currentUser}/sources.list /etc/apt/sources.list"
434
+ run "rm /home/#{$currentUser}/sources.list"
435
+
436
+ # after changing sources.list, update package manager to refresh package list
437
+ runSudo "aptitude update"
438
+ end
439
+
440
+ # Create an instance of SSHotgun and pass it the list of hosts and your custom
441
+ # processing class.
442
+ sshotgun = SSHotgun.new(hostlist, MyProcessingClass)
443
+
444
+ # If you are calling a sudo command then prompt the user for the sudo password.
445
+ # I recommend that you NEVER HARDCODE A PASSWORD IN A SCRIPT! Configure the
446
+ # ask command to hide the password as the user types it. Note that the you
447
+ # must have the highline gem package installed for the "ask" command
448
+ #sshotgun.sudopassword = ask(">>> Enter your sudo password: ") { |q| q.echo = "*" } # or q.echo = false
449
+
450
+ # If you need to forward all calls via a gateway ssh server, set it here. Once
451
+ # set, all ssh connections go through the gateway machine
452
+ #sshotgun.gatewayhost = "someserver.somedomain.com"
453
+
454
+ # You can also use the 'ask' command to prompt the user for any other
455
+ # information. Configure the ask command to display the string as the user
456
+ # types it. Be sure to test password-less login from your gateway server to
457
+ # clear out any prompts asking you to verify the authenticity of the remote
458
+ # host.
459
+ #gatewayhost = ask(">>> Enter the hostname of the gateway server: ") { |q| q.echo = true }
460
+ #sshotgun.gatewayhost = gatewayhost
461
+
462
+ # Create and use a global variable to get additional data into your doProcessing method.
463
+ $userid = ask(">>> Enter the userid to find: ") { |q| q.echo = true }
464
+
465
+ # Number of simultaneous processing threads (hosts) to run. In terms of load to
466
+ # your local machine, each processing thread equates to about one ssh client
467
+ # running. Default is 50
468
+ # sshotgun.maxThreads = 30
469
+
470
+ # A processing thread will be killed after this amount of time. Default is 30 minutes.
471
+ # sshotgun.threadTimeoutInSeconds = 900
472
+
473
+ # Period for displaying status information about processing threads that
474
+ # are still running. Default is 30
475
+ # sshotgun.monitorPollIntervalInSeconds = 20
476
+
477
+ # Any output is also written to a file logger. You can disable the file logging
478
+ # by setting isFileLogging to false.
479
+ # sshotgun.isFileLogging = false.
480
+
481
+ # What should the behavior be when a command returns a non-zero exit status? A
482
+ # non-zero exit status usually indicates failure of the command. Some sshotgun
483
+ # users wish to immediately stop the processing for that server and stop it's
484
+ # thread. Other people will check the exit status after each command and decide
485
+ # whether to continue or return from the processing method. Note that if
486
+ # isStopOnNonZeroExit is true then the processing thread exits immediately and
487
+ # you never have the opportunity to check the exit status in your processing
488
+ # method code. The default is false (don't stop on non-zero exit).
489
+ # sshotgun.isStopOnNonZeroExit = true
490
+
491
+ # Start processing. The start call should be the very last line in a SSHotgun
492
+ # script.
493
+ sshotgun.start
494
+
495
+ == LICENSE:
496
+
497
+ (Simplified BSD License)
498
+
499
+ Copyright (c) 2008, Vick Perry
500
+ All rights reserved.
501
+
502
+ Redistribution and use in source and binary forms, with or without
503
+ modification, are permitted provided that the following conditions are met:
504
+
505
+ * Redistributions of source code must retain the above copyright notice, this
506
+ list of conditions and the following disclaimer.
507
+ * Redistributions in binary form must reproduce the above copyright notice,
508
+ this list of conditions and the following disclaimer in the documentation
509
+ and/or other materials provided with the distribution.
510
+ * Neither the name of the <ORGANIZATION> nor the names of its contributors may
511
+ be used to endorse or promote products derived from this software without
512
+ specific prior written permission.
513
+
514
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
515
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
516
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
517
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
518
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
519
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
520
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
521
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
522
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
523
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/lib/sshotgun.rb ADDED
@@ -0,0 +1,475 @@
1
+ #:title: SSHotgun
2
+ #Author:: Vick Perry (vick.perry @nospam@ gmail.com)
3
+ #Copyright:: Copyright (c) 2008, Vick Perry
4
+ #License:: Simplified BSD License.
5
+ #
6
+ require 'open4'
7
+ require 'logger'
8
+
9
+ # Class to contain information about each server
10
+ #
11
+ class HostDefinition
12
+ # hostname is the primary hostname or fqdn (NOT an alias). It is better to only refer to the primary DNS entry for a machine or VM.
13
+ attr_accessor :hostname
14
+
15
+ # category is a freeform identifier for the type of the host. e.g. "production", "devint", "qaint", "staging" (optional)
16
+ attr_accessor :category
17
+
18
+ # os is a freeform string indicating os name + server/workstation + version. e.g. ubuntu_server_7.10 (optional)
19
+ attr_accessor :os
20
+
21
+ # desc is a one sentence string describing main purpose of the host (optional)
22
+ attr_accessor :desc
23
+
24
+ # For internal use. True if processing was done for this server.
25
+ attr_accessor :startedProcess
26
+
27
+ # For internal use. True if processing was finished for this server. This means that doProcess exited
28
+ # normally with no exceptions.
29
+ attr_accessor :finishedProcess
30
+
31
+ # For internal use. True if one or more commands on a server returned with a non-zero exit status
32
+ attr_accessor :hadNonZeroExitStatus
33
+
34
+ def initialize(hostname, category = "", os = "", desc = "")
35
+ @hostname = hostname
36
+ @category = category
37
+ @os = os
38
+ @desc = desc
39
+ @startedProcess = false
40
+ @finishedProcess = false
41
+ @hadNonZeroExitStatus = false
42
+ end
43
+ end
44
+
45
+ # This class contains the returned information from commands such as run, runSudo, runLocal, runLocalSudo
46
+ #
47
+ class RunStatus
48
+ # The exit status of the command
49
+ attr_accessor :exitstatus
50
+
51
+ # The stdout stream data from the command stored in a string
52
+ attr_accessor :stdoutstr
53
+
54
+ # The stderr stream data from the command stored in a string
55
+ attr_accessor :stderrstr
56
+
57
+ def initialize
58
+ @exitstatus = 0
59
+ @stdoutstr = ""
60
+ @stderrstr = ""
61
+ end
62
+ end
63
+
64
+ # This is the base processing class. Your custom processing class MUST inherit from this base class.
65
+ #
66
+ class BaseProcessingClass
67
+ # A SSHotgun object
68
+ attr_accessor :sshotgun
69
+
70
+ # The current HostDefinition object (represents the remote server for processing)
71
+ attr_accessor :hostdef
72
+
73
+ # Default constructor. Don't create a constructor in your custom processing
74
+ # class so that this constructor will be called.
75
+ #
76
+ def initialize(sshotgun, hostdef)
77
+ @sshotgun = sshotgun
78
+ @hostdef = hostdef
79
+ end
80
+
81
+ # You must override the doProcessing method in your own custom processing class
82
+ #
83
+ def doProcessing
84
+ log "[" + @hostdef.hostname + "] info: " + "The doProcessing method must be created in your custom processing class"
85
+ end
86
+
87
+ # Run a command on a remote server
88
+ #
89
+ # cmd is a string containing the command to run on a remote server. e.g. "ls -alF"
90
+ #
91
+ # Returns a RunStatus object
92
+ #
93
+ def run(cmd)
94
+ log "[" + @hostdef.hostname + "] run: " + cmd
95
+ if @sshotgun.gatewayhost
96
+ cmdline = "ssh -A -t -x " + @sshotgun.gatewayhost + " 'ssh -A -x " + @hostdef.hostname + " \"" + cmd + "\"'"
97
+ else
98
+ cmdline = "ssh -A -t -x " + @hostdef.hostname + " \"" + cmd + "\""
99
+ end
100
+ runLocal cmdline, false
101
+ end
102
+
103
+ # Sudo a command on a remote server
104
+ #
105
+ # cmd is a string containing the sudo command to run on a remote server. e.g. "ls -alF"
106
+ #
107
+ # Returns a RunStatus object
108
+ #
109
+ def runSudo(cmd)
110
+ log "[" + @hostdef.hostname + "] runSudo: " + cmd
111
+ if @sshotgun.gatewayhost
112
+ cmdline = "ssh -A -t -x " + @sshotgun.gatewayhost + " 'ssh -A -x " + @hostdef.hostname + " sudo -S \"" + cmd + "\"'"
113
+ else
114
+ cmdline = "ssh -A -t -x " + @hostdef.hostname + " sudo -S \"" + cmd + "\""
115
+ end
116
+ runLocal cmdline, true
117
+ end
118
+
119
+ # Copy a remote file to local
120
+ #
121
+ # remotefile is the remote file to fetch. e.g. "/usr/local/bin/myscript.sh"
122
+ #
123
+ # localfile is the name of the destination local file. e.g. "/home/vickp/temp/myscript.sh"
124
+ #
125
+ # modestr is the string containing parameters for the chmod operation.
126
+ # The string must be in the NNNN or ugo format. e.g. "0755" or "u=rwx,g=rw,o=r"
127
+ #
128
+ def copyRemoteFileToLocal(remotefile, localfile, modestr = "0644")
129
+ log "[" + @hostdef.hostname + "] copyRemoteFileToLocal: " + @hostdef.hostname + ":" + remotefile + " " + localfile
130
+ if @sshotgun.gatewayhost
131
+ cmdline = "ssh -A -t -x " + @sshotgun.gatewayhost + " 'ssh -A -x " + @hostdef.hostname + " \"cat " + remotefile + " | gzip - \"' | gzip -d - > " + localfile + "; chmod " + modestr + " " + localfile
132
+ else
133
+ cmdline = "ssh -A -t -x " + @hostdef.hostname + " \"cat " + remotefile + " | gzip - \" | gzip -d - > " + localfile + "; chmod " + modestr + " " + localfile
134
+ end
135
+ runLocal cmdline, false
136
+ end
137
+
138
+ # Copy a local file to remote
139
+ #
140
+ # localfile is the local file to copy. e.g. "/home/vickp/temp/myscript.sh"
141
+ #
142
+ # remotefile is the name of the destination remote file. e.g. "/usr/local/bin/myscript.sh"
143
+ #
144
+ # modestr is the string containing parameters for the chmod operation.
145
+ # The string must be in the NNNN or ugo format. e.g. "0755" or "u=rwx,g=rw,o=r"
146
+ #
147
+ def copyLocalFileToRemote(localfile, remotefile, modestr = "0644")
148
+ log "[" + @hostdef.hostname + "] copyLocalFileToRemote: " + localfile + " " + @hostdef.hostname + ":" + remotefile
149
+ if @sshotgun.gatewayhost
150
+ cmdline = "cat " + localfile + " | gzip - | ssh -A -t -x " + @sshotgun.gatewayhost + " 'ssh -A -x " + @hostdef.hostname + " \"cat | gzip -d > " + remotefile + "; chmod " + modestr + " " + remotefile + "\"'"
151
+ else
152
+ cmdline = "cat " + localfile + " | gzip - | ssh -A -t -x " + @hostdef.hostname + " \"cat | gzip -d > " + remotefile + "; chmod " + modestr + " " + remotefile + "\""
153
+ end
154
+ runLocal cmdline, false
155
+ end
156
+
157
+ # Create a remote text file from a content string.
158
+ #
159
+ # contentstr is the content to write to the file.
160
+ #
161
+ # remotefile is the name of the destination remote file. e.g. "/usr/local/bin/myscript.sh"
162
+ #
163
+ # modestr is the string containing parameters for the chmod operation.
164
+ # The string must be in the NNNN or ugo format. e.g. "0755" or "u=rwx,g=rw,o=r"
165
+ #
166
+ def createRemoteFile(contentstr, remotefile, modestr = "0644")
167
+ log "[" + @hostdef.hostname + "] createRemoteFile: " + @hostdef.hostname + ":" + remotefile
168
+ if @sshotgun.gatewayhost
169
+ cmdline = "echo \"" + contentstr + "\" | gzip - | ssh -A -t -x " + @sshotgun.gatewayhost + " 'ssh -A -x " + @hostdef.hostname + " \"cat | gzip -d > " + remotefile + "; chmod " + modestr + " " + remotefile + "\"'"
170
+ else
171
+ cmdline = "echo \"" + contentstr + "\" | gzip - | ssh -A -t -x " + @hostdef.hostname + " \"cat | gzip -d > " + remotefile + "; chmod " + modestr + " " + remotefile + "\""
172
+ end
173
+ runLocal cmdline, false
174
+ end
175
+
176
+ # Execute a command locally without launching ssh. Note that this will be executed for EACH host.
177
+ #
178
+ # cmdline is the command line to run
179
+ #
180
+ # isSudo is the flag to launch as a sudo command
181
+ #
182
+ def runLocal (cmdline, isSudo=false)
183
+ #log "[" + @hostdef.hostname + "] debug: " + cmdline
184
+ currentThread = Thread.current
185
+ runstatus = RunStatus.new
186
+ retval = Open4::popen4( cmdline ) do |pid, stdin, stdout, stderr|
187
+ # isSudo and if sudo password is defined, insert the sudo password via stdin
188
+ if isSudo
189
+ if @sshotgun.sudopassword
190
+ stdin.puts @sshotgun.sudopassword
191
+ end
192
+ end
193
+ runstatus.stdoutstr = stdout.read.strip
194
+ runstatus.stdoutstr.each do |line|
195
+ log "[" + @hostdef.hostname + "] stdout: " + line
196
+ end
197
+ runstatus.stderrstr = stderr.read.strip
198
+ runstatus.stderrstr.each do |line|
199
+ log "[" + @hostdef.hostname + "] stderr: " + line
200
+ end
201
+ end
202
+ runstatus.exitstatus = retval.exitstatus
203
+
204
+ if runstatus.exitstatus != 0
205
+ # set flag to indicate a non-zero exit status
206
+ # this flag is needed by the status report at the end of the script run
207
+ @hostdef.hadNonZeroExitStatus = true
208
+
209
+ # am I supposed to stop thread?
210
+ if @sshotgun.isStopOnNonZeroExit
211
+ log "[" + @hostdef.hostname + "] stderr: Non-zero exit status - Stop processing for this server (" + runstatus.exitstatus.to_s + ")"
212
+ currentThread.exit
213
+ else
214
+ # did not exit thread so just log that a non-zero exit occured
215
+ log "[" + @hostdef.hostname + "] stderr: Non-zero exit status (" + runstatus.exitstatus.to_s + ")"
216
+ end
217
+ end
218
+
219
+ # return runstatus to caller
220
+ return runstatus
221
+ end
222
+
223
+ # Execute a sudo command locally without launching ssh. Note that this will be executed for EACH host.
224
+ #
225
+ # cmdline is the command line to run
226
+ #
227
+ def runLocalSudo (cmdline)
228
+ runLocal(cmdline, true)
229
+ end
230
+
231
+ # Log a string to the console.
232
+ #
233
+ # s is the string to log
234
+ #
235
+ def log(s)
236
+ @sshotgun.log s
237
+ end
238
+ end
239
+
240
+ # This is the main class of SSHotgun. This class stores the configuration and launches the processing threads.
241
+ #
242
+ class SSHotgun
243
+
244
+ # Array containing all of the HostDefinitions (hosts to process)
245
+ attr_accessor :hostlist
246
+
247
+ # Your custom processing class
248
+ attr_accessor :processingclass
249
+
250
+ # The sudo password, if needed. (optional)
251
+ attr_accessor :sudopassword
252
+
253
+ # The gateway host for use as an ssh relay. (optional)
254
+ attr_accessor :gatewayhost
255
+
256
+ # The maximum number of processing threads to run simultaneously. Each
257
+ # processing thread roughly equates to one ssh client running on your local
258
+ # machine. Default is 30. (optional)
259
+ attr_accessor :maxThreads
260
+
261
+ # Timeout for processing threads. Default is 30 minutes. (optional)
262
+ attr_accessor :threadTimeoutInSeconds
263
+
264
+ # Interval time to periodically display thread status information. Default is 30 sec. (optional)
265
+ attr_accessor :monitorPollIntervalInSeconds
266
+
267
+ # For internal use. Mutex for writing to the log
268
+ attr_reader :logmutex
269
+
270
+ # For internal use. Mutex for launching processing threads
271
+ attr_reader :threadStartMutex
272
+
273
+ # File logger. Set your own if desired. (optional)
274
+ attr_accessor :filelogger
275
+
276
+ # Turn on/off file logging. Default is on. (optional)
277
+ attr_accessor :isFileLogging
278
+
279
+ # For internal use. Script start time
280
+ attr_reader :startTime
281
+
282
+ # Turn on/off behavior where processing for a server is stopped if any
283
+ # command (run, runSudo, etc) returns with a non-zero exit code. Default is
284
+ # off (don't stop). Note that if isStopOnNonZeroExit is true then the
285
+ # processing thread exits immediately, you never have the opportunity to
286
+ # check the exit status in your processing code.
287
+ attr_accessor :isStopOnNonZeroExit
288
+
289
+ def initialize(hostlist, processingclass)
290
+ @hostlist = hostlist
291
+ @processingclass = processingclass
292
+ @logmutex = Mutex.new
293
+ @threadStartMutex = Mutex.new
294
+ @maxThreads = 30 # allow NN processing threads to run at any time
295
+ @threadTimeoutInSeconds = 1800 # timeout after 30 minutes
296
+ @monitorPollIntervalInSeconds = 30 # period for displaying thread monitor
297
+ timestr = Time.now.strftime("%Y%m%d%H%M%S")
298
+ logfilename = File.basename($0) + ".#{timestr}.log"
299
+ @filelogger = Logger.new(logfilename)
300
+ @isFileLogging = true
301
+ @isStopOnNonZeroExit = false
302
+ end
303
+
304
+ # Start the processing. In most cases, this call will be the last line in
305
+ # your SSHotgun processing script.
306
+ #
307
+ def start
308
+ # trap ctrl-c, show stats and exit
309
+ trap("INT") {
310
+ log "[_sshotgun_] info: Script terminated early by ctrl-c"
311
+ showRunStats
312
+ exit
313
+ }
314
+ @startTime = Time.now
315
+ log "[_sshotgun_] info: Started script " + $0 + " at " + @startTime.to_s
316
+
317
+ # Display configuration info
318
+ log "[_sshotgun_] info: User=" + ENV["USER"].to_s
319
+ log "[_sshotgun_] info: Gateway host=" + @gatewayhost.to_s
320
+ log "[_sshotgun_] info: Thread timeout=" + @threadTimeoutInSeconds.to_s + "s"
321
+ log "[_sshotgun_] info: Max concurrent threads=" + @maxThreads.to_s
322
+ log "[_sshotgun_] info: Monitor poll interval=" + @monitorPollIntervalInSeconds.to_s + "s"
323
+ log "[_sshotgun_] info: File logging is=" + @isFileLogging.to_s
324
+ log "[_sshotgun_] info: Will stop thread upon non-zero exit=" + @isStopOnNonZeroExit.to_s
325
+
326
+ hostarr = []
327
+ @hostlist.each do |hostdef|
328
+ hostarr << hostdef.hostname
329
+ end
330
+ log "[_sshotgun_] info: Host list=" + hostarr.join(", ")
331
+
332
+ currentHostIndex = 0
333
+ processingThreads = []
334
+ checkRunningThreadsCounter = 0
335
+ while true
336
+
337
+ # how many active threads now?
338
+ numThreadsAlive = 0
339
+ processingThreads.each { |aThread|
340
+ if aThread.alive?
341
+ numThreadsAlive += 1
342
+ end
343
+ }
344
+
345
+ # calc number of threads to launch with this
346
+ # pass through the loop
347
+ numThreadsToLaunch = @maxThreads - numThreadsAlive
348
+ numThreadsLeftToLaunch = @hostlist.length - currentHostIndex
349
+ if numThreadsToLaunch > numThreadsLeftToLaunch
350
+ numThreadsToLaunch = numThreadsLeftToLaunch
351
+ end
352
+
353
+ # from current index in hosts array loop and launch threads up to maxThreads running
354
+ i = 0
355
+ while i < numThreadsToLaunch do
356
+ # I hate to admit it but I don't know why i needed to synchronize this block
357
+ # Without it, some threads seemed to freeze at startup
358
+ @threadStartMutex.synchronize {
359
+ hostdef = @hostlist[currentHostIndex]
360
+ processingThreads << Thread.new {
361
+ aThread = Thread.current
362
+ aThread["threadstarttime"] = Time.now # store start time in threadlocal variable
363
+ aThread["hostname"] = hostdef.hostname # store hostname time in threadlocal variable
364
+ log "[_sshotgun_] info: Started thread for host = " + hostdef.hostname + " at " + aThread["threadstarttime"].to_s
365
+ begin
366
+ hostdef.startedProcess = true
367
+ o = @processingclass.new(self, hostdef)
368
+ o.doProcessing
369
+ hostdef.finishedProcess = true
370
+ rescue => ex
371
+ log "[_sshotgun_] stderr: Thread blew up for host = " + hostdef.hostname + ". ex = " + ex.inspect + "\n" + ex.backtrace.join("\n")
372
+ end
373
+ }
374
+
375
+ # bump loop counter
376
+ i += 1
377
+ currentHostIndex += 1
378
+ }
379
+ end
380
+
381
+ # kill expired threads
382
+ processingThreads.each { |aThread|
383
+ if aThread.alive?
384
+ # ensure that the threadlocal var is actually set before
385
+ # doing a calc with it. This is race condition where you
386
+ # get here so fast and threadstarttime is nil
387
+ if aThread["threadstarttime"]
388
+ if Time.now - aThread["threadstarttime"] > @threadTimeoutInSeconds
389
+ log "[_sshotgun_] info: Killed thread for host = " + aThread["hostname"]
390
+ aThread.kill
391
+ end
392
+ end
393
+ end
394
+ }
395
+
396
+ # display list of running threads as visual indicator to user
397
+ checkRunningThreadsCounter += 1
398
+ if checkRunningThreadsCounter > @monitorPollIntervalInSeconds
399
+ # display running threads
400
+ processingThreads.each { |aThread|
401
+ if aThread.alive?
402
+ log "[_sshotgun_] monitor: Thread still running for host = " + aThread["hostname"] + ". Elapsed time is " + (Time.now - aThread["threadstarttime"] ).to_s + "s"
403
+ end
404
+ }
405
+ checkRunningThreadsCounter = 0
406
+ end
407
+
408
+ # any processing threads still alive?
409
+ aThreadIsAlive = false
410
+ processingThreads.each { |aThread|
411
+ if aThread.alive?
412
+ aThreadIsAlive = true
413
+ break
414
+ end
415
+ }
416
+
417
+ # break out of this main loop if no processing threads are alive
418
+ if aThreadIsAlive
419
+ sleep 1
420
+ else
421
+ break
422
+ end
423
+ end
424
+
425
+ # ended normally, show stats
426
+ log "[_sshotgun_] info: Script terminated normally"
427
+ showRunStats
428
+ end
429
+
430
+ # Display these status at end of script or when control-c is pressed
431
+ def showRunStats
432
+ # Display script ending message
433
+ stopTime = Time.now
434
+ log "[_sshotgun_] info: Ended script " + $0 + " at " + stopTime.to_s
435
+ log "[_sshotgun_] info: Elapsed script " + $0 + " time " + (stopTime - @startTime).to_s + "s"
436
+
437
+ hostarr = []
438
+ @hostlist.each do |hostdef|
439
+ if hostdef.startedProcess == false
440
+ # did not start processing
441
+ hostarr << (hostdef.hostname + "(np)")
442
+ else
443
+ # started processing
444
+ if hostdef.hadNonZeroExitStatus
445
+ # one or more exit status were non-zero
446
+ hostarr << (hostdef.hostname + "(*)")
447
+ else
448
+ if hostdef.finishedProcess
449
+ # finished with no errors during processing
450
+ hostarr << hostdef.hostname
451
+ else
452
+ # Incomplete processing. This usually means that the method threw
453
+ # an exception
454
+ hostarr << (hostdef.hostname + "(i)")
455
+ end
456
+ end
457
+ end
458
+ end
459
+ log "[_sshotgun_] info: Status codes are '*'=had one|more non-zero exit status, 'i'=incomplete, 'np'=not started, blank=finished"
460
+ log "[_sshotgun_] info: Hosts summary = " + hostarr.join(", ")
461
+ end
462
+
463
+ # Log a string to the console and to the file logger
464
+ #
465
+ # s is the string to log
466
+ #
467
+ def log(s)
468
+ @logmutex.synchronize {
469
+ puts s.chomp
470
+ if @isFileLogging
471
+ @filelogger.info s.chomp
472
+ end
473
+ }
474
+ end
475
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sshotgun
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.4
5
+ platform: ruby
6
+ authors:
7
+ - Vick Perry
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-09-08 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: open4
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 0.9.6
23
+ version:
24
+ - !ruby/object:Gem::Dependency
25
+ name: highline
26
+ version_requirement:
27
+ version_requirements: !ruby/object:Gem::Requirement
28
+ requirements:
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 1.4.0
32
+ version:
33
+ description:
34
+ email: vick.perry @nospam@ gmail.com
35
+ executables: []
36
+
37
+ extensions: []
38
+
39
+ extra_rdoc_files:
40
+ - README.rdoc
41
+ files:
42
+ - lib/sshotgun.rb
43
+ - README.rdoc
44
+ has_rdoc: true
45
+ homepage: http://sshotgun.rubyforge.org
46
+ post_install_message:
47
+ rdoc_options:
48
+ - --line-numbers
49
+ - --inline-source
50
+ - --title
51
+ - sshotgun
52
+ - --main
53
+ - README.rdoc
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: "0"
61
+ version:
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: "0"
67
+ version:
68
+ requirements: []
69
+
70
+ rubyforge_project: sshotgun
71
+ rubygems_version: 1.1.1
72
+ signing_key:
73
+ specification_version: 2
74
+ summary: A lib for writing server management scripts in Ruby. Useful for managing lots of servers.
75
+ test_files: []
76
+