mesa_test 1.2.0 → 2.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mesa_test +644 -126
  3. data/lib/mesa_test.rb +685 -179
  4. metadata +7 -6
data/lib/mesa_test.rb CHANGED
@@ -8,11 +8,13 @@ require 'net/https'
8
8
  require 'thor'
9
9
  require 'json'
10
10
  require 'base64'
11
+ require 'digest'
11
12
 
12
13
  MesaDirError = Class.new(StandardError)
13
14
  TestCaseDirError = Class.new(StandardError)
14
15
  InvalidDataType = Class.new(StandardError)
15
16
  GitHubError = Class.new(StandardError)
17
+ TestHubError = Class.new(StandardError)
16
18
 
17
19
  GITHUB_HTTPS = 'https://github.com/MESAHub/mesa.git'.freeze
18
20
  GITHUB_SSH = 'git@github.com:MESAHub/mesa.git'.freeze
@@ -20,45 +22,53 @@ GITHUB_SSH = 'git@github.com:MESAHub/mesa.git'.freeze
20
22
  class MesaTestSubmitter
21
23
  DEFAULT_URI = 'https://testhub.mesastar.org'.freeze
22
24
 
25
+ # How to get an API key, for the setup wizard and for every command that
26
+ # finds none.
27
+ API_KEY_HELP = <<~HELP.freeze
28
+ mesa_test authenticates with a per-computer API key. To get one:
29
+ 1. Log in to MESATestHub (#{DEFAULT_URI}) and open My Computers.
30
+ 2. Open this computer's page (add the computer first if it isn't
31
+ listed; a computer used only for `search` and `count` is fine).
32
+ 3. Click "Generate key" and copy the key, which starts with mth_.
33
+ HELP
34
+
23
35
  # set up config file for computer
24
36
  def setup
25
37
  update do |s|
26
- shell.say 'This wizard will guide you through setting up a computer
27
- profile and default data for test case submissions to MESATestHub. You
28
- will be able to confirm entries at the end. Default/current values are always
29
- shown in parentheses at the end of a prompt. Pressing enter will accept the
30
- default values.
31
-
32
- To submit to MESATestHub, a valid computer name, email address, and password
33
- are all required. To actually run a test, you need to specify a location for
34
- your base MESA git repository. All other data are useful, but optional. Any data
35
- transferred to MESATestHub will be encrypted via HTTPS, but be warned that your
36
- e-mail and password will be stored in plain text.'
37
- # Get computer name
38
- response = shell.ask('What is the name of this computer (required)? ' \
39
- "(#{s.computer_name}):", :blue)
40
- s.computer_name = response unless response.empty?
41
-
42
- # Get user e-mail
43
- response = shell.ask 'What is the email you can be reached ' \
44
- "at (required)? (#{s.email}):", :blue
45
- s.email = response unless response.empty?
46
-
47
- # Get user password
48
- response = shell.ask 'What is the password associated with the email ' \
49
- "#{s.email} (required)? (#{s.password})", :blue
50
- s.password = response unless response.empty?
38
+ shell.say <<~INTRO
39
+ This wizard sets up this computer's profile for MESATestHub. Current
40
+ values are shown in parentheses; press enter to keep them. You can
41
+ confirm everything at the end.
42
+
43
+ #{API_KEY_HELP}
44
+ (MESATESTHUB_API_KEY, if set, overrides the key saved here.) The key
45
+ can only act as this one computer, can't log in to the website, and
46
+ can be revoked on its own, but it is stored in plain text in
47
+ #{config_file}, as is the logs token.
48
+ INTRO
49
+
50
+ ask_for_api_key(s)
51
+
52
+ # Optional run modes this computer can serve when MESATestHub asks for
53
+ # them (see `mesa_test request_work` and `install_and_test best`)
54
+ current = s.capabilities.select { |_, on| on }.keys.join(', ')
55
+ response = shell.ask 'Which optional test modes can this computer ' \
56
+ 'run when a commit asks for them? Any of: full_inlists, fpe, ' \
57
+ "converge, separated by commas, or 'none'. " \
58
+ "(#{current.empty? ? 'none' : current}):", :blue
59
+ s.capabilities = parse_capabilities(response) unless response.strip.empty?
51
60
 
52
61
  # Get API key for submitting failure logs
53
- response = shell.ask 'What is the logs submission API token associated '\
54
- "with the email #{s.email} (required; if you don't have one, ask a "\
55
- "MESA testing maintainer or a representative from Flatiron for a "\
56
- "key)? (#{s.logs_token})", :blue
62
+ response = shell.ask 'What is your token for uploading failure logs to ' \
63
+ "the logs server? (if you don't have one, ask a MESA testing " \
64
+ 'maintainer or a representative from Flatiron for a key) ' \
65
+ "(#{s.logs_token}):", :blue
57
66
  s.logs_token = response unless response.empty?
58
67
 
59
68
  # Determine if we'll use ssh or https to access github
60
69
  response = shell.ask 'When accessing GitHub, which protocol do you '\
61
- 'want to use?', :blue, limited_to: %w[ssh https]
70
+ 'want to use?', :blue, limited_to: %w[ssh https],
71
+ default: s.github_protocol.to_s
62
72
  s.github_protocol = response.strip.downcase.to_sym
63
73
 
64
74
  # Get location of source MESA repo (the mirror)
@@ -74,21 +84,6 @@ e-mail and password will be stored in plain text.'
74
84
  "files it uses are cached in the mirror repo to save time later. " \
75
85
  "(#{s.mesa_work}):", :blue
76
86
  s.mesa_work = response unless response.empty?
77
-
78
- # Get platform information
79
- response = shell.ask 'What is the platform of this computer (eg. ' \
80
- "macOS, Ubuntu)? (#{s.platform}):", :blue
81
- s.platform = response unless response.empty?
82
- response = shell.ask 'What is the version of the platform (eg. 10.15.5, ' \
83
- "Ubuntu 16.04)? (#{s.platform_version}):", :blue
84
- s.platform_version = response unless response.empty?
85
-
86
- # we are powerless to do change the location for now, so stop asking
87
- # about it
88
- # # Confirm save location
89
- # response = shell.ask "This will be saved in #{s.config_file}. Press " \
90
- # 'enter to accept or enter a new location:', :blue, path: true
91
- # s.config_file = response unless response.empty?
92
87
  end
93
88
 
94
89
  # Confirm data. If not confirmed, restart whole wizard.
@@ -100,6 +95,42 @@ e-mail and password will be stored in plain text.'
100
95
  end
101
96
  end
102
97
 
