mesa_test 1.2.1 → 2.0.0

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 +673 -136
  3. data/lib/mesa_test.rb +734 -295
  4. metadata +9 -5
data/lib/mesa_test.rb CHANGED
@@ -8,11 +8,33 @@ 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)
18
+
19
+ # Output conventions (CLAUDE.md, "Output"): a bold step heading after one
20
+ # blank line for each phase; green when MESA work succeeded (a build, a
21
+ # passing test, a standalone submission), red when something failed, yellow
22
+ # for a warning (the run continues, but you may want to act), blue only for
23
+ # setup-wizard questions; everything else plain. No color for plumbing.
24
+ def say_step(shell, text)
25
+ shell.say ''
26
+ shell.say text, :bold
27
+ end
28
+
29
+ def short_sha(sha)
30
+ sha.to_s[0, 7]
31
+ end
32
+
33
+ # A warning on STDERR (so `request_work`'s JSON stays clean), yellow on a
34
+ # terminal.
35
+ def warn_yellow(text)
36
+ $stderr.puts($stderr.tty? ? "\e[33m#{text}\e[0m" : text)
37
+ end
16
38
 
17
39
  GITHUB_HTTPS = 'https://github.com/MESAHub/mesa.git'.freeze
18
40
  GITHUB_SSH = 'git@github.com:MESAHub/mesa.git'.freeze
@@ -20,86 +42,122 @@ GITHUB_SSH = 'git@github.com:MESAHub/mesa.git'.freeze
20
42
  class MesaTestSubmitter
21
43
  DEFAULT_URI = 'https://testhub.mesastar.org'.freeze
22
44
 
45
+ # How to get an API key, for the setup wizard and for every command that
46
+ # finds none.
47
+ API_KEY_HELP = <<~HELP.freeze
48
+ mesa_test authenticates with a per-computer API key. To get one:
49
+ 1. Log in to MESATestHub (#{DEFAULT_URI}) and open My Computers.
50
+ 2. Open this computer's page (add the computer first if it isn't
51
+ listed; a computer used only for `search` and `count` is fine).
52
+ 3. Click "Generate key" and copy the key, which starts with mth_.
53
+ HELP
54
+
23
55
  # set up config file for computer
24
56
  def setup
25
57
  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?
58
+ shell.say <<~INTRO
59
+ This wizard sets up this computer's profile for MESATestHub. Each
60
+ question shows the current value; press enter to keep it. You can
61
+ review everything at the end.
62
+
63
+ #{API_KEY_HELP}
64
+ (MESATESTHUB_API_KEY, if set, overrides the key saved here.) The key
65
+ can only act as this one computer, can't log in to the website, and
66
+ can be revoked on its own, but it is stored in plain text in
67
+ #{config_file}, as is the logs token.
68
+ INTRO
69
+
70
+ ask_for_api_key(s)
71
+
72
+ # Optional run modes this computer can serve when MESATestHub asks for
73
+ # them (see `mesa_test request_work` and `install_and_test best`)
74
+ current = s.capabilities.select { |_, on| on }.keys.join(', ')
75
+ response = question('Which optional test modes can this computer run when a ' \
76
+ 'commit asks for them? Any of: full_inlists, fpe, converge, ' \
77
+ "separated by commas, or 'none'.", current.empty? ? 'none' : current)
78
+ s.capabilities = parse_capabilities(response) unless response.empty?
51
79
 
52
80
  # 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
81
+ response = question('What is your token for uploading failure logs to the logs ' \
82
+ "server? (If you don't have one, ask a MESA testing maintainer " \
83
+ 'or a representative from Flatiron for a key.)', s.logs_token)
57
84
  s.logs_token = response unless response.empty?
58
85
 
59
86
  # Determine if we'll use ssh or https to access github
60
- response = shell.ask 'When accessing GitHub, which protocol do you '\
61
- 'want to use?', :blue, limited_to: %w[ssh https]
87
+ shell.say ''
88
+ shell.say 'When accessing GitHub, which protocol do you want to use?', :blue
89
+ response = shell.ask '>', limited_to: %w[ssh https], default: s.github_protocol.to_s
62
90
  s.github_protocol = response.strip.downcase.to_sym
63
91
 
64
92
  # Get location of source MESA repo (the mirror)
65
- response = shell.ask "Where is/should your mirrored MESA repository " \
66
- "located? This is where a mirror will be stored from which test " \
67
- "repos will be generated. You won't touch this in regular operation. " \
68
- "(#{s.mesa_mirror}):", :blue
93
+ response = question('Where is/should your mirrored MESA repository be? This is ' \
94
+ 'where a mirror will be stored from which test repos will be ' \
95
+ "generated. You won't touch this in regular operation.", s.mesa_mirror)
69
96
  s.mesa_mirror = response unless response.empty?
70
97
 
71
98
  # Get location of source MESA work (where testing happens)
72
- response = shell.ask "Where is/should your working directory for "\
73
- "testing be located? This is where testing actually occurs, but all "\
74
- "files it uses are cached in the mirror repo to save time later. " \
75
- "(#{s.mesa_work}):", :blue
99
+ response = question('Where is/should your working directory for testing be? This ' \
100
+ 'is where testing actually occurs, but all files it uses are ' \
101
+ 'cached in the mirror repo to save time later.', s.mesa_work)
76
102
  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
103
  end
93
104
 
94
105
  # Confirm data. If not confirmed, restart whole wizard.
95
106
  if confirm_computer_data
96
107
  save_computer_data
97
108
  else
98
- shell.say "Restarting wizard.\n"
109
+ shell.say 'Starting over.'
99
110
  setup
100
111
  end
101
112
  end
102
113
 
114
+ # Ask one wizard question; returns the stripped answer, '' to keep
115
+ # +current+. The question goes out through `say`, which the terminal
116
+ # wraps normally; the prompt that Readline draws stays a short, plain
117
+ # "> ". Readline counts color codes as visible characters, so a long or
118
+ # colored prompt garbles at the terminal's edge (the start of the line
119
+ # repeats before it wraps).
120
+ def question(text, current = nil)
121
+ shell.say ''
122
+ shell.say(current.to_s.empty? ? text : "#{text} (current: #{current})", :blue)
123
+ shell.ask('>').to_s.strip
124
+ end
125
+
126
+ # Ask for the API key until MESATestHub accepts one, and take the computer
127
+ # name from it, since a key belongs to exactly one computer. If the hub
128
+ # can't be reached, keep the key and ask for the name instead.
129
+ def ask_for_api_key(s)
130
+ loop do
131
+ if ENV['MESATESTHUB_API_KEY']
132
+ shell.say "Using the API key in MESATESTHUB_API_KEY (#{s.masked_api_key}). " \
133
+ 'Unset it to enter one here.', :yellow
134
+ else
135
+ response = question('What is the MESATestHub API key for this computer?',
136
+ s.masked_api_key || 'none')
137
+ s.api_key = response unless response.empty?
138
+ next shell.say('An API key is required.', :red) unless s.api_key
139
+ end
140
+
141
+ check = s.confirm_computer(computer_name: nil)
142
+ if check['valid']
143
+ s.computer_name = check['computer'] || s.computer_name
144
+ shell.say "Key accepted: this computer is #{s.computer_name} on MESATestHub.", :green
145
+ return
146
+ elsif check['reachable'] == false
147
+ shell.say 'Saving the key unverified.', :yellow
148
+ response = question('What is the name of this computer on MESATestHub?', s.computer_name)
149
+ s.computer_name = response unless response.empty?
150
+ return
151
+ end
152
+
153
+ shell.say check['message'], :red
154
+ if ENV['MESATESTHUB_API_KEY']
155
+ raise TestHubError, 'MESATESTHUB_API_KEY was refused; fix or unset it.'
156
+ end
157
+ s.api_key = nil
158
+ end
159
+ end
160
+
103
161
  def self.new_from_config(
104
162
  config_file: File.join(ENV['HOME'], '.mesa_test', 'config.yml'),
105
163
  force_setup: false,
@@ -107,6 +165,9 @@ e-mail and password will be stored in plain text.'
107
165
  )
108
166
  new_submitter = new(config_file: config_file, base_uri: base_uri)
