mesa_test 1.2.0 → 2.0.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/mesa_test +644 -126
- data/lib/mesa_test.rb +685 -179
- metadata +7 -6
data/lib/mesa_test.rb
CHANGED
|
@@ -8,11 +8,13 @@ require 'net/https'
|
|
|
8
8
|
require 'thor'
|
|
9
9
|
require 'json'
|
|
10
10
|
require 'base64'
|
|
11
|
+
require 'digest'
|
|
11
12
|
|
|
12
13
|
MesaDirError = Class.new(StandardError)
|
|
13
14
|
TestCaseDirError = Class.new(StandardError)
|
|
14
15
|
InvalidDataType = Class.new(StandardError)
|
|
15
16
|
GitHubError = Class.new(StandardError)
|
|
17
|
+
TestHubError = Class.new(StandardError)
|
|
16
18
|
|
|
17
19
|
GITHUB_HTTPS = 'https://github.com/MESAHub/mesa.git'.freeze
|
|
18
20
|
GITHUB_SSH = 'git@github.com:MESAHub/mesa.git'.freeze
|
|
@@ -20,45 +22,53 @@ GITHUB_SSH = 'git@github.com:MESAHub/mesa.git'.freeze
|
|
|
20
22
|
class MesaTestSubmitter
|
|
21
23
|
DEFAULT_URI = 'https://testhub.mesastar.org'.freeze
|
|
22
24
|
|
|
25
|
+
# How to get an API key, for the setup wizard and for every command that
|
|
26
|
+
# finds none.
|
|
27
|
+
API_KEY_HELP = <<~HELP.freeze
|
|
28
|
+
mesa_test authenticates with a per-computer API key. To get one:
|
|
29
|
+
1. Log in to MESATestHub (#{DEFAULT_URI}) and open My Computers.
|
|
30
|
+
2. Open this computer's page (add the computer first if it isn't
|
|
31
|
+
listed; a computer used only for `search` and `count` is fine).
|
|
32
|
+
3. Click "Generate key" and copy the key, which starts with mth_.
|
|
33
|
+
HELP
|
|
34
|
+
|
|
23
35
|
# set up config file for computer
|
|
24
36
|
def setup
|
|
25
37
|
update do |s|
|
|
26
|
-
shell.say
|
|
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,23 +445,39 @@ e-mail and password will be stored in plain text.'
|
|
|
314
445
|
[test_case.results_hash]
|
|
315
446
|
end
|
|
316
447
|
|
|
317
|
-
# Phone home to testhub and confirm that
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
457
|
+
request = Net::HTTP::Post.new(uri, testhub_headers)
|
|
458
|
+
request.body = { computer_name: computer_name }.compact.to_json
|
|
459
|
+
response = testhub_request(https, request)
|
|
460
|
+
# if the hub was unreachable, behave as an unverified computer; the
|
|
461
|
+
# network error has already been reported by testhub_request
|
|
462
|
+
if response.nil?
|
|
463
|
+
return { 'valid' => false, 'reachable' => false,
|
|
464
|
+
'message' => 'Could not verify this computer with MESATestHub.' }
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Anything but a 200 (a 429 from the rate limiter, a 5xx, a proxy's HTML
|
|
468
|
+
# error page) carries no valid/message keys, so name the status and the
|
|
469
|
+
# server's error instead of failing with a blank line.
|
|
470
|
+
unless response.is_a?(Net::HTTPOK)
|
|
471
|
+
return { 'valid' => false,
|
|
472
|
+
'message' => 'Could not verify this computer with MESATestHub ' \
|
|
473
|
+
"(HTTP #{response.code}: #{error_message(response)})." }
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
JSON.parse(response.body).to_hash
|
|
477
|
+
rescue JSON::ParserError
|
|
478
|
+
{ 'valid' => false,
|
|
479
|
+
'message' => 'Could not verify this computer: MESATestHub sent an ' \
|
|
480
|
+
'unreadable response.' }
|
|
334
481
|
end
|
|
335
482
|
|
|
336
483
|
# submit entire commit's worth of test cases, OR submit compilation status
|
|
@@ -344,10 +491,7 @@ e-mail and password will be stored in plain text.'
|
|
|
344
491
|
https = Net::HTTP.new(uri.hostname, uri.port)
|
|
345
492
|
https.use_ssl = true if base_uri.include? 'https'
|
|
346
493
|
|
|
347
|
-
request = Net::HTTP::Post.new(
|
|
348
|
-
uri,
|
|
349
|
-
initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
|
|
350
|
-
)
|
|
494
|
+
request = Net::HTTP::Post.new(uri, testhub_headers)
|
|
351
495
|
|
|
352
496
|
# create the request body for submission to the submissions API
|
|
353
497
|
#
|
|
@@ -358,19 +502,32 @@ e-mail and password will be stored in plain text.'
|
|
|
358
502
|
empty ||= !mesa.installed?
|
|
359
503
|
request_data = {submitter: submitter_params,
|
|
360
504
|
commit: commit_params(mesa, empty: empty, entire: !empty)}
|
|
505
|
+
request_data[:claim] = run_mode_params(mesa) if mesa.recorded_run_modes.any?
|
|
506
|
+
# Fingerprints let MESATestHub ignore results it already has (a retried
|
|
507
|
+
# submit, a head node re-sending what compute nodes sent).
|
|
508
|
+
fingerprints = {}
|
|
509
|
+
fingerprints['build'] = mesa.build_fingerprint if mesa.build_fingerprint
|
|
361
510
|
# don't need test instances if it's an empty submission or if compilation
|
|
362
511
|
# failed
|
|
512
|
+
tests = []
|
|
363
513
|
if !empty && request_data[:commit][:compiled]
|
|
364
|
-
|
|
514
|
+
tests = submittable_tests(mesa)
|
|
515
|
+
request_data[:instances] = tests.map(&:results_hash)
|
|
516
|
+
tests.each { |t| fingerprints[t.fingerprint_key] = t.fingerprint }
|
|
365
517
|
end
|
|
518
|
+
request_data[:fingerprints] = fingerprints unless fingerprints.empty?
|
|
366
519
|
request.body = request_data.to_json
|
|
367
520
|
|
|
368
521
|
# actually do the submission
|
|
369
|
-
response = https
|
|
522
|
+
response = testhub_request(https, request)
|
|
370
523
|
|
|
371
|
-
if
|
|
524
|
+
if response.nil?
|
|
525
|
+
# network failure; testhub_request already explained why
|
|
526
|
+
false
|
|
527
|
+
elsif !response.is_a? Net::HTTPCreated
|
|
372
528
|
shell.say "\nFailed to submit some or all test case instances and/or "\
|
|
373
|
-
|
|
529
|
+
"commit data (server responded #{response.code} "\
|
|
530
|
+
"#{response.message}).", :red
|
|
374
531
|
false
|
|
375
532
|
else
|
|
376
533
|
shell.say "\nSuccessfully submitted commit #{mesa.sha}.", :green
|
|
@@ -382,15 +539,13 @@ e-mail and password will be stored in plain text.'
|
|
|
382
539
|
|
|
383
540
|
# compilation succeded, so submit any logs for failing tests
|
|
384
541
|
res = true
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
end
|
|
393
|
-
end
|
|
542
|
+
# logs for the failing tests just submitted (or all, with force_logs)
|
|
543
|
+
tests.each do |test_case|
|
|
544
|
+
if !test_case.passed? || force_logs
|
|
545
|
+
# (not `res &&= submit_test_log(...)`: after one failed upload that
|
|
546
|
+
# would skip every remaining test's logs)
|
|
547
|
+
sent = submit_test_log(test_case, skip_passing: !force_logs)
|
|
548
|
+
res &&= sent
|
|
394
549
|
end
|
|
395
550
|
end
|
|
396
551
|
|
|
@@ -412,10 +567,7 @@ e-mail and password will be stored in plain text.'
|
|
|
412
567
|
https = Net::HTTP.new(uri.hostname, uri.port)
|
|
413
568
|
https.use_ssl = true if base_uri.include? 'https'
|
|
414
569
|
|
|
415
|
-
request = Net::HTTP::Post.new(
|
|
416
|
-
uri,
|
|
417
|
-
initheader = { 'Accept' => 'application/json', 'Content-Type' => 'application/json' }
|
|
418
|
-
)
|
|
570
|
+
request = Net::HTTP::Post.new(uri, testhub_headers)
|
|
419
571
|
|
|
420
572
|
# create the request body for submission to the submissions API
|
|
421
573
|
#
|
|
@@ -423,16 +575,22 @@ e-mail and password will be stored in plain text.'
|
|
|
423
575
|
# entire (... there is only test case)
|
|
424
576
|
request_data = {submitter: submitter_params,
|
|
425
577
|
commit: commit_params(mesa, empty: false, entire: false),
|
|
426
|
-
instances: single_instance_params(test_case)
|
|
578
|
+
instances: single_instance_params(test_case),
|
|
579
|
+
fingerprints: { test_case.fingerprint_key => test_case.fingerprint }}
|
|
580
|
+
request_data[:claim] = run_mode_params(mesa) if mesa.recorded_run_modes.any?
|
|
427
581
|
request.body = request_data.to_json
|
|
428
582
|
|
|
429
583
|
# actually do the submission
|
|
430
|
-
response = https
|
|
584
|
+
response = testhub_request(https, request)
|
|
431
585
|
|
|
432
|
-
if
|
|
586
|
+
if response.nil?
|
|
587
|
+
# network failure; testhub_request already explained why
|
|
588
|
+
return false
|
|
589
|
+
elsif !response.is_a? Net::HTTPCreated
|
|
433
590
|
shell.say "\nFailed to submit #{test_case.test_name} for commit "\
|
|
434
|
-
"#{mesa.sha}"
|
|
435
|
-
|
|
591
|
+
"#{mesa.sha} (server responded #{response.code} "\
|
|
592
|
+
"#{response.message}).", :red
|
|
593
|
+
return false
|
|
436
594
|
else
|
|
437
595
|
shell.say "\nSuccessfully submitted instance of #{test_case.test_name} "\
|
|
438
596
|
"for commit #{mesa.sha}.", :green
|
|
@@ -444,17 +602,50 @@ e-mail and password will be stored in plain text.'
|
|
|
444
602
|
end
|
|
445
603
|
end
|
|
446
604
|
|
|
605
|
+
# Perform an HTTP request against the test hub with bounded connect/read
|
|
606
|
+
# timeouts, so a slow or unreachable server (e.g. while it is under heavy
|
|
607
|
+
# load) fails fast with a clear message instead of hanging on the default
|
|
608
|
+
# 60-second connect timeout and then dumping a raw Ruby backtrace. Returns
|
|
609
|
+
# the Net::HTTPResponse, or +nil+ if the request could not be completed
|
|
610
|
+
# because of a network problem.
|
|
611
|
+
def testhub_request(https, request)
|
|
612
|
+
https.open_timeout = 10
|
|
613
|
+
https.read_timeout = 60
|
|
614
|
+
https.request(request)
|
|
615
|
+
rescue StandardError => e
|
|
616
|
+
shell.say "\nCould not reach the test hub at #{https.address} "\
|
|
617
|
+
"(#{e.class}: #{e.message}).", :red
|
|
618
|
+
nil
|
|
619
|
+
end
|
|
620
|
+
|
|
447
621
|
# make generic request to LOGS server
|
|
448
622
|
# +params+ is a hash of data to be encoded as JSON and sent off
|
|
623
|
+
#
|
|
624
|
+
# Returns the Net::HTTPResponse on success, or +nil+ if the request could
|
|
625
|
+
# not be completed because of a network problem (the LOGS server being
|
|
626
|
+
# unreachable, slow, or refusing connections). The LOGS server only receives
|
|
627
|
+
# diagnostic build/test output, so a failure here must never crash the run or
|
|
628
|
+
# fail a CI build whose actual test results already reached the test hub. We
|
|
629
|
+
# cap the connect/read time so we fail fast instead of hanging on the default
|
|
630
|
+
# 60-second open timeout for every test case.
|
|
449
631
|
def submit_logs(params)
|
|
450
632
|
#uri = URI('https://logs.mesastar.org/uploads')
|
|
451
633
|
uri = URI('https://mesa-logs.flatironinstitute.org/uploads')
|
|
452
634
|
https = Net::HTTP.new(uri.host, uri.port)
|
|
453
635
|
https.use_ssl = true
|
|
636
|
+
https.open_timeout = 10
|
|
637
|
+
https.read_timeout = 30
|
|
454
638
|
req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json',
|
|
455
639
|
'X-Api-Key' => logs_token)
|
|
456
640
|
req.body = params.to_json
|
|
457
|
-
|
|
641
|
+
begin
|
|
642
|
+
https.request(req)
|
|
643
|
+
rescue StandardError => e
|
|
644
|
+
shell.say "\nCould not reach the LOGS server at #{uri.host} "\
|
|
645
|
+
"(#{e.class}: #{e.message}). Skipping log upload; this does "\
|
|
646
|
+
'not affect test results already sent to the test hub.', :yellow
|
|
647
|
+
nil
|
|
648
|
+
end
|
|
458
649
|
end
|
|
459
650
|
|
|
460
651
|
# send build log to the logs server
|
|
@@ -473,9 +664,13 @@ e-mail and password will be stored in plain text.'
|
|
|
473
664
|
res = submit_logs(build_log_params(mesa))
|
|
474
665
|
|
|
475
666
|
# report out results
|
|
476
|
-
if
|
|
667
|
+
if res.nil?
|
|
668
|
+
# network failure; submit_logs already explained why. Don't fail the run.
|
|
669
|
+
false
|
|
670
|
+
elsif !res.is_a? Net::HTTPOK
|
|
477
671
|
shell.say "\nFailed to submit build.log to the LOGS server for commit "\
|
|
478
|
-
"#{mesa.sha}.",
|
|
672
|
+
"#{mesa.sha} (server responded #{res.code} #{res.message}).",
|
|
673
|
+
:red
|
|
479
674
|
false
|
|
480
675
|
else
|
|
481
676
|
shell.say "\nSuccessfully submitted build.log to the LOGS server for "\
|
|
@@ -503,9 +698,13 @@ e-mail and password will be stored in plain text.'
|
|
|
503
698
|
res = submit_logs(test_log_params(test_case))
|
|
504
699
|
|
|
505
700
|
# report out results
|
|
506
|
-
if
|
|
701
|
+
if res.nil?
|
|
702
|
+
# network failure; submit_logs already explained why. Don't fail the run.
|
|
703
|
+
false
|
|
704
|
+
elsif !res.is_a? Net::HTTPOK
|
|
507
705
|
shell.say "Failed to submit logs for test case #{test_case.test_name} "\
|
|
508
|
-
"in commit #{test_case.mesa.sha}
|
|
706
|
+
"in commit #{test_case.mesa.sha} (server responded "\
|
|
707
|
+
"#{res.code} #{res.message}).", :red
|
|
509
708
|
false
|
|
510
709
|
else
|
|
511
710
|
shell.say "Successfully submitted logs for test case "\
|
|
@@ -515,9 +714,8 @@ e-mail and password will be stored in plain text.'
|
|
|
515
714
|
end
|
|
516
715
|
end
|
|
517
716
|
|
|
518
|
-
# GET a search query from the testhub.
|
|
519
|
-
#
|
|
520
|
-
# the controller also accepts session auth, but a CLI sends them in-band).
|
|
717
|
+
# GET a search query from the testhub. The query rides in the URL's
|
|
718
|
+
# query_text parameter; the API key in the Authorization header.
|
|
521
719
|
# Returns the raw Net::HTTP response.
|
|
522
720
|
def search(query_text)
|
|
523
721
|
get_search('/test_instances/search.json', query_text)
|
|
@@ -529,23 +727,112 @@ e-mail and password will be stored in plain text.'
|
|
|
529
727
|
get_search('/test_instances/search_count.json', query_text)
|
|
530
728
|
end
|
|
531
729
|
|
|
730
|
+
# What a claimed run is running on, for MESATestHub to show on pending
|
|
731
|
+
# tests (so a long-running claim can be explained: an old machine on two
|
|
732
|
+
# threads, a different SDK). Only what's actually known is sent:
|
|
733
|
+
# - with a built +mesa+, its testhub.yml compiler record (SDK, compiler,
|
|
734
|
+
# math backend), which is accurate even if the shell's SDK changed
|
|
735
|
+
# - otherwise (a build claim, made before compiling) just the SDK the
|
|
736
|
+
# shell has active, from mesasdk_init.sh's MESASDK_VERSION
|
|
737
|
+
# - OMP_NUM_THREADS and this computer's platform version
|
|
738
|
+
def claim_environment(mesa = nil)
|
|
739
|
+
env = {}
|
|
740
|
+
if mesa&.install_attempted?
|
|
741
|
+
env.merge!(mesa.compiler_hash.reject { |_k, v| v.to_s.strip.empty? || v == 'Unknown' })
|
|
742
|
+
end
|
|
743
|
+
sdk = ENV['MESASDK_VERSION'].to_s.strip
|
|
744
|
+
env[:sdk_version] ||= sdk unless sdk.empty?
|
|
745
|
+
threads = ENV['OMP_NUM_THREADS'].to_i
|
|
746
|
+
env[:omp_num_threads] = threads if threads.positive?
|
|
747
|
+
env[:platform_version] = platform_version unless platform_version.to_s.strip.empty?
|
|
748
|
+
env
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
# Ask MESATestHub what this computer should do next (POST /api/v1/dispatch).
|
|
752
|
+
# +scope+ is 'build' (which commit to build) or 'test' (which test to run;
|
|
753
|
+
# pass the +sha+ you built). +capabilities+ defaults to the configured
|
|
754
|
+
# optional modes; for a test, +fpe+ should say whether *this build* has
|
|
755
|
+
# FPE checks, since MESA_FPE_CHECKS_ON is a compile-time switch.
|
|
756
|
+
#
|
|
757
|
+
# Returns the recommendation as a Hash (commit_sha, branch, scope,
|
|
758
|
+
# test_case_module, test_case_name, flags, reasons, ...), or nil when
|
|
759
|
+
# there's nothing useful to do. Raises TestHubError on any other answer.
|
|
760
|
+
def dispatch(scope:, sha: nil, capabilities: self.capabilities)
|
|
761
|
+
body = { scope: scope }
|
|
762
|
+
body[:commit_sha] = sha if sha
|
|
763
|
+
CAPABILITIES.each { |c| body[:"can_#{c}"] = !!capabilities[c] }
|
|
764
|
+
response = post_testhub('/api/v1/dispatch', submitter: submitter_params, dispatch: body)
|
|
765
|
+
raise TestHubError, 'Dispatch failed: could not reach MESATestHub.' if response.nil?
|
|
766
|
+
return nil if response.is_a?(Net::HTTPNoContent)
|
|
767
|
+
return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
|
|
768
|
+
|
|
769
|
+
raise TestHubError, "Dispatch failed (HTTP #{response.code}): #{error_message(response)}"
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Tell MESATestHub this computer is about to build +sha+ (scope 'build') or
|
|
773
|
+
# run a test on it (scope 'test' with +mod+ and +test_case+, or
|
|
774
|
+
# +all_test_cases: true+ for the whole suite). The commit then shows as
|
|
775
|
+
# pending instead of untested. The later submission fulfills the claim by
|
|
776
|
+
# matching computer, commit, and test, so nothing needs remembering here.
|
|
777
|
+
#
|
|
778
|
+
# Claims are advisory: a failure is reported and swallowed (returns nil) so
|
|
779
|
+
# it never stops a test run.
|
|
780
|
+
#
|
|
781
|
+
# The claim also says what the run is running on (see #claim_environment),
|
|
782
|
+
# which MESATestHub shows on the pending test. Pass +mesa+ for test claims
|
|
783
|
+
# so the build's own compiler record is used.
|
|
784
|
+
def claim(sha:, scope:, mod: nil, test_case: nil, all_test_cases: false,
|
|
785
|
+
flags: {}, dispatched_at: nil, mesa: nil)
|
|
786
|
+
body = { commit_sha: sha, scope: scope }
|
|
787
|
+
body.merge!(claim_environment(scope.to_s == 'test' ? mesa : nil))
|
|
788
|
+
body[:test_case_module] = mod.to_s if mod
|
|
789
|
+
body[:test_case_name] = test_case if test_case
|
|
790
|
+
body[:all_test_cases] = true if all_test_cases
|
|
791
|
+
body[:dispatched_at] = dispatched_at if dispatched_at
|
|
792
|
+
CAPABILITIES.each { |c| body[:"use_#{c}"] = !!flags[c] }
|
|
793
|
+
response = post_testhub('/api/v1/claims', submitter: submitter_params, claim: body)
|
|
794
|
+
return nil if response.nil? # unreachable; testhub_request said so
|
|
795
|
+
return JSON.parse(response.body) if response.is_a?(Net::HTTPCreated)
|
|
796
|
+
|
|
797
|
+
shell.say "Could not claim #{claim_label(scope, mod, test_case, all_test_cases)} " \
|
|
798
|
+
"(HTTP #{response.code}: #{error_message(response)}); continuing anyway.",
|
|
799
|
+
:yellow
|
|
800
|
+
nil
|
|
801
|
+
end
|
|
802
|
+
|
|
532
803
|
private
|
|
533
804
|
|
|
805
|
+
# POST JSON to the hub with testhub_request's bounded timeouts. Returns
|
|
806
|
+
# the response, or nil after a network failure (already reported).
|
|
807
|
+
def post_testhub(path, payload)
|
|
808
|
+
uri = URI.parse(base_uri + path)
|
|
809
|
+
https = Net::HTTP.new(uri.hostname, uri.port)
|
|
810
|
+
https.use_ssl = base_uri.include? 'https'
|
|
811
|
+
request = Net::HTTP::Post.new(uri, testhub_headers)
|
|
812
|
+
request.body = payload.to_json
|
|
813
|
+
testhub_request(https, request)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def error_message(response)
|
|
817
|
+
JSON.parse(response.body.to_s)['error'] || response.message
|
|
818
|
+
rescue JSON::ParserError
|
|
819
|
+
response.message
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def claim_label(scope, mod, test_case, all_test_cases)
|
|
823
|
+
return 'the build' if scope.to_s == 'build'
|
|
824
|
+
return 'every test' if all_test_cases
|
|
825
|
+
"#{mod}/#{test_case}"
|
|
826
|
+
end
|
|
827
|
+
|
|
534
828
|
def get_search(path, query_text)
|
|
535
829
|
uri = URI.parse(base_uri + path)
|
|
536
|
-
uri.query = URI.encode_www_form(
|
|
537
|
-
email: email,
|
|
538
|
-
password: password,
|
|
539
|
-
query_text: query_text
|
|
540
|
-
)
|
|
830
|
+
uri.query = URI.encode_www_form(query_text: query_text)
|
|
541
831
|
|
|
542
832
|
https = Net::HTTP.new(uri.hostname, uri.port)
|
|
543
833
|
https.use_ssl = base_uri.include? 'https'
|
|
544
834
|
|
|
545
|
-
request = Net::HTTP::Get.new(
|
|
546
|
-
uri,
|
|
547
|
-
initheader = { 'Accept' => 'application/json' }
|
|
548
|
-
)
|
|
835
|
+
request = Net::HTTP::Get.new(uri, testhub_headers(json_body: false))
|
|
549
836
|
|
|
550
837
|
https.request(request)
|
|
551
838
|
end
|
|
@@ -738,23 +1025,65 @@ class Mesa
|
|
|
738
1025
|
b64_file(build_log)
|
|
739
1026
|
end
|
|
740
1027
|
|
|
1028
|
+
COMPILER_KEYS = %i[compiler compiler_version sdk_version math_backend].freeze
|
|
1029
|
+
|
|
741
1030
|
# sourced from $MESA_DIR/testhub.yml, which should be created after
|
|
742
|
-
# installation
|
|
1031
|
+
# installation. Only the compiler keys are taken: the file also carries
|
|
1032
|
+
# mesa_test's own run-mode block (see #record_run_modes), which doesn't
|
|
1033
|
+
# belong in the commit payload.
|
|
743
1034
|
def compiler_hash
|
|
744
|
-
data_file = File.join(mesa_dir, 'testhub.yml')
|
|
745
1035
|
res = {
|
|
746
1036
|
compiler: 'Unknown',
|
|
747
1037
|
sdk_version: 'Unknown',
|
|
748
1038
|
math_backend: 'Unknown'
|
|
749
1039
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
1040
|
+
# testhub.yml has string keys; symbolize them so they replace the
|
|
1041
|
+
# defaults above rather than sitting beside them as duplicate keys
|
|
1042
|
+
# (which json >= 3 refuses to serialize).
|
|
1043
|
+
found = install_data.transform_keys(&:to_sym).slice(*COMPILER_KEYS)
|
|
1044
|
+
res.merge(found)
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# The key under which `mesa_test install` records, in $MESA_DIR/testhub.yml,
|
|
1048
|
+
# the run modes this build was made for. The file is written by MESA's own
|
|
1049
|
+
# install (math/test), so it is recreated on every build and can't go stale.
|
|
1050
|
+
RUN_MODES_KEY = 'mesa_test_run_modes'.freeze
|
|
1051
|
+
|
|
1052
|
+
# Run modes recorded at install, as a hash with any of
|
|
1053
|
+
# fpe: true/false (compiled with MESA_FPE_CHECKS_ON?)
|
|
1054
|
+
# skip_optional: true/false (MESA_SKIP_OPTIONAL set or unset)
|
|
1055
|
+
# resolution_factor: a factor string, or false for "unset"
|
|
1056
|
+
# Only modes that were explicitly chosen at install are present; anything
|
|
1057
|
+
# absent falls back to the caller's environment.
|
|
1058
|
+
def recorded_run_modes
|
|
1059
|
+
(install_data[RUN_MODES_KEY] || {}).transform_keys(&:to_sym)
|
|
1060
|
+
.slice(*RUN_MODE_KEYS)
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
# Append the modes chosen for this build to testhub.yml. Call after the
|
|
1064
|
+
# install, which rewrites the file.
|
|
1065
|
+
def record_run_modes(modes)
|
|
1066
|
+
data_file = File.join(mesa_dir, 'testhub.yml')
|
|
1067
|
+
return unless File.exist?(data_file)
|
|
1068
|
+
|
|
1069
|
+
modes = modes.slice(*RUN_MODE_KEYS)
|
|
1070
|
+
return if modes.empty?
|
|
1071
|
+
|
|
1072
|
+
block = { RUN_MODES_KEY => modes.transform_keys(&:to_s) }.to_yaml.sub(/\A---\n/, '')
|
|
1073
|
+
File.open(data_file, 'a') do |f|
|
|
1074
|
+
f.puts '# Added by mesa_test install: the run modes this build was made for.'
|
|
1075
|
+
f.puts '# `mesa_test test` applies them unless told otherwise.'
|
|
1076
|
+
f.write block
|
|
755
1077
|
end
|
|
756
1078
|
end
|
|
757
1079
|
|
|
1080
|
+
def install_data
|
|
1081
|
+
data_file = File.join(mesa_dir, 'testhub.yml')
|
|
1082
|
+
return {} unless File.exist?(data_file)
|
|
1083
|
+
|
|
1084
|
+
YAML.safe_load(File.read(data_file)) || {}
|
|
1085
|
+
end
|
|
1086
|
+
|
|
758
1087
|
## TEST SUITE METHODS
|
|
759
1088
|
|
|
760
1089
|
def check_mod(mod)
|
|
@@ -845,6 +1174,18 @@ class Mesa
|
|
|
845
1174
|
)
|
|
846
1175
|
end
|
|
847
1176
|
|
|
1177
|
+
# Fingerprint of this build (a hash of build.log), so MESATestHub can
|
|
1178
|
+
# recognize a build report it already has. nil before any build.
|
|
1179
|
+
def build_fingerprint
|
|
1180
|
+
file_fingerprint(File.join(mesa_dir, 'build.log'))
|
|
1181
|
+
end
|
|
1182
|
+
|
|
1183
|
+
# When this build finished (build.log's mtime), or nil.
|
|
1184
|
+
def built_at
|
|
1185
|
+
build_log = File.join(mesa_dir, 'build.log')
|
|
1186
|
+
File.exist?(build_log) ? File.mtime(build_log) : nil
|
|
1187
|
+
end
|
|
1188
|
+
|
|
848
1189
|
def install_attempted?
|
|
849
1190
|
File.exist? File.join(mesa_dir, 'testhub.yml')
|
|
850
1191
|
end
|
|
@@ -1019,6 +1360,23 @@ class MesaTestCase
|
|
|
1019
1360
|
File.join(test_case_dir, 'testhub.yml')
|
|
1020
1361
|
end
|
|
1021
1362
|
|
|
1363
|
+
# Run this test like #do_one, but as its own subprocess with its own
|
|
1364
|
+
# working directory and environment (+env+ overrides; nil unsets), so
|
|
1365
|
+
# several can run at once -- #do_one changes the process-wide directory
|
|
1366
|
+
# and expects the environment to be set globally. Output goes to
|
|
1367
|
+
# each_test_run.log in the test's directory. Returns true if
|
|
1368
|
+
# each_test_run exited cleanly (the test's pass/fail is in testhub.yml).
|
|
1369
|
+
def run_isolated(env: {})
|
|
1370
|
+
log = File.join(test_case_dir, 'each_test_run.log')
|
|
1371
|
+
pid = Process.spawn(env, './each_test_run', position.to_s,
|
|
1372
|
+
chdir: test_suite_dir, out: log, err: %i[child out], pgroup: true)
|
|
1373
|
+
RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS[pid] = test_name }
|
|
1374
|
+
_, status = Process.wait2(pid)
|
|
1375
|
+
status.success?
|
|
1376
|
+
ensure
|
|
1377
|
+
RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS.delete(pid) } if pid
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1022
1380
|
# just punt to +each_test_run+ in the test_suite directory. It's your problem
|
|
1023
1381
|
# now, sucker!
|
|
1024
1382
|
def do_one
|
|
@@ -1036,6 +1394,23 @@ class MesaTestCase
|
|
|
1036
1394
|
YAML.load(File.read(testhub_file))
|
|
1037
1395
|
end
|
|
1038
1396
|
|
|
1397
|
+
# How MESATestHub keys this test's fingerprint: "<module>/<test>".
|
|
1398
|
+
def fingerprint_key
|
|
1399
|
+
"#{mod}/#{test_name}"
|
|
1400
|
+
end
|
|
1401
|
+
|
|
1402
|
+
# Fingerprint of this run's results (a hash of testhub.yml).
|
|
1403
|
+
def fingerprint
|
|
1404
|
+
file_fingerprint(testhub_file)
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1407
|
+
# Whether this test wrote results at or after +time+, i.e. during the
|
|
1408
|
+
# run that started then (not a testhub.yml left from an earlier run).
|
|
1409
|
+
# mtimes can be whole seconds, so compare at that resolution.
|
|
1410
|
+
def ran_since?(time)
|
|
1411
|
+
ran? && File.mtime(testhub_file) >= time.floor
|
|
1412
|
+
end
|
|
1413
|
+
|
|
1039
1414
|
# rough proxy for whether or not the test has even been run
|
|
1040
1415
|
def ran?
|
|
1041
1416
|
File.exist?(testhub_file)
|
|
@@ -1170,6 +1545,137 @@ def bashticks(command)
|
|
|
1170
1545
|
`bash -c "#{command}"`.chomp
|
|
1171
1546
|
end
|
|
1172
1547
|
|
|
1548
|
+
# Run modes: how MESA is told to run a test, and the environment variables
|
|
1549
|
+
# behind them. MESA's each_test_run records each one in the test's
|
|
1550
|
+
# testhub.yml (fpe_checks / run_optional / resolution_factor), which is what
|
|
1551
|
+
# MESATestHub reads.
|
|
1552
|
+
#
|
|
1553
|
+
# fpe: MESA_FPE_CHECKS_ON=1. Compile-time as well as run-time
|
|
1554
|
+
# (MESA's make reads it), so it belongs to the build.
|
|
1555
|
+
# skip_optional: MESA_SKIP_OPTIONAL set. MESA runs every inlist unless
|
|
1556
|
+
# this is set.
|
|
1557
|
+
# resolution_factor: MESA_TEST_SUITE_RESOLUTION_FACTOR, which is what
|
|
1558
|
+
# `[ci converge]` sets.
|
|
1559
|
+
#
|
|
1560
|
+
# A mode that is nil/absent leaves the variable as the user's shell has it.
|
|
1561
|
+
RUN_MODE_KEYS = %i[fpe skip_optional resolution_factor].freeze
|
|
1562
|
+
DEFAULT_CONVERGE_FACTOR = '0.8'.freeze
|
|
1563
|
+
|
|
1564
|
+
# ENV overrides for +modes+ (nil value = unset the variable).
|
|
1565
|
+
def run_mode_env(modes)
|
|
1566
|
+
env = {}
|
|
1567
|
+
env['MESA_FPE_CHECKS_ON'] = modes[:fpe] ? '1' : nil unless modes[:fpe].nil?
|
|
1568
|
+
unless modes[:skip_optional].nil?
|
|
1569
|
+
env['MESA_SKIP_OPTIONAL'] = modes[:skip_optional] ? 't' : nil
|
|
1570
|
+
end
|
|
1571
|
+
unless modes[:resolution_factor].nil?
|
|
1572
|
+
env['MESA_TEST_SUITE_RESOLUTION_FACTOR'] =
|
|
1573
|
+
modes[:resolution_factor] ? modes[:resolution_factor].to_s : nil
|
|
1574
|
+
end
|
|
1575
|
+
env
|
|
1576
|
+
end
|
|
1577
|
+
|
|
1578
|
+
# The resolution factor a converge run should use: the user's own, if their
|
|
1579
|
+
# shell sets one, else DEFAULT_CONVERGE_FACTOR.
|
|
1580
|
+
def converge_factor
|
|
1581
|
+
ENV['MESA_TEST_SUITE_RESOLUTION_FACTOR'] || DEFAULT_CONVERGE_FACTOR
|
|
1582
|
+
end
|
|
1583
|
+
|
|
1584
|
+
# Run modes a MESATestHub dispatch asked for. Requests only ever *add* to the
|
|
1585
|
+
# user's setup: a mode the hub didn't ask for is left out (so the shell
|
|
1586
|
+
# decides), never forced off.
|
|
1587
|
+
#
|
|
1588
|
+
# A converge request may name its factor (`[ci converge 0.5]` arrives as
|
|
1589
|
+
# rec['converge_factor']). That's the commit author's request, so it wins
|
|
1590
|
+
# over +preferred+ -- a factor from --resolution-factor or the shell's
|
|
1591
|
+
# MESA_TEST_SUITE_RESOLUTION_FACTOR -- with a warning when they differ.
|
|
1592
|
+
# Without a named factor, +preferred+ (else the default) is used.
|
|
1593
|
+
def dispatch_run_modes(rec, preferred: nil, preferred_source: nil)
|
|
1594
|
+
flags = rec['flags'] || {}
|
|
1595
|
+
modes = {}
|
|
1596
|
+
modes[:fpe] = true if flags['use_fpe']
|
|
1597
|
+
modes[:skip_optional] = false if flags['use_full_inlists']
|
|
1598
|
+
if flags['use_converge']
|
|
1599
|
+
requested = rec['converge_factor']
|
|
1600
|
+
preferred ||= ENV['MESA_TEST_SUITE_RESOLUTION_FACTOR']
|
|
1601
|
+
preferred_source ||= 'MESA_TEST_SUITE_RESOLUTION_FACTOR'
|
|
1602
|
+
if requested
|
|
1603
|
+
factor = requested.to_s
|
|
1604
|
+
if preferred && preferred.to_f != requested.to_f
|
|
1605
|
+
warn "MESATestHub asked for resolution factor #{factor} ([ci converge #{factor}]); " \
|
|
1606
|
+
"using it instead of your #{preferred_source}=#{preferred}."
|
|
1607
|
+
end
|
|
1608
|
+
modes[:resolution_factor] = factor
|
|
1609
|
+
else
|
|
1610
|
+
modes[:resolution_factor] = preferred || DEFAULT_CONVERGE_FACTOR
|
|
1611
|
+
end
|
|
1612
|
+
end
|
|
1613
|
+
modes
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
# Process groups of tests started by MesaTestCase#run_isolated, so an
|
|
1617
|
+
# interrupted parallel run can stop them all.
|
|
1618
|
+
RUNNING_TESTS = {}
|
|
1619
|
+
RUNNING_TESTS_LOCK = Mutex.new
|
|
1620
|
+
|
|
1621
|
+
def stop_running_tests
|
|
1622
|
+
pids = RUNNING_TESTS_LOCK.synchronize { RUNNING_TESTS.keys }
|
|
1623
|
+
pids.each do |pid|
|
|
1624
|
+
Process.kill('TERM', -pid)
|
|
1625
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
1626
|
+
nil
|
|
1627
|
+
end
|
|
1628
|
+
end
|
|
1629
|
+
|
|
1630
|
+
# Run +jobs+ worker threads, each calling the block with its index until
|
|
1631
|
+
# the block returns :done. On Ctrl-C, kills the workers first (so none
|
|
1632
|
+
# goes on to submit a half-run test), then every running test, then
|
|
1633
|
+
# re-raises. Exceptions in a worker propagate when it's joined.
|
|
1634
|
+
def run_workers(jobs)
|
|
1635
|
+
workers = []
|
|
1636
|
+
jobs.times do |i|
|
|
1637
|
+
workers << Thread.new do
|
|
1638
|
+
Thread.current.report_on_exception = false
|
|
1639
|
+
loop { break if yield(i) == :done }
|
|
1640
|
+
end
|
|
1641
|
+
end
|
|
1642
|
+
workers.each(&:join)
|
|
1643
|
+
rescue Interrupt
|
|
1644
|
+
workers.each(&:kill)
|
|
1645
|
+
stop_running_tests
|
|
1646
|
+
raise
|
|
1647
|
+
end
|
|
1648
|
+
|
|
1649
|
+
# Hand each of +items+ to the block, +jobs+ at a time.
|
|
1650
|
+
def parallel_each(items, jobs)
|
|
1651
|
+
queue = Queue.new
|
|
1652
|
+
items.each { |item| queue << item }
|
|
1653
|
+
run_workers([jobs, items.size].min) do
|
|
1654
|
+
item = begin
|
|
1655
|
+
queue.pop(true)
|
|
1656
|
+
rescue ThreadError
|
|
1657
|
+
next :done
|
|
1658
|
+
end
|
|
1659
|
+
yield item
|
|
1660
|
+
nil
|
|
1661
|
+
end
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
# Run the block with +overrides+ applied to ENV (nil deletes a variable),
|
|
1665
|
+
# restoring the previous values afterwards.
|
|
1666
|
+
def with_env(overrides)
|
|
1667
|
+
saved = overrides.keys.map { |k| [k, ENV[k]] }.to_h
|
|
1668
|
+
overrides.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
|
1669
|
+
yield
|
|
1670
|
+
ensure
|
|
1671
|
+
saved&.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
|
|
1672
|
+
end
|
|
1673
|
+
|
|
1674
|
+
# SHA-256 of a file's contents, or nil if it doesn't exist.
|
|
1675
|
+
def file_fingerprint(path)
|
|
1676
|
+
File.exist?(path) ? Digest::SHA256.file(path).hexdigest : nil
|
|
1677
|
+
end
|
|
1678
|
+
|
|
1173
1679
|
# encode the contents of a file as base-64
|
|
1174
1680
|
def b64_file(filename)
|
|
1175
1681
|
Base64.encode64(File.open(filename).read)
|