98
+ # Ask for the API key until MESATestHub accepts one, and take the computer
99
+ # name from it, since a key belongs to exactly one computer. If the hub
100
+ # can't be reached, keep the key and ask for the name instead.
101
+ def ask_for_api_key(s)
102
+ loop do
103
+ if ENV['MESATESTHUB_API_KEY']
104
+ shell.say "Using the API key in MESATESTHUB_API_KEY (#{s.masked_api_key}). " \
105
+ 'Unset it to enter one here.', :yellow
106
+ else
107
+ response = shell.ask 'What is the MESATestHub API key for this ' \
108
+ "computer? (#{s.masked_api_key || 'none'}):", :blue
109
+ s.api_key = response.strip unless response.strip.empty?
110
+ next shell.say('An API key is required.', :red) unless s.api_key
111
+ end
112
+
113
+ check = s.confirm_computer(computer_name: nil)
114
+ if check['valid']
115
+ s.computer_name = check['computer'] || s.computer_name
116
+ shell.say "Key accepted: this computer is #{s.computer_name} on MESATestHub.", :green
117
+ return
118
+ elsif check['reachable'] == false
119
+ shell.say 'Saving the key unverified.', :yellow
120
+ response = shell.ask 'What is the name of this computer on ' \
121
+ "MESATestHub? (#{s.computer_name}):", :blue
122
+ s.computer_name = response unless response.empty?
123
+ return
124
+ end
125
+
126
+ shell.say check['message'], :red
127
+ if ENV['MESATESTHUB_API_KEY']
128
+ raise TestHubError, 'MESATESTHUB_API_KEY was refused; fix or unset it.'
129
+ end
130
+ s.api_key = nil
131
+ end
132
+ end
133
+
103
134
  def self.new_from_config(
104
135
  config_file: File.join(ENV['HOME'], '.mesa_test', 'config.yml'),
105
136
  force_setup: false,
@@ -107,6 +138,9 @@ e-mail and password will be stored in plain text.'
107
138
  )
108
139
  new_submitter = new(config_file: config_file, base_uri: base_uri)
109
140
  if force_setup
141
+ # Start the wizard from the current settings, including a 1.x
142
+ # config's (logs token, directories), so upgrading keeps them.
143
+ new_submitter.load_computer_data if File.exist? config_file
110
144
  new_submitter.setup
111
145
  elsif not File.exist? config_file
112
146
  puts "No such config file #{config_file}. Starting setup wizard."
@@ -116,45 +150,41 @@ e-mail and password will be stored in plain text.'
116
150
  return new_submitter
117
151
  end
118
152
 
119
- attr_accessor :computer_name, :user_name, :email, :password, :platform,
120
- :mesa_mirror, :mesa_work, :platform_version, :processor,
121
- :config_file, :base_uri, :github_protocol, :logs_token
153
+ # Optional run modes a commit can request through its message
154
+ # (`[ci optional]`, `[ci fpe]`, `[ci converge]`), in MESATestHub's names.
155
+ CAPABILITIES = %i[full_inlists fpe converge].freeze
156
+
157
+ attr_accessor :computer_name, :user_name,
158
+ :mesa_mirror, :mesa_work, :processor,
159
+ :config_file, :base_uri, :github_protocol, :logs_token,
160
+ :capabilities
161
+ attr_writer :api_key
122
162
 
123
163
  attr_reader :shell
124
164
 
125
165
  # many defaults are set in body
126
166
  def initialize(
127
- computer_name: nil, user_name: nil, email: nil, github_protocol: nil,
128
- mesa_mirror: nil, platform: nil, platform_version: nil, processor: nil,
167
+ computer_name: nil, user_name: nil, github_protocol: nil,
168
+ mesa_mirror: nil, processor: nil,
129
169
  config_file: nil, base_uri: nil, logs_token: nil
130
170
  )
131
171
  @computer_name = computer_name || Socket.gethostname.scan(/^[^\.]+\.?/)[0]
132
172
  @computer_name.chomp!('.') if @computer_name
133
173
  @user_name = user_name || (ENV['USER'] || ENV['USERNAME'])
134
- @email = email || ''
135
- @password = password || ''
136
174
  @github_protocol = github_protocol || :ssh
137
175
  @mesa_mirror = mesa_mirror ||
138
176
  File.join(ENV['HOME'], '.mesa_test', 'mirror')
139
177
  @mesa_work = mesa_work ||
140
178
  File.join(ENV['HOME'], '.mesa_test', 'work')
141
- @platform = platform
142
- if @platform.nil?
143
- @platform =
144
- if OS.osx?
145
- 'macOS'
146
- elsif OS.linux?
147
- 'Linux'
148
- else
149
- ''
150
- end
151
- end
152
- @platform_version = platform_version || ''
153
179
  @processor = processor || ''
154
180
  @config_file = config_file || File.join(ENV['HOME'], '.mesa_test',
155
181
  'config.yml')
156
- @base_uri = base_uri
182
+ # MESATESTHUB_URL points the client at another testhub (a local dev
183
+ # server, staging) without editing MODE in bin/mesa_test.
184
+ @base_uri = ENV['MESATESTHUB_URL'] || base_uri
157
185
  @logs_token = logs_token || ENV['MESA_LOGS_TOKEN']
186
+ @api_key = nil
187
+ @capabilities = CAPABILITIES.map { |c| [c, false] }.to_h
158
188
 
159
189
  # set up thor-proof way to get responses from user. Thor hijacks the
160
190
  # gets command, so we have to use its built-in "ask" method, which is
@@ -169,17 +199,52 @@ e-mail and password will be stored in plain text.'
169
199
  yield self if block_given?
170
200
  end
171
201
 
202
+ # The per-computer API key: MESATESTHUB_API_KEY from the environment wins
203
+ # over the config file, so a cluster job can supply it without the key
204
+ # ever being written to disk. nil means no key (see #require_api_key!).
205
+ def api_key
206
+ key = ENV['MESATESTHUB_API_KEY'] || @api_key
207
+ key.nil? || key.strip.empty? ? nil : key.strip
208
+ end
209
+
210
+ # Every MESATestHub request needs a key, so commands that talk to the hub
211
+ # call this first, before checking anything out or building.
212
+ def require_api_key!
213
+ return if api_key
214
+
215
+ raise TestHubError, <<~MSG
216
+ No MESATestHub API key is set. (This version of mesa_test no longer
217
+ signs in with your email and password.)
218
+
219
+ #{API_KEY_HELP}
220
+ Then run `mesa_test setup` and paste it when asked, or export
221
+ MESATESTHUB_API_KEY, which overrides the config file (e.g. to keep the
222
+ key off disk on a cluster).
223
+ MSG
224
+ end
225
+
226
+ def masked_api_key
227
+ key = api_key
228
+ key && "#{key[0, 10]}..."
229
+ end
230
+
231
+ # "fpe, full_inlists" => { full_inlists: true, fpe: true, converge: false }
232
+ def parse_capabilities(text)
233
+ wanted = text.downcase.split(/[\s,]+/).map(&:to_sym)
234
+ CAPABILITIES.map { |c| [c, wanted.include?(c)] }.to_h
235
+ end
236
+
172
237
  def confirm_computer_data
173
- puts 'Ready to submit the following data:'
238
+ puts "This computer's mesa_test settings:"
174
239
  puts '-------------------------------------------------------'
175
240
  puts "Computer Name #{computer_name}"
