sshwrap 0.0.1
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.
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +36 -0
- data/Rakefile +8 -0
- data/TODO +11 -0
- data/bin/sshwrap +41 -0
- data/lib/sshwrap/main.rb +151 -0
- data/lib/sshwrap/version.rb +3 -0
- data/lib/sshwrap.rb +5 -0
- data/sshwrap.gemspec +19 -0
- data/test/test_options.rb +72 -0
- metadata +104 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Jason Heiss
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# SSHwrap
|
2
|
+
|
3
|
+
This provides a wrapper for ssh that execute a command on multiple machines,
|
4
|
+
optionally in parallel, and handles any sudo prompts that result.
|
5
|
+
|
6
|
+
## Installation
|
7
|
+
|
8
|
+
Add this line to your application's Gemfile:
|
9
|
+
|
10
|
+
gem 'sshwrap'
|
11
|
+
|
12
|
+
And then execute:
|
13
|
+
|
14
|
+
$ bundle
|
15
|
+
|
16
|
+
Or install it yourself as:
|
17
|
+
|
18
|
+
$ gem install sshwrap
|
19
|
+
|
20
|
+
## Usage
|
21
|
+
|
22
|
+
Usage: sshwrap [options]
|
23
|
+
-c, --command, --cmd=CMD Command to run
|
24
|
+
-u, --user=USER SSH as specified user
|
25
|
+
-k, --ssh-key=KEY Use the specified SSH private key
|
26
|
+
--abort-on-failure Abort if connection or command fails with any target
|
27
|
+
--max-workers=NUM Use specified number of parallel connections
|
28
|
+
--debug Enable debugging
|
29
|
+
|
30
|
+
## Contributing
|
31
|
+
|
32
|
+
1. Fork it
|
33
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
34
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
35
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
36
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/TODO
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
Options for displaying the output as the command runs on the various targets:
|
2
|
+
|
3
|
+
* Immediate (lines from various targets intermingled)
|
4
|
+
* Immediate, prefaced by hostname
|
5
|
+
* Captured (lines for a target displayed together once command completes)
|
6
|
+
|
7
|
+
Other ways of specifying targets:
|
8
|
+
|
9
|
+
* stdin
|
10
|
+
* groups, with user-supplied group expansion script
|
11
|
+
* read from file
|
data/bin/sshwrap
ADDED
@@ -0,0 +1,41 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'sshwrap'
|
4
|
+
require 'optparse'
|
5
|
+
|
6
|
+
@options = {}
|
7
|
+
@cmd = nil
|
8
|
+
|
9
|
+
opts = OptionParser.new(nil, 24, ' ')
|
10
|
+
opts.banner = 'Usage: sshwrap [options]'
|
11
|
+
opts.on('--command', '--cmd', '-c', '=CMD', 'Command to run') do |opt|
|
12
|
+
@cmd = opt
|
13
|
+
end
|
14
|
+
opts.on('--user', '-u', '=USER', 'SSH as specified user') do |opt|
|
15
|
+
@options[:user] = opt
|
16
|
+
end
|
17
|
+
opts.on('--ssh-key', '-k', '=KEY', 'Use the specified SSH private key') do |opt|
|
18
|
+
@options[:ssh_key] = opt
|
19
|
+
end
|
20
|
+
opts.on('--abort-on-failure', 'Abort if connection or command fails with any target') do |opt|
|
21
|
+
options[:abort_on_failure] = opt
|
22
|
+
end
|
23
|
+
opts.on('--max-workers', '=NUM', 'Use specified number of parallel connections') do |opt|
|
24
|
+
options[:max_workers] = opt
|
25
|
+
end
|
26
|
+
opts.on('--debug', 'Enable debugging') do |opt|
|
27
|
+
@options[:debug] = opt
|
28
|
+
end
|
29
|
+
|
30
|
+
leftovers = nil
|
31
|
+
begin
|
32
|
+
leftovers = opts.parse(ARGV)
|
33
|
+
rescue OptionParser::ParseError => e
|
34
|
+
$stderr.puts "Error parsing arguments, try --help"
|
35
|
+
$stderr.puts e.message
|
36
|
+
exit 1
|
37
|
+
end
|
38
|
+
@targets = leftovers
|
39
|
+
|
40
|
+
sshwrap = SSHwrap::Main.new(@options)
|
41
|
+
sshwrap.sshwrap(@cmd, @targets)
|
data/lib/sshwrap/main.rb
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
require 'highline/import'
|
3
|
+
|
4
|
+
class SSHwrap::Main
|
5
|
+
def initialize(options={})
|
6
|
+
@mutex = Mutex.new
|
7
|
+
@max_workers = options[:max_workers] || 1
|
8
|
+
@abort_on_failure = options[:abort_on_failure]
|
9
|
+
@user = options[:user] || Etc.getlogin
|
10
|
+
@ssh_key = options[:ssh_key]
|
11
|
+
@debug = options[:debug]
|
12
|
+
@password = nil
|
13
|
+
end
|
14
|
+
|
15
|
+
def get_password
|
16
|
+
@mutex.synchronize do
|
17
|
+
if !@password
|
18
|
+
@password = ask("Password for #{@user}: ") { |q| q.echo = "x" }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
@password
|
22
|
+
end
|
23
|
+
|
24
|
+
def ssh_execute(cmd, target)
|
25
|
+
exitstatus = nil
|
26
|
+
stdout = []
|
27
|
+
stderr = []
|
28
|
+
|
29
|
+
params = {}
|
30
|
+
if @ssh_key
|
31
|
+
params[:keys] = [@ssh_key]
|
32
|
+
end
|
33
|
+
using_password = false
|
34
|
+
if @password
|
35
|
+
using_password = true
|
36
|
+
params[:password] = @password
|
37
|
+
end
|
38
|
+
|
39
|
+
begin
|
40
|
+
Net::SSH.start(target, @user, params) do |ssh|
|
41
|
+
puts "Connecting to #{target}" if @debug
|
42
|
+
ch = ssh.open_channel do |channel|
|
43
|
+
# Now we request a "pty" (i.e. interactive) session so we can send
|
44
|
+
# data back and forth if needed. It WILL NOT WORK without this,
|
45
|
+
# and it has to be done before any call to exec.
|
46
|
+
channel.request_pty do |ch_pty, success|
|
47
|
+
if !success
|
48
|
+
raise "Could not obtain pty (interactive ssh session) on #{target}"
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
channel.exec(cmd) do |ch_exec, success|
|
53
|
+
puts "Executing '#{cmd}' on #{target}" if @debug
|
54
|
+
# 'success' isn't related to process exit codes or anything, but
|
55
|
+
# more about ssh internals. Not sure why it would fail at such
|
56
|
+
# a basic level, but it seems smart to do something about it.
|
57
|
+
if !success
|
58
|
+
raise "SSH unable to execute command on #{target}"
|
59
|
+
end
|
60
|
+
|
61
|
+
# on_data is a hook that fires when ssh returns output data. This
|
62
|
+
# is what we've been doing all this for; now we can check to see
|
63
|
+
# if it's a password prompt, and interactively return data if so
|
64
|
+
# (see request_pty above).
|
65
|
+
channel.on_data do |ch_data, data|
|
66
|
+
# This is the standard sudo password prompt
|
67
|
+
if data =~ /Password:/
|
68
|
+
channel.send_data "#{get_password}\n"
|
69
|
+
else
|
70
|
+
stdout << data unless (data.nil? or data.empty?)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
channel.on_extended_data do |ch_onextdata, type, data|
|
75
|
+
stderr << data unless (data.nil? or data.empty?)
|
76
|
+
end
|
77
|
+
|
78
|
+
channel.on_request "exit-status" do |ch_onreq, data|
|
79
|
+
exitstatus = data.read_long
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
ch.wait
|
84
|
+
ssh.loop
|
85
|
+
end
|
86
|
+
rescue Net::SSH::AuthenticationFailed
|
87
|
+
if !using_password
|
88
|
+
get_password
|
89
|
+
return ssh_execute(cmd, target)
|
90
|
+
else
|
91
|
+
stderr << "Authentication failed to #{target}"
|
92
|
+
end
|
93
|
+
rescue Exception => e
|
94
|
+
stderr << "SSH connection error: #{e.message}"
|
95
|
+
end
|
96
|
+
|
97
|
+
[exitstatus, stdout, stderr]
|
98
|
+
end
|
99
|
+
|
100
|
+
# cmd is a string or array of strings containing the command and arguments
|
101
|
+
# targets is an array of remote system hostnames
|
102
|
+
def sshwrap(cmd, targets)
|
103
|
+
cmdstring = nil
|
104
|
+
if cmd.kind_of?(Array)
|
105
|
+
cmdstring = cmd.join(' ')
|
106
|
+
else
|
107
|
+
cmdstring = cmd.to_s
|
108
|
+
end
|
109
|
+
|
110
|
+
statuses = {}
|
111
|
+
|
112
|
+
threads = (1..@max_workers).map do |i|
|
113
|
+
Thread.new("worker#{i}") do |tname|
|
114
|
+
while true
|
115
|
+
target = nil
|
116
|
+
@mutex.synchronize do
|
117
|
+
target = targets.shift
|
118
|
+
end
|
119
|
+
if !target
|
120
|
+
break
|
121
|
+
end
|
122
|
+
puts "Thread #{tname} processing target #{target}" if @debug
|
123
|
+
|
124
|
+
exitstatus, stdout, stderr = ssh_execute(cmdstring, target)
|
125
|
+
statuses[target] = exitstatus
|
126
|
+
|
127
|
+
@mutex.synchronize do
|
128
|
+
puts '=================================================='
|
129
|
+
if !stdout.empty?
|
130
|
+
puts "Output from #{target}:"
|
131
|
+
puts stdout.join
|
132
|
+
end
|
133
|
+
if !stderr.empty?
|
134
|
+
puts "Error from #{target}:"
|
135
|
+
puts stderr.join
|
136
|
+
end
|
137
|
+
puts "Exit status from #{target}: #{exitstatus}"
|
138
|
+
end
|
139
|
+
|
140
|
+
if @abort_on_failure && exitstatus != 0
|
141
|
+
exit exitstatus
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
threads.each(&:join)
|
148
|
+
|
149
|
+
statuses
|
150
|
+
end
|
151
|
+
end
|
data/lib/sshwrap.rb
ADDED
data/sshwrap.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
require File.expand_path('../lib/sshwrap/version', __FILE__)
|
3
|
+
|
4
|
+
Gem::Specification.new do |gem|
|
5
|
+
gem.authors = ['Jason Heiss']
|
6
|
+
gem.email = ['jheiss@aput.net']
|
7
|
+
gem.description = File.read('README.md')
|
8
|
+
gem.summary = 'Perform batch SSH operations, handling sudo prompts'
|
9
|
+
gem.homepage = 'https://github.com/jheiss/sshwrap'
|
10
|
+
|
11
|
+
gem.files = `git ls-files`.split($\)
|
12
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
13
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
14
|
+
gem.name = 'sshwrap'
|
15
|
+
gem.require_paths = ['lib']
|
16
|
+
gem.version = SSHwrap::VERSION
|
17
|
+
gem.add_dependency('net-ssh')
|
18
|
+
gem.add_dependency('highline')
|
19
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
#
|
2
|
+
# Test sshwrap command line options
|
3
|
+
#
|
4
|
+
|
5
|
+
require 'test/unit'
|
6
|
+
require 'open4'
|
7
|
+
require 'rbconfig'
|
8
|
+
|
9
|
+
RUBY = File.join(*RbConfig::CONFIG.values_at("bindir", "ruby_install_name")) +
|
10
|
+
RbConfig::CONFIG["EXEEXT"]
|
11
|
+
LIBDIR = File.join(File.dirname(File.dirname(__FILE__)), 'lib')
|
12
|
+
SSHWRAP = File.expand_path('../bin/sshwrap', File.dirname(__FILE__))
|
13
|
+
|
14
|
+
class SSHwrapOptionTests < Test::Unit::TestCase
|
15
|
+
def test_help
|
16
|
+
output = nil
|
17
|
+
IO.popen("#{RUBY} -I #{LIBDIR} #{SSHWRAP} --help") do |pipe|
|
18
|
+
output = pipe.readlines
|
19
|
+
end
|
20
|
+
# Make sure at least something resembling help output is there
|
21
|
+
assert(output.any? {|line| line.include?('Usage: sshwrap [options]')}, 'help output content')
|
22
|
+
# Make sure it fits on the screen
|
23
|
+
assert(output.all? {|line| line.length <= 80}, 'help output columns')
|
24
|
+
assert(output.size <= 23, 'help output lines')
|
25
|
+
end
|
26
|
+
|
27
|
+
def test_command_arg_required
|
28
|
+
end
|
29
|
+
def test_command
|
30
|
+
end
|
31
|
+
|
32
|
+
def test_user_arg_required
|
33
|
+
end
|
34
|
+
def test_user
|
35
|
+
end
|
36
|
+
|
37
|
+
def test_ssh_key_arg_required
|
38
|
+
output = nil
|
39
|
+
error = nil
|
40
|
+
status = Open4.popen4("#{RUBY} -I #{LIBDIR} #{SSHWRAP} --ssh-key") do |pid, stdin, stdout, stderr|
|
41
|
+
stdin.close
|
42
|
+
output = stdout.readlines
|
43
|
+
error = stderr.readlines
|
44
|
+
end
|
45
|
+
assert_equal(1, status.exitstatus, "-ssh-key arg required exitstatus")
|
46
|
+
# Make sure the expected lines are there
|
47
|
+
assert(error.any? {|line| line.include?('missing argument: --ssh-key')})
|
48
|
+
end
|
49
|
+
def test_ssh_key_bogus_file
|
50
|
+
# error = nil
|
51
|
+
# status = Open4.popen4("#{RUBY} -I #{LIBDIR} #{SSHWRAP} --ssh-key bogus") do |pid, stdin, stdout, stderr|
|
52
|
+
# stdin.close
|
53
|
+
# error = stderr.readlines
|
54
|
+
# end
|
55
|
+
# # Make sure the expected lines are there
|
56
|
+
# assert(error.any? {|line| line.include?('Unable to read ssh key from bogus')})
|
57
|
+
end
|
58
|
+
def test_ssh_key
|
59
|
+
end
|
60
|
+
|
61
|
+
def test_abort_on_failure
|
62
|
+
end
|
63
|
+
|
64
|
+
def test_max_workers_arg_required
|
65
|
+
end
|
66
|
+
def test_max_workers
|
67
|
+
end
|
68
|
+
|
69
|
+
def test_debug
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
metadata
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sshwrap
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Jason Heiss
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-09-23 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: net-ssh
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: highline
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ! '>='
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '0'
|
38
|
+
type: :runtime
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ! '>='
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
description: ! "# SSHwrap\n\nThis provides a wrapper for ssh that execute a command
|
47
|
+
on multiple machines,\noptionally in parallel, and handles any sudo prompts that
|
48
|
+
result.\n\n## Installation\n\nAdd this line to your application's Gemfile:\n\n gem
|
49
|
+
'sshwrap'\n\nAnd then execute:\n\n $ bundle\n\nOr install it yourself as:\n\n
|
50
|
+
\ $ gem install sshwrap\n\n## Usage\n\n Usage: sshwrap [options]\n -c,
|
51
|
+
--command, --cmd=CMD Command to run\n -u, --user=USER SSH as specified
|
52
|
+
user\n -k, --ssh-key=KEY Use the specified SSH private key\n --abort-on-failure
|
53
|
+
\ Abort if connection or command fails with any target\n --max-workers=NUM
|
54
|
+
\ Use specified number of parallel connections\n --debug Enable
|
55
|
+
debugging\n\n## Contributing\n\n1. Fork it\n2. Create your feature branch (`git
|
56
|
+
checkout -b my-new-feature`)\n3. Commit your changes (`git commit -am 'Added some
|
57
|
+
feature'`)\n4. Push to the branch (`git push origin my-new-feature`)\n5. Create
|
58
|
+
new Pull Request\n"
|
59
|
+
email:
|
60
|
+
- jheiss@aput.net
|
61
|
+
executables:
|
62
|
+
- sshwrap
|
63
|
+
extensions: []
|
64
|
+
extra_rdoc_files: []
|
65
|
+
files:
|
66
|
+
- .gitignore
|
67
|
+
- Gemfile
|
68
|
+
- LICENSE
|
69
|
+
- README.md
|
70
|
+
- Rakefile
|
71
|
+
- TODO
|
72
|
+
- bin/sshwrap
|
73
|
+
- lib/sshwrap.rb
|
74
|
+
- lib/sshwrap/main.rb
|
75
|
+
- lib/sshwrap/version.rb
|
76
|
+
- sshwrap.gemspec
|
77
|
+
- test/test_options.rb
|
78
|
+
homepage: https://github.com/jheiss/sshwrap
|
79
|
+
licenses: []
|
80
|
+
post_install_message:
|
81
|
+
rdoc_options: []
|
82
|
+
require_paths:
|
83
|
+
- lib
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
85
|
+
none: false
|
86
|
+
requirements:
|
87
|
+
- - ! '>='
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
91
|
+
none: false
|
92
|
+
requirements:
|
93
|
+
- - ! '>='
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: '0'
|
96
|
+
requirements: []
|
97
|
+
rubyforge_project:
|
98
|
+
rubygems_version: 1.8.24
|
99
|
+
signing_key:
|
100
|
+
specification_version: 3
|
101
|
+
summary: Perform batch SSH operations, handling sudo prompts
|
102
|
+
test_files:
|
103
|
+
- test/test_options.rb
|
104
|
+
has_rdoc:
|