Module: Runsible

Defined in:
lib/runsible.rb

Overview

  • this module is deliberately written without private state

  • it is meant to be used in the helper style

  • 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: Error

Constant Summary collapse

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

Class Method Summary collapse

Class Method Details

.alert(topic, message, settings) ⇒ Object



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

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


234
235
236
237
238
# File 'lib/runsible.rb', line 234

def self.banner_wrap(msg)
  self.warn self.begin_banner(msg)
  yield if block_given?
  self.warn self.end_banner(msg)
end

.begin_banner(msg) ⇒ Object



222
223
224
# File 'lib/runsible.rb', line 222

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

.default_settingsObject



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

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

.die!(msg, settings) ⇒ Object



140
141
142
143
144
# File 'lib/runsible.rb', line 140

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

.end_banner(msg) ⇒ Object



226
227
228
# File 'lib/runsible.rb', line 226

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

.exec(ssh, cmd) ⇒ Object

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



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/runsible.rb', line 182

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
  exit_code == 0 or raise(CommandFailure, "[exit #{exit_code}] #{cmd}")
end

.exec_retry(ssh, cmd, retries) ⇒ Object

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



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/runsible.rb', line 204

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.



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/runsible.rb', line 147

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
      self.warn e
      msg = "#{retries} retries exhausted; on_failure: #{on_failure}"
      self.warn msg
      self.alert(settings['email'], "retries exhausted", e.to_s)

      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



56
57
58
59
60
61
62
63
64
65
66
# File 'lib/runsible.rb', line 56

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#{e}")
  end
  yaml
end

.merge(opts, settings) ⇒ Object



92
93
94
95
96
97
98
# File 'lib/runsible.rb', line 92

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(ssh_options = Hash.new) ⇒ Object



51
52
53
54
# File 'lib/runsible.rb', line 51

def self.run(ssh_options = Hash.new)
  opts = Runsible.slop_parse
  Runsible.spoon(opts, self.extract_yaml(opts), ssh_options)
end

.slop_parseObject



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/runsible.rb', line 68

def self.slop_parse
  d = SETTINGS
  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(opts, yaml, ssh_options = Hash.new) ⇒ Object



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

def self.spoon(opts, yaml, ssh_options = Hash.new)
  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



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

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



230
231
232
# File 'lib/runsible.rb', line 230

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

.usage(opts, msg = nil) ⇒ Object



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

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

.versionObject



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

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

.warn(msg) ⇒ Object



118
119
120
121
# File 'lib/runsible.rb', line 118

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