176
- puts "User email #{email}"
177
- puts 'Password ***********'
241
+ puts "API key #{masked_api_key || 'none'}"
242
+ puts "Optional test modes #{capabilities.select { |_, on| on }.keys.join(', ').then { |c| c.empty? ? 'none' : c }}"
178
243
  puts "logs API token #{logs_token}"
179
244
  puts "GitHub Protocol #{github_protocol}"
180
245
  puts "MESA Mirror Location #{mesa_mirror}"
181
246
  puts "MESA Work Location #{mesa_work}"
182
- puts "Platform #{platform} #{platform_version}"
247
+ puts "OS (detected each run) #{platform_version}"
183
248
  # puts "Config location #{config_file}"
184
249
  puts '-------------------------------------------------------'
185
250
  puts ''
@@ -187,53 +252,92 @@ e-mail and password will be stored in plain text.'
187
252
  response.strip.casecmp('y').zero?
188
253
  end
189
254
 
190
- # For one "computer" on the web server, and for [subjective] consistency
191
- # reasons, the platform, processor, and RAM cannot be changed! If you
192
- # change platforms (i.e. switch from mac to linux, or change between linux
193
- # flavors), you should create a new computer account. Similarly, create new
194
- # computer accounts if you change your RAM or processor. You do not need
195
- # to change computers if you upgrade your platform (macOS 10.12 -> 10.13
196
255
  def save_computer_data
197
256
  data_hash = {
198
257
  'computer_name' => computer_name,
199
- 'email' => email,
200
- 'password' => password,
258
+ 'api_key' => @api_key,
259
+ 'capabilities' => capabilities.select { |_, on| on }.keys.map(&:to_s),
201
260
  'logs_token' => logs_token,
202
261
  'github_protocol' => github_protocol,
203
262
  'mesa_mirror' => mesa_mirror,
204
- 'mesa_work' => mesa_work,
205
- 'platform' => platform,
206
- 'platform_version' => platform_version,
263
+ 'mesa_work' => mesa_work
207
264
  }
208
265
  # make sure there's a directory to write to
209
266
  unless dir_or_symlink_exists? File.dirname(config_file)
210
267
  FileUtils.mkdir_p File.dirname(config_file)
211
268
  end
212
- File.open(config_file, 'w') { |f| f.write(YAML.dump(data_hash)) }
269
+ # Owner-only: the file holds the API key and logs token.
270
+ File.write(config_file, YAML.dump(data_hash), perm: 0o600)
271
+ File.chmod(0o600, config_file)
213
272
  end
214
273
 
274
+ # A setting missing from the file (e.g. capabilities in a 1.x config)
275
+ # keeps its default.
215
276
  def load_computer_data
216
- data_hash = YAML.load(File.read(config_file))
217
- @computer_name = data_hash['computer_name']
218
- @email = data_hash['email']
219
- @password = data_hash['password']
220
- @logs_token = data_hash['logs_token']
221
- @github_protocol = data_hash['github_protocol'].to_sym
222
- @mesa_mirror = data_hash['mesa_mirror']
223
- @mesa_work = data_hash['mesa_work']
224
- @platform = data_hash['platform']
225
- @platform_version = data_hash['platform_version']
277
+ data_hash = YAML.load(File.read(config_file)) || {}
278
+ @computer_name = data_hash.fetch('computer_name', @computer_name)
279
+ @api_key = data_hash.fetch('api_key', @api_key)
280
+ if data_hash.key?('capabilities')
281
+ @capabilities = parse_capabilities(Array(data_hash['capabilities']).join(','))
282
+ end
283
+ @logs_token = data_hash['logs_token'] || @logs_token
284
+ @github_protocol = (data_hash['github_protocol'] || @github_protocol).to_sym
285
+ @mesa_mirror = data_hash.fetch('mesa_mirror', @mesa_mirror)
286
+ @mesa_work = data_hash.fetch('mesa_work', @mesa_work)
287
+ end
288
+
289
+ # This computer's OS version, detected (once per run) rather than
290
+ # configured, since a typed-in value goes stale with the first OS update.
291
+ # MESATestHub shows it after the computer's platform (macOS / linux), so
292
+ # macOS sends just the number and Linux the distribution:
293
+ # "27.0.1", "Rocky Linux 8.10", "Ubuntu 24.04 (WSL)".
294
+ def platform_version
295
+ @platform_version ||= self.class.detect_platform_version
296
+ end
297
+
298
+ def self.detect_platform_version
299
+ version = OS.mac? ? `sw_vers -productVersion 2>/dev/null`.strip : linux_version
300
+ version = `uname -sr 2>/dev/null`.strip if version.to_s.empty?
301
+ version
302
+ rescue StandardError
303
+ `uname -sr 2>/dev/null`.strip rescue ''
304
+ end
305
+
306
+ # The freedesktop os-release standard (every current distribution, and
307
+ # containers; /usr/lib/os-release is its fallback location):
308
+ # NAME="Rocky Linux", VERSION_ID="8.10". Rolling releases (Arch) have no
309
+ # VERSION_ID. RHEL/CentOS 6 predate it but have /etc/redhat-release.
310
+ def self.linux_version
311
+ path = %w[/etc/os-release /usr/lib/os-release].find { |f| File.readable?(f) }
312
+ name =
313
+ if path
314
+ os = File.readlines(path, chomp: true)
315
+ .map { |line| line.split('=', 2) }.select { |kv| kv.size == 2 }
316
+ .to_h { |k, v| [k.strip, v.strip.delete('"\'')] }
317
+ [os['NAME'], os['VERSION_ID']].compact.join(' ')
318
+ elsif File.readable?('/etc/redhat-release')
319
+ # "CentOS release 6.10 (Final)"
320
+ File.read('/etc/redhat-release').strip.sub(/ \(.*\)\z/, '')
321
+ end
322
+ return name if name.to_s.empty?
323
+
324
+ wsl = File.readable?('/proc/version') && File.read('/proc/version').match?(/microsoft/i)
325
+ wsl ? "#{name} (WSL)" : name
226
326
  end
227
327
 
228
328
  # Parameters to be submitted in JSON format for reporting information about
229
- # the submitting user and computer
329
+ # the submitting computer. The API key identifies it; the name is a
330
+ # cross-check (the hub refuses a key that belongs to another computer).
230
331
  def submitter_params
231
- {
232
- email: email,
233
- password: password,
234
- computer: computer_name,
235
- platform_version: platform_version
236
- }
332
+ { computer: computer_name, platform_version: platform_version }
333
+ end
334
+
335
+ # Headers for every MESATestHub request.
336
+ def testhub_headers(json_body: true)
337
+ headers = { 'Accept' => 'application/json' }
338
+ headers['Content-Type'] = 'application/json' if json_body
339
+ headers['Authorization'] = "Bearer #{api_key}" if api_key
340
+ headers
237
341
  end
238
342
 
239
343
  # Parameters to be submitted in JSON format for reporting information about
@@ -261,28 +365,55 @@ e-mail and password will be stored in plain text.'
261
365
  # test cases and the test results. These will be encoded as an array of