109
167
  if force_setup
168
+ # Start the wizard from the current settings, including a 1.x
169
+ # config's (logs token, directories), so upgrading keeps them.
170
+ new_submitter.load_computer_data if File.exist? config_file
110
171
  new_submitter.setup
111
172
  elsif not File.exist? config_file
112
173
  puts "No such config file #{config_file}. Starting setup wizard."
@@ -116,45 +177,41 @@ e-mail and password will be stored in plain text.'
116
177
  return new_submitter
117
178
  end
118
179
 
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
180
+ # Optional run modes a commit can request through its message
181
+ # (`[ci optional]`, `[ci fpe]`, `[ci converge]`), in MESATestHub's names.
182
+ CAPABILITIES = %i[full_inlists fpe converge].freeze
183
+
184
+ attr_accessor :computer_name, :user_name,
185
+ :mesa_mirror, :mesa_work, :processor,
186
+ :config_file, :base_uri, :github_protocol, :logs_token,
187
+ :capabilities
188
+ attr_writer :api_key
122
189
 
123
190
  attr_reader :shell
124
191
 
125
192
  # many defaults are set in body
126
193
  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,
194
+ computer_name: nil, user_name: nil, github_protocol: nil,
195
+ mesa_mirror: nil, processor: nil,
129
196
  config_file: nil, base_uri: nil, logs_token: nil
130
197
  )
131
198
  @computer_name = computer_name || Socket.gethostname.scan(/^[^\.]+\.?/)[0]
132
199
  @computer_name.chomp!('.') if @computer_name
133
200
  @user_name = user_name || (ENV['USER'] || ENV['USERNAME'])
134
- @email = email || ''
135
- @password = password || ''
136
201
  @github_protocol = github_protocol || :ssh
137
202
  @mesa_mirror = mesa_mirror ||
138
203
  File.join(ENV['HOME'], '.mesa_test', 'mirror')
139
204
  @mesa_work = mesa_work ||
140
205
  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
206
  @processor = processor || ''
154
207
  @config_file = config_file || File.join(ENV['HOME'], '.mesa_test',
155
208
  'config.yml')
156
- @base_uri = base_uri
209
+ # MESATESTHUB_URL points the client at another testhub (a local dev
210
+ # server, staging) without editing MODE in bin/mesa_test.
211
+ @base_uri = ENV['MESATESTHUB_URL'] || base_uri
157
212
  @logs_token = logs_token || ENV['MESA_LOGS_TOKEN']
213
+ @api_key = nil
214
+ @capabilities = CAPABILITIES.map { |c| [c, false] }.to_h
158
215
 
159
216
  # set up thor-proof way to get responses from user. Thor hijacks the
160
217
  # gets command, so we have to use its built-in "ask" method, which is
@@ -169,71 +226,144 @@ e-mail and password will be stored in plain text.'
169
226
  yield self if block_given?
170
227
  end
171
228
 
229
+ # The per-computer API key: MESATESTHUB_API_KEY from the environment wins
230
+ # over the config file, so a cluster job can supply it without the key
231
+ # ever being written to disk. nil means no key (see #require_api_key!).
232
+ def api_key
233
+ key = ENV['MESATESTHUB_API_KEY'] || @api_key
234
+ key.nil? || key.strip.empty? ? nil : key.strip
235
+ end
236
+
237
+ # Every MESATestHub request needs a key, so commands that talk to the hub
238
+ # call this first, before checking anything out or building.
239
+ def require_api_key!
240
+ return if api_key
241
+
242
+ raise TestHubError, <<~MSG
243
+ No MESATestHub API key is set. (This version of mesa_test no longer
244
+ signs in with your email and password.)
245
+
246
+ #{API_KEY_HELP}
247
+ Then run `mesa_test setup` and paste it when asked, or export
248
+ MESATESTHUB_API_KEY, which overrides the config file (e.g. to keep the
249
+ key off disk on a cluster).
250
+ MSG
251
+ end
252
+
253
+ def masked_api_key
254
+ key = api_key
255
+ key && "#{key[0, 10]}..."
256
+ end
257
+
258
+ # "fpe, full_inlists" => { full_inlists: true, fpe: true, converge: false }
259
+ def parse_capabilities(text)
260
+ wanted = text.downcase.split(/[\s,]+/).map(&:to_sym)
261
+ CAPABILITIES.map { |c| [c, wanted.include?(c)] }.to_h
262
+ end
263
+
172
264
  def confirm_computer_data
173
- puts 'Ready to submit the following data:'
174
- puts '-------------------------------------------------------'
175
- puts "Computer Name #{computer_name}"
176
- puts "User email #{email}"
177
- puts 'Password ***********'
178
- puts "logs API token #{logs_token}"
179
- puts "GitHub Protocol #{github_protocol}"
180
- puts "MESA Mirror Location #{mesa_mirror}"
181
- puts "MESA Work Location #{mesa_work}"
182
- puts "Platform #{platform} #{platform_version}"
183
- # puts "Config location #{config_file}"
184
- puts '-------------------------------------------------------'
185
- puts ''
186
- response = shell.ask 'Is this correct? (y/Y = Yes, anything else = No):'
187
- response.strip.casecmp('y').zero?
188
- end
189
-
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
265
+ modes = capabilities.select { |_, on| on }.keys.join(', ')
266
+ say_step(shell, "This computer's settings")
267
+ [['Computer', computer_name],
268
+ ['API key', masked_api_key || 'none'],
269
+ ['Optional test modes', modes.empty? ? 'none' : modes],
270
+ ['Logs token', logs_token.to_s.empty? ? 'none' : logs_token],
271
+ ['GitHub protocol', github_protocol],
272
+ ['MESA mirror', mesa_mirror],
273
+ ['Work directory', mesa_work],
274
+ ['OS (detected)', platform_version]].each do |label, value|
275
+ shell.say format(' %-20s %s', label, value)
276
+ end
277
+ shell.say ''
278
+ shell.ask('Save these settings? [y/N]').to_s.strip.casecmp('y').zero?
279
+ end
280
+
196
281
  def save_computer_data
197
282
  data_hash = {
198
283
  'computer_name' => computer_name,
199
- 'email' => email,
200
- 'password' => password,
284
+ 'api_key' => @api_key,
285
+ 'capabilities' => capabilities.select { |_, on| on }.keys.map(&:to_s),
201
286
  'logs_token' => logs_token,
202
287
  'github_protocol' => github_protocol,
203
288
  'mesa_mirror' => mesa_mirror,
204
- 'mesa_work' => mesa_work,
205
- 'platform' => platform,
206
- 'platform_version' => platform_version,
289
+ 'mesa_work' => mesa_work
207
290
  }
208
291
  # make sure there's a directory to write to
209
292
  unless dir_or_symlink_exists? File.dirname(config_file)
210
293
  FileUtils.mkdir_p File.dirname(config_file)
211
294
  end
212
- File.open(config_file, 'w') { |f| f.write(YAML.dump(data_hash)) }
295
+ # Owner-only: the file holds the API key and logs token.
296
+ File.write(config_file, YAML.dump(data_hash), perm: 0o600)
297
+ File.chmod(0o600, config_file)
213
298
  end
214
299
 
300
+ # A setting missing from the file (e.g. capabilities in a 1.x config)
301
+ # keeps its default.
215
302
  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']
