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

send alert depending on settings[‘backend’]



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/runsible.rb', line 49

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

display begin/end banners, yielding to the block in between



272
273
274
275
276
277
# File 'lib/runsible.rb', line 272

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

delimits the beginning of command output



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

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

.default_settingsObject

return SETTINGS with string keys



35
36
37
38
39
40
41
# File 'lib/runsible.rb', line 35

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

.die!(msg, settings) ⇒ Object

warn, alert, exit 1



74
75
76
77
78
# File 'lib/runsible.rb', line 74

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

.end_banner(msg) ⇒ Object

delimits the end of command output



262
263
264
# File 'lib/runsible.rb', line 262

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

.excp(excp) ⇒ Object

provide a better string representation for all Exceptions



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

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:



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/runsible.rb', line 220

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



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/runsible.rb', line 200

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.



164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/runsible.rb', line 164

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

load yaml from CLI arguments



120
121
122
123
124
125
126
127
128
129
130
# File 'lib/runsible.rb', line 120

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

opts has symbol keys, overrides settings (string keys) return a hash with string keys



248
249
250
251
252
253
254
# File 'lib/runsible.rb', line 248

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



145
146
147
148
149
# File 'lib/runsible.rb', line 145

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

parse CLI arguments



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/runsible.rb', line 95

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



86
87
88
89
90
91
92
# File 'lib/runsible.rb', line 86

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



152
153
154
155
156
157
158
159
160
161
# File 'lib/runsible.rb', line 152

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

self-explanatory



267
268
269
# File 'lib/runsible.rb', line 267

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

.usage(opts, msg = nil) ⇒ Object

provide a friendly message for the user



133
134
135
136
137
138
# File 'lib/runsible.rb', line 133

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

.versionObject

read VERSION from filesystem



44
45
46
# File 'lib/runsible.rb', line 44

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

.warn(msg) ⇒ Object

send warnings to both STDOUT and STDERR



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

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