262
366
  # JSON objects.
263
367
  def instance_params(mesa)
264
- has_errors = []
265
- res = []
266
- mesa.test_case_names.each do |mod, names|
267
- names.each do |test_name|
268
- begin
269
- test_case = mesa.test_cases[mod][test_name]
270
- res << test_case.results_hash
271
- rescue TestCaseDirError
272
- # shell.say "It appears that #{test_case.test_name} has not been "\
273
- # 'run yet. Unable to submit data for this test.', :red
274
- has_errors << test_case
368
+ submittable_tests(mesa).map(&:results_hash)
369
+ end
370
+
371
+ # Tests with results from *this* build: run, readable, and not older than
372
+ # the build itself. A reused work directory (`install --no-checkout`) can
373
+ # still hold testhub.yml files from an earlier build; those would be filed
374
+ # under the wrong commit, so they're skipped (and counted).
375
+ def submittable_tests(mesa)
376
+ built_at = mesa.built_at
377
+ unread = []
378
+ stale = 0
379
+ tests = mesa.test_case_names.flat_map do |mod, names|
380
+ names.filter_map do |test_name|
381
+ test_case = mesa.test_cases[mod][test_name]
382
+ next unless test_case&.ran?
383
+
384
+ if built_at && File.mtime(test_case.testhub_file) < built_at
385
+ stale += 1
386
+ next
275
387
  end
388
+ test_case.results_hash
389
+ test_case
390
+ rescue TestCaseDirError, Psych::Exception
391
+ unread << test_case
392
+ nil
276
393
  end
277
394
  end
278
- unless has_errors.empty?
279
- shell.say "The following test cases could NOT be read for submission:",
280
- :red
281
- has_errors.each do |test_case|
282
- shell.say "- #{test_case.test_name}", :red
283
- end
395
+ if stale.positive?
396
+ shell.say "Skipping #{stale} test result#{'s' unless stale == 1} older than this " \
397
+ 'build (left over from an earlier one).', :yellow
284
398
  end
285
- res
399
+ unless unread.empty?
400
+ shell.say 'The following test cases could NOT be read for submission:', :red
401
+ unread.each { |test_case| shell.say "- #{test_case.test_name}", :red }
402
+ end
403
+ tests
404
+ end
405
+
406
+ # The build's recorded run modes in the submissions API's `claim:` block
407
+ # (use_* flags; informational on the server, which reads the modes each
408
+ # test actually ran with from its testhub.yml).
409
+ def run_mode_params(mesa)
410
+ modes = mesa.recorded_run_modes
411
+ {
412
+ use_fpe: modes[:fpe] == true,
413
+ use_full_inlists: modes[:skip_optional] == false,
414
+ use_converge: modes[:resolution_factor].is_a?(String) ||
415
+ modes[:resolution_factor].is_a?(Numeric)
416
+ }
286
417
  end
287
418
 
288
419
  # Parameters for reporting a failed compilation to the logs server
@@ -314,23 +445,39 @@ e-mail and password will be stored in plain text.'
314
445
  [test_case.results_hash]
315
446
  end
316
447
 
317
- # Phone home to testhub and confirm that computer and user are valid. Useful
318
- # for confirming that submissions will be accepted before wasting time on a
319
- # test later.
320
- def confirm_computer
448
+ # Phone home to testhub and confirm that the API key is valid (and, given a
449
+ # computer name, that it belongs to that computer). Useful for confirming
450
+ # that submissions will be accepted before wasting time on a test later.
451
+ # 'reachable' is false when the hub couldn't be asked at all.
452
+ def confirm_computer(computer_name: self.computer_name)
321
453
  uri = URI.parse(base_uri + '/check_computer.json')
322
454
  https = Net::HTTP.new(uri.hostname, uri.port)
323
455
  https.use_ssl = base_uri.include? 'https'
324
456
 
325
- request = Net::HTTP::Post.new(
326
- uri, initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
327
- )
328
- request.body = {
329
- email: email,
330
- password: password,
331
- computer_name: computer_name
332
- }.to_json
333
- JSON.parse(https.request(request).body).to_hash
457
+ request = Net::HTTP::Post.new(uri, testhub_headers)
458
+ request.body = { computer_name: computer_name }.compact.to_json
459
+ response = testhub_request(https, request)
460
+ # if the hub was unreachable, behave as an unverified computer; the
461
+ # network error has already been reported by testhub_request
462
+ if response.nil?
463
+ return { 'valid' => false, 'reachable' => false,
464
+ 'message' => 'Could not verify this computer with MESATestHub.' }
465
+ end
466
+
467
+ # Anything but a 200 (a 429 from the rate limiter, a 5xx, a proxy's HTML
468
+ # error page) carries no valid/message keys, so name the status and the
469
+ # server's error instead of failing with a blank line.
470
+ unless response.is_a?(Net::HTTPOK)
471
+ return { 'valid' => false,
472
+ 'message' => 'Could not verify this computer with MESATestHub ' \
473
+ "(HTTP #{response.code}: #{error_message(response)})." }
474
+ end
475
+
476
+ JSON.parse(response.body).to_hash
477
+ rescue JSON::ParserError
478
+ { 'valid' => false,
479
+ 'message' => 'Could not verify this computer: MESATestHub sent an ' \
480
+ 'unreadable response.' }
334
481
  end
335
482
 
336
483
  # submit entire commit's worth of test cases, OR submit compilation status
@@ -344,10 +491,7 @@ e-mail and password will be stored in plain text.'
344
491
  https = Net::HTTP.new(uri.hostname, uri.port)
345
492
  https.use_ssl = true if base_uri.include? 'https'
346
493
 
347
- request = Net::HTTP::Post.new(
348
- uri,
349
- initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
350
- )
494
+ request = Net::HTTP::Post.new(uri, testhub_headers)
351
495
 
352
496
  # create the request body for submission to the submissions API
353
497
  #
@@ -358,19 +502,32 @@ e-mail and password will be stored in plain text.'
358
502
  empty ||= !mesa.installed?
359
503
  request_data = {submitter: submitter_params,
360
504
  commit: commit_params(mesa, empty: empty, entire: !empty)}
505
+ request_data[:claim] = run_mode_params(mesa) if mesa.recorded_run_modes.any?
506
+ # Fingerprints let MESATestHub ignore results it already has (a retried
507
+ # submit, a head node re-sending what compute nodes sent).
508
+ fingerprints = {}
509
+ fingerprints['build'] = mesa.build_fingerprint if mesa.build_fingerprint
361
510
  # don't need test instances if it's an empty submission or if compilation
362
511
  # failed
512
+ tests = []
363
513
  if !empty && request_data[:commit][:compiled]
364
- request_data[:instances] = instance_params(mesa)
514
+ tests = submittable_tests(mesa)
515
+ request_data[:instances] = tests.map(&:results_hash)
516
+ tests.each { |t| fingerprints[t.fingerprint_key] = t.fingerprint }
365
517
  end