303
+ data_hash = YAML.load(File.read(config_file)) || {}
304
+ @computer_name = data_hash.fetch('computer_name', @computer_name)
305
+ @api_key = data_hash.fetch('api_key', @api_key)
306
+ if data_hash.key?('capabilities')
307
+ @capabilities = parse_capabilities(Array(data_hash['capabilities']).join(','))
308
+ end
309
+ @logs_token = data_hash['logs_token'] || @logs_token
310
+ @github_protocol = (data_hash['github_protocol'] || @github_protocol).to_sym
311
+ @mesa_mirror = data_hash.fetch('mesa_mirror', @mesa_mirror)
312
+ @mesa_work = data_hash.fetch('mesa_work', @mesa_work)
313
+ end
314
+
315
+ # This computer's OS version, detected (once per run) rather than
316
+ # configured, since a typed-in value goes stale with the first OS update.
317
+ # MESATestHub shows it after the computer's platform (macOS / linux), so
318
+ # macOS sends just the number and Linux the distribution:
319
+ # "27.0.1", "Rocky Linux 8.10", "Ubuntu 24.04 (WSL)".
320
+ def platform_version
321
+ @platform_version ||= self.class.detect_platform_version
322
+ end
323
+
324
+ def self.detect_platform_version
325
+ version = OS.mac? ? `sw_vers -productVersion 2>/dev/null`.strip : linux_version
326
+ version = `uname -sr 2>/dev/null`.strip if version.to_s.empty?
327
+ version
328
+ rescue StandardError
329
+ `uname -sr 2>/dev/null`.strip rescue ''
330
+ end
331
+
332
+ # The freedesktop os-release standard (every current distribution, and
333
+ # containers; /usr/lib/os-release is its fallback location):
334
+ # NAME="Rocky Linux", VERSION_ID="8.10". Rolling releases (Arch) have no
335
+ # VERSION_ID. RHEL/CentOS 6 predate it but have /etc/redhat-release.
336
+ def self.linux_version
337
+ path = %w[/etc/os-release /usr/lib/os-release].find { |f| File.readable?(f) }
338
+ name =
339
+ if path
340
+ os = File.readlines(path, chomp: true)
341
+ .map { |line| line.split('=', 2) }.select { |kv| kv.size == 2 }
342
+ .to_h { |k, v| [k.strip, v.strip.delete('"\'')] }
343
+ [os['NAME'], os['VERSION_ID']].compact.join(' ')
344
+ elsif File.readable?('/etc/redhat-release')
345
+ # "CentOS release 6.10 (Final)"
346
+ File.read('/etc/redhat-release').strip.sub(/ \(.*\)\z/, '')
347
+ end
348
+ return name if name.to_s.empty?
349
+
350
+ wsl = File.readable?('/proc/version') && File.read('/proc/version').match?(/microsoft/i)
351
+ wsl ? "#{name} (WSL)" : name
226
352
  end
227
353
 
228
354
  # Parameters to be submitted in JSON format for reporting information about
229
- # the submitting user and computer
355
+ # the submitting computer. The API key identifies it; the name is a
356
+ # cross-check (the hub refuses a key that belongs to another computer).
230
357
  def submitter_params
231
- {
232
- email: email,
233
- password: password,
234
- computer: computer_name,
235
- platform_version: platform_version
236
- }
358
+ { computer: computer_name, platform_version: platform_version }
359
+ end
360
+
361
+ # Headers for every MESATestHub request.
362
+ def testhub_headers(json_body: true)
363
+ headers = { 'Accept' => 'application/json' }
364
+ headers['Content-Type'] = 'application/json' if json_body
365
+ headers['Authorization'] = "Bearer #{api_key}" if api_key
366
+ headers
237
367
  end
238
368
 
239
369
  # Parameters to be submitted in JSON format for reporting information about
@@ -261,28 +391,55 @@ e-mail and password will be stored in plain text.'
261
391
  # test cases and the test results. These will be encoded as an array of
262
392
  # JSON objects.
263
393
  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
394
+ submittable_tests(mesa).map(&:results_hash)
395
+ end
396
+
397
+ # Tests with results from *this* build: run, readable, and not older than
398
+ # the build itself. A reused work directory (`install --no-checkout`) can
399
+ # still hold testhub.yml files from an earlier build; those would be filed
400
+ # under the wrong commit, so they're skipped (and counted).
401
+ def submittable_tests(mesa)
402
+ built_at = mesa.built_at
403
+ unread = []
404
+ stale = 0
405
+ tests = mesa.test_case_names.flat_map do |mod, names|
406
+ names.filter_map do |test_name|
407
+ test_case = mesa.test_cases[mod][test_name]
408
+ next unless test_case&.ran?
409
+
410
+ if built_at && File.mtime(test_case.testhub_file) < built_at
411
+ stale += 1
412
+ next
275
413
  end
414
+ test_case.results_hash
415
+ test_case
416
+ rescue TestCaseDirError, Psych::Exception
417
+ unread << test_case
418
+ nil
276
419
  end
277
420
  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
421
+ if stale.positive?
422
+ shell.say "Skipping #{stale} test result#{'s' unless stale == 1} older than this " \
423
+ 'build (left over from an earlier one).', :yellow
284
424
  end
285
- res
425
+ unless unread.empty?
426
+ shell.say "Couldn't read the results of #{unread.map(&:test_name).join(', ')}; " \
427
+ 'not submitting them.', :red
428
+ end
429
+ tests
430
+ end
431
+
432
+ # The build's recorded run modes in the submissions API's `claim:` block
433
+ # (use_* flags; informational on the server, which reads the modes each
434
+ # test actually ran with from its testhub.yml).
435
+ def run_mode_params(mesa)
436
+ modes = mesa.recorded_run_modes
437
+ {
438
+ use_fpe: modes[:fpe] == true,
439
+ use_full_inlists: modes[:skip_optional] == false,
440
+ use_converge: modes[:resolution_factor].is_a?(String) ||
441
+ modes[:resolution_factor].is_a?(Numeric)
442
+ }
286
443
  end
287
444
 
288
445
  # Parameters for reporting a failed compilation to the logs server
@@ -314,28 +471,39 @@ e-mail and password will be stored in plain text.'
314
471
  [test_case.results_hash]
315
472
  end
316
473
 
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
474
+ # Phone home to testhub and confirm that the API key is valid (and, given a
475
+ # computer name, that it belongs to that computer). Useful for confirming
476
+ # that submissions will be accepted before wasting time on a test later.
477
+ # 'reachable' is false when the hub couldn't be asked at all.
478
+ def confirm_computer(computer_name: self.computer_name)
321
479
  uri = URI.parse(base_uri + '/check_computer.json')
322
480
  https = Net::HTTP.new(uri.hostname, uri.port)
323
481
  https.use_ssl = base_uri.include? 'https'
324
482
 
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
483
+ request = Net::HTTP::Post.new(uri, testhub_headers)
484
+ request.body = { computer_name: computer_name }.compact.to_json
333
485
  response = testhub_request(https, request)
334
486
  # if the hub was unreachable, behave as an unverified computer; the
335
487
  # network error has already been reported by testhub_request
336
- return {} if response.nil?
488
+ if response.nil?
489
+ return { 'valid' => false, 'reachable' => false,
490
+ 'message' => 'Could not verify this computer with MESATestHub.' }
491
+ end
492
+
493
+ # Anything but a 200 (a 429 from the rate limiter, a 5xx, a proxy's HTML
494
+ # error page) carries no valid/message keys, so name the status and the
495
+ # server's error instead of failing with a blank line.
496
+ unless response.is_a?(Net::HTTPOK)
497
+ return { 'valid' => false,
498
+ 'message' => 'Could not verify this computer with MESATestHub ' \
499
+ "(HTTP #{response.code}: #{error_message(response)})." }
500
+ end
337
501
 
338
502
  JSON.parse(response.body).to_hash
503
+ rescue JSON::ParserError
504
+ { 'valid' => false,
505
+ 'message' => 'Could not verify this computer: MESATestHub sent an ' \
506
+ 'unreadable response.' }
339
507
  end
340
508
 
341
509
  # submit entire commit's worth of test cases, OR submit compilation status
@@ -349,10 +517,7 @@ e-mail and password will be stored in plain text.'
349
517
  https = Net::HTTP.new(uri.hostname, uri.port)
350
518
  https.use_ssl = true if base_uri.include? 'https'
351
519
 
352
- request = Net::HTTP::Post.new(
353
- uri,
354
- initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
355
- )
520
+ request = Net::HTTP::Post.new(uri, testhub_headers)
356
521
 
357
522
  # create the request body for submission to the submissions API
358
523
  #
@@ -363,11 +528,20 @@ e-mail and password will be stored in plain text.'
363
528
  empty ||= !mesa.installed?
364
529
  request_data = {submitter: submitter_params,
365
530
  commit: commit_params(mesa, empty: empty, entire: !empty)}
