aboutsummaryrefslogtreecommitdiff
path: root/server/cros/dynamic_suite/tools.py
blob: 2027c9d989b18b27ad99c0af8a218432413f7018 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
# 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., stub_ServerToClientPass.
    @return: the test job's name, e.g.,
             lumpy-release/R31-1234.0.0/bvt/stub_ServerToClientPass.
    """
    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/stub_ServerToClientPass.
    @return: the test name, e.g., stub_ServerToClientPass.
    """
    # Do not change this naming convention without updating
    # site_utils.parse_job_name.
    return test_job_name.replace('%s/%s/' % (build, suite), '')