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.
@@ -0,0 +1,5 @@
1
+ === 1.0.0 / 2010-09-28
2
+
3
+ * 1 major enhancement
4
+
5
+ * First release!
@@ -0,0 +1,12 @@
1
+ History.txt
2
+ Manifest.txt
3
+ README.txt
4
+ Rakefile
5
+ lib/nutshell.rb
6
+ lib/nutshell/remote_shell.rb
7
+ lib/nutshell/shell.rb
8
+ test/mocks/mock_object.rb
9
+ test/mocks/mock_open4.rb
10
+ test/test_helper.rb
11
+ test/test_remote_shell.rb
12
+ test/test_shell.rb
@@ -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
+
@@ -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
+
@@ -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
+