mesa_test 1.2.1 → 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 +619 -167
  4. metadata +11 -7
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,28 +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
457
+ request = Net::HTTP::Post.new(uri, testhub_headers)
458
+ request.body = { computer_name: computer_name }.compact.to_json
333
459
  response = testhub_request(https, request)
334
460
  # if the hub was unreachable, behave as an unverified computer; the
335
461
  # network error has already been reported by testhub_request
336
- return {} if response.nil?
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
337
475
 
338
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.' }
339
481
  end
340
482
 
341
483
  # submit entire commit's worth of test cases, OR submit compilation status
@@ -349,10 +491,7 @@ e-mail and password will be stored in plain text.'
349
491
  https = Net::HTTP.new(uri.hostname, uri.port)
350
492
  https.use_ssl = true if base_uri.include? 'https'
351
493
 
352
- request = Net::HTTP::Post.new(
353
- uri,
354
- initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
355
- )
494
+ request = Net::HTTP::Post.new(uri, testhub_headers)
356
495
 
357
496
  # create the request body for submission to the submissions API
358
497
  #
@@ -363,11 +502,20 @@ e-mail and password will be stored in plain text.'
363
502
  empty ||= !mesa.installed?
364
503
  request_data = {submitter: submitter_params,
365
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
366
510
  # don't need test instances if it's an empty submission or if compilation
367
511
  # failed
512
+ tests = []
368
513
  if !empty && request_data[:commit][:compiled]
369
- 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 }
370
517
  end
518
+ request_data[:fingerprints] = fingerprints unless fingerprints.empty?
371
519
  request.body = request_data.to_json
372
520
 
373
521
  # actually do the submission
@@ -391,15 +539,13 @@ e-mail and password will be stored in plain text.'
391
539
 
392
540
  # compilation succeded, so submit any logs for failing tests
393
541
  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
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
403
549
  end
404
550
  end
405
551
 
@@ -421,10 +567,7 @@ e-mail and password will be stored in plain text.'
421
567
  https = Net::HTTP.new(uri.hostname, uri.port)
422
568
  https.use_ssl = true if base_uri.include? 'https'
423
569
 
424
- request = Net::HTTP::Post.new(
425
- uri,
426
- initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
427
- )
570
+ request = Net::HTTP::Post.new(uri, testhub_headers)
428
571
 
429
572
  # create the request body for submission to the submissions API
430
573
  #
@@ -432,7 +575,9 @@ e-mail and password will be stored in plain text.'
432
575
  # entire (... there is only test case)
433
576
  request_data = {submitter: submitter_params,
434
577
  commit: commit_params(mesa, empty: false, entire: false),
435
- 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?
436
581
  request.body = request_data.to_json
437
582
 
438
583
  # actually do the submission
@@ -569,9 +714,8 @@ e-mail and password will be stored in plain text.'
569
714
  end
570
715
  end
571
716
 
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).
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.
575
719
  # Returns the raw Net::HTTP response.
576
720
  def search(query_text)
577
721
  get_search('/test_instances/search.json', query_text)
@@ -583,23 +727,112 @@ e-mail and password will be stored in plain text.'
583
727
  get_search('/test_instances/search_count.json', query_text)
584
728
  end
585
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
+
586
803
  private
587
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
+
588
828
  def get_search(path, query_text)
589
829
  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
- )
830
+ uri.query = URI.encode_www_form(query_text: query_text)
595
831
 
596
832
  https = Net::HTTP.new(uri.hostname, uri.port)
597
833
  https.use_ssl = base_uri.include? 'https'
598
834
 
599
- request = Net::HTTP::Get.new(
600
- uri,
601
- initheader = { 'Accept' => 'application/json' }
602
- )
835
+ request = Net::HTTP::Get.new(uri, testhub_headers(json_body: false))
603
836
 
604
837
  https.request(request)
605
838
  end
@@ -792,23 +1025,65 @@ class Mesa
792
1025
  b64_file(build_log)
793
1026
  end
794
1027
 
1028
+ COMPILER_KEYS = %i[compiler compiler_version sdk_version math_backend].freeze
1029
+
795
1030
  # sourced from $MESA_DIR/testhub.yml, which should be created after
796
- # 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.
797
1034
  def compiler_hash
798
- data_file = File.join(mesa_dir, 'testhub.yml')
799
1035
  res = {
800
1036
  compiler: 'Unknown',
801
1037
  sdk_version: 'Unknown',
802
1038
  math_backend: 'Unknown'
803
1039
  }
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
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
809
1077
  end
810
1078
  end
811
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
+
812
1087
  ## TEST SUITE METHODS
813
1088
 
814
1089
  def check_mod(mod)
@@ -899,6 +1174,18 @@ class Mesa
899
1174
  )
900
1175
  end
901
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
+
902
1189
  def install_attempted?
903
1190
  File.exist? File.join(mesa_dir, 'testhub.yml')
904
1191
  end
@@ -1073,6 +1360,23 @@ class MesaTestCase
1073
1360
  File.join(test_case_dir, 'testhub.yml')
1074
1361
  end
1075
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
+
1076
1380
  # just punt to +each_test_run+ in the test_suite directory. It's your problem
1077
1381
  # now, sucker!
1078
1382
  def do_one
@@ -1090,6 +1394,23 @@ class MesaTestCase
1090
1394
  YAML.load(File.read(testhub_file))
1091
1395
  end
1092
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
+
1093
1414
  # rough proxy for whether or not the test has even been run
1094
1415
  def ran?
1095
1416
  File.exist?(testhub_file)
@@ -1224,6 +1545,137 @@ def bashticks(command)
1224
1545
  `bash -c "#{command}"`.chomp
1225
1546
  end
1226
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
+
1227
1679
  # encode the contents of a file as base-64
1228
1680
  def b64_file(filename)
1229
1681
  Base64.encode64(File.open(filename).read)