531
+ request_data[:claim] = run_mode_params(mesa) if mesa.recorded_run_modes.any?
532
+ # Fingerprints let MESATestHub ignore results it already has (a retried
533
+ # submit, a head node re-sending what compute nodes sent).
534
+ fingerprints = {}
535
+ fingerprints['build'] = mesa.build_fingerprint if mesa.build_fingerprint
366
536
  # don't need test instances if it's an empty submission or if compilation
367
537
  # failed
538
+ tests = []
368
539
  if !empty && request_data[:commit][:compiled]
369
- request_data[:instances] = instance_params(mesa)
540
+ tests = submittable_tests(mesa)
541
+ request_data[:instances] = tests.map(&:results_hash)
542
+ tests.each { |t| fingerprints[t.fingerprint_key] = t.fingerprint }
370
543
  end
544
+ request_data[:fingerprints] = fingerprints unless fingerprints.empty?
371
545
  request.body = request_data.to_json
372
546
 
373
547
  # actually do the submission
@@ -377,41 +551,39 @@ e-mail and password will be stored in plain text.'
377
551
  # network failure; testhub_request already explained why
378
552
  false
379
553
  elsif !response.is_a? Net::HTTPCreated
380
- shell.say "\nFailed to submit some or all test case instances and/or "\
381
- "commit data (server responded #{response.code} "\
382
- "#{response.message}).", :red
554
+ shell.say "MESATestHub refused the results for #{short_sha(mesa.sha)} " \
555
+ "(HTTP #{response.code}: #{error_message(response)}).", :red
383
556
  false
384
557
  else
385
- shell.say "\nSuccessfully submitted commit #{mesa.sha}.", :green
386
- # commit submitted to testhub, now submit build log if compilation failed
387
- # and exit
558
+ if !mesa.installed?
559
+ shell.say "Reported the failed build of #{short_sha(mesa.sha)} to MESATestHub."
560
+ elsif empty || tests.empty?
561
+ shell.say "Reported the build of #{short_sha(mesa.sha)} to MESATestHub."
562
+ else
563
+ shell.say "Submitted the build and #{tests.size} test result#{'s' unless tests.size == 1} " \
564
+ "for #{short_sha(mesa.sha)}.", :green
565
+ end
566
+ # Upload the build log if compilation failed. A logs-server problem is
567
+ # only a warning: the results are already in.
388
568
  unless mesa.installed?
389
- return submit_build_log(mesa)
569
+ submit_build_log(mesa)
570
+ return true
390
571
  end
391
572
 
392
- # compilation succeded, so submit any logs for failing tests
393
- res = true
394
- unless empty
395
- mesa.test_cases.each do |mod, test_case_hash|
396
- test_case_hash.each do |tc_name, test_case|
397
- # get at each individual test case, see if it failed, and if it
398
- # did, submit its log files
399
- if !test_case.passed? || force_logs
400
- res &&= submit_test_log(test_case, skip_passing: !force_logs)
401
- end
402
- end
403
- end
573
+ # logs for the failing tests just submitted (or all, with force_logs)
574
+ tests.each do |test_case|
575
+ submit_test_log(test_case, skip_passing: !force_logs) if !test_case.passed? || force_logs
404
576
  end
405
-
406
- # a true return value means that any and all log submission were
407
- # successful
408
- res
577
+ true
409
578
  end
410
579
  end
411
580
 
412
581
  # submit results for a single test case instance. Does *not* report overall
413
- # compilation status to testhub. Use an empty commit submission for that
414
- def submit_instance(mesa, test_case, force_logs: false)
582
+ # compilation status to testhub. Use an empty commit submission for that.
583
+ # +quiet+ leaves the success line to the caller (a test run's progress
584
+ # line says "submitted"). Returns whether the result went in; a log
585
+ # upload problem is only a warning.
586
+ def submit_instance(mesa, test_case, force_logs: false, quiet: false)
415
587
  unless mesa.install_attempted?
416
588
  raise MesaDirError, 'No testhub.yml file found in installation; '\
417
589
  'must attempt to install before subitting.'
@@ -421,10 +593,7 @@ e-mail and password will be stored in plain text.'
421
593
  https = Net::HTTP.new(uri.hostname, uri.port)
422
594
  https.use_ssl = true if base_uri.include? 'https'
423
595
 
424
- request = Net::HTTP::Post.new(
425
- uri,
426
- initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
427
- )
596
+ request = Net::HTTP::Post.new(uri, testhub_headers)
428
597
 
429
598
  # create the request body for submission to the submissions API
430
599
  #
@@ -432,7 +601,9 @@ e-mail and password will be stored in plain text.'
432
601
  # entire (... there is only test case)
433
602
  request_data = {submitter: submitter_params,
434
603
  commit: commit_params(mesa, empty: false, entire: false),
435
- instances: single_instance_params(test_case)}
604
+ instances: single_instance_params(test_case),
605
+ fingerprints: { test_case.fingerprint_key => test_case.fingerprint }}
606
+ request_data[:claim] = run_mode_params(mesa) if mesa.recorded_run_modes.any?
436
607
  request.body = request_data.to_json
437
608
 
438
609
  # actually do the submission
@@ -442,17 +613,13 @@ e-mail and password will be stored in plain text.'
442
613
  # network failure; testhub_request already explained why
443
614
  return false
444
615
  elsif !response.is_a? Net::HTTPCreated
445
- shell.say "\nFailed to submit #{test_case.test_name} for commit "\
446
- "#{mesa.sha} (server responded #{response.code} "\
447
- "#{response.message}).", :red
616
+ shell.say "MESATestHub refused #{test_case.mod}/#{test_case.test_name} " \
617
+ "(HTTP #{response.code}: #{error_message(response)}).", :red
448
618
  return false
449
619
  else
450
- shell.say "\nSuccessfully submitted instance of #{test_case.test_name} "\
451
- "for commit #{mesa.sha}.", :green
620
+ shell.say "Submitted #{test_case.mod}/#{test_case.test_name}.", :green unless quiet
452
621
  # submit logs if test failed
453
- if !test_case.passed? || force_logs
454
- return submit_test_log(test_case, skip_passing: !force_logs)
455
- end
622
+ submit_test_log(test_case, skip_passing: !force_logs) if !test_case.passed? || force_logs
456
623
  true
457
624
  end
458
625
  end
@@ -468,8 +635,7 @@ e-mail and password will be stored in plain text.'
468
635
  https.read_timeout = 60
469
636
  https.request(request)
470
637
  rescue StandardError => e
471
- shell.say "\nCould not reach the test hub at #{https.address} "\
472
- "(#{e.class}: #{e.message}).", :red
638
+ shell.say "Could not reach MESATestHub at #{https.address} (#{e.class}: #{e.message}).", :red
473
639
  nil
474
640
  end
475
641
 
@@ -496,9 +662,8 @@ e-mail and password will be stored in plain text.'
496
662
  begin
497
663
  https.request(req)
498
664
  rescue StandardError => e
499
- shell.say "\nCould not reach the LOGS server at #{uri.host} "\
500
- "(#{e.class}: #{e.message}). Skipping log upload; this does "\
501
- 'not affect test results already sent to the test hub.', :yellow
665
+ shell.say "Could not reach the logs server at #{uri.host} (#{e.class}: #{e.message}); " \
666
+ 'skipping the log upload. The results themselves are in.', :yellow
502
667
  nil
503
668
  end
504
669
  end
@@ -508,12 +673,7 @@ e-mail and password will be stored in plain text.'
508
673
  # intercept and don't send if mesa was properly installed
509
674
  return true if mesa.installed?
510
675
 
511
- # don't even try unless we have a logs token set
512
- unless logs_token
513
- shell.say 'Cannot submit to logs server; need to set mesa_logs_token '\
514
- 'in the mesa_test config file.'
515
- return false
516
- end
676
+ return false unless logs_token?
517
677
 
518
678
  # do submission
519
679
  res = submit_logs(build_log_params(mesa))
@@ -523,13 +683,10 @@ e-mail and password will be stored in plain text.'
523
683
  # network failure; submit_logs already explained why. Don't fail the run.
524
684
  false
525
685
  elsif !res.is_a? Net::HTTPOK
