blob: 047f15f04eb6899404250d77fdac7e1150e2036f [file] [log] [blame]
Emily Hanley08a62aea2018-02-07 14:41:011#!/usr/bin/env python
2# Copyright 2018 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""Custom swarming base trigger class.
6
7This base class consolidates custom swarming triggering logic, to allow one bot
8to conceptually span multiple Swarming configurations, while lumping all trigger
9calls under one logical step. It also gives the subclasses the ability to
10define their own logic for pruning the configurations they want to trigger
11jobs on and what configurations to use.
12
Brian Sheedy5ea8f6c62020-05-21 03:05:0513See perf_device_triggerer.py for an example of how to use this base class.
Emily Hanley08a62aea2018-02-07 14:41:0114
15"""
16
Emily Hanley08a62aea2018-02-07 14:41:0117import copy
18import json
19import os
Emily Hanley08a62aea2018-02-07 14:41:0120import subprocess
21import sys
22import tempfile
Wenbin Zhang7af256c2020-01-18 01:52:2123import logging
Wenbin Zhangc56b1252021-10-05 02:49:0624import six
Emily Hanley08a62aea2018-02-07 14:41:0125
Takuto Ikutac8ebda32021-06-28 15:28:1726SRC_DIR = os.path.dirname(
27 os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
Emily Hanley08a62aea2018-02-07 14:41:0128
Ye Kuang0279e572020-10-23 06:43:3829# .exe on Windows.
30EXECUTABLE_SUFFIX = '.exe' if sys.platform == 'win32' else ''
31
Ye Kuang0279e572020-10-23 06:43:3832SWARMING_GO = os.path.join(SRC_DIR, 'tools', 'luci-go',
33 'swarming' + EXECUTABLE_SUFFIX)
34
35
36def _convert_to_go_swarming_args(args):
Takuto Ikutac8ebda32021-06-28 15:28:1737 go_args = []
38 i = 0
39 while i < len(args):
40 current_arg = args[i]
41 if current_arg == '--swarming':
42 current_arg = '--server'
43 go_args.append(current_arg)
44 i += 1
45 if current_arg == '--dimension':
46 go_args.append('{}={}'.format(args[i], args[i + 1]))
47 i += 2
48 return go_args
Ye Kuang0279e572020-10-23 06:43:3849
Emily Hanley08a62aea2018-02-07 14:41:0150
Emily Hanley08a62aea2018-02-07 14:41:0151def strip_unicode(obj):
Takuto Ikutac8ebda32021-06-28 15:28:1752 """Recursively re-encodes strings as utf-8 inside |obj|. Returns the result.
Emily Hanley08a62aea2018-02-07 14:41:0153 """
Wenbin Zhangc56b1252021-10-05 02:49:0654 if isinstance(obj, six.text_type):
Takuto Ikutac8ebda32021-06-28 15:28:1755 return obj.encode('utf-8', 'replace')
56 if isinstance(obj, list):
57 return list(map(strip_unicode, obj))
Emily Hanley08a62aea2018-02-07 14:41:0158
Takuto Ikutac8ebda32021-06-28 15:28:1759 if isinstance(obj, dict):
60 new_obj = type(obj)(
Wenbin Zhangc56b1252021-10-05 02:49:0661 (strip_unicode(k), strip_unicode(v)) for k, v in obj.items())
Takuto Ikutac8ebda32021-06-28 15:28:1762 return new_obj
63 return obj
Emily Hanley08a62aea2018-02-07 14:41:0164
65
Joshua Hood7827f5f02022-03-01 16:31:0066class BaseTestTriggerer(object): # pylint: disable=useless-object-inheritance
Takuto Ikutac8ebda32021-06-28 15:28:1767 def __init__(self):
68 self._bot_configs = None
69 self._bot_statuses = []
70 self._total_bots = 0
Emily Hanley08a62aea2018-02-07 14:41:0171
Takuto Ikutac8ebda32021-06-28 15:28:1772 def modify_args(self,
73 all_args,
74 bot_index,
75 shard_index,
76 total_shards,
77 temp_file,
78 shard_map=None):
79 """Modifies the given argument list.
Emily Hanley08a62aea2018-02-07 14:41:0180
81 Specifically, it does the following:
82 * Adds a --dump_json argument, to read in the results of the
83 individual trigger command.
84 * Adds the dimensions associated with the bot config at the given index.
85 * If the number of shards is greater than one, adds --env
86 arguments to set the GTEST_SHARD_INDEX and GTEST_TOTAL_SHARDS
87 environment variables to _shard_index_ and _total_shards_,
88 respectively.
89
90 The arguments are structured like this:
Takuto Ikuta0d816ea2021-02-01 18:32:0391 <args to swarming trigger> -- <args to bot running isolate>
Emily Hanley08a62aea2018-02-07 14:41:0192 This means we have to add arguments to specific locations in the argument
93 list, to either affect the trigger command, or what the bot runs.
94
95 """
Takuto Ikutac8ebda32021-06-28 15:28:1796 bot_args = ['--dump-json', temp_file]
97 if total_shards > 1:
98 bot_args.append('--env')
99 bot_args.append('GTEST_SHARD_INDEX=%s' % shard_index)
100 bot_args.append('--env')
101 bot_args.append('GTEST_TOTAL_SHARDS=%s' % total_shards)
102 if self._bot_configs:
Wenbin Zhangc56b1252021-10-05 02:49:06103 for key, val in sorted(self._bot_configs[bot_index].items()):
Takuto Ikutac8ebda32021-06-28 15:28:17104 bot_args.append('--dimension')
105 bot_args.append(key)
106 bot_args.append(val)
107 if '--' in all_args:
108 dash_ind = all_args.index('--')
109 additional_args = all_args[:dash_ind] + bot_args + all_args[
110 dash_ind:]
111 else:
112 additional_args = all_args + bot_args
113 additional_args = self.append_additional_args(additional_args,
114 shard_index)
115 # crbug/1140389: debug print outs
116 logging.info('DEBUG: Before adding shardmap args: %s', additional_args)
117 if shard_map:
118 shard_map_str = json.dumps(shard_map, separators=(',', ':'))
119 shard_map_args = ['--use-dynamic-shards']
120 shard_map_args.append('--dynamic-shardmap=%s' % shard_map_str)
121 additional_args += shard_map_args
122 return additional_args
Emily Hanley08a62aea2018-02-07 14:41:01123
Takuto Ikutac8ebda32021-06-28 15:28:17124 def append_additional_args(self, args, shard_index):
125 """ Gives subclasses ability to append additional args if necessary
Emily Hanley08a62aea2018-02-07 14:41:01126
Emily Hanley5e0e8dd92018-04-11 18:01:49127 Base class just returns given args."""
Takuto Ikutac8ebda32021-06-28 15:28:17128 del shard_index # unused
129 return args
Emily Hanley08a62aea2018-02-07 14:41:01130
Takuto Ikutac8ebda32021-06-28 15:28:17131 def parse_bot_configs(self, args):
132 try:
133 self._bot_configs = strip_unicode(
134 json.loads(args.multiple_trigger_configs))
135 except Exception as e:
Joshua Hood7827f5f02022-03-01 16:31:00136 six.raise_from(ValueError(
Takuto Ikutac8ebda32021-06-28 15:28:17137 'Error while parsing JSON from bot config string %s: %s' %
Joshua Hood7827f5f02022-03-01 16:31:00138 (args.multiple_trigger_configs, str(e))), e)
Takuto Ikutac8ebda32021-06-28 15:28:17139 # Validate the input.
140 if not isinstance(self._bot_configs, list):
141 raise ValueError('Bot configurations must be a list, were: %s' %
142 args.multiple_trigger_configs)
143 if len(self._bot_configs) < 1:
144 raise ValueError(
145 'Bot configuration list must have at least one entry')
146 if not all(isinstance(entry, dict) for entry in self._bot_configs):
147 raise ValueError('Bot configurations must all be dictionaries')
Emily Hanley08a62aea2018-02-07 14:41:01148
Takuto Ikuta51620152021-07-19 06:30:30149 def list_bots(self,
150 dimensions,
Takuto Ikuta51620152021-07-19 06:30:30151 server='chromium-swarm.appspot.com'):
152 """List bots having specified bot dimensions.
153
154 Type of returned value is list of
155 https://source.chromium.org/search?q=%22class%20BotInfo(messages.Message)%22%20f:luci%2Fappengine%2Fswarming&ssfr=1
156 """
157
158 args = [SWARMING_GO, 'bots', '-server', server]
159
160 for key in sorted(dimensions):
161 args.extend(['-dimension', '%s=%s' % (key, dimensions[key])])
162
Takuto Ikutad5e0b4b2021-07-20 17:14:49163 logging.info('Running Go `swarming` with args: %s', args)
Takuto Ikuta51620152021-07-19 06:30:30164
165 with tempfile.NamedTemporaryFile(delete=False) as result_json:
166 result_json.close()
167 args.extend(['--json', result_json.name])
168 subprocess.check_call(args)
169 with open(result_json.name) as f:
170 return json.load(f)
171
Takuto Ikuta97621d82021-08-24 06:35:15172 def list_tasks(self, tags, limit=None,
173 server='chromium-swarm.appspot.com'):
174 """List bots having specified task tags.
175
176 Type of returned value is list of
177 https://source.chromium.org/search?q=%22class%20TaskResult(messages.Message):%22%20f:luci%2Fappengine%2Fswarming&ssfr=1
178 """
179
180 args = [SWARMING_GO, 'tasks', '-server', server]
181
182 for tag in sorted(tags):
183 args.extend(['-tag', tag])
184
185 if limit is not None:
186 args.extend(['-limit', str(limit)])
187
188 logging.info('Running Go `swarming` with args: %s', args)
189
190 with tempfile.NamedTemporaryFile(delete=False) as result_json:
191 result_json.close()
192 args.extend(['-json', result_json.name])
193 subprocess.check_call(args)
194 with open(result_json.name) as f:
195 return json.load(f)
Emily Hanley681d1d42018-04-30 17:36:21196
Takuto Ikutac8ebda32021-06-28 15:28:17197 def remove_swarming_dimension(self, args, dimension):
Wenbin Zhangc56b1252021-10-05 02:49:06198 for i in range(len(args)):
Takuto Ikutac8ebda32021-06-28 15:28:17199 if args[i] == '--dimension' and args[i + 1] == dimension:
200 return args[:i] + args[i + 3:]
201 return args
Emily Hanley08a62aea2018-02-07 14:41:01202
Takuto Ikutac8ebda32021-06-28 15:28:17203 def make_temp_file(self, prefix=None, suffix=None):
204 # This trick of closing the file handle is needed on Windows in order to
205 # make the file writeable.
206 h, temp_file = tempfile.mkstemp(prefix=prefix, suffix=suffix)
207 os.close(h)
208 return temp_file
Emily Hanley08a62aea2018-02-07 14:41:01209
Takuto Ikutac8ebda32021-06-28 15:28:17210 def delete_temp_file(self, temp_file):
211 os.remove(temp_file)
Emily Hanley08a62aea2018-02-07 14:41:01212
Takuto Ikutac8ebda32021-06-28 15:28:17213 def read_json_from_temp_file(self, temp_file):
214 with open(temp_file) as f:
215 return json.load(f)
Emily Hanley08a62aea2018-02-07 14:41:01216
Takuto Ikutac8ebda32021-06-28 15:28:17217 def read_encoded_json_from_temp_file(self, temp_file):
218 return strip_unicode(self.read_json_from_temp_file(temp_file))
Emily Hanley681d1d42018-04-30 17:36:21219
Takuto Ikutac8ebda32021-06-28 15:28:17220 def write_json_to_file(self, merged_json, output_file):
221 with open(output_file, 'w') as f:
222 json.dump(merged_json, f)
Emily Hanley08a62aea2018-02-07 14:41:01223
Takuto Ikutac8ebda32021-06-28 15:28:17224 def run_swarming_go(self,
225 args,
Takuto Ikutac8ebda32021-06-28 15:28:17226 json_path,
227 shard_index,
228 shards,
229 merged_json=None):
Takuto Ikutad5e0b4b2021-07-20 17:14:49230
231 logging.info('Running Go `swarming` with args: %s', args)
Takuto Ikutac8dce8f2021-01-21 22:23:06232
Takuto Ikutac8ebda32021-06-28 15:28:17233 if merged_json is None:
234 merged_json = {}
Takuto Ikutac8dce8f2021-01-21 22:23:06235
Takuto Ikutac8ebda32021-06-28 15:28:17236 if 'tasks' not in merged_json:
237 merged_json['tasks'] = {}
Takuto Ikutac8dce8f2021-01-21 22:23:06238
Takuto Ikutac8ebda32021-06-28 15:28:17239 ret = subprocess.call([SWARMING_GO] +
240 _convert_to_go_swarming_args(args))
241 result_json = self.read_json_from_temp_file(json_path)
Takuto Ikutac8dce8f2021-01-21 22:23:06242
Takuto Ikutac8ebda32021-06-28 15:28:17243 tasks = {}
244 for task in result_json['tasks']:
245 k = task['request']['task_id']
246 tasks[k] = task['request']
247 invocation = task.get('task_result', {}).get('resultdb_info',
248 {}).get('invocation')
249 if invocation:
250 tasks[k]['invocation'] = invocation
Chan Li5058e2132021-03-31 00:44:42251
Takuto Ikutac8ebda32021-06-28 15:28:17252 for k, v in tasks.items():
253 v['shard_index'] = shard_index
254 merged_json['tasks'][k + ':%d:%d' % (shard_index, shards)] = v
255 self.write_json_to_file(merged_json, json_path)
256 return ret
Ye Kuang0279e572020-10-23 06:43:38257
Takuto Ikutad5e0b4b2021-07-20 17:14:49258 def prune_test_specific_configs(self, args):
Takuto Ikutac8ebda32021-06-28 15:28:17259 # Ability for base class to further prune configs to
260 # run tests on.
261 pass
Emily Hanley08a62aea2018-02-07 14:41:01262
Takuto Ikutad5e0b4b2021-07-20 17:14:49263 def select_config_indices(self, args):
Takuto Ikutac8ebda32021-06-28 15:28:17264 # Main implementation for base class to determine which bot config to
265 # trigger for each shard.
266 #
267 # Returns a list of tuples (shard_index, bot_config_index).
268 # bot_config_index is an index into self._bot_configs
269 pass
Emily Hanley08a62aea2018-02-07 14:41:01270
Takuto Ikutac8ebda32021-06-28 15:28:17271 def indices_to_trigger(self, args):
272 """Returns the indices of the swarming shards that should be
273 triggered."""
274 if args.shard_index is None:
Wenbin Zhangc56b1252021-10-05 02:49:06275 return list(range(args.shards))
Joshua Hood7827f5f02022-03-01 16:31:00276 return [args.shard_index]
Erik Chen1d5e5aa2019-01-31 21:21:46277
Takuto Ikutad5e0b4b2021-07-20 17:14:49278 def generate_shard_map(self, args, buildername, selected_config):
Takuto Ikutac8ebda32021-06-28 15:28:17279 """Returns shard map generated on runtime if needed."""
Joshua Hood7827f5f02022-03-01 16:31:00280 pass # pylint: disable=unnecessary-pass
Wenbin Zhang59d1cfc82021-03-12 23:33:13281
Takuto Ikutac8ebda32021-06-28 15:28:17282 def trigger_tasks(self, args, remaining):
283 """Triggers tasks for each bot.
Emily Hanley08a62aea2018-02-07 14:41:01284
Takuto Ikutac8ebda32021-06-28 15:28:17285 Args:
286 args: Parsed arguments which we need to use.
287 remaining: The remainder of the arguments, which should be passed to
288 swarming.py calls.
Emily Hanley08a62aea2018-02-07 14:41:01289
Takuto Ikutac8ebda32021-06-28 15:28:17290 Returns:
291 Exit code for the script.
292 """
Takuto Ikutad5e0b4b2021-07-20 17:14:49293 if args.multiple_dimension_script_verbose:
294 logging.basicConfig(level=logging.DEBUG)
295
Wenbin Zhangc1396432021-03-15 20:32:59296 # crbug/1140389: debug print outs
Takuto Ikutac8ebda32021-06-28 15:28:17297 logging.info('DEBUG: init: %s', remaining)
Takuto Ikutad5e0b4b2021-07-20 17:14:49298
Takuto Ikutac8ebda32021-06-28 15:28:17299 self.parse_bot_configs(args)
300 # Prunes config list to the exact set of configurations to trigger jobs
301 # on. This logic is specific to the base class if they want to prune
302 # list further.
Takuto Ikutad5e0b4b2021-07-20 17:14:49303 self.prune_test_specific_configs(args)
Takuto Ikutac8ebda32021-06-28 15:28:17304
305 # In the remaining arguments, find the Swarming dimensions that are
306 # specified by the bot configs and remove them, because for each shard,
307 # we're going to select one of the bot configs and put all of its
308 # Swarming dimensions on the command line.
309 filtered_remaining_args = copy.deepcopy(remaining)
310 for config in self._bot_configs:
Wenbin Zhangc56b1252021-10-05 02:49:06311 for k in config.keys():
Takuto Ikutac8ebda32021-06-28 15:28:17312 filtered_remaining_args = self.remove_swarming_dimension(
313 filtered_remaining_args, k)
Wenbin Zhangc1396432021-03-15 20:32:59314 # crbug/1140389: debug print outs
Takuto Ikutac8ebda32021-06-28 15:28:17315 logging.info('DEBUG: After filtered: %s', filtered_remaining_args)
Emily Hanley08a62aea2018-02-07 14:41:01316
Takuto Ikutac8ebda32021-06-28 15:28:17317 merged_json = {}
Joshua Hood7827f5f02022-03-01 16:31:00318 #pylint: disable=assignment-from-no-return
Takuto Ikutad5e0b4b2021-07-20 17:14:49319 selected_config = self.select_config_indices(args)
Takuto Ikutac8ebda32021-06-28 15:28:17320 shard_map = self.generate_shard_map(
321 args, self._findBuilderName(filtered_remaining_args),
Takuto Ikutad5e0b4b2021-07-20 17:14:49322 selected_config)
Joshua Hood7827f5f02022-03-01 16:31:00323 #pylint: enable=assignment-from-no-return
Takuto Ikutac8ebda32021-06-28 15:28:17324 # Choose selected configs for this run of the test suite.
325 for shard_index, bot_index in selected_config:
326 # For each shard that we're going to distribute, do the following:
327 # 1. Pick which bot configuration to use.
328 # 2. Insert that bot configuration's dimensions as command line
329 # arguments, and invoke "swarming.py trigger".
330 # Holds the results of the swarming.py trigger call.
331 try:
332 json_temp = self.make_temp_file(
333 prefix='base_trigger_dimensions', suffix='.json')
334 # crbug/1140389: debug print outs
335 logging.info('DEBUG: Before modify args: %s',
336 filtered_remaining_args)
337 args_to_pass = self.modify_args(filtered_remaining_args,
338 bot_index, shard_index,
339 args.shards, json_temp,
340 shard_map)
341 # crbug/1140389: debug print outs
342 logging.info('DEBUG: Before calling swarming: %s',
343 args_to_pass)
Takuto Ikutad5e0b4b2021-07-20 17:14:49344 ret = self.run_swarming_go(args_to_pass, json_temp,
Takuto Ikutac8ebda32021-06-28 15:28:17345 shard_index, args.shards,
346 merged_json)
347 if ret:
348 sys.stderr.write('Failed to trigger a task, aborting\n')
349 return ret
350 finally:
351 self.delete_temp_file(json_temp)
352 self.write_json_to_file(merged_json, args.dump_json)
353 return 0
Wenbin Zhang59d1cfc82021-03-12 23:33:13354
Joshua Hood7827f5f02022-03-01 16:31:00355 # pylint: disable=inconsistent-return-statements
Takuto Ikutac8ebda32021-06-28 15:28:17356 def _findBuilderName(self, args):
357 args_length = len(args)
358 for i in range(args_length):
359 if (args[i] == '--tag' and i < args_length - 1
360 and args[i + 1].startswith('buildername:')):
361 return args[i + 1].split(':', 1)[1]
Joshua Hood7827f5f02022-03-01 16:31:00362 # pylint: enable=inconsistent-return-statements
Wenbin Zhang59d1cfc82021-03-12 23:33:13363
Takuto Ikutac8ebda32021-06-28 15:28:17364 @staticmethod
365 def setup_parser_contract(parser):
366 parser.add_argument(
367 '--multiple-trigger-configs',
368 type=str,
369 required=False,
370 help='The Swarming configurations to trigger tasks on, '
371 'in the form of a JSON array of dictionaries (these are'
372 ' Swarming dimension_sets). At least one entry is'
373 'required if you dont override parse_bot_configs')
374 parser.add_argument('--multiple-dimension-script-verbose',
375 type=bool,
376 default=False,
377 help='Turn on verbose logging')
378 parser.add_argument(
379 '--dump-json',
380 required=True,
381 help='(Swarming Trigger Script API) Where to dump the'
382 ' resulting json which indicates which tasks were'
383 ' triggered for which shards.')
384 parser.add_argument(
385 '--shards',
386 type=int,
387 default=1,
388 help='How many shards to trigger. Duplicated from the'
389 ' `swarming.py trigger` command.')
390 parser.add_argument('--shard-index',
391 type=int,
392 default=None,
393 help='Which shard to trigger. Duplicated from the '
394 '`swarming.py trigger` command.')
395 return parser