518
+ request_data[:fingerprints] = fingerprints unless fingerprints.empty?
366
519
  request.body = request_data.to_json
367
520
 
368
521
  # actually do the submission
369
- response = https.request request
522
+ response = testhub_request(https, request)
370
523
 
371
- if !response.is_a? Net::HTTPCreated
524
+ if response.nil?
525
+ # network failure; testhub_request already explained why
526
+ false
527
+ elsif !response.is_a? Net::HTTPCreated
372
528
  shell.say "\nFailed to submit some or all test case instances and/or "\
373
- 'commit data.', :red
529
+ "commit data (server responded #{response.code} "\
530
+ "#{response.message}).", :red
374
531
  false
375
532
  else
376
533
  shell.say "\nSuccessfully submitted commit #{mesa.sha}.", :green
@@ -382,15 +539,13 @@ e-mail and password will be stored in plain text.'
382
539
 
383
540
  # compilation succeded, so submit any logs for failing tests
384
541
  res = true
385
- unless empty
386
- mesa.test_cases.each do |mod, test_case_hash|
387
- test_case_hash.each do |tc_name, test_case|
388
- # get at each individual test case, see if it failed, and if it
389
- # did, submit its log files
390
- if !test_case.passed? || force_logs
391
- res &&= submit_test_log(test_case, skip_passing: !force_logs)
392
- end
393
- end
542
+ # logs for the failing tests just submitted (or all, with force_logs)
543
+ tests.each do |test_case|
544
+ if !test_case.passed? || force_logs
545
+ # (not `res &&= submit_test_log(...)`: after one failed upload that
546
+ # would skip every remaining test's logs)
547
+ sent = submit_test_log(test_case, skip_passing: !force_logs)
548
+ res &&= sent
394
549
  end
395
550
  end
396
551
 
@@ -412,10 +567,7 @@ e-mail and password will be stored in plain text.'
412
567
  https = Net::HTTP.new(uri.hostname, uri.port)
413
568
  https.use_ssl = true if base_uri.include? 'https'
414
569
 
415
- request = Net::HTTP::Post.new(
416
- uri,
417
- initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
418
- )
570
+ request = Net::HTTP::Post.new(uri, testhub_headers)
419
571
 
420
572
  # create the request body for submission to the submissions API
421
573
  #
@@ -423,16 +575,22 @@ e-mail and password will be stored in plain text.'
423
575
  # entire (... there is only test case)
424
576
  request_data = {submitter: submitter_params,
425
577
  commit: commit_params(mesa, empty: false, entire: false),
426
- instances: single_instance_params(test_case)}
578
+ instances: single_instance_params(test_case),
579
+ fingerprints: { test_case.fingerprint_key => test_case.fingerprint }}
580
+ request_data[:claim] = run_mode_params(mesa) if mesa.recorded_run_modes.any?
427
581
  request.body = request_data.to_json
428
582
 
429
583
  # actually do the submission
430
- response = https.request request
584
+ response = testhub_request(https, request)
431
585
 
432
- if !response.is_a? Net::HTTPCreated
586
+ if response.nil?
587
+ # network failure; testhub_request already explained why
588
+ return false
589
+ elsif !response.is_a? Net::HTTPCreated
433
590
  shell.say "\nFailed to submit #{test_case.test_name} for commit "\
434
- "#{mesa.sha}", :red
435
- false
591
+ "#{mesa.sha} (server responded #{response.code} "\
592
+ "#{response.message}).", :red
593
+ return false
436
594
  else
437
595
  shell.say "\nSuccessfully submitted instance of #{test_case.test_name} "\
438
596
  "for commit #{mesa.sha}.", :green
@@ -444,17 +602,50 @@ e-mail and password will be stored in plain text.'
444
602
  end
445
603
  end
446
604
 
605
+ # Perform an HTTP request against the test hub with bounded connect/read
606
+ # timeouts, so a slow or unreachable server (e.g. while it is under heavy
607
+ # load) fails fast with a clear message instead of hanging on the default
608
+ # 60-second connect timeout and then dumping a raw Ruby backtrace. Returns
609
+ # the Net::HTTPResponse, or +nil+ if the request could not be completed
610
+ # because of a network problem.
611
+ def testhub_request(https, request)
612
+ https.open_timeout = 10
613
+ https.read_timeout = 60
614
+ https.request(request)
615
+ rescue StandardError => e
616
+ shell.say "\nCould not reach the test hub at #{https.address} "\
617
+ "(#{e.class}: #{e.message}).", :red
618
+ nil
619
+ end
620
+
447
621
  # make generic request to LOGS server
448
622
  # +params+ is a hash of data to be encoded as JSON and sent off
623
+ #
624
+ # Returns the Net::HTTPResponse on success, or +nil+ if the request could
625
+ # not be completed because of a network problem (the LOGS server being
626
+ # unreachable, slow, or refusing connections). The LOGS server only receives
627
+ # diagnostic build/test output, so a failure here must never crash the run or
628
+ # fail a CI build whose actual test results already reached the test hub. We
629
+ # cap the connect/read time so we fail fast instead of hanging on the default
630
+ # 60-second open timeout for every test case.
449
631
  def submit_logs(params)
450
632
  #uri = URI('https://logs.mesastar.org/uploads')
451
633
  uri = URI('https://mesa-logs.flatironinstitute.org/uploads')
452
634
  https = Net::HTTP.new(uri.host, uri.port)
453
635
  https.use_ssl = true
636
+ https.open_timeout = 10
637
+ https.read_timeout = 30
454
638
  req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json',
455
639
  'X-Api-Key' => logs_token)
456
640
  req.body = params.to_json
457
- https.request(req)
641
+ begin
642
+ https.request(req)
643
+ rescue StandardError => e
644
+ shell.say "\nCould not reach the LOGS server at #{uri.host} "\
645
+ "(#{e.class}: #{e.message}). Skipping log upload; this does "\
646
+ 'not affect test results already sent to the test hub.', :yellow
647
+ nil
648
+ end
458
649
  end
459
650
 
460
651
  # send build log to the logs server
@@ -473,9 +664,13 @@ e-mail and password will be stored in plain text.'
473
664
  res = submit_logs(build_log_params(mesa))
474
665
 
475
666
  # report out results
476
- if !res.is_a? Net::HTTPOK
667
+ if res.nil?
668
+ # network failure; submit_logs already explained why. Don't fail the run.
669
+ false
670
+ elsif !res.is_a? Net::HTTPOK
477
671
  shell.say "\nFailed to submit build.log to the LOGS server for commit "\
478
- "#{mesa.sha}.", :red
672
+ "#{mesa.sha} (server responded #{res.code} #{res.message}).",
673
+ :red
479
674
  false
480
675
  else
481
676
  shell.say "\nSuccessfully submitted build.log to the LOGS server for "\
@@ -503,9 +698,13 @@ e-mail and password will be stored in plain text.'
503
698
  res = submit_logs(test_log_params(test_case))
504
699
 
505
700
  # report out results