526
- shell.say "\nFailed to submit build.log to the LOGS server for commit "\
527
- "#{mesa.sha} (server responded #{res.code} #{res.message}).",
528
- :red
686
+ shell.say "The logs server refused the build log (HTTP #{res.code} #{res.message}).", :yellow
529
687
  false
530
688
  else
531
- shell.say "\nSuccessfully submitted build.log to the LOGS server for "\
532
- "#{mesa.sha}.", :green
689
+ shell.say 'Uploaded the build log to the logs server.'
533
690
  true
534
691
  end
535
692
  end
@@ -542,13 +699,8 @@ e-mail and password will be stored in plain text.'
542
699
  return true
543
700
  end
544
701
 
545
- # don't even try unless we have a logs token set
546
- unless logs_token
547
- shell.say 'Cannot submit to logs server; need to set mesa_logs_token '\
548
- 'in the mesa_test config file..'
549
- return false
550
- end
551
-
702
+ return false unless logs_token?
703
+
552
704
  # do submission
553
705
  res = submit_logs(test_log_params(test_case))
554
706
 
@@ -557,21 +709,29 @@ e-mail and password will be stored in plain text.'
557
709
  # network failure; submit_logs already explained why. Don't fail the run.
558
710
  false
559
711
  elsif !res.is_a? Net::HTTPOK
560
- shell.say "Failed to submit logs for test case #{test_case.test_name} "\
561
- "in commit #{test_case.mesa.sha} (server responded "\
562
- "#{res.code} #{res.message}).", :red
712
+ shell.say "The logs server refused the logs for #{test_case.test_name} " \
713
+ "(HTTP #{res.code} #{res.message}).", :yellow
563
714
  false
564
715
  else
565
- shell.say "Successfully submitted logs for test case "\
566
- "#{test_case.test_name} in commit #{test_case.mesa.sha}.",
567
- :green
716
+ shell.say "Uploaded the logs for #{test_case.test_name} to the logs server."
568
717
  true
569
718
  end
570
719
  end
571
720
 
572
- # GET a search query from the testhub. Credentials and the query string
573
- # ride in the URL's query parameters (per the testhub search API contract;
574
- # the controller also accepts session auth, but a CLI sends them in-band).
721
+ # Whether failure logs can be uploaded; says why not once per run.
722
+ def logs_token?
723
+ return true if logs_token && !logs_token.to_s.empty?
724
+
725
+ unless @warned_no_logs_token
726
+ shell.say 'No logs token is set, so failure logs are not being uploaded ' \
727
+ '(add one with `mesa_test setup`).', :yellow
728
+ @warned_no_logs_token = true
729
+ end
730
+ false
731
+ end
732
+
733
+ # GET a search query from the testhub. The query rides in the URL's
734
+ # query_text parameter; the API key in the Authorization header.
575
735
  # Returns the raw Net::HTTP response.
576
736
  def search(query_text)
577
737
  get_search('/test_instances/search.json', query_text)
@@ -583,23 +743,112 @@ e-mail and password will be stored in plain text.'
583
743
  get_search('/test_instances/search_count.json', query_text)
584
744
  end
585
745
 
746
+ # What a claimed run is running on, for MESATestHub to show on pending
747
+ # tests (so a long-running claim can be explained: an old machine on two
748
+ # threads, a different SDK). Only what's actually known is sent:
749
+ # - with a built +mesa+, its testhub.yml compiler record (SDK, compiler,
750
+ # math backend), which is accurate even if the shell's SDK changed
751
+ # - otherwise (a build claim, made before compiling) just the SDK the
752
+ # shell has active, from mesasdk_init.sh's MESASDK_VERSION
753
+ # - OMP_NUM_THREADS and this computer's platform version
754
+ def claim_environment(mesa = nil)
755
+ env = {}
756
+ if mesa&.install_attempted?
757
+ env.merge!(mesa.compiler_hash.reject { |_k, v| v.to_s.strip.empty? || v == 'Unknown' })
758
+ end
759
+ sdk = ENV['MESASDK_VERSION'].to_s.strip
760
+ env[:sdk_version] ||= sdk unless sdk.empty?
761
+ threads = ENV['OMP_NUM_THREADS'].to_i
762
+ env[:omp_num_threads] = threads if threads.positive?
763
+ env[:platform_version] = platform_version unless platform_version.to_s.strip.empty?
764
+ env
765
+ end
766
+
767
+ # Ask MESATestHub what this computer should do next (POST /api/v1/dispatch).
768
+ # +scope+ is 'build' (which commit to build) or 'test' (which test to run;
769
+ # pass the +sha+ you built). +capabilities+ defaults to the configured
770
+ # optional modes; for a test, +fpe+ should say whether *this build* has
771
+ # FPE checks, since MESA_FPE_CHECKS_ON is a compile-time switch.
772
+ #
773
+ # Returns the recommendation as a Hash (commit_sha, branch, scope,
774
+ # test_case_module, test_case_name, flags, reasons, ...), or nil when
775
+ # there's nothing useful to do. Raises TestHubError on any other answer.
776
+ def dispatch(scope:, sha: nil, capabilities: self.capabilities)
777
+ body = { scope: scope }
778
+ body[:commit_sha] = sha if sha
779
+ CAPABILITIES.each { |c| body[:"can_#{c}"] = !!capabilities[c] }
780
+ response = post_testhub('/api/v1/dispatch', submitter: submitter_params, dispatch: body)
781
+ raise TestHubError, 'Dispatch failed: could not reach MESATestHub.' if response.nil?
782
+ return nil if response.is_a?(Net::HTTPNoContent)
783
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
784
+
785
+ raise TestHubError, "Dispatch failed (HTTP #{response.code}): #{error_message(response)}"
786
+ end
787
+
788
+ # Tell MESATestHub this computer is about to build +sha+ (scope 'build') or
789
+ # run a test on it (scope 'test' with +mod+ and +test_case+, or
790
+ # +all_test_cases: true+ for the whole suite). The commit then shows as
791
+ # pending instead of untested. The later submission fulfills the claim by
792
+ # matching computer, commit, and test, so nothing needs remembering here.
793
+ #
794
+ # Claims are advisory: a failure is reported and swallowed (returns nil) so
795
+ # it never stops a test run.
796
+ #
797
+ # The claim also says what the run is running on (see #claim_environment),
798
+ # which MESATestHub shows on the pending test. Pass +mesa+ for test claims
799
+ # so the build's own compiler record is used.
800
+ def claim(sha:, scope:, mod: nil, test_case: nil, all_test_cases: false,
801
+ flags: {}, dispatched_at: nil, mesa: nil)
802
+ body = { commit_sha: sha, scope: scope }
803
+ body.merge!(claim_environment(scope.to_s == 'test' ? mesa : nil))
804
+ body[:test_case_module] = mod.to_s if mod
805
+ body[:test_case_name] = test_case if test_case
806
+ body[:all_test_cases] = true if all_test_cases
807
+ body[:dispatched_at] = dispatched_at if dispatched_at
808
+ CAPABILITIES.each { |c| body[:"use_#{c}"] = !!flags[c] }
809
+ response = post_testhub('/api/v1/claims', submitter: submitter_params, claim: body)
810
+ return nil if response.nil? # unreachable; testhub_request said so
811
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPCreated)
812
+
813
+ shell.say "Could not claim #{claim_label(scope, mod, test_case, all_test_cases)} " \
814
+ "(HTTP #{response.code}: #{error_message(response)}); continuing anyway.",
815
+ :yellow
816
+ nil
817
+ end
818
+
586
819
  private
587
820
 
821
+ # POST JSON to the hub with testhub_request's bounded timeouts. Returns
822
+ # the response, or nil after a network failure (already reported).
823
+ def post_testhub(path, payload)
824
+ uri = URI.parse(base_uri + path)
825
+ https = Net::HTTP.new(uri.hostname, uri.port)
826
+ https.use_ssl = base_uri.include? 'https'
827
+ request = Net::HTTP::Post.new(uri, testhub_headers)
828
+ request.body = payload.to_json
829
+ testhub_request(https, request)
830
+ end
831
+
832
+ def error_message(response)
833
+ JSON.parse(response.body.to_s)['error'] || response.message
834
+ rescue JSON::ParserError
835
+ response.message
836
+ end
837
+
838
+ def claim_label(scope, mod, test_case, all_test_cases)
839
+ return 'the build' if scope.to_s == 'build'
840
+ return 'every test' if all_test_cases
841
+ "#{mod}/#{test_case}"
842
+ end
843
+
588
844
  def get_search(path, query_text)
