summaryrefslogtreecommitdiffstats
path: root/cucumber/features/support/helpers/misc_helpers.rb
blob: 865d2978ee37b2f6570daee21486dbd4cc25cdd4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
require 'date'
require 'io/console'
require 'pry'
require 'timeout'
require 'test/unit'

# Test::Unit adds an at_exit hook which, among other things, consumes
# the command-line arguments that were intended for cucumber. If
# e.g. `--format` was passed it will throw an error since it's not a
# valid option for Test::Unit, and it throwing an error at this time
# (at_exit) will make Cucumber think it failed and consequently exit
# with an error. Fooling Test::Unit that this hook has already run
# works around this craziness.
Test::Unit.run = true

# Make all the assert_* methods easily accessible in any context.
include Test::Unit::Assertions

def assert_vmcommand_success(p, msg = nil)
  assert(p.success?, msg.nil? ? "Command failed: #{p.cmd}\n" + \
                                "error code: #{p.returncode}\n" \
                                "stderr: #{p.stderr}" : \
                                msg)
end

# It's forbidden to throw this exception (or subclasses) in anything
# but try_for() below. Just don't use it anywhere else!
class UniqueTryForTimeoutError < Exception
end

# Call block (ignoring any exceptions it may throw) repeatedly with
# one second breaks until it returns true, or until `timeout` seconds have
# passed when we throw a Timeout::Error exception. If `timeout` is `nil`,
# then we just run the code block with no timeout.
def try_for(timeout, options = {})
  if block_given? && timeout.nil?
    return yield
  end
  options[:delay] ||= 1
  last_exception = nil
  # Create a unique exception used only for this particular try_for
  # call's Timeout to allow nested try_for:s. If we used the same one,
  # the innermost try_for would catch all outer ones', creating a
  # really strange situation.
  unique_timeout_exception = Class.new(UniqueTryForTimeoutError)
  Timeout::timeout(timeout, unique_timeout_exception) do
    loop do
      begin
        return if yield
      rescue NameError, UniqueTryForTimeoutError => e
        # NameError most likely means typos, and hiding that is rarely
        # (never?) a good idea, so we rethrow them. See below why we
        # also rethrow *all* the unique exceptions.
        raise e
      rescue Exception => e
        # All other exceptions are ignored while trying the
        # block. Well we save the last exception so we can print it in
        # case of a timeout.
        last_exception = e
      end
      sleep options[:delay]
    end
  end
  # At this point the block above either succeeded and we'll return,
  # or we are throwing an exception. If the latter, we either have a
  # NameError that we'll not catch (and will any try_for below us in
  # the stack), or we have a unique exception. That can mean one of
  # two things:
  # 1. it's the one unique to this try_for, and in that case we'll
  #    catch it, rethrowing it as something that will be ignored by
  #    inside the blocks of all try_for:s below us in the stack.
  # 2. it's an exception unique to another try_for. Assuming that we
  #    do not throw the unique exceptions in any other place or way
  #    than we do it in this function, this means that there is a
  #    try_for below us in the stack to which this exception must be
  #    unique to.
  # Let 1 be the base step, and 2 the inductive step, and we sort of
  # an inductive proof for the correctness of try_for when it's
  # nested. It shows that for an infinite stack of try_for:s, any of
  # the unique exceptions will be caught only by the try_for instance
  # it is unique to, and all try_for:s in between will ignore it so it
  # ends up there immediately.
rescue unique_timeout_exception => e
  msg = options[:msg] || 'try_for() timeout expired'
  exc_class = options[:exception] || Timeout::Error
  if last_exception
    msg += "\nLast ignored exception was: " +
           "#{last_exception.class}: #{last_exception}"
  end
  raise exc_class.new(msg)
end

class TorFailure < StandardError
end

class MaxRetriesFailure < StandardError
end

