nutshell 1.0.0
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/History.txt +5 -0
- data/Manifest.txt +12 -0
- data/README.txt +76 -0
- data/Rakefile +46 -0
- data/lib/nutshell.rb +42 -0
- data/lib/nutshell/remote_shell.rb +268 -0
- data/lib/nutshell/shell.rb +440 -0
- data/test/mocks/mock_object.rb +179 -0
- data/test/mocks/mock_open4.rb +117 -0
- data/test/test_helper.rb +108 -0
- data/test/test_remote_shell.rb +102 -0
- data/test/test_shell.rb +98 -0
- metadata +120 -0
data/History.txt
ADDED
data/Manifest.txt
ADDED
data/README.txt
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
= Nutshell
|
2
|
+
|
3
|
+
* http://github.com/yaksnrainbows/nutshell
|
4
|
+
|
5
|
+
== DESCRIPTION:
|
6
|
+
|
7
|
+
A light weight ssh client that wraps the ssh and rsync commands.
|
8
|
+
|
9
|
+
== SYNOPSIS:
|
10
|
+
|
11
|
+
remote = Nutshell::RemoteShell.new "user@example.com"
|
12
|
+
remote.connected?
|
13
|
+
#=> false
|
14
|
+
|
15
|
+
remote.connect
|
16
|
+
remote.connected?
|
17
|
+
#=> <#ssh pid>
|
18
|
+
|
19
|
+
remote.call "whoami"
|
20
|
+
#=> "user"
|
21
|
+
|
22
|
+
remote.call "whoami", :sudo => true
|
23
|
+
#=> "root"
|
24
|
+
|
25
|
+
remote.upload "myfile.txt", "/tmp/myfile.txt"
|
26
|
+
remote.disconnect
|
27
|
+
|
28
|
+
remote.session do |remote|
|
29
|
+
remote.connected?
|
30
|
+
#=> <#ssh pid>
|
31
|
+
|
32
|
+
remote.tty!
|
33
|
+
# starts an interactive shell session
|
34
|
+
end
|
35
|
+
|
36
|
+
remote.connected?
|
37
|
+
#=> false
|
38
|
+
|
39
|
+
|
40
|
+
== REQUIREMENTS:
|
41
|
+
|
42
|
+
* open4 gem
|
43
|
+
|
44
|
+
* highline gem
|
45
|
+
|
46
|
+
* Unix based OS
|
47
|
+
|
48
|
+
== INSTALL:
|
49
|
+
|
50
|
+
* gem install nutshell
|
51
|
+
|
52
|
+
== LICENSE:
|
53
|
+
|
54
|
+
(The MIT License)
|
55
|
+
|
56
|
+
Copyright (c) 2010 FIX
|
57
|
+
|
58
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
59
|
+
a copy of this software and associated documentation files (the
|
60
|
+
'Software'), to deal in the Software without restriction, including
|
61
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
62
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
63
|
+
permit persons to whom the Software is furnished to do so, subject to
|
64
|
+
the following conditions:
|
65
|
+
|
66
|
+
The above copyright notice and this permission notice shall be
|
67
|
+
included in all copies or substantial portions of the Software.
|
68
|
+
|
69
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
70
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
71
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
72
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
73
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
74
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
75
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
76
|
+
|
data/Rakefile
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
# -*- ruby -*-
|
2
|
+
require 'rubygems'
|
3
|
+
require 'hoe'
|
4
|
+
require 'rake'
|
5
|
+
require 'rake/testtask'
|
6
|
+
|
7
|
+
task :manifest do
|
8
|
+
manifest_file = "Manifest.txt"
|
9
|
+
|
10
|
+
gem_files = record_files do |f|
|
11
|
+
next if f =~ /^(tmp|pkg|deploy_scripts)/
|
12
|
+
puts(f)
|
13
|
+
true
|
14
|
+
end
|
15
|
+
|
16
|
+
gem_files.push(manifest_file)
|
17
|
+
gem_files = gem_files.uniq.sort.join("\n")
|
18
|
+
|
19
|
+
File.open(manifest_file, "w+") do |file|
|
20
|
+
file.write gem_files
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def record_files(path="*", file_arr=[], &block)
|
25
|
+
Dir[path].each do |child_path|
|
26
|
+
if File.file?(child_path)
|
27
|
+
next if block_given? && !yield(child_path)
|
28
|
+
file_arr << child_path
|
29
|
+
end
|
30
|
+
record_files(child_path+"/*", file_arr, &block) if
|
31
|
+
File.directory?(child_path)
|
32
|
+
end
|
33
|
+
return file_arr
|
34
|
+
end
|
35
|
+
|
36
|
+
Hoe.plugin :isolate
|
37
|
+
|
38
|
+
Hoe.spec 'nutshell' do |p|
|
39
|
+
developer('Jeremie Castagna', 'yaksnrainbows@gmail.com')
|
40
|
+
self.extra_deps << ['open4', '>= 1.0.1']
|
41
|
+
self.extra_deps << ['highline', '>= 1.5.1']
|
42
|
+
end
|
43
|
+
|
44
|
+
# vim: syntax=Ruby
|
45
|
+
|
46
|
+
|
data/lib/nutshell.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'open4'
|
3
|
+
require 'highline'
|
4
|
+
|
5
|
+
require 'fileutils'
|
6
|
+
require 'tmpdir'
|
7
|
+
|
8
|
+
|
9
|
+
module Nutshell
|
10
|
+
|
11
|
+
VERSION = "1.0.0"
|
12
|
+
|
13
|
+
##
|
14
|
+
# Temp directory used by various nutshell classes
|
15
|
+
# for uploads, checkouts, etc...
|
16
|
+
TMP_DIR = File.join Dir.tmpdir, "nutshell_#{$$}"
|
17
|
+
FileUtils.mkdir_p TMP_DIR
|
18
|
+
|
19
|
+
|
20
|
+
class TimeoutError < Exception; end
|
21
|
+
class CmdError < Exception; end
|
22
|
+
class ConnectionError < Exception; end
|
23
|
+
|
24
|
+
|
25
|
+
def self.config
|
26
|
+
@config ||= {}
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
def self.timeout
|
31
|
+
config[:timeout]
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
def self.interactive?
|
36
|
+
config[:interactive]
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
require 'nutshell/shell'
|
41
|
+
require 'nutshell/remote_shell'
|
42
|
+
end
|
@@ -0,0 +1,268 @@
|
|
1
|
+
module Nutshell
|
2
|
+
|
3
|
+
##
|
4
|
+
# Keeps an SSH connection open to a server the app will be deployed to.
|
5
|
+
# Deploy servers use the ssh command and support any ssh feature.
|
6
|
+
# By default, deploy servers use the ControlMaster feature to share
|
7
|
+
# socket connections, with the ControlPath = ~/.ssh/nutshell-%r%h:%p
|
8
|
+
#
|
9
|
+
# Setting session-persistant environment variables is supported by
|
10
|
+
# accessing the @env attribute.
|
11
|
+
|
12
|
+
class RemoteShell < Shell
|
13
|
+
|
14
|
+
|
15
|
+
##
|
16
|
+
# The loop to keep the ssh connection open.
|
17
|
+
LOGIN_LOOP = "echo ok; echo ready; "+
|
18
|
+
"for (( ; ; )); do kill -0 $PPID && sleep 10 || exit; done;"
|
19
|
+
|
20
|
+
LOGIN_TIMEOUT = 30
|
21
|
+
|
22
|
+
|
23
|
+
##
|
24
|
+
# Closes all remote shell connections.
|
25
|
+
|
26
|
+
def self.disconnect_all
|
27
|
+
return unless defined?(@remote_shells)
|
28
|
+
@remote_shells.each{|rs| rs.disconnect}
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
##
|
33
|
+
# Registers a remote shell for global access from the class.
|
34
|
+
# Handled automatically on initialization.
|
35
|
+
|
36
|
+
def self.register remote_shell
|
37
|
+
(@remote_shells ||= []) << remote_shell
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
attr_reader :host, :user, :pid
|
42
|
+
attr_accessor :ssh_flags, :rsync_flags
|
43
|
+
|
44
|
+
|
45
|
+
##
|
46
|
+
# Remote shells essentially need a host and optional user.
|
47
|
+
# Typical instantiation is done through either of these methods:
|
48
|
+
# RemoteShell.new "user@host"
|
49
|
+
# RemoteShell.new "host", :user => "user"
|
50
|
+
#
|
51
|
+
# The constructor also supports the following options:
|
52
|
+
# :env:: hash - hash of environment variables to set for the ssh session
|
53
|
+
# :password:: string - password for ssh login; if missing the deploy server
|
54
|
+
# will attempt to prompt the user for a password.
|
55
|
+
|
56
|
+
def initialize host, options={}
|
57
|
+
super $stdout, options
|
58
|
+
|
59
|
+
@host, @user = host.split("@").reverse
|
60
|
+
|
61
|
+
@user ||= options[:user]
|
62
|
+
|
63
|
+
@rsync_flags = ["-azP"]
|
64
|
+
@rsync_flags.concat [*options[:rsync_flags]] if options[:rsync_flags]
|
65
|
+
|
66
|
+
@ssh_flags = [
|
67
|
+
"-o ControlMaster=auto",
|
68
|
+
"-o ControlPath=~/.ssh/nutshell-%r@%h:%p"
|
69
|
+
]
|
70
|
+
@ssh_flags.concat ["-l", @user] if @user
|
71
|
+
@ssh_flags.concat [*options[:ssh_flags]] if options[:ssh_flags]
|
72
|
+
|
73
|
+
@pid, @inn, @out, @err = nil
|
74
|
+
|
75
|
+
self.class.register self
|
76
|
+
end
|
77
|
+
|
78
|
+
|
79
|
+
##
|
80
|
+
# Runs a command via SSH. Optional block is passed the
|
81
|
+
# stream(stderr, stdout) and string data.
|
82
|
+
|
83
|
+
def call command_str, options={}, &block
|
84
|
+
execute build_remote_cmd(command_str, options), &block
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
##
|
89
|
+
# Connect to host via SSH and return process pid
|
90
|
+
|
91
|
+
def connect
|
92
|
+
return true if connected?
|
93
|
+
|
94
|
+
cmd = ssh_cmd quote_cmd(LOGIN_LOOP), :sudo => false
|
95
|
+
|
96
|
+
@pid, @inn, @out, @err = popen4 cmd.join(" ")
|
97
|
+
@inn.sync = true
|
98
|
+
|
99
|
+
data = ""
|
100
|
+
ready = nil
|
101
|
+
start_time = Time.now.to_i
|
102
|
+
|
103
|
+
until ready || @out.eof?
|
104
|
+
data << @out.readpartial(1024)
|
105
|
+
ready = data =~ /ready/
|
106
|
+
|
107
|
+
raise TimeoutError if timed_out?(start_time, LOGIN_TIMEOUT)
|
108
|
+
end
|
109
|
+
|
110
|
+
unless connected?
|
111
|
+
disconnect
|
112
|
+
host_info = [@user, @host].compact.join("@")
|
113
|
+
raise ConnectionError, "Can't connect to #{host_info}"
|
114
|
+
end
|
115
|
+
|
116
|
+
@inn.close
|
117
|
+
@pid
|
118
|
+
end
|
119
|
+
|
120
|
+
|
121
|
+
##
|
122
|
+
# Check if SSH session is open and returns process pid
|
123
|
+
|
124
|
+
def connected?
|
125
|
+
Process.kill(0, @pid) && @pid rescue false
|
126
|
+
end
|
127
|
+
|
128
|
+
|
129
|
+
##
|
130
|
+
# Disconnect from host
|
131
|
+
|
132
|
+
def disconnect
|
133
|
+
@inn.close rescue nil
|
134
|
+
@out.close rescue nil
|
135
|
+
@err.close rescue nil
|
136
|
+
|
137
|
+
kill_process @pid, "HUP" rescue nil
|
138
|
+
|
139
|
+
@pid = nil
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
##
|
144
|
+
# Download a file via rsync
|
145
|
+
|
146
|
+
def download from_path, to_path, options={}, &block
|
147
|
+
from_path = "#{@host}:#{from_path}"
|
148
|
+
execute rsync_cmd(from_path, to_path, options), &block
|
149
|
+
end
|
150
|
+
|
151
|
+
|
152
|
+
##
|
153
|
+
# Expand a path:
|
154
|
+
# shell.expand_path "~user/thing"
|
155
|
+
# #=> "/home/user/thing"
|
156
|
+
|
157
|
+
def expand_path path
|
158
|
+
dir = File.dirname path
|
159
|
+
full_dir = call "cd #{dir} && pwd"
|
160
|
+
File.join full_dir, File.basename(path)
|
161
|
+
end
|
162
|
+
|
163
|
+
|
164
|
+
##
|
165
|
+
# Checks if the given file exists
|
166
|
+
|
167
|
+
def file? filepath
|
168
|
+
syscall "test -f #{filepath}"
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
##
|
173
|
+
# Start an interactive shell with preset permissions and env.
|
174
|
+
# Optionally pass a command to be run first.
|
175
|
+
|
176
|
+
def tty! cmd=nil
|
177
|
+
sync do
|
178
|
+
cmd = [cmd, "sh -il"].compact.join " && "
|
179
|
+
cmd = quote_cmd cmd
|
180
|
+
|
181
|
+
pid = fork do
|
182
|
+
exec \
|
183
|
+
ssh_cmd(sudo_cmd(env_cmd(cmd)), :flags => "-t").to_a.join(" ")
|
184
|
+
end
|
185
|
+
Process.waitpid pid
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
|
190
|
+
##
|
191
|
+
# Create a file remotely
|
192
|
+
|
193
|
+
def make_file filepath, content, options={}
|
194
|
+
|
195
|
+
temp_filepath =
|
196
|
+
"#{TMP_DIR}/#{File.basename(filepath)}_#{Time.now.to_i}#{rand(10000)}"
|
197
|
+
|
198
|
+
File.open(temp_filepath, "w+"){|f| f.write(content)}
|
199
|
+
|
200
|
+
self.upload temp_filepath, filepath, options
|
201
|
+
|
202
|
+
File.delete(temp_filepath)
|
203
|
+
end
|
204
|
+
|
205
|
+
|
206
|
+
##
|
207
|
+
# Builds an ssh command with permissions, env, etc.
|
208
|
+
|
209
|
+
def build_remote_cmd cmd, options={}
|
210
|
+
cmd = sh_cmd cmd
|
211
|
+
cmd = env_cmd cmd
|
212
|
+
cmd = sudo_cmd cmd, options
|
213
|
+
cmd = ssh_cmd cmd, options
|
214
|
+
end
|
215
|
+
|
216
|
+
|
217
|
+
##
|
218
|
+
# Uploads a file via rsync
|
219
|
+
|
220
|
+
def upload from_path, to_path, options={}, &block
|
221
|
+
to_path = "#{@host}:#{to_path}"
|
222
|
+
execute rsync_cmd(from_path, to_path, options), &block
|
223
|
+
end
|
224
|
+
|
225
|
+
|
226
|
+
##
|
227
|
+
# Figure out which rsync flags to use.
|
228
|
+
|
229
|
+
def build_rsync_flags options
|
230
|
+
flags = @rsync_flags.dup
|
231
|
+
|
232
|
+
remote_rsync = 'rsync'
|
233
|
+
rsync_sudo = sudo_cmd remote_rsync, options
|
234
|
+
|
235
|
+
unless rsync_sudo == remote_rsync
|
236
|
+
flags << "--rsync-path='#{ rsync_sudo.join(" ") }'"
|
237
|
+
end
|
238
|
+
|
239
|
+
flags << "-e \"ssh #{@ssh_flags.join(' ')}\"" if @ssh_flags
|
240
|
+
|
241
|
+
flags.concat [*options[:flags]] if options[:flags]
|
242
|
+
|
243
|
+
flags
|
244
|
+
end
|
245
|
+
|
246
|
+
|
247
|
+
##
|
248
|
+
# Creates an rsync command.
|
249
|
+
|
250
|
+
def rsync_cmd from_path, to_path, options={}
|
251
|
+
cmd = ["rsync", build_rsync_flags(options), from_path, to_path]
|
252
|
+
cmd.flatten.compact.join(" ")
|
253
|
+
end
|
254
|
+
|
255
|
+
|
256
|
+
##
|
257
|
+
# Wraps the command in an ssh call.
|
258
|
+
|
259
|
+
def ssh_cmd cmd, options=nil
|
260
|
+
options ||= {}
|
261
|
+
|
262
|
+
flags = [*options[:flags]].concat @ssh_flags
|
263
|
+
|
264
|
+
["ssh", flags, @host, cmd].flatten.compact
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|