506
- if !res.is_a? Net::HTTPOK
701
+ if res.nil?
702
+ # network failure; submit_logs already explained why. Don't fail the run.
703
+ false
704
+ elsif !res.is_a? Net::HTTPOK
507
705
  shell.say "Failed to submit logs for test case #{test_case.test_name} "\
508
- "in commit #{test_case.mesa.sha}.", :red
706
+ "in commit #{test_case.mesa.sha} (server responded "\
707
+ "#{res.code} #{res.message}).", :red
509
708
  false
510
709
  else
511
710
  shell.say "Successfully submitted logs for test case "\
@@ -515,9 +714,8 @@ e-mail and password will be stored in plain text.'
515
714
  end
516
715
  end
517
716
 
518
- # GET a search query from the testhub. Credentials and the query string
519
- # ride in the URL's query parameters (per the testhub search API contract;
520
- # the controller also accepts session auth, but a CLI sends them in-band).
717
+ # GET a search query from the testhub. The query rides in the URL's
718
+ # query_text parameter; the API key in the Authorization header.
521
719
  # Returns the raw Net::HTTP response.
522
720
  def search(query_text)
523
721
  get_search('/test_instances/search.json', query_text)
@@ -529,23 +727,112 @@ e-mail and password will be stored in plain text.'
529
727
  get_search('/test_instances/search_count.json', query_text)
530
728
  end
531
729
 
730
+ # What a claimed run is running on, for MESATestHub to show on pending
731
+ # tests (so a long-running claim can be explained: an old machine on two
732
+ # threads, a different SDK). Only what's actually known is sent:
733
+ # - with a built +mesa+, its testhub.yml compiler record (SDK, compiler,
734
+ # math backend), which is accurate even if the shell's SDK changed
735
+ # - otherwise (a build claim, made before compiling) just the SDK the
736
+ # shell has active, from mesasdk_init.sh's MESASDK_VERSION
737
+ # - OMP_NUM_THREADS and this computer's platform version
738
+ def claim_environment(mesa = nil)
739
+ env = {}
740
+ if mesa&.install_attempted?
741
+ env.merge!(mesa.compiler_hash.reject { |_k, v| v.to_s.strip.empty? || v == 'Unknown' })
742
+ end
743
+ sdk = ENV['MESASDK_VERSION'].to_s.strip
744
+ env[:sdk_version] ||= sdk unless sdk.empty?
745
+ threads = ENV['OMP_NUM_THREADS'].to_i
746
+ env[:omp_num_threads] = threads if threads.positive?
747
+ env[:platform_version] = platform_version unless platform_version.to_s.strip.empty?
748
+ env
749
+ end
750
+
751
+ # Ask MESATestHub what this computer should do next (POST /api/v1/dispatch).
752
+ # +scope+ is 'build' (which commit to build) or 'test' (which test to run;
753
+ # pass the +sha+ you built). +capabilities+ defaults to the configured
754
+ # optional modes; for a test, +fpe+ should say whether *this build* has
755
+ # FPE checks, since MESA_FPE_CHECKS_ON is a compile-time switch.
756
+ #
757
+ # Returns the recommendation as a Hash (commit_sha, branch, scope,
758
+ # test_case_module, test_case_name, flags, reasons, ...), or nil when
759
+ # there's nothing useful to do. Raises TestHubError on any other answer.
760
+ def dispatch(scope:, sha: nil, capabilities: self.capabilities)
761
+ body = { scope: scope }
762
+ body[:commit_sha] = sha if sha
763
+ CAPABILITIES.each { |c| body[:"can_#{c}"] = !!capabilities[c] }
764
+ response = post_testhub('/api/v1/dispatch', submitter: submitter_params, dispatch: body)
765
+ raise TestHubError, 'Dispatch failed: could not reach MESATestHub.' if response.nil?
766
+ return nil if response.is_a?(Net::HTTPNoContent)
767
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
768
+
769
+ raise TestHubError, "Dispatch failed (HTTP #{response.code}): #{error_message(response)}"
770
+ end
771
+
772
+ # Tell MESATestHub this computer is about to build +sha+ (scope 'build') or
773
+ # run a test on it (scope 'test' with +mod+ and +test_case+, or
774
+ # +all_test_cases: true+ for the whole suite). The commit then shows as
775
+ # pending instead of untested. The later submission fulfills the claim by
776
+ # matching computer, commit, and test, so nothing needs remembering here.
777
+ #
778
+ # Claims are advisory: a failure is reported and swallowed (returns nil) so
779
+ # it never stops a test run.
780
+ #
781
+ # The claim also says what the run is running on (see #claim_environment),
782
+ # which MESATestHub shows on the pending test. Pass +mesa+ for test claims
783
+ # so the build's own compiler record is used.
784
+ def claim(sha:, scope:, mod: nil, test_case: nil, all_test_cases: false,
785
+ flags: {}, dispatched_at: nil, mesa: nil)
786
+ body = { commit_sha: sha, scope: scope }
787
+ body.merge!(claim_environment(scope.to_s == 'test' ? mesa : nil))
788
+ body[:test_case_module] = mod.to_s if mod
789
+ body[:test_case_name] = test_case if test_case
790
+ body[:all_test_cases] = true if all_test_cases
791
+ body[:dispatched_at] = dispatched_at if dispatched_at
792
+ CAPABILITIES.each { |c| body[:"use_#{c}"] = !!flags[c] }
793
+ response = post_testhub('/api/v1/claims', submitter: submitter_params, claim: body)
794
+ return nil if response.nil? # unreachable; testhub_request said so
795
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPCreated)
796
+
797
+ shell.say "Could not claim #{claim_label(scope, mod, test_case, all_test_cases)} " \
798
+ "(HTTP #{response.code}: #{error_message(response)}); continuing anyway.",
799
+ :yellow
800
+ nil
801
+ end
802
+
532
803
  private
533
804
 
805
+ # POST JSON to the hub with testhub_request's bounded timeouts. Returns
806
+ # the response, or nil after a network failure (already reported).
807
+ def post_testhub(path, payload)
808
+ uri = URI.parse(base_uri + path)
809
+ https = Net::HTTP.new(uri.hostname, uri.port)
810
+ https.use_ssl = base_uri.include? 'https'
811
+ request = Net::HTTP::Post.new(uri, testhub_headers)
812
+ request.body = payload.to_json
813
+ testhub_request(https, request)
814
+ end
815
+
816
+ def error_message(response)
817
+ JSON.parse(response.body.to_s)['error'] || response.message
818
+ rescue JSON::ParserError
819
+ response.message
820
+ end
821
+
822
+ def claim_label(scope, mod, test_case, all_test_cases)
823
+ return 'the build' if scope.to_s == 'build'
824
+ return 'every test' if all_test_cases
825
+ "#{mod}/#{test_case}"
826
+ end
827
+
534
828
  def get_search(path, query_text)
535
829
  uri = URI.parse(base_uri + path)
536
- uri.query = URI.encode_www_form(
537
- email: email,
538
- password: password,
539
- query_text: query_text
540
- )
830
+ uri.query = URI.encode_www_form(query_text: query_text)
541
831
 