589
845
  uri = URI.parse(base_uri + path)
590
- uri.query = URI.encode_www_form(
591
- email: email,
592
- password: password,
593
- query_text: query_text
594
- )
846
+ uri.query = URI.encode_www_form(query_text: query_text)
595
847
 
596
848
  https = Net::HTTP.new(uri.hostname, uri.port)
597
849
  https.use_ssl = base_uri.include? 'https'
598
850
 
599
- request = Net::HTTP::Get.new(
600
- uri,
601
- initheader = { 'Accept' => 'application/json' }
602
- )
851
+ request = Net::HTTP::Get.new(uri, testhub_headers(json_body: false))
603
852
 
604
853
  https.request(request)
605
854
  end
@@ -643,35 +892,29 @@ class Mesa
643
892
  end
644
893
 
645
894
  def checkout(new_sha: 'HEAD')
895
+ # A branch name is worth showing; a SHA is shown with its subject below.
896
+ say_step(shell, new_sha =~ /\A\h{40}\z/ ? 'Checking out MESA' : "Checking out #{new_sha}")
646
897
  # before anything confirm that git-lfs has been installed
647
- shell.say "\nEnsuring that git-lfs is installed... ", :blue
648
898
  command = 'git lfs help >> /dev/null 2>&1'
649
- if bash_execute(command)
650
- shell.say "yes", :green
651
- else
652
- shell.say "no", :red
653
- raise(GitHubError, "The command #{command} returned with an error "\
654
- 'status, indicating that git-lfs is not installed. '\
655
- 'Make sure it is installed and try again.')
899
+ unless bash_execute(command)
900
+ raise(GitHubError, 'git-lfs is not installed (`git lfs help` failed); ' \
901
+ 'MESA needs it. Install it and try again.')
656
902
  end
657
903
 
658
904
  # set up mirror if it doesn't exist
659
905
  unless dir_or_symlink_exists?(mirror_dir)
660
- shell.say "\nCreating initial mirror at #{mirror_dir}. "\
661
- 'This might take awhile...', :blue
906
+ shell.say "Creating a mirror of MESA in #{mirror_dir} (first time only; " \
907
+ 'this can take a while).'
662
908
  FileUtils.mkdir_p mirror_dir
663
909
  case github_protocol
664
910
  when :ssh
665
911
  command = "git clone --mirror #{GITHUB_SSH} #{mirror_dir}"
666
- shell.say command
667
912
  # fail loudly if this doesn't work
668
913
  unless bash_execute(command)
669
914
  # nuke the mirror directory since it is probably bogus (make this
670
915
  # code fire off the next time checkout is done)
671
- shell.say "Failed. Removing the [potentially corrupted] mirror.", :red
672
- command = "rm -rf #{mirror_dir}"
673
- shell.say command
674
- bash_execute(command)
916
+ shell.say 'Cloning failed; removing the partial mirror.', :red
917
+ bash_execute("rm -rf #{mirror_dir}")
675
918
 
676
919
  raise(GitHubError, 'Error while executing the following command:'\
677
920
  "#{command}. Perhaps you haven't set up "\
@@ -679,15 +922,12 @@ class Mesa
679
922
  end
680
923
  when :https
681
924
  command = "git clone --mirror #{GITHUB_HTTPS} #{mirror_dir}"
682
- shell.say command
683
925
  # fail loudly if this doesn't work
684
926
  unless bash_execute(command)
685
927
  # nuke the mirror directory since it is probably bogus (make this
686
928
  # code fire off the next time checkout is done)
687
- shell.say "Failed. Removing the [potentially corrupted] mirror.", :red
688
- command = "rm -rf #{mirror_dir}"
689
- shell.say command
690
- bash_execute(command)
929
+ shell.say 'Cloning failed; removing the partial mirror.', :red
930
+ bash_execute("rm -rf #{mirror_dir}")
691
931
 
692
932
  raise(GitHubError, 'Error while executing the following command: '\
693
933
  "#{command}. Perhaps you need to configure "\
@@ -705,20 +945,19 @@ class Mesa
705
945
  update_mirror
706
946
 
707
947
  # create "work" directory with proper commit
708
- shell.say "\nSetting up worktree repo...", :blue
709
948
  FileUtils.mkdir_p mesa_dir
710
- command = "git -C #{mirror_dir} worktree add #{mesa_dir} #{new_sha}"
711
- shell.say command
712
- return if bash_execute(command)
949
+ command = "git -C #{mirror_dir} worktree add --quiet #{mesa_dir} #{new_sha}"
950
+ if bash_execute(command)
951
+ shell.say "#{bashticks("git -C #{mesa_dir} log -1 --format='%h %s'")} (in #{mesa_dir})"
952
+ return
953
+ end
713
954
 
714
955
  raise(GitHubError, 'Failed while executing the following command: '\
715
956
  "\"#{command}\".")
716
957
  end
717
958
 
718
959
  def update_mirror
719
- shell.say "\nFetching MESA history...", :blue
720
- command = "git -C #{mirror_dir} fetch origin --prune"
721
- shell.say command
960
+ command = "git -C #{mirror_dir} fetch origin --prune --quiet"
722
961
  # fail loudly
723
962
  return if bash_execute(command)
724
963
 
@@ -728,15 +967,11 @@ class Mesa
728
967
 
729
968
  def remove
730
969
  return unless File.exist? mesa_dir
731
- shell.say "\nRemoving work directory from worktree (clearing old data)...",
732
- :blue
733
970
  command = "git -C #{mirror_dir} worktree remove --force #{mesa_dir}"
734
- shell.say command
735
971
  return if bash_execute(command)
736
972
 
737
- shell.say "Failed. Simply trying to remove the directory.", :red
973
+ # Not a worktree of this mirror (or git refused): just delete it.
738
974
  command = "rm -rf #{mesa_dir}"
739
- shell.say command
740
975
  # fail loudly (the "true" tells bash_execute to raise an exception if
741
976
  # the command fails)
742
977
  bash_execute(command, true)
@@ -754,8 +989,6 @@ class Mesa
754
989
  with_mesa_dir do
755
990
  visit_and_check mesa_dir, MesaDirError, 'E\countered a problem in ' \
756
991
  "running `clean` in #{mesa_dir}." do
757
- shell.say('MESA_DIR = ' + ENV['MESA_DIR'])
758
- shell.say './clean'
759
992
  bash_execute('./clean')
760
993
  end
761
994
  end
@@ -766,8 +999,6 @@ class Mesa
766
999
  with_mesa_dir do
767
1000
  visit_and_check mesa_dir, MesaDirError, 'Encountered a problem in ' \
768
1001
  "running `install` in #{mesa_dir}." do
769
- shell.say('MESA_DIR = ' + ENV['MESA_DIR'])
770
- shell.say './install'
771
1002
  bash_execute('./install')
772
1003
  end
773
1004
  end
@@ -792,23 +1023,65 @@ class Mesa
792
1023
  b64_file(build_log)
793
1024
  end
794
1025
 
1026
+ COMPILER_KEYS = %i[compiler compiler_version sdk_version math_backend].freeze
1027
+
795
1028
  # sourced from $MESA_DIR/testhub.yml, which should be created after
796
- # installation
1029
+ # installation. Only the compiler keys are taken: the file also carries
1030
+ # mesa_test's own run-mode block (see #record_run_modes), which doesn't
1031
+ # belong in the commit payload.
797
1032
  def compiler_hash
798
- data_file = File.join(mesa_dir, 'testhub.yml')
799
1033
  res = {
800
1034
  compiler: 'Unknown',
801
1035
  sdk_version: 'Unknown',
802
1036
  math_backend: 'Unknown'
803
1037
  }
804
- if File.exist? data_file
805
- res = res.merge(YAML.safe_load(File.read(data_file)) || {})
806
- # currently version_number is reported, but we don't need that in Git land
807
- res.delete('version_number') # returns the value, not the updated hash
808
- res
1038
+ # testhub.yml has string keys; symbolize them so they replace the
1039
+ # defaults above rather than sitting beside them as duplicate keys
1040
+ # (which json >= 3 refuses to serialize).
1041
+ found = install_data.transform_keys(&:to_sym).slice(*COMPILER_KEYS)
1042
+ res.merge(found)
1043
+ end
1044
+
1045
+ # The key under which `mesa_test install` records, in $MESA_DIR/testhub.yml,
1046
+ # the run modes this build was made for. The file is written by MESA's own
1047
+ # install (math/test), so it is recreated on every build and can't go stale.
1048
+ RUN_MODES_KEY = 'mesa_test_run_modes'.freeze
1049
+
1050
+ # Run modes recorded at install, as a hash with any of
1051
+ # fpe: true/false (compiled with MESA_FPE_CHECKS_ON?)
1052
+ # skip_optional: true/false (MESA_SKIP_OPTIONAL set or unset)
1053
+ # resolution_factor: a factor string, or false for "unset"
1054
+ # Only modes that were explicitly chosen at install are present; anything
1055
+ # absent falls back to the caller's environment.
1056
+ def recorded_run_modes
1057
+ (install_data[RUN_MODES_KEY] || {}).transform_keys(&:to_sym)
1058
+ .slice(*RUN_MODE_KEYS)
1059
+ end
1060
+
1061
+ # Append the modes chosen for this build to testhub.yml. Call after the
1062
+ # install, which rewrites the file.
1063
+ def record_run_modes(modes)
1064
+ data_file = File.join(mesa_dir, 'testhub.yml')
1065
+ return unless File.exist?(data_file)
1066
+
1067
+ modes = modes.slice(*RUN_MODE_KEYS)
1068
+ return if modes.empty?
1069
+
1070
+ block = { RUN_MODES_KEY => modes.transform_keys(&:to_s) }.to_yaml.sub(/\A---\n/, '')
1071
+ File.open(data_file, 'a') do |f|
1072
+ f.puts '# Added by mesa_test install: the run modes this build was made for.'
1073
+ f.puts '# `mesa_test test` applies them unless told otherwise.'
1074
+ f.write block
809
1075
  end
810
1076
  end
811
1077
 
1078
+ def install_data
1079
+ data_file = File.join(mesa_dir, 'testhub.yml')
1080
+ return {} unless File.exist?(data_file)
1081
+
1082
+ YAML.safe_load(File.read(data_file)) || {}
1083
+ end
1084
+
812
1085
  ## TEST SUITE METHODS
813
1086
 
814
1087
  def check_mod(mod)
@@ -839,7 +1112,7 @@ class Mesa
839
1112
  @names_to_numbers[mod] = {}
840
1113
  @test_case_names[mod] = []
841
1114
  @test_cases[mod] = {}
842
- visit_dir(test_suite_dir(mod: mod), quiet: true) do
1115
+ visit_dir(test_suite_dir(mod: mod)) do
843
1116
  bashticks('./list_tests').split("\n").each do |line|
844
1117
  num, tc_name = line.strip.split
845
1118
  @names_to_numbers[mod][tc_name.strip] = num.to_i
@@ -852,7 +1125,7 @@ class Mesa
852
1125
  mesa: self
853
1126
  )
854
1127
  rescue TestCaseDirError
855
- shell.say "No such test case #{tc_name.strip}. Skipping loading it.", :red
1128
+ shell.say "do1_test_source lists #{tc_name.strip}, but it has no directory; skipping it.", :yellow
856
1129
  end
857
1130
  end
858
1131
  end
@@ -899,6 +1172,18 @@ class Mesa
899
1172
  )