def force_new_tor_circuit()
  debug_log("Forcing new Tor circuit...")
  # Tor rate limits NEWNYM to at most one per 10 second period.
  interval = 10
  if $__last_newnym
    elapsed = Time.now - $__last_newnym
    # We sleep an extra second to avoid tight timings.
    sleep interval - elapsed + 1 if 0 < elapsed && elapsed < interval
  end
  $vm.execute_successfully('tor_control_send "signal NEWNYM"', :libs => 'tor')
  $__last_newnym = Time.now
end

# This will retry the block up to MAX_NEW_TOR_CIRCUIT_RETRIES
# times. The block must raise an exception for a run to be considered
# as a failure. After a failure recovery_proc will be called (if
# given) and the intention with it is to bring us back to the state
# expected by the block, so it can be retried.
def retry_tor(recovery_proc = nil, &block)
  tor_recovery_proc = Proc.new do
    force_new_tor_circuit
    recovery_proc.call if recovery_proc
  end

  retry_action($config['MAX_NEW_TOR_CIRCUIT_RETRIES'],
               :recovery_proc => tor_recovery_proc,
               :operation_name => 'Tor operation', &block)
end

def retry_action(max_retries, options = {}, &block)
  assert(max_retries.is_a?(Integer), "max_retries must be an integer")
  options[:recovery_proc] ||= nil
  options[:operation_name] ||= 'Operation'

  retries = 1
  loop do
    begin
      block.call
      return
    rescue NameError => e
      # NameError most likely means typos, and hiding that is rarely
      # (never?) a good idea, so we rethrow them.
      raise e
    rescue Exception => e
      if retries <= max_retries
        debug_log("#{options[:operation_name]} failed (Try #{retries} of " +
                  "#{max_retries}) with:\n" +
                  "#{e.class}: #{e.message}")
        options[:recovery_proc].call if options[:recovery_proc]
        retries += 1
      else
        raise MaxRetriesFailure.new("#{options[:operation_name]} failed (despite retrying " +
                                    "#{max_retries} times) with\n" +
                                    "#{e.class}: #{e.message}")
      end
    end
  end
end

alias :retry_times :retry_action

class TorBootstrapFailure < StandardError
end

def wait_until_tor_is_working
  try_for(270) { $vm.execute('/usr/local/sbin/tor-has-bootstrapped').success? }
rescue Timeout::Error
  raise TorBootstrapFailure.new('Tor failed to bootstrap')
end

def convert_bytes_mod(unit)
  case unit
  when "bytes", "b" then mod = 1
  when "KB"         then mod = 10**3
  when "k", "KiB"   then mod = 2**10
  when "MB"         then mod = 10**6
  when "M", "MiB"   then mod = 2**20
  when "GB"         then mod = 10**9
  when "G", "GiB"   then mod = 2**30
  when "TB"         then mod = 10**12
  when "T", "TiB"   then mod = 2**40
  else
    raise "invalid memory unit '#{unit}'"
  end
  return mod
end

def convert_to_bytes(size, unit)
  return (size*convert_bytes_mod(unit)).to_i
end

def convert_to_MiB(size, unit)
  return (size*convert_bytes_mod(unit) / (2**20)).to_i
end

def convert_from_bytes(size, unit)
  return size.to_f/convert_bytes_mod(unit).to_f
end

def cmd_helper(cmd, env = {})
  if cmd.instance_of?(Array)
    cmd << {:err => [:child, :out]}
  elsif cmd.instance_of?(String)
    cmd += " 2>&1"
  end
  env = ENV.to_h.merge(env)
  IO.popen(env, cmd) do |p|
    out = p.readlines.join("\n")
    p.close
    ret = $?
    assert_equal(0, ret, "Command failed (returned #{ret}): #{cmd}:\n#{out}")
    return out
  end
end

def all_tor_hosts
  nodes = Array.new
  chutney_torrcs = Dir.glob(
    "#{$config['TMPDIR']}/chutney-data/nodes/*/torrc"
  )
  chutney_torrcs.each do |torrc|
    open(torrc) do |f|
      nodes += f.grep(/^(Or|Dir)Port\b/).map do |line|
        { address: $vmnet.bridge_ip_addr, port: line.split.last.to_i }
      end
    end
  end
  return nodes