542
832
  https = Net::HTTP.new(uri.hostname, uri.port)
543
833
  https.use_ssl = base_uri.include? 'https'
544
834
 
545
- request = Net::HTTP::Get.new(
546
- uri,
547
- initheader = { 'Accept' => 'application/json' }
548
- )
835
+ request = Net::HTTP::Get.new(uri, testhub_headers(json_body: false))
549
836
 
550
837
  https.request(request)
551
838
  end
@@ -738,23 +1025,65 @@ class Mesa
738
1025
  b64_file(build_log)
739
1026
  end
740
1027
 
1028
+ COMPILER_KEYS = %i[compiler compiler_version sdk_version math_backend].freeze
1029
+
741
1030
  # sourced from $MESA_DIR/testhub.yml, which should be created after
742
- # installation
1031
+ # installation. Only the compiler keys are taken: the file also carries
1032
+ # mesa_test's own run-mode block (see #record_run_modes), which doesn't
1033
+ # belong in the commit payload.
743
1034
  def compiler_hash
744
- data_file = File.join(mesa_dir, 'testhub.yml')
745
1035
  res = {
746
1036
  compiler: 'Unknown',
747
1037
  sdk_version: 'Unknown',
748
1038
  math_backend: 'Unknown'
749
1039
  }
750
- if File.exist? data_file
751
- res = res.merge(YAML.safe_load(File.read(data_file)) || {})
752
- # currently version_number is reported, but we don't need that in Git land
753
- res.delete('version_number') # returns the value, not the updated hash
754
- res
1040
+ # testhub.yml has string keys; symbolize them so they replace the
1041
+ # defaults above rather than sitting beside them as duplicate keys
1042
+ # (which json >= 3 refuses to serialize).
1043
+ found = install_data.transform_keys(&:to_sym).slice(*COMPILER_KEYS)
1044
+ res.merge(found)
1045
+ end
1046
+
1047
+ # The key under which `mesa_test install` records, in $MESA_DIR/testhub.yml,
1048
+ # the run modes this build was made for. The file is written by MESA's own
1049
+ # install (math/test), so it is recreated on every build and can't go stale.
1050
+ RUN_MODES_KEY = 'mesa_test_run_modes'.freeze
1051
+
1052
+ # Run modes recorded at install, as a hash with any of
1053
+ # fpe: true/false (compiled with MESA_FPE_CHECKS_ON?)
1054
+ # skip_optional: true/false (MESA_SKIP_OPTIONAL set or unset)
1055
+ # resolution_factor: a factor string, or false for "unset"
1056
+ # Only modes that were explicitly chosen at install are present; anything
1057
+ # absent falls back to the caller's environment.
1058
+ def recorded_run_modes
1059
+ (install_data[RUN_MODES_KEY] || {}).transform_keys(&:to_sym)
1060
+ .slice(*RUN_MODE_KEYS)
1061
+ end
1062
+
1063
+ # Append the modes chosen for this build to testhub.yml. Call after the
1064
+ # install, which rewrites the file.
1065
+ def record_run_modes(modes)
1066
+ data_file = File.join(mesa_dir, 'testhub.yml')
1067
+ return unless File.exist?(data_file)
1068
+
1069
+ modes = modes.slice(*RUN_MODE_KEYS)
1070
+ return if modes.empty?
1071
+
1072
+ block = { RUN_MODES_KEY => modes.transform_keys(&:to_s) }.to_yaml.sub(/\A---\n/, '')
1073
+ File.open(data_file, 'a') do |f|
1074
+ f.puts '# Added by mesa_test install: the run modes this build was made for.'
1075
+ f.puts '# `mesa_test test` applies them unless told otherwise.'
1076
+ f.write block
755
1077
  end
756
1078
  end
757
1079
 
1080
+ def install_data
1081
+ data_file = File.join(mesa_dir, 'testhub.yml')
1082
+ return {} unless File.exist?(data_file)
1083
+
1084
+ YAML.safe_load(File.read(data_file)) || {}
1085
+ end
1086
+
758
1087
  ## TEST SUITE METHODS
759
1088
 
760
1089
  def check_mod(mod)
@@ -845,6 +1174,18 @@ class Mesa
845
1174
  )
846
1175
  end
847
1176
 
1177
+ # Fingerprint of this build (a hash of build.log), so MESATestHub can
1178
+ # recognize a build report it already has. nil before any build.
1179
+ def build_fingerprint
1180
+ file_fingerprint(File.join(mesa_dir, 'build.log'))
1181
+ end
1182
+
1183
+ # When this build finished (build.log's mtime), or nil.
1184
+ def built_at
1185
+ build_log = File.join(mesa_dir, 'build.log')
1186
+ File.exist?(build_log) ? File.mtime(build_log) : nil
1187
+ end
1188
+
848
1189
  def install_attempted?
849
1190
  File.exist? File.join(mesa_dir, 'testhub.yml')
850
1191
  end
@@ -1019,6 +1360,23 @@ class MesaTestCase
1019
1360
  File.join(test_case_dir, 'testhub.yml')
1020
1361
  end
1021
1362
 
1363
+ # Run this test like #do_one, but as its own subprocess with its own
1364
+ # working directory and environment (+env+ overrides; nil unsets), so
1365
+ # several can run at once -- #do_one changes the process-wide directory
1366
+ # and expects the environment to be set globally. Output goes to
1367
+ # each_test_run.log in the test's directory. Returns true if
1368
+ # each_test_run exited cleanly (the test's pass/fail is in testhub.yml).
1369
+ def run_isolated(env: {})
1370
+ log = File.join(test_case_dir, 'each_test_run.log')
1371
+ pid = Process.spawn(env, './each_test_run', position.to_s,
1372
+ chdir: test_suite_dir, out: log, err: %i[child out], pgroup: true)
1373
+ RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS[pid] = test_name }
1374
+ _, status = Process.wait2(pid)
1375
+ status.success?
1376
+ ensure
1377
+ RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS.delete(pid) } if pid
1378
+ end
1379
+
1022
1380
  # just punt to +each_test_run+ in the test_suite directory. It's your problem
1023
1381
  # now, sucker!
1024
1382
  def do_one
@@ -1036,6 +1394,23 @@ class MesaTestCase
1036
1394
  YAML.load(File.read(testhub_file))
1037
1395
  end
1038
1396
 
1397
+ # How MESATestHub keys this test's fingerprint: "<module>/<test>".
1398
+ def fingerprint_key
1399
+ "#{mod}/#{test_name}"
1400
+ end
1401
+
1402
+ # Fingerprint of this run's results (a hash of testhub.yml).
1403
+ def fingerprint
1404
+ file_fingerprint(testhub_file)
1405
+ end
1406
+
1407
+ # Whether this test wrote results at or after +time+, i.e. during the
1408
+ # run that started then (not a testhub.yml left from an earlier run).
1409
+ # mtimes can be whole seconds, so compare at that resolution.
1410
+ def ran_since?(time)
1411
+ ran? && File.mtime(testhub_file) >= time.floor
1412
+ end
1413
+
1039
1414
  # rough proxy for whether or not the test has even been run
