sshkit 1.18.1 → 1.21.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/release-drafter.yml +17 -0
- data/.github/workflows/push.yml +12 -0
- data/.rubocop.yml +63 -0
- data/.rubocop_todo.yml +637 -0
- data/.travis.yml +3 -0
- data/CHANGELOG.md +1 -774
- data/Dangerfile +1 -1
- data/Gemfile +0 -7
- data/README.md +31 -14
- data/RELEASING.md +3 -4
- data/Rakefile +3 -6
- data/examples/simple_connection.rb +9 -0
- data/lib/sshkit/backends/abstract.rb +9 -6
- data/lib/sshkit/backends/connection_pool.rb +7 -3
- data/lib/sshkit/backends/netssh.rb +2 -1
- data/lib/sshkit/command.rb +18 -13
- data/lib/sshkit/host.rb +6 -4
- data/lib/sshkit/version.rb +1 -1
- data/sshkit.gemspec +3 -0
- data/test/functional/backends/test_local.rb +18 -0
- data/test/functional/backends/test_netssh.rb +8 -11
- data/test/helper.rb +1 -1
- data/test/support/vagrant_wrapper.rb +10 -1
- data/test/unit/backends/test_abstract.rb +12 -0
- data/test/unit/backends/test_netssh.rb +7 -0
- data/test/unit/formatters/test_pretty.rb +1 -1
- data/test/unit/test_command.rb +32 -7
- data/test/unit/test_host.rb +5 -0
- metadata +10 -4
data/Dangerfile
CHANGED
@@ -1 +1 @@
|
|
1
|
-
danger.import_dangerfile(github: "capistrano/danger")
|
1
|
+
danger.import_dangerfile(github: "capistrano/danger", branch: "no-changelog")
|
data/Gemfile
CHANGED
@@ -7,13 +7,6 @@ platforms :rbx do
|
|
7
7
|
gem 'json'
|
8
8
|
end
|
9
9
|
|
10
|
-
# Chandler requires Ruby >= 2.1.0, but depending on the Travis environment,
|
11
|
-
# we may not meet that requirement. Only include the chandler gem if the Ruby
|
12
|
-
# requirement is met. (Chandler is used only for `rake release`; see Rakefile.)
|
13
|
-
if Gem::Requirement.new('>= 2.1.0').satisfied_by?(Gem::Version.new(RUBY_VERSION))
|
14
|
-
gem 'chandler', '>= 0.1.1'
|
15
|
-
end
|
16
|
-
|
17
10
|
# public_suffix 3+ requires ruby 2.1+
|
18
11
|
if Gem::Requirement.new('< 2.1').satisfied_by?(Gem::Version.new(RUBY_VERSION))
|
19
12
|
gem 'public_suffix', '< 3'
|
data/README.md
CHANGED
@@ -6,29 +6,31 @@ more servers.
|
|
6
6
|
[](https://rubygems.org/gems/sshkit)
|
7
7
|
[](https://travis-ci.org/capistrano/sshkit)
|
8
8
|
|
9
|
-
##
|
9
|
+
## Example
|
10
10
|
|
11
|
-
|
11
|
+
- Connect to 2 servers
|
12
|
+
- Execute commands as `deploy` user with `RAILS_ENV=production`
|
13
|
+
- Execute commands in serial (default is `:parallel`)
|
12
14
|
|
13
15
|
```ruby
|
14
16
|
require 'sshkit'
|
15
17
|
require 'sshkit/dsl'
|
16
18
|
include SSHKit::DSL
|
17
19
|
|
18
|
-
on
|
20
|
+
on ["1.example.com", "2.example.com"], in: :sequence do |host|
|
21
|
+
puts "Now executing on #{host}"
|
19
22
|
within "/opt/sites/example.com" do
|
20
23
|
as :deploy do
|
21
|
-
with
|
22
|
-
rake
|
23
|
-
runner "S3::Sync.notify"
|
24
|
-
execute :node, "socket_server.js"
|
24
|
+
with RAILS_ENV: 'production' do
|
25
|
+
execute :rake, "assets:precompile"
|
26
|
+
execute :rails, "runner", "S3::Sync.notify"
|
25
27
|
end
|
26
28
|
end
|
27
29
|
end
|
28
30
|
end
|
29
31
|
```
|
30
32
|
|
31
|
-
|
33
|
+
Many other examples are in [EXAMPLES.md](EXAMPLES.md).
|
32
34
|
|
33
35
|
## Basic usage
|
34
36
|
|
@@ -37,7 +39,7 @@ You can pass one or more hosts as parameters; this runs commands via SSH. Altern
|
|
37
39
|
pass `:local` to run commands locally. By default SSKit will run the commands on all hosts in
|
38
40
|
parallel.
|
39
41
|
|
40
|
-
|
42
|
+
### Running commands
|
41
43
|
|
42
44
|
All backends support the `execute(*args)`, `test(*args)` & `capture(*args)` methods
|
43
45
|
for executing a command. You can call any of these methods in the context of an `on()`
|
@@ -63,7 +65,7 @@ end
|
|
63
65
|
By default the `capture` methods strips whitespace. If you need to preserve whitespace
|
64
66
|
you can pass the `strip: false` option: `capture(:ls, '-l', strip: false)`
|
65
67
|
|
66
|
-
|
68
|
+
### Transferring files
|
67
69
|
|
68
70
|
All backends also support the `upload!` and `download!` methods for transferring files.
|
69
71
|
For the remote backend, the file is transferred with scp.
|
@@ -75,7 +77,7 @@ on '1.example.com' do
|
|
75
77
|
end
|
76
78
|
```
|
77
79
|
|
78
|
-
|
80
|
+
### Users, working directories, environment variables and umask
|
79
81
|
|
80
82
|
When running commands, you can tell SSHKit to set up the context for those
|
81
83
|
commands using the following methods:
|
@@ -113,6 +115,11 @@ the raised error.
|
|
113
115
|
Helpers such as `runner()` and `rake()` which expand to `execute(:rails, "runner", ...)` and
|
114
116
|
`execute(:rake, ...)` are convenience helpers for Ruby, and Rails based apps.
|
115
117
|
|
118
|
+
### Verbosity / Silence
|
119
|
+
|
120
|
+
- raise verbosity of a command: `execute "echo DEAD", verbosity: :ERROR`
|
121
|
+
- hide a command from output: `execute "echo HIDDEN", verbosity: :DEBUG`
|
122
|
+
|
116
123
|
## Parallel
|
117
124
|
|
118
125
|
Notice on the `on()` call the `in: :sequence` option, the following will do
|
@@ -567,10 +574,20 @@ In order to do special gymnastics with SSH, tunneling, aliasing, complex options
|
|
567
574
|
|
568
575
|
These system level files are the preferred way of setting up tunneling and proxies because the system implementations of these things are faster and better than the Ruby implementations you would get if you were to configure them through Net::SSH. In cases where it's not possible (Windows?), it should be possible to make use of the Net::SSH APIs to setup tunnels and proxy commands before deferring control to Capistrano/SSHKit..
|
569
576
|
|
570
|
-
##
|
577
|
+
## Proxying
|
571
578
|
|
572
|
-
|
579
|
+
To connect to the target host via a jump/bastion host, use a `Net::SSH::Proxy::Jump`
|
573
580
|
|
574
|
-
|
581
|
+
```ruby
|
582
|
+
host = SSHKit::Host.new(
|
583
|
+
hostname: 'target.host.com',
|
584
|
+
ssh_options: { proxy: Net::SSH::Proxy::Jump.new("proxy.bar.com") }
|
585
|
+
)
|
586
|
+
on [host] do
|
587
|
+
execute :echo, '1'
|
588
|
+
end
|
589
|
+
```
|
590
|
+
|
591
|
+
## SSHKit Related Blog Posts
|
575
592
|
|
576
593
|
[Embedded Capistrano with SSHKit](http://ryandoyle.net/posts/embedded-capistrano-with-sshkit/)
|
data/RELEASING.md
CHANGED
@@ -5,7 +5,6 @@
|
|
5
5
|
* You must have commit rights to the SSHKit repository.
|
6
6
|
* You must have push rights for the sshkit gem on rubygems.org.
|
7
7
|
* You must be using Ruby >= 2.1.0.
|
8
|
-
* Your `~/.netrc` must be configured with your GitHub credentials, [as explained here](https://github.com/mattbrictson/chandler#2-configure-netrc).
|
9
8
|
|
10
9
|
## How to release
|
11
10
|
|
@@ -13,6 +12,6 @@
|
|
13
12
|
2. **Ensure the tests are passing by running `rake test`.** If functional tests fail, ensure you have [Vagrant](https://www.vagrantup.com) installed and have started it with `vagrant up`.
|
14
13
|
3. Determine which would be the correct next version number according to [semver](http://semver.org/).
|
15
14
|
4. Update the version in `./lib/sshkit/version.rb`.
|
16
|
-
5.
|
17
|
-
6.
|
18
|
-
7.
|
15
|
+
5. Commit the `version.rb` change with a message like "Preparing vX.Y.Z"
|
16
|
+
6. Run `rake release`; this will tag, push to GitHub, and publish to rubygems.org
|
17
|
+
7. Update the draft release on the [GitHub releases page](https://github.com/capistrano/sshkit/releases) to point to the new tag and publish the release
|
data/Rakefile
CHANGED
@@ -30,10 +30,7 @@ RuboCop::RakeTask.new(:lint) do |task|
|
|
30
30
|
task.options = ['--lint']
|
31
31
|
end
|
32
32
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
# We assume that the person doing `rake release` has Ruby >= 2.1.
|
37
|
-
require "chandler/tasks"
|
38
|
-
Rake.application.invoke_task("chandler:push")
|
33
|
+
Rake::Task["release"].enhance do
|
34
|
+
puts "Don't forget to publish the release on GitHub!"
|
35
|
+
system "open https://github.com/capistrano/sshkit/releases"
|
39
36
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'shellwords'
|
2
|
+
|
1
3
|
module SSHKit
|
2
4
|
|
3
5
|
module Backend
|
@@ -80,13 +82,14 @@ module SSHKit
|
|
80
82
|
|
81
83
|
def within(directory, &_block)
|
82
84
|
(@pwd ||= []).push directory.to_s
|
85
|
+
escaped = Command.shellescape_except_tilde(pwd_path)
|
83
86
|
execute <<-EOTEST, verbosity: Logger::DEBUG
|
84
|
-
if test ! -d #{
|
85
|
-
then echo "Directory does not exist '#{
|
87
|
+
if test ! -d #{escaped}
|
88
|
+
then echo "Directory does not exist '#{escaped}'" 1>&2
|
86
89
|
false
|
87
90
|
fi
|
88
|
-
|
89
|
-
|
91
|
+
EOTEST
|
92
|
+
yield
|
90
93
|
ensure
|
91
94
|
@pwd.pop
|
92
95
|
end
|
@@ -108,8 +111,8 @@ module SSHKit
|
|
108
111
|
@group = nil
|
109
112
|
end
|
110
113
|
execute <<-EOTEST, verbosity: Logger::DEBUG
|
111
|
-
if ! sudo -u #{@user} whoami > /dev/null
|
112
|
-
then echo "You cannot switch to user '#{@user}' using sudo, please check the sudoers file" 1>&2
|
114
|
+
if ! sudo -u #{@user.to_s.shellescape} whoami > /dev/null
|
115
|
+
then echo "You cannot switch to user '#{@user.to_s.shellescape}' using sudo, please check the sudoers file" 1>&2
|
113
116
|
false
|
114
117
|
fi
|
115
118
|
EOTEST
|
@@ -21,7 +21,7 @@ end
|
|
21
21
|
|
22
22
|
# The ConnectionPool caches connections and allows them to be reused, so long as
|
23
23
|
# the reuse happens within the `idle_timeout` period. Timed out connections are
|
24
|
-
# closed, forcing a new connection to be used in that case.
|
24
|
+
# eventually closed, forcing a new connection to be used in that case.
|
25
25
|
#
|
26
26
|
# Additionally, a background thread is started to check for abandoned
|
27
27
|
# connections that have timed out without any attempt at being reused. These
|
@@ -46,7 +46,11 @@ class SSHKit::Backend::ConnectionPool
|
|
46
46
|
@caches = {}
|
47
47
|
@caches.extend(MonitorMixin)
|
48
48
|
@timed_out_connections = Queue.new
|
49
|
-
|
49
|
+
|
50
|
+
# Spin up eviction loop only if caching is enabled
|
51
|
+
if cache_enabled?
|
52
|
+
Thread.new { run_eviction_loop }
|
53
|
+
end
|
50
54
|
end
|
51
55
|
|
52
56
|
# Creates a new connection or reuses a cached connection (if possible) and
|
@@ -133,7 +137,7 @@ class SSHKit::Backend::ConnectionPool
|
|
133
137
|
process_deferred_close
|
134
138
|
|
135
139
|
# Periodically sweep all Caches to evict stale connections
|
136
|
-
sleep(
|
140
|
+
sleep(5)
|
137
141
|
caches.values.each(&:evict)
|
138
142
|
end
|
139
143
|
end
|
@@ -109,7 +109,8 @@ module SSHKit
|
|
109
109
|
message = "#{action} #{name} #{percentage.round(2)}%"
|
110
110
|
percentage_r = (percentage / log_percent).truncate * log_percent
|
111
111
|
if percentage_r > 0 && (last_name != name || last_percentage != percentage_r)
|
112
|
-
|
112
|
+
verbosity = (options[:verbosity] || :INFO).downcase # TODO: ideally reuse command.rb logic
|
113
|
+
public_send verbosity, message
|
113
114
|
last_name = name
|
114
115
|
last_percentage = percentage_r
|
115
116
|
else
|
data/lib/sshkit/command.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'digest/sha1'
|
2
2
|
require 'securerandom'
|
3
|
+
require 'shellwords'
|
3
4
|
|
4
5
|
# @author Lee Hambley
|
5
6
|
module SSHKit
|
@@ -9,7 +10,7 @@ module SSHKit
|
|
9
10
|
|
10
11
|
Failed = Class.new(SSHKit::StandardError)
|
11
12
|
|
12
|
-
attr_reader :command, :args, :options, :started_at, :started, :exit_status, :full_stdout, :full_stderr
|
13
|
+
attr_reader :command, :args, :options, :started_at, :started, :exit_status, :full_stdout, :full_stderr, :uuid
|
13
14
|
|
14
15
|
# Initialize a new Command object
|
15
16
|
#
|
@@ -25,6 +26,7 @@ module SSHKit
|
|
25
26
|
@args = args
|
26
27
|
@options.symbolize_keys!
|
27
28
|
@stdout, @stderr, @full_stdout, @full_stderr = String.new, String.new, String.new, String.new
|
29
|
+
@uuid = Digest::SHA1.hexdigest(SecureRandom.random_bytes(10))[0..7]
|
28
30
|
end
|
29
31
|
|
30
32
|
def complete?
|
@@ -41,10 +43,6 @@ module SSHKit
|
|
41
43
|
@started = new_started
|
42
44
|
end
|
43
45
|
|
44
|
-
def uuid
|
45
|
-
@uuid ||= Digest::SHA1.hexdigest(SecureRandom.random_bytes(10))[0..7]
|
46
|
-
end
|
47
|
-
|
48
46
|
def success?
|
49
47
|
exit_status.nil? ? false : exit_status.to_i == 0
|
50
48
|
end
|
@@ -145,7 +143,7 @@ module SSHKit
|
|
145
143
|
|
146
144
|
def within(&_block)
|
147
145
|
return yield unless options[:in]
|
148
|
-
|
146
|
+
"cd #{self.class.shellescape_except_tilde(options[:in])} && #{yield}"
|
149
147
|
end
|
150
148
|
|
151
149
|
def environment_hash
|
@@ -161,28 +159,30 @@ module SSHKit
|
|
161
159
|
end
|
162
160
|
|
163
161
|
def with(&_block)
|
164
|
-
|
165
|
-
|
162
|
+
env_string = environment_string
|
163
|
+
return yield if env_string.empty?
|
164
|
+
"( export #{env_string} ; #{yield} )"
|
166
165
|
end
|
167
166
|
|
168
167
|
def user(&_block)
|
169
168
|
return yield unless options[:user]
|
170
|
-
|
169
|
+
env_string = environment_string
|
170
|
+
"sudo -u #{options[:user].to_s.shellescape} #{env_string + " " unless env_string.empty?}-- sh -c #{yield.shellescape}"
|
171
171
|
end
|
172
172
|
|
173
173
|
def in_background(&_block)
|
174
174
|
return yield unless options[:run_in_background]
|
175
|
-
|
175
|
+
"( nohup #{yield} > /dev/null & )"
|
176
176
|
end
|
177
177
|
|
178
178
|
def umask(&_block)
|
179
179
|
return yield unless SSHKit.config.umask
|
180
|
-
|
180
|
+
"umask #{SSHKit.config.umask} && #{yield}"
|
181
181
|
end
|
182
182
|
|
183
183
|
def group(&_block)
|
184
184
|
return yield unless options[:group]
|
185
|
-
|
185
|
+
"sg #{options[:group].to_s.shellescape} -c #{yield.shellescape}"
|
186
186
|
# We could also use the so-called heredoc format perhaps:
|
187
187
|
#"newgrp #{options[:group]} <<EOC \\\"%s\\\" EOC" % %Q{#{yield}}
|
188
188
|
end
|
@@ -219,6 +219,11 @@ module SSHKit
|
|
219
219
|
end
|
220
220
|
end
|
221
221
|
|
222
|
+
# allow using home directory but escape everything else like spaces etc
|
223
|
+
def self.shellescape_except_tilde(file)
|
224
|
+
file.shellescape.gsub("\\~", "~")
|
225
|
+
end
|
226
|
+
|
222
227
|
private
|
223
228
|
|
224
229
|
def default_options
|
@@ -234,7 +239,7 @@ module SSHKit
|
|
234
239
|
|
235
240
|
def call_interaction_handler(stream_name, data, channel)
|
236
241
|
interaction_handler = options[:interaction_handler]
|
237
|
-
interaction_handler = MappingInteractionHandler.new(interaction_handler) if interaction_handler.kind_of?(Hash)
|
242
|
+
interaction_handler = MappingInteractionHandler.new(interaction_handler) if interaction_handler.kind_of?(Hash) or interaction_handler.kind_of?(Proc)
|
238
243
|
interaction_handler.on_data(self, stream_name, data, channel) if interaction_handler.respond_to?(:on_data)
|
239
244
|
end
|
240
245
|
|
data/lib/sshkit/host.rb
CHANGED
@@ -151,18 +151,20 @@ module SSHKit
|
|
151
151
|
# @private
|
152
152
|
# :nodoc:
|
153
153
|
class IPv6HostWithPortParser < SimpleHostParser
|
154
|
+
IPV6_REGEX = /\[([a-fA-F0-9:]+)\](?:\:(\d+))?/
|
154
155
|
|
155
156
|
def self.suitable?(host_string)
|
156
|
-
host_string.match(
|
157
|
+
host_string.match(IPV6_REGEX)
|
157
158
|
end
|
158
159
|
|
159
160
|
def port
|
160
|
-
@host_string.
|
161
|
+
prt = @host_string.match(IPV6_REGEX)[2]
|
162
|
+
prt = prt.to_i unless prt.nil?
|
163
|
+
prt
|
161
164
|
end
|
162
165
|
|
163
166
|
def hostname
|
164
|
-
@host_string.
|
165
|
-
@host_string.split(':')[0..-2].join(':')
|
167
|
+
@host_string.match(IPV6_REGEX)[1]
|
166
168
|
end
|
167
169
|
|
168
170
|
end
|
data/lib/sshkit/version.rb
CHANGED
data/sshkit.gemspec
CHANGED
@@ -9,6 +9,9 @@ Gem::Specification.new do |gem|
|
|
9
9
|
gem.description = %q{A comprehensive toolkit for remotely running commands in a structured manner on groups of servers.}
|
10
10
|
gem.homepage = "http://github.com/capistrano/sshkit"
|
11
11
|
gem.license = "MIT"
|
12
|
+
gem.metadata = {
|
13
|
+
"changelog_uri" => "https://github.com/capistrano/sshkit/releases"
|
14
|
+
}
|
12
15
|
|
13
16
|
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
14
17
|
gem.files = `git ls-files`.split("\n")
|
@@ -99,6 +99,24 @@ module SSHKit
|
|
99
99
|
end.run
|
100
100
|
assert_equal("Enter Data\nCaptured SOME DATA", captured_command_result)
|
101
101
|
end
|
102
|
+
|
103
|
+
def test_interaction_handler_with_proc
|
104
|
+
captured_command_result = nil
|
105
|
+
Local.new do
|
106
|
+
command = 'echo Enter Data; read the_data; echo Captured $the_data;'
|
107
|
+
captured_command_result = capture(command, interaction_handler:
|
108
|
+
lambda { |data|
|
109
|
+
case data
|
110
|
+
when "Enter Data\n"
|
111
|
+
"SOME DATA\n"
|
112
|
+
when "Captured SOME DATA\n"
|
113
|
+
nil
|
114
|
+
end
|
115
|
+
}
|
116
|
+
)
|
117
|
+
end.run
|
118
|
+
assert_equal("Enter Data\nCaptured SOME DATA", captured_command_result)
|
119
|
+
end
|
102
120
|
end
|
103
121
|
end
|
104
122
|
end
|
@@ -38,7 +38,7 @@ module SSHKit
|
|
38
38
|
"Command: /usr/bin/env ls -l\n",
|
39
39
|
"Command: if test ! -d /tmp; then echo \"Directory does not exist '/tmp'\" 1>&2; false; fi\n",
|
40
40
|
"Command: if ! sudo -u root whoami > /dev/null; then echo \"You cannot switch to user 'root' using sudo, please check the sudoers file\" 1>&2; false; fi\n",
|
41
|
-
"Command: cd /tmp && ( export RAILS_ENV=\"production\" ; sudo -u root RAILS_ENV=\"production\" -- sh -c
|
41
|
+
"Command: cd /tmp && ( export RAILS_ENV=\"production\" ; sudo -u root RAILS_ENV=\"production\" -- sh -c /usr/bin/env\\ touch\\ restart.txt )\n"
|
42
42
|
], command_lines
|
43
43
|
end
|
44
44
|
|
@@ -82,7 +82,7 @@ module SSHKit
|
|
82
82
|
command_lines = @output.lines.select { |line| line.start_with?('Command:') }
|
83
83
|
assert_equal [
|
84
84
|
"Command: if ! sudo -u root whoami > /dev/null; then echo \"You cannot switch to user 'root' using sudo, please check the sudoers file\" 1>&2; false; fi\n",
|
85
|
-
"Command: sudo -u root -- sh -c
|
85
|
+
"Command: sudo -u root -- sh -c sg\\ admin\\ -c\\ /usr/bin/env\\\\\\ touch\\\\\\ restart.txt\n"
|
86
86
|
], command_lines
|
87
87
|
end
|
88
88
|
|
@@ -96,21 +96,18 @@ module SSHKit
|
|
96
96
|
end
|
97
97
|
|
98
98
|
def test_ssh_option_merge
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
{ paranoid: true }
|
103
|
-
end
|
104
|
-
a_host.ssh_options = verify_host_opt
|
99
|
+
keepalive_opt = { keepalive: true }
|
100
|
+
test_host = a_host.dup
|
101
|
+
test_host.ssh_options = keepalive_opt
|
105
102
|
host_ssh_options = {}
|
106
103
|
SSHKit::Backend::Netssh.config.ssh_options = { forward_agent: false }
|
107
|
-
Netssh.new(
|
104
|
+
Netssh.new(test_host) do |host|
|
108
105
|
capture(:uname)
|
109
106
|
host_ssh_options = host.ssh_options
|
110
107
|
end.run
|
111
|
-
assert_equal [:forward_agent, *
|
108
|
+
assert_equal [:forward_agent, *keepalive_opt.keys, :known_hosts, :logger, :password_prompt].sort, host_ssh_options.keys.sort
|
112
109
|
assert_equal false, host_ssh_options[:forward_agent]
|
113
|
-
assert_equal
|
110
|
+
assert_equal keepalive_opt.values.first, host_ssh_options[keepalive_opt.keys.first]
|
114
111
|
assert_instance_of SSHKit::Backend::Netssh::KnownHosts, host_ssh_options[:known_hosts]
|
115
112
|
end
|
116
113
|
|