sshkit 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
![SSHKit Logo](/Users/leehambley/Projects/deployrb-deploy/assets/images/logo.png)
|
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
|