Module: Runsible

Defined in:
lib/runsible.rb

Overview

  • this module is deliberately written without private state

  • whenever a remote command sends data to STDOUT or STDERR, Runsible will immediately send it to the corresponding local IO

  • Runsible itself writes some warnings and timestamped command delimeters to STDOUT and STDERR

Defined Under Namespace

Classes: CommandFailure, Error

Constant Summary collapse

SETTINGS =
{
  user: ENV['USER'],
  host: '127.0.0.1',
  port: 22,
  retries: 0,
  vars: [],
}
SSH_CNX_TIMEOUT =
10

Class Method Summary collapse

Class Method Details

.alert(topic, message, settings) ⇒ Object



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/runsible.rb', line 45

def self.alert(topic, message, settings)
  backend = settings['alerts'] && settings['alerts']['backend']
  case backend
  when 'disabled', nil, false
    return
  when 'email'
    Pony.mail(to: settings.fetch(:address),
              from: 'runsible@spoon',
              subject: topic,
              body: message)
  when 'kafka', 'slack'
    # TODO
    raise Error, "unsupported backend: #{backend.inspect}"
  else
    raise Error, "unknown backend: #{backend.inspect}"
  end
end


257
258
259
260
261
262
# File 'lib/runsible.rb', line 257

def self.banner_wrap(msg)
  self.warn self.begin_banner(msg)
  val = block_given? ? yield : true
  self.warn self.end_banner(msg)
  val
end

.begin_banner(msg) ⇒ Object



245
246
247
# File 'lib/runsible.rb', line 245

def self.begin_banner(msg)
  "RUNSIBLE >>> [#{self.timestamp}] >>> #{msg} >>>>>"
end

.default_settingsObject



33
34
35
36
37
38
39
# File 'lib/runsible.rb', line 33

def self.default_settings
  hsh = {}
  SETTINGS.each { |sym, v|
    hsh[sym.to_s] = v
  }
  hsh
end

.die!(msg, settings) ⇒ Object



68
69
70
71
72
# File 'lib/runsible.rb', line 68

def self.die!(msg, settings)
  self.warn(msg)
  self.alert("runsible:fatal:#{Process.pid}", msg, settings)
  exit 1
end

.end_banner(msg) ⇒ Object



249
250
251
# File 'lib/runsible.rb', line 249

def self.end_banner(msg)
  "<<<<< #{msg} <<< [#{self.timestamp}] <<< RUNSIBLE"
end

.excp(excp) ⇒ Object

Utility stuff



29
30
31
# File 'lib/runsible.rb', line 29

def self.excp(excp)
  "#{excp.class}: #{excp.message}"
end

.exec(ssh, cmd) ⇒ Object

prints remote STDOUT to local STDOUT, likewise for STDERR raises on SSH channel exec failure or nonzero exit status

Raises:



211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/runsible.rb', line 211

def self.exec(ssh, cmd)
  exit_code = nil
  ssh.open_channel do |channel|
    channel.exec(cmd) do |ch, success|
      raise(Net::SSH::Exception, "SSH channel exec failure") unless success
      channel.on_data do |ch,data|
        $stdout.puts data
      end
      channel.on_extended_data do |ch,type,data|
        $stderr.puts data
      end
      channel.on_request("exit-status") do |ch,data|
        exit_code = data.read_long
      end
    end
  end
  ssh.loop # nothing actually executes until this call
  raise(CommandFailure, "[exit #{exit_code}] #{cmd}") unless exit_code == 0
  true
end

.exec_retry(ssh, cmd, retries) ⇒ Object

retry several times, rescuing CommandFailure raises on SSH channel exec failure and CommandFailure on final retry



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/runsible.rb', line 191

def self.exec_retry(ssh, cmd, retries)
  self.banner_wrap(cmd) {
    success = false
    retries.times { |i|
      begin
        success = self.exec(ssh, cmd)
        break
      rescue CommandFailure => e
        $stdout.puts "#{e}; retrying shortly..."
        $stderr.puts e
        sleep 2
      end
    }
    # the final retry, may blow up
    success or self.exec(ssh, cmd)
  }
end

.exec_runlist(ssh, runlist, settings, yaml = Hash.new) ⇒ Object

execute runlist with failure handling, retries, alerting, etc.



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/runsible.rb', line 155

def self.exec_runlist(ssh, runlist, settings, yaml = Hash.new)
  runlist.each { |run|
    cmd = run.fetch('command')
    retries = run['retries'] || settings['retries']
    on_failure = run['on_failure'] || 'exit'

    begin
      self.exec_retry(ssh, cmd, retries)
    rescue CommandFailure, Net::SSH::Exception => e
      excp = self.excp(e)
      self.warn excp
      msg = "#{retries} retries exhausted; on_failure: #{on_failure}"
      self.warn msg
      self.alert(settings['email'], "retries exhausted", excp)

      case on_failure
      when 'continue'
        next
      when 'exit'
      else
        if yaml[on_failure]
          self.warn "found #{yaml[on_failure]} runlist"
          # pass empty hash for yaml here to prevent infinite loops
          self.exec_runlist(ssh, yaml[on_failure], settings, Hash.new)
          self.warn "exiting failure after #{yaml[on_failure]}"
        else
          self.warn "#{on_failure} unknown"
        end
      end
      self.die!("exiting after `#{cmd}` ultimately failed", settings)
    end
  }
