sshkit 0.0.1

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.
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