runsible 0.1.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/VERSION +1 -0
  3. data/bin/runsible +5 -0
  4. data/lib/runsible.rb +239 -0
  5. data/runsible.gemspec +26 -0
  6. metadata +104 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8eddd85cd8f461bb7429cc4ec8b791426abfa579
4
+ data.tar.gz: d0f88b818945461a24e13825fc1ca0494a294dd6
5
+ SHA512:
6
+ metadata.gz: fe055d42239b15ff92f99ecce93a17a3291a542c83e27d3c9b7eb1855fdc28fc0650c1e1f774c6fe51c88936516ccb4d2480098756978805b5752b88b576b880
7
+ data.tar.gz: 3f41d2e3a40e9ff47e054a8da23314f228453b1d5b9e9d17b868aa20ff06700f5d4fc9b0c74d764ac08aff555f00461665fbdda1442de7a1cc7e0bbb01e67810
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.2.1
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'runsible'
4
+
5
+ Runsible.run
@@ -0,0 +1,239 @@
1
+ require 'yaml'
2
+ require 'slop'
3
+ require 'pony'
4
+ require 'net/ssh'
5
+
6
+ # for nonzero exit status of a remote command
7
+ class CommandFailure < RuntimeError
8
+ def to_s(*args)
9
+ "#{self.class}: #{super(*args)}"
10
+ end
11
+ end
12
+
13
+ # - this module is deliberately written without private state
14
+ # - it is meant to be used in the helper style
15
+ # - whenever a remote command sends data to STDOUT or STDERR, Runsible will
16
+ # immediately send it to the corresponding local IO
17
+ # - Runsible itself writes some warnings and timestamped command delimeters to
18
+ # STDOUT and STDERR
19
+ #
20
+ module Runsible
21
+ SSH_CNX_TIMEOUT = 10
22
+ class Error < RuntimeError; end
23
+
24
+ SETTINGS = {
25
+ user: ENV['USER'],
26
+ host: '127.0.0.1',
27
+ port: 22,
28
+ retries: 0,
29
+ vars: [],
30
+ }
31
+
32
+ def self.default_settings
33
+ hsh = {}
34
+ SETTINGS.each { |sym, v|
35
+ hsh[sym.to_s] = v
36
+ }
37
+ hsh
38
+ end
39
+
40
+ def self.version
41
+ File.read(File.join(__dir__, '..', 'VERSION'))
42
+ end
43
+
44
+ def self.usage(opts, msg=nil)
45
+ puts opts
46
+ puts
47
+ puts msg if msg
48
+ exit 1
49
+ end
50
+
51
+ def self.run(ssh_options = Hash.new)
52
+ opts = Runsible.slop_parse
53
+ Runsible.spoon(opts, self.extract_yaml(opts), ssh_options)
54
+ end
55
+
56
+ def self.extract_yaml(opts)
57
+ yaml_filename = opts.arguments.shift
58
+ self.usage(opts, "yaml_file is required") if yaml_filename.nil?
59
+
60
+ begin
61
+ yaml = YAML.load_file(yaml_filename)
62
+ rescue RuntimeError => e
63
+ Runsible.usage(opts, "could not load yaml_file\n#{e}")
64
+ end
65
+ yaml
66
+ end
67
+
68
+ def self.slop_parse
69
+ d = SETTINGS
70
+ Slop.parse do |o|
71
+ o.banner = "usage: runsible [options] yaml_file"
72
+ o.on '-h', '--help' do
73
+ puts o
74
+ exit 0
75
+ end
76
+ o.on '-v', '--version', 'show Runsible version' do
77
+ puts Runsible.version
78
+ exit 0
79
+ end
80
+
81
+ o.string '-u', '--user', "remote user [#{d[:user]}]"
82
+ o.string '-H', '--host', "remote host [#{d[:host]}]"
83
+ o.int '-p', '--port', "remote port [#{d[:port]}]"
84
+ o.int '-r', '--retries', "retry count [#{d[:retries]}]"
85
+ o.bool '-s', '--silent', 'suppress alerts'
86
+ # this feature does not yet work as expected
87
+ # https://github.com/net-ssh/net-ssh/issues/236
88
+ # o.string '-v', '--vars', 'list of vars to pass, e.g.: "FOO BAR"'
89
+ end
90
+ end
91
+
92
+ def self.merge(opts, settings)
93
+ Runsible::SETTINGS.keys.each { |sym|
94
+ settings[sym.to_s] = opts[sym] if opts[sym]
95
+ }
96
+ settings['alerts'] = {} if opts.silent?
97
+ settings
98
+ end
99
+
100
+ def self.alert(topic, message, settings)
101
+ backend = settings['alerts'] && settings['alerts']['backend']
102
+ case backend
103
+ when 'disabled', nil, false
104
+ return
105
+ when 'email'
106
+ Pony.mail(to: settings.fetch(:address),
107
+ from: 'runsible@spoon',
108
+ subject: topic,
109
+ body: message)
110
+ when 'kafka', 'slack'
111
+ # TODO
112
+ raise Error, "unsupported backend: #{backend.inspect}"
113
+ else
114
+ raise Error, "unknown backend: #{backend.inspect}"
115
+ end
116
+ end
117
+
118
+ def self.warn(msg)
119
+ $stdout.puts msg
120
+ $stderr.puts msg
121
+ end
122
+
123
+ def self.spoon(opts, yaml, ssh_options = Hash.new)
124
+ settings = Runsible.default_settings.merge(yaml['settings'] || Hash.new)
125
+ settings = self.merge(opts, settings)
126
+ self.ssh_runlist(settings, yaml['runlist'] || Array.new, ssh_options, yaml)
127
+ end
128
+
129
+ def self.ssh_runlist(settings, runlist, ssh_options, yaml)
130
+ ssh_options[:forward_agent] ||= true
131
+ ssh_options[:port] ||= settings.fetch('port')
132
+ ssh_options[:send_env] ||= settings['vars'] if settings['vars']
133
+ ssh_options[:timeout] ||= SSH_CNX_TIMEOUT
134
+ host, user = settings.fetch('host'), settings.fetch('user')
135
+ Net::SSH.start(host, user, ssh_options) { |ssh|
136
+ self.exec_runlist(ssh, runlist, settings, yaml)
137
+ }
138
+ end
139
+
140
+ def self.die!(msg, settings)
141
+ self.warn(msg)
142
+ self.alert("runsible:fatal:#{Process.pid}", msg, settings)
143
+ exit 1
144
+ end
145
+
146
+ # execute runlist with failure handling, retries, alerting, etc.
147
+ def self.exec_runlist(ssh, runlist, settings, yaml = Hash.new)
148
+ runlist.each { |run|
149
+ cmd = run.fetch('command')
150
+ retries = run['retries'] || settings['retries']
151
+ on_failure = run['on_failure'] || 'exit'
152
+
153
+ begin
154
+ self.exec_retry(ssh, cmd, retries)
155
+ rescue CommandFailure, Net::SSH::Exception => e
156
+ self.warn e
157
+ msg = "#{retries} retries exhausted; on_failure: #{on_failure}"
158
+ self.warn msg
159
+ self.alert(settings['email'], "retries exhausted", e.to_s)
160
+
161
+ case on_failure
162
+ when 'continue'
163
+ next
164
+ when 'exit'
165
+ else
166
+ if yaml[on_failure]
167
+ self.warn "found #{yaml[on_failure]} runlist"
168
+ # pass empty hash for yaml here to prevent infinite loops
169
+ self.exec_runlist(ssh, yaml[on_failure], settings, Hash.new)
170
+ self.warn "exiting failure after #{yaml[on_failure]}"
171
+ else
172
+ self.warn "#{on_failure} unknown"
173
+ end
174
+ end
175
+ self.die!("exiting after `#{cmd}` ultimately failed", settings)
176
+ end
177
+ }
178
+ end
179
+
180
+ # prints remote STDOUT to local STDOUT, likewise for STDERR
181
+ # raises on SSH channel exec failure or nonzero exit status
182
+ def self.exec(ssh, cmd)
183
+ exit_code = nil
184
+ ssh.open_channel do |channel|
185
+ channel.exec(cmd) do |ch, success|
186
+ raise(Net::SSH::Exception, "SSH channel exec failure") unless success
187
+ channel.on_data do |ch,data|
188
+ $stdout.puts data
189
+ end
190
+ channel.on_extended_data do |ch,type,data|
191
+ $stderr.puts data
192
+ end
193
+ channel.on_request("exit-status") do |ch,data|
194
+ exit_code = data.read_long
195
+ end
196
+ end
197
+ end
198
+ ssh.loop # nothing actually executes until this call
199
+ exit_code == 0 or raise(CommandFailure, "[exit #{exit_code}] #{cmd}")
200
+ end
201
+
202
+ # retry several times, rescuing CommandFailure
203
+ # raises on SSH channel exec failure and CommandFailure on final retry
204
+ def self.exec_retry(ssh, cmd, retries)
205
+ self.banner_wrap(cmd) {
206
+ success = false
207
+ retries.times { |i|
208
+ begin
209
+ success = self.exec(ssh, cmd)
210
+ break
211
+ rescue CommandFailure => e
212
+ $stdout.puts "#{e}; retrying shortly..."
213
+ $stderr.puts e
214
+ sleep 2
215
+ end
216
+ }
217
+ # the final retry, may blow up
218
+ success or self.exec(ssh, cmd)
219
+ }
220
+ end
221
+
222
+ def self.begin_banner(msg)
223
+ "RUNSIBLE >>> [#{self.timestamp}] >>> #{msg} >>>>>"
224
+ end
225
+
226
+ def self.end_banner(msg)
227
+ "<<<<< #{msg} <<< [#{self.timestamp}] <<< RUNSIBLE"
228
+ end
229
+
230
+ def self.timestamp(t = Time.now)
231
+ t.strftime("%b%d %H:%M:%S")
232
+ end
233
+
234
+ def self.banner_wrap(msg)
235
+ self.warn self.begin_banner(msg)
236
+ yield if block_given?
237
+ self.warn self.end_banner(msg)
238
+ end
239
+ end
@@ -0,0 +1,26 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'runsible'
3
+ s.required_ruby_version = "~> 2"
4
+ s.version = File.read(File.join(__dir__, 'VERSION'))
5
+ s.summary = 'Run remote tasks sanely via SSH with failure handling'
6
+ s.description =
7
+ 'Runsible runs remote commands via net-ssh with retries and alerting'
8
+ s.authors = ['Rick Hull']
9
+ s.homepage = 'https://github.com/rickhull/runsible'
10
+ s.license = 'GPL'
11
+
12
+ s.files = [
13
+ 'runsible.gemspec',
14
+ 'VERSION',
15
+ 'lib/runsible.rb',
16
+ 'bin/runsible',
17
+ ]
18
+ s.executables = ['runsible']
19
+ s.add_runtime_dependency 'slop', '~> 4.0'
20
+ s.add_runtime_dependency 'net-ssh', '~> 2.7'
21
+ s.add_runtime_dependency 'pony', '~> 1.0'
22
+
23
+ s.add_development_dependency 'buildar', '~> 2.0'
24
+
25
+ s.has_rdoc = false
26
+ end
metadata ADDED
@@ -0,0 +1,104 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: runsible
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Rick Hull
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-04-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: slop
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '4.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '4.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: net-ssh
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pony
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: buildar
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.0'
69
+ description: Runsible runs remote commands via net-ssh with retries and alerting
70
+ email:
71
+ executables:
72
+ - runsible
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - VERSION
77
+ - bin/runsible
78
+ - lib/runsible.rb
79
+ - runsible.gemspec
80
+ homepage: https://github.com/rickhull/runsible
81
+ licenses:
82
+ - GPL
83
+ metadata: {}
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - "~>"
91
+ - !ruby/object:Gem::Version
92
+ version: '2'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubyforge_project:
100
+ rubygems_version: 2.4.5
101
+ signing_key:
102
+ specification_version: 4
103
+ summary: Run remote tasks sanely via SSH with failure handling
104
+ test_files: []