end

.extract_yaml(opts) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
# File 'lib/runsible.rb', line 112

def self.extract_yaml(opts)
  yaml_filename = opts.arguments.shift
  self.usage(opts, "yaml_file is required") if yaml_filename.nil?

  begin
    yaml = YAML.load_file(yaml_filename)
  rescue RuntimeError => e
    Runsible.usage(opts, "could not load yaml_file\n#{self.excp(e)}")
  end
  yaml
end

.merge(opts, settings) ⇒ Object

Necessities



237
238
239
240
241
242
243
# File 'lib/runsible.rb', line 237

def self.merge(opts, settings)
  Runsible::SETTINGS.keys.each { |sym|
    settings[sym.to_s] = opts[sym] if opts[sym]
  }
  settings['alerts'] = {} if opts.silent?
  settings
end

.run_yaml(yaml_filename, ssh_options = Hash.new) ⇒ Object

run a YAML file without any consideration for command line options



136
137
138
139
140
# File 'lib/runsible.rb', line 136

def self.run_yaml(yaml_filename, ssh_options = Hash.new)
  yaml = YAML.load_file(yaml_filename)
  self.ssh_runlist(self.default_settings, yaml['runlist'] || Array.new,
                   ssh_options, yaml)
end

.slop_parseObject



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/runsible.rb', line 88

def self.slop_parse
  d = SETTINGS # display defaults
  Slop.parse do |o|
    o.banner = "usage: runsible [options] yaml_file"
    o.on '-h', '--help' do
      puts o
      exit 0
    end
    o.on     '-v', '--version', 'show runsible version' do
      puts Runsible.version
      exit 0
    end

    o.string '-u', '--user',    "remote user [#{d[:user]}]"
    o.string '-H', '--host',    "remote host [#{d[:host]}]"
    o.int    '-p', '--port',    "remote port [#{d[:port]}]"
    o.int    '-r', '--retries', "retry count [#{d[:retries]}]"
    o.bool   '-s', '--silent',  'suppress alerts'
    # this feature does not yet work as expected
    # https://github.com/net-ssh/net-ssh/issues/236
    #  o.string '-v', '--vars',    'list of vars to pass, e.g.: "FOO BAR"'
  end
end

.spoon(ssh_options = Hash.new) ⇒ Object

bin/runsible entry point



80
81
82
83
84
85
86
# File 'lib/runsible.rb', line 80

def self.spoon(ssh_options = Hash.new)
  opts = Runsible.slop_parse
  yaml = self.extract_yaml(opts)
  settings = Runsible.default_settings.merge(yaml['settings'] || Hash.new)
  settings = self.merge(opts, settings)
  self.ssh_runlist(settings, yaml['runlist'] || Array.new, ssh_options, yaml)
end

.ssh_runlist(settings, runlist, ssh_options, yaml) ⇒ Object

initiate ssh connection, perform the runlist



143
144
145
146
147
148
149
150
151
152
# File 'lib/runsible.rb', line 143

def self.ssh_runlist(settings, runlist, ssh_options, yaml)
  ssh_options[:forward_agent] ||= true
  ssh_options[:port] ||= settings.fetch('port')
  ssh_options[:send_env] ||= settings['vars'] if settings['vars']
  ssh_options[:timeout] ||= SSH_CNX_TIMEOUT
  host, user = settings.fetch('host'), settings.fetch('user')
  Net::SSH.start(host, user, ssh_options) { |ssh|
    self.exec_runlist(ssh, runlist, settings, yaml)
  }
end

.timestamp(t = Time.now) ⇒ Object



253
254
255
# File 'lib/runsible.rb', line 253

def self.timestamp(t = Time.now)
  t.strftime("%b%d %H:%M:%S")
end

.usage(opts, msg = nil) ⇒ Object



124
125
126
127
128
129
# File 'lib/runsible.rb', line 124

def self.usage(opts, msg=nil)
  puts opts
  puts
  puts msg if msg
  exit 1
end

.versionObject



41
42
43
# File 'lib/runsible.rb', line 41

def self.version
  File.read(File.join(__dir__, '..', 'VERSION'))
end

.warn(msg) ⇒ Object



63
64
65
66
# File 'lib/runsible.rb', line 63

def self.warn(msg)
  $stdout.puts msg
  $stderr.puts msg
end