900
1173
  end
901
1174
 
1175
+ # Fingerprint of this build (a hash of build.log), so MESATestHub can
1176
+ # recognize a build report it already has. nil before any build.
1177
+ def build_fingerprint
1178
+ file_fingerprint(File.join(mesa_dir, 'build.log'))
1179
+ end
1180
+
1181
+ # When this build finished (build.log's mtime), or nil.
1182
+ def built_at
1183
+ build_log = File.join(mesa_dir, 'build.log')
1184
+ File.exist?(build_log) ? File.mtime(build_log) : nil
1185
+ end
1186
+
902
1187
  def install_attempted?
903
1188
  File.exist? File.join(mesa_dir, 'testhub.yml')
904
1189
  end
@@ -909,15 +1194,13 @@ class Mesa
909
1194
  # change MESA_DIR, holding on to old value
910
1195
  orig_mesa_dir = ENV['MESA_DIR']
911
1196
  ENV['MESA_DIR'] = mesa_dir
912
- shell.say "Temporarily changed MESA_DIR to #{ENV['MESA_DIR']}.", :blue
913
-
1197
+
914
1198
  # do the stuff
915
1199
  begin
916
1200
  yield
917
1201
  # make sure we undo MESA_DIR change
918
1202
  ensure
919
1203
  ENV['MESA_DIR'] = orig_mesa_dir
920
- shell.say "Changed MESA_DIR back to #{ENV['MESA_DIR']}.", :blue
921
1204
  end
922
1205
  end
923
1206
 
@@ -1073,10 +1356,26 @@ class MesaTestCase
1073
1356
  File.join(test_case_dir, 'testhub.yml')
1074
1357
  end
1075
1358
 
1359
+ # Run this test like #do_one, but as its own subprocess with its own
1360
+ # working directory and environment (+env+ overrides; nil unsets), so
1361
+ # several can run at once -- #do_one changes the process-wide directory
1362
+ # and expects the environment to be set globally. Output goes to
1363
+ # each_test_run.log in the test's directory. Returns true if
1364
+ # each_test_run exited cleanly (the test's pass/fail is in testhub.yml).
1365
+ def run_isolated(env: {})
1366
+ log = File.join(test_case_dir, 'each_test_run.log')
1367
+ pid = Process.spawn(env, './each_test_run', position.to_s,
1368
+ chdir: test_suite_dir, out: log, err: %i[child out], pgroup: true)
1369
+ RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS[pid] = test_name }
1370
+ _, status = Process.wait2(pid)
1371
+ status.success?
1372
+ ensure
1373
+ RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS.delete(pid) } if pid
1374
+ end
1375
+
1076
1376
  # just punt to +each_test_run+ in the test_suite directory. It's your problem
1077
1377
  # now, sucker!
1078
1378
  def do_one
1079
- shell.say("Testing #{test_name}", :yellow)
1080
1379
  visit_dir(test_suite_dir) do
1081
1380
  bash_execute("./each_test_run #{position}")
1082
1381
  end
@@ -1090,6 +1389,23 @@ class MesaTestCase
1090
1389
  YAML.load(File.read(testhub_file))
1091
1390
  end
1092
1391
 
1392
+ # How MESATestHub keys this test's fingerprint: "<module>/<test>".
1393
+ def fingerprint_key
1394
+ "#{mod}/#{test_name}"
1395
+ end
1396
+
1397
+ # Fingerprint of this run's results (a hash of testhub.yml).
1398
+ def fingerprint
1399
+ file_fingerprint(testhub_file)
1400
+ end
1401
+
1402
+ # Whether this test wrote results at or after +time+, i.e. during the
1403
+ # run that started then (not a testhub.yml left from an earlier run).
1404
+ # mtimes can be whole seconds, so compare at that resolution.
1405
+ def ran_since?(time)
1406
+ ran? && File.mtime(testhub_file) >= time.floor
1407
+ end
1408
+
1093
1409
  # rough proxy for whether or not the test has even been run
1094
1410
  def ran?
1095
1411
  File.exist?(testhub_file)
@@ -1157,12 +1473,8 @@ end
1157
1473
  # exception if the block failed (returned false or nil)
1158
1474
  def visit_and_check(new_dir, exception, message)
1159
1475
  cwd = Dir.getwd
1160
- shell.say "Leaving #{cwd}", :blue
1161
- shell.say "\nEntering #{new_dir}.", :blue
1162
1476
  Dir.chdir(new_dir)
1163
1477
  success = yield if block_given?
