sshkit 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 +2 -0
- data/.travis.yml +5 -0
- data/.yardoc/checksums +13 -0
- data/.yardoc/object_types +0 -0
- data/.yardoc/objects/root.dat +0 -0
- data/.yardoc/proxy_types +0 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +0 -0
- data/EXAMPLES.md +167 -0
- data/FAQ.md +17 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +69 -0
- data/LICENSE.md +674 -0
- data/README.md +181 -0
- data/Rakefile +37 -0
- data/Vagrantfile +18 -0
- data/assets/images/logo.png +0 -0
- data/example.rb +70 -0
- data/lib/core_ext/array.rb +5 -0
- data/lib/core_ext/hash.rb +11 -0
- data/lib/sshkit/all.rb +13 -0
- data/lib/sshkit/backends/abstract.rb +83 -0
- data/lib/sshkit/backends/netssh.rb +82 -0
- data/lib/sshkit/backends/printer.rb +28 -0
- data/lib/sshkit/command.rb +153 -0
- data/lib/sshkit/configuration.rb +29 -0
- data/lib/sshkit/connection_manager.rb +93 -0
- data/lib/sshkit/dsl.rb +15 -0
- data/lib/sshkit/host.rb +151 -0
- data/lib/sshkit/version.rb +3 -0
- data/lib/sshkit.rb +27 -0
- data/sshkit.gemspec +34 -0
- data/test/functional/test_connection_manager.rb +17 -0
- data/test/functional/test_ssh_server_comes_up_for_functional_tests.rb +23 -0
- data/test/helper.rb +125 -0
- data/test/integration/backends/test_netssh.rb +99 -0
- data/test/unit/backends/test_netssh.rb +14 -0
- data/test/unit/backends/test_printer.rb +62 -0
- data/test/unit/core_ext/test_string.rb +12 -0
- data/test/unit/test_command.rb +113 -0
- data/test/unit/test_configuration.rb +47 -0
- data/test/unit/test_connection_manager.rb +78 -0
- data/test/unit/test_host.rb +65 -0
- metadata +301 -0
data/README.md
ADDED
@@ -0,0 +1,181 @@
|
|
1
|
+