1040
1415
  def ran?
1041
1416
  File.exist?(testhub_file)
@@ -1170,6 +1545,137 @@ def bashticks(command)
1170
1545
  `bash -c "#{command}"`.chomp
1171
1546
  end
1172
1547
 
1548
+ # Run modes: how MESA is told to run a test, and the environment variables
1549
+ # behind them. MESA's each_test_run records each one in the test's
1550
+ # testhub.yml (fpe_checks / run_optional / resolution_factor), which is what
1551
+ # MESATestHub reads.
1552
+ #
1553
+ # fpe: MESA_FPE_CHECKS_ON=1. Compile-time as well as run-time
1554
+ # (MESA's make reads it), so it belongs to the build.
1555
+ # skip_optional: MESA_SKIP_OPTIONAL set. MESA runs every inlist unless
1556
+ # this is set.
1557
+ # resolution_factor: MESA_TEST_SUITE_RESOLUTION_FACTOR, which is what
1558
+ # `[ci converge]` sets.
1559
+ #
1560
+ # A mode that is nil/absent leaves the variable as the user's shell has it.
1561
+ RUN_MODE_KEYS = %i[fpe skip_optional resolution_factor].freeze
1562
+ DEFAULT_CONVERGE_FACTOR = '0.8'.freeze
1563
+
1564
+ # ENV overrides for +modes+ (nil value = unset the variable).
1565
+ def run_mode_env(modes)
1566
+ env = {}
1567
+ env['MESA_FPE_CHECKS_ON'] = modes[:fpe] ? '1' : nil unless modes[:fpe].nil?
1568
+ unless modes[:skip_optional].nil?
1569
+ env['MESA_SKIP_OPTIONAL'] = modes[:skip_optional] ? 't' : nil
1570
+ end
1571
+ unless modes[:resolution_factor].nil?
1572
+ env['MESA_TEST_SUITE_RESOLUTION_FACTOR'] =
1573
+ modes[:resolution_factor] ? modes[:resolution_factor].to_s : nil
1574
+ end
1575
+ env
1576
+ end
1577
+
1578
+ # The resolution factor a converge run should use: the user's own, if their
1579
+ # shell sets one, else DEFAULT_CONVERGE_FACTOR.
1580
+ def converge_factor
1581
+ ENV['MESA_TEST_SUITE_RESOLUTION_FACTOR'] || DEFAULT_CONVERGE_FACTOR
1582
+ end
1583
+
1584
+ # Run modes a MESATestHub dispatch asked for. Requests only ever *add* to the
1585
+ # user's setup: a mode the hub didn't ask for is left out (so the shell
1586
+ # decides), never forced off.
1587
+ #
1588
+ # A converge request may name its factor (`[ci converge 0.5]` arrives as
1589
+ # rec['converge_factor']). That's the commit author's request, so it wins
1590
+ # over +preferred+ -- a factor from --resolution-factor or the shell's
1591
+ # MESA_TEST_SUITE_RESOLUTION_FACTOR -- with a warning when they differ.
1592
+ # Without a named factor, +preferred+ (else the default) is used.
1593
+ def dispatch_run_modes(rec, preferred: nil, preferred_source: nil)
1594
+ flags = rec['flags'] || {}
1595
+ modes = {}
1596
+ modes[:fpe] = true if flags['use_fpe']
1597
+ modes[:skip_optional] = false if flags['use_full_inlists']
1598
+ if flags['use_converge']
1599
+ requested = rec['converge_factor']
1600
+ preferred ||= ENV['MESA_TEST_SUITE_RESOLUTION_FACTOR']
1601
+ preferred_source ||= 'MESA_TEST_SUITE_RESOLUTION_FACTOR'
1602
+ if requested
1603
+ factor = requested.to_s
1604
+ if preferred && preferred.to_f != requested.to_f
1605
+ warn "MESATestHub asked for resolution factor #{factor} ([ci converge #{factor}]); " \
1606
+ "using it instead of your #{preferred_source}=#{preferred}."
1607
+ end
1608
+ modes[:resolution_factor] = factor
1609
+ else
1610
+ modes[:resolution_factor] = preferred || DEFAULT_CONVERGE_FACTOR
1611
+ end
1612
+ end
1613
+ modes
1614
+ end
1615
+
1616
+ # Process groups of tests started by MesaTestCase#run_isolated, so an
1617
+ # interrupted parallel run can stop them all.
1618
+ RUNNING_TESTS = {}
1619
+ RUNNING_TESTS_LOCK = Mutex.new
1620
+
1621
+ def stop_running_tests
1622
+ pids = RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS.keys }
1623
+ pids.each do |pid|
1624
+ Process.kill('TERM', -pid)
1625
+ rescue Errno::ESRCH, Errno::EPERM
1626
+ nil
1627
+ end
1628
+ end
1629
+
1630
+ # Run +jobs+ worker threads, each calling the block with its index until
1631
+ # the block returns :done. On Ctrl-C, kills the workers first (so none
1632
+ # goes on to submit a half-run test), then every running test, then
1633
+ # re-raises. Exceptions in a worker propagate when it's joined.
1634
+ def run_workers(jobs)
1635
+ workers = []
1636
+ jobs.times do |i|
1637
+ workers << Thread.new do
1638
+ Thread.current.report_on_exception = false
1639
+ loop { break if yield(i) == :done }
1640
+ end
1641
+ end
1642
+ workers.each(&:join)
1643
+ rescue Interrupt
1644
+ workers.each(&:kill)
1645
+ stop_running_tests
1646
+ raise
1647
+ end
1648
+
1649
+ # Hand each of +items+ to the block, +jobs+ at a time.
1650
+ def parallel_each(items, jobs)
1651
+ queue = Queue.new
1652
+ items.each { |item| queue << item }
1653
+ run_workers([jobs, items.size].min) do
1654
+ item = begin
1655
+ queue.pop(true)
1656
+ rescue ThreadError
1657
+ next :done
1658
+ end
1659
+ yield item
1660
+ nil
1661
+ end
1662
+ end
1663
+
1664
+ # Run the block with +overrides+ applied to ENV (nil deletes a variable),
1665
+ # restoring the previous values afterwards.
1666
+ def with_env(overrides)
1667
+ saved = overrides.keys.map { |k| [k, ENV[k]] }.to_h
1668
+ overrides.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
1669
+ yield
1670
+ ensure
1671
+ saved&.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
1672
+ end
1673
+
1674
+ # SHA-256 of a file's contents, or nil if it doesn't exist.
1675
+ def file_fingerprint(path)
1676
+ File.exist?(path) ? Digest::SHA256.file(path).hexdigest : nil
1677
+ end
1678
+
1173
1679
  # encode the contents of a file as base-64
1174
1680
  def b64_file(filename)
1175
1681
  Base64.encode64(File.open(filename).read)