1164
- shell.say "Leaving #{new_dir}", :blue
1165
- shell.say "\nEntering #{cwd}.", :blue
1166
1478
  Dir.chdir(cwd)
1167
1479
  return if success
1168
1480
  raise exception, message
@@ -1170,19 +1482,15 @@ end
1170
1482
 
1171
1483
  # cd into a new directory, execute a block, then cd back into original
1172
1484
  # directory
1173
- def visit_dir(new_dir, quiet: false)
1485
+ def visit_dir(new_dir)
1174
1486
  cwd = Dir.getwd
1175
- shell.say "Leaving #{cwd}\n", :blue unless quiet
1176
- shell.say "\nEntering #{new_dir}.", :blue unless quiet
1177
1487
  Dir.chdir(new_dir)
1178
1488
  yield if block_given?
1179
- shell.say "Leaving #{new_dir}\n", :blue unless quiet
1180
- shell.say "\nRe-entering #{cwd}.", :blue unless quiet
1181
1489
  Dir.chdir(cwd)
1182
1490
  end
1183
1491
 
1184
1492
  # the next function probalby doesn't belong here, but keep it anyway, please
1185
- # create seed data for test cases for MesaTestHub of a given mesa version
1493
+ # create seed data for test cases for MESATestHub of a given mesa version
1186
1494
  def generate_seeds_rb(mesa_dir, outfile)
1187
1495
  m = Mesa.new(mesa_dir: mesa_dir)
1188
1496
  m.load_test_source_data
@@ -1224,6 +1532,137 @@ def bashticks(command)
1224
1532
  `bash -c "#{command}"`.chomp
1225
1533
  end
1226
1534
 
1535
+ # Run modes: how MESA is told to run a test, and the environment variables
1536
+ # behind them. MESA's each_test_run records each one in the test's
1537
+ # testhub.yml (fpe_checks / run_optional / resolution_factor), which is what
1538
+ # MESATestHub reads.
1539
+ #
1540
+ # fpe: MESA_FPE_CHECKS_ON=1. Compile-time as well as run-time
1541
+ # (MESA's make reads it), so it belongs to the build.
1542
+ # skip_optional: MESA_SKIP_OPTIONAL set. MESA runs every inlist unless
1543
+ # this is set.
1544
+ # resolution_factor: MESA_TEST_SUITE_RESOLUTION_FACTOR, which is what
1545
+ # `[ci converge]` sets.
1546
+ #
1547
+ # A mode that is nil/absent leaves the variable as the user's shell has it.
1548
+ RUN_MODE_KEYS = %i[fpe skip_optional resolution_factor].freeze
1549
+ DEFAULT_CONVERGE_FACTOR = '0.8'.freeze
1550
+
1551
+ # ENV overrides for +modes+ (nil value = unset the variable).
1552
+ def run_mode_env(modes)
1553
+ env = {}
1554
+ env['MESA_FPE_CHECKS_ON'] = modes[:fpe] ? '1' : nil unless modes[:fpe].nil?
1555
+ unless modes[:skip_optional].nil?
1556
+ env['MESA_SKIP_OPTIONAL'] = modes[:skip_optional] ? 't' : nil
1557
+ end
1558
+ unless modes[:resolution_factor].nil?
1559
+ env['MESA_TEST_SUITE_RESOLUTION_FACTOR'] =
1560
+ modes[:resolution_factor] ? modes[:resolution_factor].to_s : nil
1561
+ end
1562
+ env
1563
+ end
1564
+
1565
+ # The resolution factor a converge run should use: the user's own, if their
1566
+ # shell sets one, else DEFAULT_CONVERGE_FACTOR.
1567
+ def converge_factor
1568
+ ENV['MESA_TEST_SUITE_RESOLUTION_FACTOR'] || DEFAULT_CONVERGE_FACTOR
1569
+ end
1570
+
1571
+ # Run modes a MESATestHub dispatch asked for. Requests only ever *add* to the
1572
+ # user's setup: a mode the hub didn't ask for is left out (so the shell
1573
+ # decides), never forced off.
1574
+ #
1575
+ # A converge request may name its factor (`[ci converge 0.5]` arrives as
1576
+ # rec['converge_factor']). That's the commit author's request, so it wins
1577
+ # over +preferred+ -- a factor from --resolution-factor or the shell's
1578
+ # MESA_TEST_SUITE_RESOLUTION_FACTOR -- with a warning when they differ.
1579
+ # Without a named factor, +preferred+ (else the default) is used.
1580
+ def dispatch_run_modes(rec, preferred: nil, preferred_source: nil)
1581
+ flags = rec['flags'] || {}
1582
+ modes = {}
1583
+ modes[:fpe] = true if flags['use_fpe']
1584
+ modes[:skip_optional] = false if flags['use_full_inlists']
1585
+ if flags['use_converge']
1586
+ requested = rec['converge_factor']
1587
+ preferred ||= ENV['MESA_TEST_SUITE_RESOLUTION_FACTOR']
1588
+ preferred_source ||= 'MESA_TEST_SUITE_RESOLUTION_FACTOR'
1589
+ if requested
1590
+ factor = requested.to_s
1591
+ if preferred && preferred.to_f != requested.to_f
1592
+ warn_yellow "MESATestHub asked for resolution factor #{factor} ([ci converge #{factor}]); " \
1593
+ "using it instead of your #{preferred_source}=#{preferred}."
1594
+ end
1595
+ modes[:resolution_factor] = factor
1596
+ else
1597
+ modes[:resolution_factor] = preferred || DEFAULT_CONVERGE_FACTOR
1598
+ end
1599
+ end
1600
+ modes
1601
+ end
1602
+
1603
+ # Process groups of tests started by MesaTestCase#run_isolated, so an
1604
+ # interrupted parallel run can stop them all.
1605
+ RUNNING_TESTS = {}
1606
+ RUNNING_TESTS_LOCK = Mutex.new
1607
+
1608
+ def stop_running_tests
1609
+ pids = RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS.keys }
1610
+ pids.each do |pid|
1611
+ Process.kill('TERM', -pid)
1612
+ rescue Errno::ESRCH, Errno::EPERM
1613
+ nil
1614
+ end
1615
+ end
1616
+
1617
+ # Run +jobs+ worker threads, each calling the block with its index until
1618
+ # the block returns :done. On Ctrl-C, kills the workers first (so none
1619
+ # goes on to submit a half-run test), then every running test, then
1620
+ # re-raises. Exceptions in a worker propagate when it's joined.
1621
+ def run_workers(jobs)
1622
+ workers = []
1623
+ jobs.times do |i|
1624
+ workers << Thread.new do
1625
+ Thread.current.report_on_exception = false
1626
+ loop { break if yield(i) == :done }
1627
+ end
1628
+ end
1629
+ workers.each(&:join)
1630
+ rescue Interrupt
1631
+ workers.each(&:kill)
1632
+ stop_running_tests
1633
+ raise
1634
+ end
1635
+
1636
+ # Hand each of +items+ to the block, +jobs+ at a time.
1637
+ def parallel_each(items, jobs)
1638
+ queue = Queue.new
1639
+ items.each { |item| queue << item }
1640
+ run_workers([jobs, items.size].min) do
1641
+ item = begin
1642
+ queue.pop(true)
1643
+ rescue ThreadError
1644
+ next :done
1645
+ end
1646
+ yield item
1647
+ nil
1648
+ end
1649
+ end
1650
+
1651
+ # Run the block with +overrides+ applied to ENV (nil deletes a variable),
1652
+ # restoring the previous values afterwards.
1653
+ def with_env(overrides)
1654
+ saved = overrides.keys.map { |k| [k, ENV[k]] }.to_h
1655
+ overrides.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
1656
+ yield
1657
+ ensure
1658
+ saved&.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
1659
+ end
1660
+
1661
+ # SHA-256 of a file's contents, or nil if it doesn't exist.
1662
+ def file_fingerprint(path)
1663
+ File.exist?(path) ? Digest::SHA256.file(path).hexdigest : nil
1664
+ end
1665
+
1227
1666
  # encode the contents of a file as base-64
1228
1667
  def b64_file(filename)
1229
1668
  Base64.encode64(File.open(filename).read)