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 delimiters to STDOUT and STDERR

Defined Under Namespace

Classes: CommandFailure, Error

Constant Summary collapse

SETTINGS =

defaults

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

defaults

{
  forward_agent: true,
  paranoid: false,
  timeout: 10, # connection timeout, raises on expiry
}

Class Method Summary collapse

Class Method Details

.alert(topic, message, settings) ⇒ Object

send alert depending on settings



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/runsible.rb', line 54

def self.alert(topic, message, settings)
  backend = settings['alerts'] && settings['alerts']['backend']
  case backend
  when 'disabled', nil, false
    self.warn "DISABLED ALERT: [#{topic}] #{message}"
    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

.apply_defaults(hsh = nil) ⇒ Object

return SETTINGS with string keys, using values from hsh if passed in



40
41
42
43
44
45
46
# File 'lib/runsible.rb', line 40

def self.apply_defaults(hsh = nil)
  hsh ||= Hash.new
  SETTINGS.each { |sym, v|
    hsh[sym.to_s] ||= v
  }
  hsh
end

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



269
270
271
272
273
274
# File 'lib/runsible.rb', line 269

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



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

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

.die!(msg, settings) ⇒ Object

warn, alert, exit 1



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

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



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

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

.excp(excp) ⇒ Object

provide a better string representation for all Exceptions



35
36
37
# File 'lib/runsible.rb', line 35

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. runlist can be nil



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 || Array.new).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("retries exhausted", excp, settings)

      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



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

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



245
246
247
248
249
250
251
# File 'lib/runsible.rb', line 245

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)
  settings = self.apply_defaults(yaml['settings'])
  self.ssh_runlist(settings, yaml['runlist'], ssh_options, yaml)
end

.slop_parseObject

parse CLI arguments



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

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



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

def self.spoon(ssh_options = Hash.new)
  opts = Runsible.slop_parse
  yaml = self.extract_yaml(opts)
  settings = self.merge(opts, Runsible.apply_defaults(yaml['settings']))
  self.ssh_runlist(settings, yaml['runlist'], 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
# File 'lib/runsible.rb', line 152

def self.ssh_runlist(settings, runlist, ssh_options, yaml)
  ssh_options = SSH_OPTIONS.merge(ssh_options)
  ssh_options[:port] ||= settings.fetch('port')
  ssh_options[:send_env] ||= settings['vars'] if settings['vars']
  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



264
265
266
# File 'lib/runsible.rb', line 264

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



127
128
129
130
131
132
# File 'lib/runsible.rb', line 127

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

.versionObject

read VERSION from filesystem



49
50
51
# File 'lib/runsible.rb', line 49

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

.warn(msg) ⇒ Object

send warnings to both STDOUT and STDERR



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

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