|
2
|
+
|
3
|
+
|
4
|
+
**SSHKit** is a toolkit for running commands in a structured way on one or
|
5
|
+
more servers.
|
6
|
+
|
7
|
+
## How might it work?
|
8
|
+
|
9
|
+
The typical use-case looks something like this:
|
10
|
+
|
11
|
+
require 'sshkit/dsl'
|
12
|
+
|
13
|
+
on %w{1.example.com 2.example.com}, in: :sequence, wait: 5 do
|
14
|
+
within "/opt/sites/example.com" do
|
15
|
+
as :deploy do
|
16
|
+
with rails_env: :production do
|
17
|
+
rake "assets:precompile"
|
18
|
+
runner "S3::Sync.notify"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
One will notice that it's quite low level, but exposes a convenient API, the
|
25
|
+
`as()`/`within()`/`with()` are nestable in any order, repeatable, and stackable.
|
26
|
+
|
27
|
+
When used inside a block in this way, `as()` and `within()` will guard
|
28
|
+
the block they are given with a check.
|
29
|
+
|
30
|
+
In the case of `within()`, an error-raising check will be made that the directory
|
31
|
+
exists; for `as()` a simple call to `sudo su -<user> whoami` wrapped in a check for
|
32
|
+
success, raising an error if unsuccessful.
|
33
|
+
|
34
|
+
The directory check is implemented like this:
|
35
|
+
|
36
|
+
if test ! -d <directory>; then echo "Directory doesn't exist" 2>&1; false; fi
|
37
|
+
|
38
|
+
And the user switching test implemented like this:
|
39
|
+
|
40
|
+
if ! sudo su <user> -c whoami > /dev/null; then echo "Can't switch user" 2>&1; false; fi
|
41
|
+
|
42
|
+
According to the defaults, any command that exits with a status other than 0
|
43
|
+
raises an error (this can be changed). The body of the message is whatever was
|
44
|
+
written to *stdout* by the process. The `1>&2` redirects the standard output
|
45
|
+
of echo to the standard error channel, so that it's available as the body of
|
46
|
+
the raised error.
|
47
|
+
|
48
|
+
Helpers such as `runner()` and `rake()` which expand to `execute(:rails, "runner", ...)` and
|
49
|
+
`execute(:rake, ...)` are convenience helpers for Ruby, and Rails based apps.
|
50
|
+
|
51
|
+
## Parallel
|
52
|
+
|
53
|
+
Notice on the `on()` call the `in: :sequence` option, the following will do
|
54
|
+
what you might expect:
|
55
|
+
|
56
|
+
on(in: :parallel, limit: 2) { ...}
|
57
|
+
on(in: :sequence, wait: 5) { ... }
|
58
|
+
on(in: :groups, limit: 2, wait: 5) { ... }
|
59
|
+
|
60
|
+
The default is to run `in: :parallel` with no limit, if you have 400 servers,
|
61
|
+
this might be a problem, and you might better look at changing that to run in
|
62
|
+
groups, or sequence.
|
63
|
+
|
64
|
+
Groups were designed in this case to relieve problems (mass Git checkouts)
|
65
|
+
where you rely on a contested resource that you don't want to DDOS by hitting
|
66
|
+
it too hard.
|
67
|
+
|
68
|
+
Sequential runs were intended to be used for rolling restarts, amongst other
|
69
|
+
similar use-cases.
|
70
|
+
|
71
|
+
## Synchronisation
|
72
|
+
|
73
|
+
The `on()` block is the unit of synchronisation, one `on()` block will wait
|
74
|
+
for all servers to complete before it returns.
|
75
|
+
|
76
|
+
For example:
|
77
|
+
|
78
|
+
all_servers = %w{one.example.com two.example.com three.example.com}
|
79
|
+
site_dir = '/opt/sites/example.com'
|
80
|
+
|
81
|
+
# Let's simulate a backup task, assuming that some servers take longer
|
82
|
+
# then others to complete
|
83
|
+
on servers do |host|
|
84
|
+
in site_dir do
|
85
|
+
execute :tar, '-czf', "backup-#{host.hostname}.tar.gz", 'current'
|
86
|
+
# Will run: "/usr/bin/env tar -czf backup-one.example.com.tar.gz current"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Now we can do something with those backups, safe in the knowledge that
|
91
|
+
# they will all exist (all tar commands exited with a success status, or
|
92
|
+
# that we will have raised an exception if one of them failed.
|
93
|
+
on servers do |host|
|
94
|
+
in site_dir do
|
95
|
+
backup_filename = "backup-#{host.hostname}.tar.gz"
|
96
|
+
target_filename = "backups/#{Time.now.utc.iso8601}/#{host.hostname}.tar.gz"
|
97
|
+
puts capture(:s3cmd, 'put', backup_filename, target_filename)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
## The Command Map
|
102
|
+
|
103
|
+
It's often a problem that programatic SSH sessions don't share the same environmental
|
104
|
+
variables as sessions that are started interactively.
|
105
|
+
|
106
|
+
This problem often comes when calling out to executables, expected to be on
|
107
|
+
the `$PATH` which, under conditions without dotfiles or other environmental
|
108
|
+
configuration are not where they are expected to be.
|
109
|
+
|
110
|
+
To try and solve this there is the `with()` helper which takes a hash of variables and makes them
|
111
|
+
available to the environment.
|
112
|
+
|
113
|
+
with path: '/usr/local/bin/rbenv/shims:$PATH' do
|
114
|
+
execute :ruby, '--version'
|
115
|
+
end
|
116
|
+
|
117
|
+
Will execute:
|
118
|
+
|
119
|
+
( PATH=/usr/local/bin/rbenv/shims:$PATH /usr/bin/env ruby --version )
|
120
|
+
|
121
|
+
**Often more preferable is to use the *command map*.**
|
122
|
+
|
123
|
+
The *command map* is used by default when instantiating a *Command* object
|
124
|
+
|
125
|
+
The *command map* exists on the configuration object, and in principle is
|
126
|
+
quite simple, it's a *Hash* structure with a default key factory block
|
127
|
+
specified, for example:
|
128
|
+
|
129
|
+
puts SSHKit.config.command_map[:ruby]
|
130
|
+
# => /usr/bin/env ruby
|
131
|
+
|
132
|
+
The `/usr/bin/env` prefix is applied to all commands, to make clear that the
|
133
|
+
environment is being deferred to to make the decision, this is what happens
|
134
|
+
anyway when one would simply attempt to execute `ruby`, however by making it
|
135
|
+
explicit, it was hoped that it might lead people to explore the documentation.
|
136
|
+
|
137
|
+
One can override the hash map for individual commands:
|
138
|
+
|
139
|
+
SSHKit.config.command_map[:rake] = "/usr/local/rbenv/shims/rake"
|
140
|
+
puts SSHKit.config.command_map[:rake]
|
141
|
+
# => /usr/local/rbenv/shims/rake
|
142
|
+
|
143
|
+
One can also override the command map completely, this may not be wise, but it
|
144
|
+
would be possible, for example:
|
145
|
+
|
146
|
+
SSHKit.config.command_map = Hash.new do |hash, command|
|
147
|
+
hash[command] = "/usr/local/rbenv/shims/#{command}"
|
148
|
+
end
|
149
|
+
|
150
|
+
This would effectively make it impossible to call any commands which didn't
|
151
|
+
provide an executable in that directory, but in some cases that might be
|
152
|
+
desirable.
|
153
|
+
|
154
|
+
*Note:* All keys should be symbolised, as the *Command* object will symbolize it's
|
155
|
+
first argument before attempting to find it in the *command map*.
|
156
|
+
|
157
|
+
## Output Handling
|
158
|
+
|
159
|
+
The output handling comprises two objects, first is the output itself, by
|
160
|
+
default this is *$stdout*, but can be any object responding to a
|
161
|
+
*StringIO*-like interface. The second part is the *formatter*.
|
162
|
+
|
163
|
+
The *formatter* and *output* have a strange relationship:
|
164
|
+
|
165
|
+
SSHKit.config.output = SSHKit.config.formatter.new($stdout)
|
166
|
+
|
167
|
+
The *formatter* will typically delegate all calls to the *output*, depending
|
168
|
+
on it's implementation it will almost certainly override the implementation of
|
169
|
+
`write()` (alias `<<()`) and query the objects it receives to determine what
|
170
|
+
should be printed.
|
171
|
+
|
172
|
+
|
173
|
+
## Known Issues
|
174
|
+
|
175
|
+
* No handling of slow / timed out connections
|
176
|
+
* No handling ot slow / hung remote commands
|
177
|
+
* No built-in way to background() something (execute and background the
|
178
|
+
process)
|
179
|
+
* No environment handling
|
180
|
+
* No arbitrary `Host` properties
|
181
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
require "bundler/gem_tasks"
|
3
|
+
require 'debugger'
|
4
|
+
require 'rake/testtask'
|
5
|
+
|
6
|
+
namespace :test do
|
7
|
+
|
8
|
+
Rake::TestTask.new(:units) do |t|
|
9
|
+
t.libs << "test"
|
10
|
+
t.test_files = FileList['test/unit/**/test*.rb']
|
11
|
+
end
|
12
|
+
|
13
|
+
Rake::TestTask.new(:functional) do |t|
|
14
|
+
t.libs << "test"
|
15
|
+
t.test_files = FileList['test/functional/**/test*.rb']
|
16
|
+
end
|
17
|
+
|
18
|
+
Rake::TestTask.new(:integration) do |t|
|
19
|
+
t.libs << "test"
|
20
|
+
t.test_files = FileList['test/integration/**/test*.rb']
|
21
|
+
end
|
22
|
+
|
23
|
+
task :test do
|
24
|
+
Rake::Task['test:units'].execute
|
25
|
+
Rake::Task['test:integration'].execute
|
26
|
+
Rake::Task['test:functional'].execute unless ENV['TRAVIS']
|
27
|
+
end
|
28
|
+
|
29
|
+
task default: :test
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
task :default => 'test:default'
|
34
|
+
|
35
|
+
Rake::Task["test:functional"].enhance do
|
36
|
+
warn "Remember there are still some VMs running, kill them with `vagrant halt` if you are finished using them."
|
37
|
+
end
|
data/Vagrantfile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# -*- mode: ruby -*-
|
2
|
+
# vi: set ft=ruby :
|
3
|
+
|
4
|
+
Vagrant::Config.run do |config|
|
5
|
+
|
6
|
+
config.vm.define :one do |one|
|
7
|
+
one.vm.box = "lucid32"
|
8
|
+
end
|
9
|
+
|
10
|
+
config.vm.define :two do |one|
|
11
|
+
one.vm.box = "lucid32"
|
12
|
+
end
|
13
|
+
|
14
|
+
config.vm.define :three do |one|
|
15
|
+
one.vm.box = "lucid32"
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
Binary file
|
data/example.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
# Ruby 1.9 doesn't include the current
|
4
|
+
# working directory on the load path.
|
5
|
+
$: << Dir.pwd + '/lib/'
|
6
|
+
|
7
|
+
# Automatically sucks in the `sshkit`
|
8
|
+
# files so that you don't need to.
|
9
|
+
require 'sshkit/dsl'
|
10
|
+
require 'forwardable'
|
11
|
+
require 'term/ansicolor'
|
12
|
+
|
13
|
+
directory = '/opt/sites/web_application'
|
14
|
+
hosts = SSHKit::Host.new("root@example.com")
|
15
|
+
|
16
|
+
#
|
17
|
+
# Custom output formatter!
|
18
|
+
#
|
19
|
+
class ColorizedFormatter < StringIO
|
20
|
+
extend Forwardable
|
21
|
+
attr_reader :original_output
|
22
|
+
def_delegators :@original_output, :read, :rewind
|
23
|
+
|
24
|
+
def initialize(oio)
|
25
|
+
@original_output = oio
|
26
|
+
end
|
27
|
+
|
28
|
+
def write(obj)
|
29
|
+
if obj.is_a? SSHKit::Command
|
30
|
+
unless obj.started?
|
31
|
+
original_output << "[#{c.green(obj.uuid)}] Running #{c.yellow(c.bold(String(obj)))} on #{c.yellow(obj.host.to_s)}\n"
|
32
|
+
end
|
33
|
+
if obj.complete? && !obj.stdout.empty?
|
34
|
+
obj.stdout.lines.each do |line|
|
35
|
+
original_output << c.green("\t" + line)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
if obj.complete? && !obj.stderr.empty?
|
39
|
+
obj.stderr.lines.each do |line|
|
40
|
+
original_output << c.red("\t" + line)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
if obj.finished?
|
44
|
+
original_output << "[#{c.green(obj.uuid)}] Finished in #{sprintf('%5.3f seconds', obj.runtime)} command #{c.bold { obj.failure? ? c.red('failed') : c.green('successful') }}.\n"
|
45
|
+
end
|
46
|
+
else
|
47
|
+
original_output << c.black(c.on_yellow("Output formatter doesn't know how to handle #{obj.inspect}\n"))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
private
|
51
|
+
def c
|
52
|
+
@c ||= Term::ANSIColor
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
SSHKit.config.output = ColorizedFormatter.new($stdout)
|
57
|
+
|
58
|
+
on hosts do |host|
|
59
|
+
target = '/opt/rack-rack-repository'
|
60
|
+
if host.hostname =~ /seven/
|
61
|
+
target = '/var/rack-rack-repository'
|
62
|
+
end
|
63
|
+
if execute(:test, "-d #{target}")
|
64
|
+
within target do
|
65
|
+
execute :git, :pull
|
66
|
+
end
|
67
|
+
else
|
68
|
+
execute :git, :clone, 'git://github.com/rack/rack.git', target
|
69
|
+
end
|
70
|
+
end
|
data/lib/sshkit/all.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require_relative '../core_ext/array'
|
2
|
+
require_relative '../core_ext/hash'
|
3
|
+
|
4
|
+
require_relative 'dsl'
|
5
|
+
require_relative 'host'
|
6
|
+
|
7
|
+
require_relative 'command'
|
8
|
+
require_relative 'configuration'
|
9
|
+
require_relative 'connection_manager'
|
10
|
+
|
11
|
+
require_relative 'backends/abstract'
|
12
|
+
require_relative 'backends/printer'
|
13
|
+
require_relative 'backends/netssh'
|
@@ -0,0 +1,83 @@
|
|
1
|
+
module SSHKit
|
2
|
+
module Backend
|
3
|
+
|
4
|
+
MethodUnavailableError = Class.new(RuntimeError)
|
5
|
+
|
6
|
+
class Abstract
|
7
|
+
|
8
|
+
attr_reader :host
|
9
|
+
|
10
|
+
def run
|
11
|
+
# Nothing to do
|
12
|
+
end
|
13
|
+
|
14
|
+
def initialize(host, &block)
|
15
|
+
raise "Must pass a Host object" unless host.is_a? Host
|
16
|
+
@host = host
|
17
|
+
@block = block
|
18
|
+
end
|
19
|
+
|
20
|
+
def make(commands=[])
|
21
|
+
raise MethodUnavailableError
|
22
|
+
end
|
23
|
+
|
24
|
+
def rake(commands=[])
|
25
|
+
raise MethodUnavailableError
|
26
|
+
end
|
27
|
+
|
28
|
+
def execute(command, args=[])
|
29
|
+
raise MethodUnavailableError
|
30
|
+
end
|
31
|
+
|
32
|
+
def capture(command, args=[])
|
33
|
+
raise MethodUnavailableError
|
34
|
+
end
|
35
|
+
|
36
|
+
def within(directory, &block)
|
37
|
+
(@pwd ||= []).push directory.to_s
|
38
|
+
execute <<-EOTEST
|
39
|
+
if test ! -d #{File.join(@pwd)}
|
40
|
+
then echo "Directory does not exist '#{File.join(@pwd)}'" 1>&2
|
41
|
+
false
|
42
|
+
fi
|
43
|
+
EOTEST
|
44
|
+
yield
|
45
|
+
ensure
|
46
|
+
@pwd.pop
|
47
|
+
end
|
48
|
+
|
49
|
+
def with(environment, &block)
|
50
|
+
@_env = (@env ||= {})
|
51
|
+
@env = @_env.merge environment
|
52
|
+
yield
|
53
|
+
ensure
|
54
|
+
@env = @_env
|
55
|
+
remove_instance_variable(:@_env)
|
56
|
+
end
|
57
|
+
|
58
|
+
def as(user, &block)
|
59
|
+
@user = user
|
60
|
+
execute <<-EOTEST
|
61
|
+
if ! sudo su #{user} -c whoami > /dev/null
|
62
|
+
then echo "You cannot switch to user '#{user}' using sudo, please check the sudoers file" 1>&2
|
63
|
+
false
|
64
|
+
fi
|
65
|
+
EOTEST
|
66
|
+
yield
|
67
|
+
ensure
|
68
|
+
remove_instance_variable(:@user)
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def command(*args)
|
74
|
+
SSHKit::Command.new(*args, in: @pwd.nil? ? nil : File.join(@pwd), env: @env, host: @host, user: @user)
|
75
|
+
end
|
76
|
+
|
77
|
+
def connection
|
78
|
+
raise "No Connection Pool Implementation"
|
79
|
+
end
|
80
|
+
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
|
3
|
+
module SSHKit
|
4
|
+
module Backend
|
5
|
+
|
6
|
+
class Netssh < Printer
|
7
|
+
|
8
|
+
include SSHKit::CommandHelper
|
9
|
+
|
10
|
+
def run
|
11
|
+
instance_exec(host, &@block)
|
12
|
+
end
|
13
|
+
|
14
|
+
def execute(*args)
|
15
|
+
_execute(*args).success?
|
16
|
+
end
|
17
|
+
|
18
|
+
def capture(*args)
|
19
|
+
_execute(*args).stdout.strip
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def _execute(*args)
|
25
|
+
command(*args).tap do |cmd|
|
26
|
+
output << cmd
|
27
|
+
cmd.started = true
|
28
|
+
ssh.open_channel do |chan|
|
29
|
+
chan.exec cmd.to_s do |ch, success|
|
30
|
+
chan.on_data do |ch, data|
|
31
|
+
cmd.stdout += data
|
32
|
+
output << cmd
|
33
|
+
end
|
34
|
+
chan.on_extended_data do |ch, type, data|
|
35
|
+
cmd.stderr += data
|
36
|
+
output << cmd
|
37
|
+
end
|
38
|
+
chan.on_request("exit-status") do |ch, data|
|
39
|
+
exit_status = data.read_long
|
40
|
+
cmd.exit_status = exit_status
|
41
|
+
output << cmd
|
42
|
+
end
|
43
|
+
#chan.on_request("exit-signal") do |ch, data|
|
44
|
+
# # TODO: This gets called if the program is killed by a signal
|
45
|
+
# # might also be a worthwhile thing to report
|
46
|
+
# exit_signal = data.read_string.to_i
|
47
|
+
# warn ">>> " + exit_signal.inspect
|
48
|
+
# output << cmd
|
49
|
+
#end
|
50
|
+
chan.on_open_failed do |ch|
|
51
|
+
# TODO: What do do here?
|
52
|
+
# I think we should raise something
|
53
|
+
end
|
54
|
+
chan.on_process do |ch|
|
55
|
+
# TODO: I don't know if this is useful
|
56
|
+
end
|
57
|
+
chan.on_eof do |ch|
|
58
|
+
# TODO: chan sends EOF before the exit status has been
|
59
|
+
# writtend
|
60
|
+
end
|
61
|
+
end
|
62
|
+
chan.wait
|
63
|
+
end
|
64
|
+
ssh.loop
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def ssh
|
69
|
+
@ssh ||= begin
|
70
|
+
Net::SSH.start(
|
71
|
+
host.hostname,
|
72
|
+
host.username,
|
73
|
+
port: host.port,
|
74
|
+
password: host.password,
|
75
|
+
)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module SSHKit
|
2
|
+
module Backend
|
3
|
+
|
4
|
+
class Printer < Abstract
|
5
|
+
|
6
|
+
include SSHKit::CommandHelper
|
7
|
+
|
8
|
+
def run
|
9
|
+
instance_exec(host, &@block)
|
10
|
+
end
|
11
|
+
|
12
|
+
def execute(*args)
|
13
|
+
output << command(*args)
|
14
|
+
end
|
15
|
+
|
16
|
+
def capture(command, args=[])
|
17
|
+
raise MethodUnavailableError
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def output
|
23
|
+
SSHKit.config.output
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,153 @@
|
|
1
|
+
require 'shellwords'
|
2
|
+
require 'digest/sha1'
|
3
|
+
require 'securerandom'
|
4
|
+
|
5
|
+
# @author Lee Hambley
|
6
|
+
module SSHKit
|
7
|
+
|
8
|
+
# @author Lee Hambley
|
9
|
+
module CommandHelper
|
10
|
+
|
11
|
+
def rake(tasks=[])
|
12
|
+
execute :rake, tasks
|
13
|
+
end
|
14
|
+
|
15
|
+
def make(tasks=[])
|
16
|
+
execute :make, tasks
|
17
|
+
end
|
18
|
+
|
19
|
+
def execute(command, args=[])
|
20
|
+
Command.new(command, args)
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def map(command)
|
26
|
+
SSHKit.config.command_map[command.to_sym]
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
# @author Lee Hambley
|
32
|
+
class Command
|
33
|
+
|
34
|
+
attr_reader :command, :args, :options, :started_at, :started, :exit_status
|
35
|
+
|
36
|
+
attr_accessor :stdout, :stderr
|
37
|
+
|
38
|
+
# Initialize a new Command object
|
39
|
+
#
|
40
|
+
# @param [Array] A list of arguments, the first is considered to be the
|
41
|
+
# command name, with optional variadaric args
|
42
|
+
# @return [Command] An un-started command object with no exit staus, and
|
43
|
+
# nothing in stdin or stdout
|
44
|
+
#
|
45
|
+
def initialize(*args)
|
46
|
+
raise ArgumentError, "May not pass no arguments to Command.new" if args.empty?
|
47
|
+
@options = args.extract_options!
|
48
|
+
@command = args.shift.to_s.strip.to_sym
|
49
|
+
@args = args
|
50
|
+
@options.symbolize_keys!
|
51
|
+
sanitize_command!
|
52
|
+
@stdout, @stderr = String.new, String.new
|
53
|
+
end
|
54
|
+
|
55
|
+
def complete?
|
56
|
+
!exit_status.nil?
|
57
|
+
end
|
58
|
+
alias :finished? :complete?
|
59
|
+
|
60
|
+
def started?
|
61
|
+
started
|
62
|
+
end
|
63
|
+
|
64
|
+
def started=(new_started)
|
65
|
+
@started_at = Time.now
|
66
|
+
@started = new_started
|
67
|
+
end
|
68
|
+
|
69
|
+
def uuid
|
70
|
+
@uuid ||= Digest::SHA1.hexdigest(SecureRandom.random_bytes(10))[0..7]
|
71
|
+
end
|
72
|
+
|
73
|
+
def success?
|
74
|
+
exit_status.nil? ? false : exit_status.to_i == 0
|
75
|
+
end
|
76
|
+
alias :successful? :success?
|
77
|
+
|
78
|
+
def failure?
|
79
|
+
exit_status.to_i > 0
|
80
|
+
end
|
81
|
+
alias :failed? :failure?
|
82
|
+
|
83
|
+
def exit_status=(new_exit_status)
|
84
|
+
@finished_at = Time.now
|
85
|
+
@exit_status = new_exit_status
|
86
|
+
end
|
87
|
+
|
88
|
+
def runtime
|
89
|
+
return nil unless complete?
|
90
|
+
@finished_at - @started_at
|
91
|
+
end
|
92
|
+
|
93
|
+
def to_hash
|
94
|
+
{
|
95
|
+
command: command,
|
96
|
+
args: args,
|
97
|
+
options: options,
|
98
|
+
exit_status: exit_status,
|
99
|
+
stdout: stdout,
|
100
|
+
stderr: stderr
|
101
|
+
}
|
102
|
+
end
|
103
|
+
|
104
|
+
def host
|
105
|
+
options[:host]
|
106
|
+
end
|
107
|
+
|
108
|
+
def to_s
|
109
|
+
return command.to_s if command.match /\s/
|
110
|
+
String.new.tap do |cs|
|
111
|
+
if options[:in]
|
112
|
+
cs << sprintf("cd %s && ", options[:in])
|
113
|
+
end
|
114
|
+
if options[:env]
|
115
|
+
cs << '( '
|
116
|
+
options[:env].each do |k,v|
|
117
|
+
cs << k.to_s.upcase
|
118
|
+
cs << "="
|
119
|
+
cs << v.to_s.shellescape
|
120
|
+
end
|
121
|
+
cs << ' '
|
122
|
+
end
|
123
|
+
if options[:user]
|
124
|
+
cs << "sudo su #{options[:user]} -c "
|
125
|
+
end
|
126
|
+
cs << SSHKit.config.command_map[command.to_sym]
|
127
|
+
if args.any?
|
128
|
+
cs << ' '
|
129
|
+
cs << args.join(' ')
|
130
|
+
end
|
131
|
+
if options[:env]
|
132
|
+
cs << ' )'
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
def sanitize_command!
|
140
|
+
command.to_s.strip!
|
141
|
+
if command.to_s.match("\n")
|
142
|
+
@command = String.new.tap do |cs|
|
143
|
+
command.to_s.lines.each do |line|
|
144
|
+
cs << line.strip
|
145
|
+
cs << '; ' unless line == command.to_s.lines.to_a.last
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
end
|
152
|
+
|
153
|
+
end
|