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.
- checksums.yaml +4 -4
- data/bin/mesa_test +644 -126
- data/lib/mesa_test.rb +619 -167
- 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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
response = shell.ask '
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
"
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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,
|
|
128
|
-
mesa_mirror: 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
|
-
|
|
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 '
|
|
238
|
+
puts "This computer's mesa_test settings:"
|
|
174
239
|
puts '-------------------------------------------------------'
|
|
175
240
|
puts "Computer Name #{computer_name}"
|
|
176
|
-
puts "
|
|
177
|
-
puts '
|
|
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 "
|
|
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
|
-
'
|
|
200
|
-
'
|
|
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
|
-
|
|
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
|
|
218
|
-
@
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
@
|
|
223
|
-
@
|
|
224
|
-
@
|
|
225
|
-
@
|
|
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
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
279
|
-
shell.say "
|
|
280
|
-
:
|
|
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
|
-
|
|
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
|
|
318
|
-
#
|
|
319
|
-
# test later.
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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.
|
|
573
|
-
#
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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)
|