sshkit 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. data/.gitignore +2 -0
  2. data/.travis.yml +5 -0
  3. data/.yardoc/checksums +13 -0
  4. data/.yardoc/object_types +0 -0
  5. data/.yardoc/objects/root.dat +0 -0
  6. data/.yardoc/proxy_types +0 -0
  7. data/.yardopts +1 -0
  8. data/CHANGELOG.md +0 -0
  9. data/EXAMPLES.md +167 -0
  10. data/FAQ.md +17 -0
  11. data/Gemfile +2 -0
  12. data/Gemfile.lock +69 -0
  13. data/LICENSE.md +674 -0
  14. data/README.md +181 -0
  15. data/Rakefile +37 -0
  16. data/Vagrantfile +18 -0
  17. data/assets/images/logo.png +0 -0
  18. data/example.rb +70 -0
  19. data/lib/core_ext/array.rb +5 -0
  20. data/lib/core_ext/hash.rb +11 -0
  21. data/lib/sshkit/all.rb +13 -0
  22. data/lib/sshkit/backends/abstract.rb +83 -0
  23. data/lib/sshkit/backends/netssh.rb +82 -0
  24. data/lib/sshkit/backends/printer.rb +28 -0
  25. data/lib/sshkit/command.rb +153 -0
  26. data/lib/sshkit/configuration.rb +29 -0
  27. data/lib/sshkit/connection_manager.rb +93 -0
  28. data/lib/sshkit/dsl.rb +15 -0
  29. data/lib/sshkit/host.rb +151 -0
  30. data/lib/sshkit/version.rb +3 -0
  31. data/lib/sshkit.rb +27 -0
  32. data/sshkit.gemspec +34 -0
  33. data/test/functional/test_connection_manager.rb +17 -0
  34. data/test/functional/test_ssh_server_comes_up_for_functional_tests.rb +23 -0
  35. data/test/helper.rb +125 -0
  36. data/test/integration/backends/test_netssh.rb +99 -0
  37. data/test/unit/backends/test_netssh.rb +14 -0
  38. data/test/unit/backends/test_printer.rb +62 -0
  39. data/test/unit/core_ext/test_string.rb +12 -0
  40. data/test/unit/test_command.rb +113 -0
  41. data/test/unit/test_configuration.rb +47 -0
  42. data/test/unit/test_connection_manager.rb +78 -0
  43. data/test/unit/test_host.rb +65 -0
  44. 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
@@ -0,0 +1,5 @@
1
+ class Array
2
+ def extract_options!
3
+ last.is_a?(::Hash) ? pop : {}
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ class Hash
2
+ def symbolize_keys
3
+ inject({}) do |options, (key, value)|
4
+ options[(key.to_sym rescue key) || key] = value
5
+ options
6
+ end
7
+ end
8
+ def symbolize_keys!
9
+ self.replace(self.symbolize_keys)
10
+ end
11
+ 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