end

def allowed_hosts_under_tor_enforcement
  all_tor_hosts + @lan_hosts
end

def get_free_space(machine, path)
  case machine
  when 'host'
    assert(File.exists?(path), "Path '#{path}' not found on #{machine}.")
    free = cmd_helper(["df", path])
  when 'guest'
    assert($vm.file_exist?(path), "Path '#{path}' not found on #{machine}.")
    free = $vm.execute_successfully("df '#{path}'")
  else
    raise 'Unsupported machine type #{machine} passed.'
  end
  output = free.split("\n").last
  return output.match(/[^\s]\s+[0-9]+\s+[0-9]+\s+([0-9]+)\s+.*/)[1].chomp.to_i
end

def random_string_from_set(set, min_len, max_len)
  len = (min_len..max_len).to_a.sample
  len ||= min_len
  (0..len-1).map { |n| set.sample }.join
end

def random_alpha_string(min_len, max_len = 0)
  alpha_set = ('A'..'Z').to_a + ('a'..'z').to_a
  random_string_from_set(alpha_set, min_len, max_len)
end

def random_alnum_string(min_len, max_len = 0)
  alnum_set = ('A'..'Z').to_a + ('a'..'z').to_a + (0..9).to_a.map { |n| n.to_s }
  random_string_from_set(alnum_set, min_len, max_len)
end

# Sanitize the filename from unix-hostile filename characters
def sanitize_filename(filename, options = {})
  options[:replacement] ||= '_'
  bad_unix_filename_chars = Regexp.new("[^A-Za-z0-9_\\-.,+:]")
  filename.gsub(bad_unix_filename_chars, options[:replacement])
end

def info_log_artifact_location(type, path)
  if $config['ARTIFACTS_BASE_URI']
    # Remove any trailing slashes, we'll add one ourselves
    base_url = $config['ARTIFACTS_BASE_URI'].gsub(/\/*$/, "")
    path = "#{base_url}/#{File.basename(path)}"
  end
  info_log("#{type.capitalize}: #{path}")
end

def notify_user(message)
  alarm_script = $config['NOTIFY_USER_COMMAND']
  return if alarm_script.nil? || alarm_script.empty?
  cmd_helper(alarm_script.gsub('%m', message))
end

def pause(message = "Paused")
  notify_user(message)
  STDERR.puts
  STDERR.puts message
  # Ring the ASCII bell for a helpful notification in most terminal
  # emulators.
  STDOUT.write "\a"
  STDERR.puts
  loop do
    STDERR.puts "Return: Continue; d: Debugging REPL"
    c = STDIN.getch
    case c
    when "\r"
      return
    when "d"
      binding.pry(quiet: true)
    end
  end
end

def dbus_send(service, object_path, method, *args, **opts)
  opts ||= {}
  ruby_type_to_dbus_type = {
    String => 'string',
    Fixnum => 'int32',
  }
  typed_args = args.map do |arg|
    type = ruby_type_to_dbus_type[arg.class]
    assert_not_nil(type, "No DBus type conversion for Ruby type '#{arg.class}'")
    "#{type}:#{arg}"
  end
  ret = $vm.execute_successfully(
    "dbus-send --print-reply --dest=#{service} #{object_path} " +
    "    #{method} #{typed_args.join(' ')}",
    **opts
  ).stdout.lines
  # The first line written is about timings and other stuff we don't
  # care about; we only care about the return values.
  ret.shift
  ret.map! do |s|
    type, val = /^\s*(\S+)\s+(\S+)$/.match(s)[1,2]
    case type
    when 'string'
      # Unquote
      val[1, val.length - 2]
    when 'int32'
      val.to_i
    else
      raise "No Ruby type conversion for DBus type '#{type}'"
    end
  end
  if ret.size == 0
    return nil
  elsif ret.size == 1
    return ret.first
  else
    return ret
  end
end