You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
368 lines
13 KiB
368 lines
13 KiB
# Lint as: python2, python3
|
|
# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
|
|
# Use of this source code is governed by a BSD-style license that can be
|
|
# found in the LICENSE file.
|
|
|
|
|
|
from __future__ import absolute_import
|
|
from __future__ import division
|
|
from __future__ import print_function
|
|
|
|
import random
|
|
import re
|
|
import six
|
|
|
|
import common
|
|
|
|
from autotest_lib.client.common_lib import global_config
|
|
|
|
_CONFIG = global_config.global_config
|
|
|
|
# comments injected into the control file.
|
|
_INJECT_BEGIN = '# INJECT_BEGIN - DO NOT DELETE THIS LINE'
|
|
_INJECT_END = '# INJECT_END - DO NOT DELETE LINE'
|
|
|
|
|
|
# The regex for an injected line in the control file with the format:
|
|
# varable_name=varable_value
|
|
_INJECT_VAR_RE = re.compile('^[_A-Za-z]\w*=.+$')
|
|
|
|
|
|
def image_url_pattern():
|
|
"""Returns image_url_pattern from global_config."""
|
|
return _CONFIG.get_config_value('CROS', 'image_url_pattern', type=str)
|
|
|
|
|
|
def firmware_url_pattern():
|
|
"""Returns firmware_url_pattern from global_config."""
|
|
return _CONFIG.get_config_value('CROS', 'firmware_url_pattern', type=str)
|
|
|
|
|
|
def factory_image_url_pattern():
|
|
"""Returns path to factory image after it's been staged."""
|
|
return _CONFIG.get_config_value('CROS', 'factory_image_url_pattern',
|
|
type=str)
|
|
|
|
|
|
def sharding_factor():
|
|
"""Returns sharding_factor from global_config."""
|
|
return _CONFIG.get_config_value('CROS', 'sharding_factor', type=int)
|
|
|
|
|
|
def infrastructure_user():
|
|
"""Returns infrastructure_user from global_config."""
|
|
return _CONFIG.get_config_value('CROS', 'infrastructure_user', type=str)
|
|
|
|
|
|
def package_url_pattern(is_launch_control_build=False):
|
|
"""Returns package_url_pattern from global_config.
|
|
|
|
@param is_launch_control_build: True if the package url is for Launch
|
|
Control build. Default is False.
|
|
"""
|
|
if is_launch_control_build:
|
|
return _CONFIG.get_config_value('ANDROID', 'package_url_pattern',
|
|
type=str)
|
|
else:
|
|
return _CONFIG.get_config_value('CROS', 'package_url_pattern', type=str)
|
|
|
|
|
|
def try_job_timeout_mins():
|
|
"""Returns try_job_timeout_mins from global_config."""
|
|
return _CONFIG.get_config_value('SCHEDULER', 'try_job_timeout_mins',
|
|
type=int, default=4*60)
|
|
|
|
|
|
def get_package_url(devserver_url, build):
|
|
"""Returns the package url from the |devserver_url| and |build|.
|
|
|
|
@param devserver_url: a string specifying the host to contact e.g.
|
|
http://my_host:9090.
|
|
@param build: the build/image string to use e.g. mario-release/R19-123.0.1.
|
|
@return the url where you can find the packages for the build.
|
|
"""
|
|
return package_url_pattern() % (devserver_url, build)
|
|
|
|
|
|
def get_devserver_build_from_package_url(package_url,
|
|
is_launch_control_build=False):
|
|
"""The inverse method of get_package_url.
|
|
|
|
@param package_url: a string specifying the package url.
|
|
@param is_launch_control_build: True if the package url is for Launch
|
|
Control build. Default is False.
|
|
|
|
@return tuple containing the devserver_url, build.
|
|
"""
|
|
pattern = package_url_pattern(is_launch_control_build)
|
|
re_pattern = pattern.replace('%s', '(\S+)')
|
|
|
|
devserver_build_tuple = re.search(re_pattern, package_url).groups()
|
|
|
|
# TODO(beeps): This is a temporary hack around the fact that all
|
|
# job_repo_urls in the database currently contain 'archive'. Remove
|
|
# when all hosts have been reimaged at least once. Ref: crbug.com/214373.
|
|
return (devserver_build_tuple[0],
|
|
devserver_build_tuple[1].replace('archive/', ''))
|
|
|
|
|
|
def get_build_from_image(image):
|
|
"""Get the build name from the image string.
|
|
|
|
@param image: A string of image, can be the build name or a url to the
|
|
build, e.g.,
|
|
http://devserver/update/alex-release/R27-3837.0.0
|
|
|
|
@return: Name of the build. Return None if fail to parse build name.
|
|
"""
|
|
if not image.startswith('http://'):
|
|
return image
|
|
else:
|
|
match = re.match('.*/([^/]+/R\d+-[^/]+)', image)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
|
|
def get_random_best_host(afe, host_list, require_usable_hosts=True):
|
|
"""
|
|
Randomly choose the 'best' host from host_list, using fresh status.
|
|
|
|
Hit the AFE to get latest status for the listed hosts. Then apply
|
|
the following heuristic to pick the 'best' set:
|
|
|
|
Remove unusable hosts (not tools.is_usable()), then
|
|
'Ready' > 'Running, Cleaning, Verifying, etc'
|
|
|
|
If any 'Ready' hosts exist, return a random choice. If not, randomly
|
|
choose from the next tier. If there are none of those either, None.
|
|
|
|
@param afe: autotest front end that holds the hosts being managed.
|
|
@param host_list: an iterable of Host objects, per server/frontend.py
|
|
@param require_usable_hosts: only return hosts currently in a usable
|
|
state.
|
|
@return a Host object, or None if no appropriate host is found.
|
|
"""
|
|
if not host_list:
|
|
return None
|
|
hostnames = [host.hostname for host in host_list]
|
|
updated_hosts = afe.get_hosts(hostnames=hostnames)
|
|
usable_hosts = [host for host in updated_hosts if is_usable(host)]
|
|
ready_hosts = [host for host in usable_hosts if host.status == 'Ready']
|
|
unusable_hosts = [h for h in updated_hosts if not is_usable(h)]
|
|
if ready_hosts:
|
|
return random.choice(ready_hosts)
|
|
if usable_hosts:
|
|
return random.choice(usable_hosts)
|
|
if not require_usable_hosts and unusable_hosts:
|
|
return random.choice(unusable_hosts)
|
|
return None
|
|
|
|
|
|
def remove_legacy_injection(control_file_in):
|
|
"""
|
|
Removes the legacy injection part from a control file.
|
|
|
|
@param control_file_in: the contents of a control file to munge.
|
|
|
|
@return The modified control file string.
|
|
"""
|
|
if not control_file_in:
|
|
return control_file_in
|
|
|
|
new_lines = []
|
|
lines = control_file_in.strip().splitlines()
|
|
remove_done = False
|
|
for line in lines:
|
|
if remove_done:
|
|
new_lines.append(line)
|
|
else:
|
|
if not _INJECT_VAR_RE.match(line):
|
|
remove_done = True
|
|
new_lines.append(line)
|
|
return '\n'.join(new_lines)
|
|
|
|
|
|
def remove_injection(control_file_in):
|
|
"""
|
|
Removes the injection part from a control file.
|
|
|
|
@param control_file_in: the contents of a control file to munge.
|
|
|
|
@return The modified control file string.
|
|
"""
|
|
if not control_file_in:
|
|
return control_file_in
|
|
|
|
start = control_file_in.find(_INJECT_BEGIN)
|
|
if start >=0:
|
|
end = control_file_in.find(_INJECT_END, start)
|
|
if start < 0 or end < 0:
|
|
return remove_legacy_injection(control_file_in)
|
|
|
|
end += len(_INJECT_END)
|
|
ch = control_file_in[end]
|
|
total_length = len(control_file_in)
|
|
while end <= total_length and (
|
|
ch == '\n' or ch == ' ' or ch == '\t'):
|
|
end += 1
|
|
if end < total_length:
|
|
ch = control_file_in[end]
|
|
return control_file_in[:start] + control_file_in[end:]
|
|
|
|
|
|
def inject_vars(vars, control_file_in):
|
|
"""
|
|
Inject the contents of |vars| into |control_file_in|.
|
|
|
|
@param vars: a dict to shoehorn into the provided control file string.
|
|
@param control_file_in: the contents of a control file to munge.
|
|
@return the modified control file string.
|
|
"""
|
|
control_file = ''
|
|
control_file += _INJECT_BEGIN + '\n'
|
|
for key, value in six.iteritems(vars):
|
|
# None gets injected as 'None' without this check; same for digits.
|
|
if isinstance(value, str):
|
|
control_file += "%s=%s\n" % (key, repr(value))
|
|
else:
|
|
control_file += "%s=%r\n" % (key, value)
|
|
|
|
args_dict_str = "%s=%s\n" % ('args_dict', repr(vars))
|
|
return control_file + args_dict_str + _INJECT_END + '\n' + control_file_in
|
|
|
|
|
|
def is_usable(host):
|
|
"""
|
|
Given a host, determine if the host is usable right now.
|
|
|
|
@param host: Host instance (as in server/frontend.py)
|
|
@return True if host is alive and not incorrectly locked. Else, False.
|
|
"""
|
|
return alive(host) and not incorrectly_locked(host)
|
|
|
|
|
|
def alive(host):
|
|
"""
|
|
Given a host, determine if the host is alive.
|
|
|
|
@param host: Host instance (as in server/frontend.py)
|
|
@return True if host is not under, or in need of, repair. Else, False.
|
|
"""
|
|
return host.status not in ['Repair Failed', 'Repairing']
|
|
|
|
|
|
def incorrectly_locked(host):
|
|
"""
|
|
Given a host, determine if the host is locked by some user.
|
|
|
|
If the host is unlocked, or locked by the test infrastructure,
|
|
this will return False. There is only one system user defined as part
|
|
of the test infrastructure and is listed in global_config.ini under the
|
|
[CROS] section in the 'infrastructure_user' field.
|
|
|
|
@param host: Host instance (as in server/frontend.py)
|
|
@return False if the host is not locked, or locked by the infra.
|
|
True if the host is locked by the infra user.
|
|
"""
|
|
return (host.locked and host.locked_by != infrastructure_user())
|
|
|
|
|
|
def _testname_to_keyval_key(testname):
|
|
"""Make a test name acceptable as a keyval key.
|
|
|
|
@param testname Test name that must be converted.
|
|
@return A string with selected bad characters replaced
|
|
with allowable characters.
|
|
"""
|
|
# Characters for keys in autotest keyvals are restricted; in
|
|
# particular, '/' isn't allowed. Alas, in the case of an
|
|
# aborted job, the test name will be a path that includes '/'
|
|
# characters. We want to file bugs for aborted jobs, so we
|
|
# apply a transform here to avoid trouble.
|
|
return testname.replace('/', '_')
|
|
|
|
|
|
_BUG_ID_KEYVAL = '-Bug_Id'
|
|
_BUG_COUNT_KEYVAL = '-Bug_Count'
|
|
|
|
|
|
def create_bug_keyvals(job_id, testname, bug_info):
|
|
"""Create keyvals to record a bug filed against a test failure.
|
|
|
|
@param testname Name of the test for which to record a bug.
|
|
@param bug_info Pair with the id of the bug and the count of
|
|
the number of times the bug has been seen.
|
|
@param job_id The afe job id of job which the test is associated to.
|
|
job_id will be a part of the key.
|
|
@return Keyvals to be recorded for the given test.
|
|
"""
|
|
testname = _testname_to_keyval_key(testname)
|
|
keyval_base = '%s_%s' % (job_id, testname) if job_id else testname
|
|
return {
|
|
keyval_base + _BUG_ID_KEYVAL: bug_info[0],
|
|
keyval_base + _BUG_COUNT_KEYVAL: bug_info[1]
|
|
}
|
|
|
|
|
|
def get_test_failure_bug_info(keyvals, job_id, testname):
|
|
"""Extract information about a bug filed against a test failure.
|
|
|
|
This method tries to extract bug_id and bug_count from the keyvals
|
|
of a suite. If for some reason it cannot retrieve the bug_id it will
|
|
return (None, None) and there will be no link to the bug filed. We will
|
|
instead link directly to the logs of the failed test.
|
|
|
|
If it cannot retrieve the bug_count, it will return (int(bug_id), None)
|
|
and this will result in a link to the bug filed, with an inline message
|
|
saying we weren't able to determine how many times the bug occured.
|
|
|
|
If it retrieved both the bug_id and bug_count, we return a tuple of 2
|
|
integers and link to the bug filed, as well as mention how many times
|
|
the bug has occured in the buildbot stages.
|
|
|
|
@param keyvals Keyvals associated with a suite job.
|
|
@param job_id The afe job id of the job that runs the test.
|
|
@param testname Name of a test from the suite.
|
|
@return None if there is no bug info, or a pair with the
|
|
id of the bug, and the count of the number of
|
|
times the bug has been seen.
|
|
"""
|
|
testname = _testname_to_keyval_key(testname)
|
|
keyval_base = '%s_%s' % (job_id, testname) if job_id else testname
|
|
bug_id = keyvals.get(keyval_base + _BUG_ID_KEYVAL)
|
|
if not bug_id:
|
|
return None, None
|
|
bug_id = int(bug_id)
|
|
bug_count = keyvals.get(keyval_base + _BUG_COUNT_KEYVAL)
|
|
bug_count = int(bug_count) if bug_count else None
|
|
return bug_id, bug_count
|
|
|
|
|
|
def create_job_name(build, suite, test_name):
|
|
"""Create the name of a test job based on given build, suite, and test_name.
|
|
|
|
@param build: name of the build, e.g., lumpy-release/R31-1234.0.0.
|
|
@param suite: name of the suite, e.g., bvt.
|
|
@param test_name: name of the test, e.g., dummy_Pass.
|
|
@return: the test job's name, e.g.,
|
|
lumpy-release/R31-1234.0.0/bvt/dummy_Pass.
|
|
"""
|
|
return '/'.join([build, suite, test_name])
|
|
|
|
|
|
def get_test_name(build, suite, test_job_name):
|
|
"""Get the test name from test job name.
|
|
|
|
Name of test job may contain information like build and suite. This method
|
|
strips these information and return only the test name.
|
|
|
|
@param build: name of the build, e.g., lumpy-release/R31-1234.0.0.
|
|
@param suite: name of the suite, e.g., bvt.
|
|
@param test_job_name: name of the test job, e.g.,
|
|
lumpy-release/R31-1234.0.0/bvt/dummy_Pass_SERVER_JOB.
|
|
@return: the test name, e.g., dummy_Pass_SERVER_JOB.
|
|
"""
|
|
# Do not change this naming convention without updating
|
|
# site_utils.parse_job_name.
|
|
return test_job_name.replace('%s/%s/' % (build, suite), '')
|