summaryrefslogtreecommitdiff
path: root/frontend/planner/rpc_interface.py
blob: b97c3d23a85cbf8b7019887e85a28fd21e061738 (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
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
"""\
Functions to expose over the RPC interface.
"""

__author__ = 'jamesren@google.com (James Ren)'


import os, re
import common
from django.db import models as django_models
from autotest_lib.frontend import thread_local
from autotest_lib.frontend.afe import model_logic, models as afe_models
from autotest_lib.frontend.afe import rpc_utils as afe_rpc_utils
from autotest_lib.frontend.afe import rpc_interface as afe_rpc_interface
from autotest_lib.frontend.tko import models as tko_models
from autotest_lib.frontend.planner import models, rpc_utils, model_attributes
from autotest_lib.frontend.planner import failure_actions
from autotest_lib.client.common_lib import utils

# basic getter/setter calls
# TODO: deprecate the basic calls and reimplement them in the REST framework

def get_plan(id):
    return afe_rpc_utils.prepare_for_serialization(
            models.Plan.smart_get(id).get_object_dict())


def modify_plan(id, **data):
    models.Plan.smart_get(id).update_object(data)


def modify_test_run(id, **data):
    models.TestRun.objects.get(id=id).update_object(data)


def modify_host(id, **data):
    models.Host.objects.get(id=id).update_object(data)


def add_job(plan_id, test_config_id, afe_job_id):
    models.Job.objects.create(
            plan=models.Plan.objects.get(id=plan_id),
            test_config=models.TestConfig.objects.get(id=test_config_id),
            afe_job=afe_models.Job.objects.get(id=afe_job_id))


# more advanced calls

def submit_plan(name, hosts, host_labels, tests, support=None,
                label_override=None, additional_parameters=None):
    """
    Submits a plan to the Test Planner

    @param name: the name of the plan
    @param hosts: a list of hostnames
    @param host_labels: a list of host labels. The hosts under test will update
                        to reflect changes in the label
    @param tests: an ordered list of dictionaries:
                      alias: an alias for the test
                      control_file: the test control file
                      is_server: True if is a server-side control file
                      estimated_runtime: estimated number of hours this test
                                         will run
    @param support: the global support script
    @param label_override: label to prepend to all AFE jobs for this test plan.
                           Defaults to the plan name.
    @param additional_parameters: A mapping of AdditionalParameters to apply to
                                  this test plan, as an ordered list. Each item
                                  of the list is a dictionary:
                                  hostname_regex: A regular expression; the
                                                  additional parameter in the
                                                  value will be applied if the
                                                  hostname matches this regex
                                  param_type: The type of additional parameter
                                  param_values: A dictionary of key=value pairs
                                                for this parameter
               example:
               [{'hostname_regex': 'host[0-9]',
                 'param_type': 'Verify',
                 'param_values': {'key1': 'value1',
                                  'key2': 'value2'}},
                {'hostname_regex': '.*',
                 'param_type': 'Verify',
                 'param_values': {'key': 'value'}}]

                                  Currently, the only (non-site-specific)
                                  param_type available is 'Verify'. Setting
                                  these parameters allows the user to specify
                                  arguments to the
                                  job.run_test('verify_test', ...) line at the
                                  beginning of the wrapped control file for each
                                  test
    """
    host_objects = []
    label_objects = []

    for host in hosts or []:
        try:
            host_objects.append(
                    afe_models.Host.valid_objects.get(hostname=host))
        except afe_models.Host.DoesNotExist:
            raise model_logic.ValidationError(
                    {'hosts': 'host %s does not exist' % host})

    for label in host_labels or []:
        try:
            label_objects.append(afe_models.Label.valid_objects.get(name=label))
        except afe_models.Label.DoesNotExist:
            raise model_logic.ValidationError(
                    {'host_labels': 'host label %s does not exist' % label})

    aliases_seen = set()
    test_required_fields = (
            'alias', 'control_file', 'is_server', 'estimated_runtime')
    for test in tests:
        for field in test_required_fields:
            if field not in test:
                raise model_logic.ValidationError(
                        {'tests': 'field %s is required' % field})

        alias = test['alias']
        if alias in aliases_seen:
            raise model_logic.Validationerror(
                    {'tests': 'alias %s occurs more than once' % alias})
        aliases_seen.add(alias)

    plan, created = models.Plan.objects.get_or_create(name=name)
    if not created:
        raise model_logic.ValidationError(
                {'name': 'Plan name %s already exists' % name})

    try:
        rpc_utils.set_additional_parameters(plan, additional_parameters)
        label = rpc_utils.create_plan_label(plan)
        try:
            for i, test in enumerate(tests):
                control, _ = models.ControlFile.objects.get_or_create(
                        contents=test['control_file'])
                models.TestConfig.objects.create(
                        plan=plan, alias=test['alias'], control_file=control,
                        is_server=test['is_server'], execution_order=i,
                        estimated_runtime=test['estimated_runtime'])

            plan.label_override = label_override
            plan.support = support or ''
            plan.save()

            plan.owners.add(afe_models.User.current_user())

            for host in host_objects:
                planner_host = models.Host.objects.create(plan=plan, host=host)

            plan.host_labels.add(*label_objects)

            rpc_utils.start_plan(plan, label)

            return plan.id
        except:
            label.delete()
            raise
    except:
        plan.delete()
        raise


def get_hosts(plan_id):
    """
    Gets the hostnames of all the hosts in this test plan.

    Resolves host labels in the plan.
    """
    plan = models.Plan.smart_get(plan_id)

    hosts = set(plan.hosts.all().values_list('hostname', flat=True))
    for label in plan.host_labels.all():
        hosts.update(label.host_set.all().values_list('hostname', flat=True))

    return afe_rpc_utils.prepare_for_serialization(hosts)


def get_atomic_group_control_file():
    """
    Gets the control file to apply the atomic group for a set of machines
    """
    return rpc_utils.lazy_load(os.path.join(os.path.dirname(__file__),
                                            'set_atomic_group_control.srv'))


def get_next_test_configs(plan_id):
    """
    Gets information about the next planner test configs that need to be run

    @param plan_id: the ID or name of the test plan
    @return a dictionary:
                complete: True or False, shows test plan completion
                next_configs: a list of dictionaries:
                    host: ID of the host
                    next_test_config_id: ID of the next Planner test to run
    """
    plan = models.Plan.smart_get(plan_id)

    result = {'next_configs': []}

    rpc_utils.update_hosts_table(plan)
    for host in models.Host.objects.filter(plan=plan):
        next_test_config = rpc_utils.compute_next_test_config(plan, host)
        if next_test_config:
            config = {'next_test_config_id': next_test_config.id,
                      'next_test_config_alias': next_test_config.alias,
                      'host': host.host.hostname}
            result['next_configs'].append(config)

    rpc_utils.check_for_completion(plan)
    result['complete'] = plan.complete

    return result


def update_test_runs(plan_id):
    """
    Add all applicable TKO jobs to the Planner DB tables

    Looks for tests in the TKO tables that were started as a part of the test
    plan, and add them to the Planner tables.

    Also updates the status of the test run if the underlying TKO test move from
    an active status to a completed status.

    @return a list of dictionaries:
                status: the status of the new (or updated) test run
                tko_test_idx: the ID of the TKO test added
                hostname: the host added
    """
    plan = models.Plan.smart_get(plan_id)
    updated = []

    for planner_job in plan.job_set.all():
        known_statuses = dict((test_run.tko_test.test_idx, test_run.status)
                              for test_run in planner_job.testrun_set.all())
        tko_tests_for_job = tko_models.Test.objects.filter(
                job__afe_job_id=planner_job.afe_job.id)

        for tko_test in tko_tests_for_job:
            status = rpc_utils.compute_test_run_status(tko_test.status.word)
            needs_update = (tko_test.test_idx not in known_statuses or
                            status != known_statuses[tko_test.test_idx])
            if needs_update:
                hostnames = tko_test.machine.hostname.split(',')
                for hostname in hostnames:
                    rpc_utils.add_test_run(
                            plan, planner_job, tko_test, hostname, status)
                    updated.append({'status': status,
                                    'tko_test_idx': tko_test.test_idx,
                                    'hostname': hostname})

    return updated


def get_failures(plan_id):
    """
    Gets a list of the untriaged failures associated with this plan

    @return a list of dictionaries:
                id: the failure ID, for passing back to triage the failure
                group: the group for the failure. Normally the same as the
                       reason, but can be different for custom queries
                machine: the failed machine
                blocked: True if the failure caused the machine to block
                test_name: Concatenation of the Planner alias and the TKO test
                           name for the failed test
                reason: test failure reason
                seen: True if the failure is marked as "seen"
    """
    plan = models.Plan.smart_get(plan_id)
    result = {}

    failures = plan.testrun_set.filter(
            finalized=True, triaged=False,
            status=model_attributes.TestRunStatus.FAILED)
    failures = failures.order_by('seen').select_related('test_job__test',
                                                        'host__host',
                                                        'tko_test')
    for failure in failures:
        test_name = '%s: %s' % (
                failure.test_job.test_config.alias, failure.tko_test.test)

        group_failures = result.setdefault(failure.tko_test.reason, [])
        failure_dict = {'id': failure.id,
                        'machine': failure.host.host.hostname,
                        'blocked': bool(failure.host.blocked),
                        'test_name': test_name,
                        'reason': failure.tko_test.reason,
                        'seen': bool(failure.seen)}
        group_failures.append(failure_dict)

    return result


def get_test_runs(**filter_data):
    """
    Gets a list of test runs that match the filter data.

    Returns a list of expanded TestRun object dictionaries. Specifically, the
    "host" and "test_job" fields are expanded. Additionally, the "test_config"
    field of the "test_job" expansion is also expanded.
    """
    result = []
    for test_run in models.TestRun.objects.filter(**filter_data):
        test_run_dict = test_run.get_object_dict()
        test_run_dict['host'] = test_run.host.get_object_dict()
        test_run_dict['test_job'] = test_run.test_job.get_object_dict()
        test_run_dict['test_job']['test_config'] = (
                test_run.test_job.test_config.get_object_dict())
        result.append(test_run_dict)
    return result


def skip_test(test_config_id, hostname):
    """
    Marks a test config as "skipped" for a given host
    """
    config = models.TestConfig.objects.get(id=test_config_id)
    config.skipped_hosts.add(afe_models.Host.objects.get(hostname=hostname))


def mark_failures_as_seen(failure_ids):
    """
    Marks a set of failures as 'seen'

    @param failure_ids: A list of failure IDs, as returned by get_failures(), to
                        mark as seen
    """
    models.TestRun.objects.filter(id__in=failure_ids).update(seen=True)


def process_failures(failure_ids, host_action, test_action, labels=(),
                     keyvals=None, bugs=(), reason=None, invalidate=False):
    """
    Triage a failure

    @param failure_id: The failure ID, as returned by get_failures()
    @param host_action: One of 'Block', 'Unblock', 'Reinstall'
    @param test_action: One of 'Skip', 'Rerun'

    @param labels: Test labels to apply, by name
    @param keyvals: Dictionary of job keyvals to add (or replace)
    @param bugs: List of bug IDs to associate with this failure
    @param reason: An override for the test failure reason
    @param invalidate: True if failure should be invalidated for the purposes of
                       reporting. Defaults to False.
    """
    host_choices = failure_actions.HostAction.values
    test_choices = failure_actions.TestAction.values
    if host_action not in host_choices:
        raise model_logic.ValidationError(
                {'host_action': ('host action %s not valid; must be one of %s'
                                 % (host_action, ', '.join(host_choices)))})
    if test_action not in test_choices:
        raise model_logic.ValidationError(
                {'test_action': ('test action %s not valid; must be one of %s'
                                 % (test_action, ', '.join(test_choices)))})

    for failure_id in failure_ids:
        rpc_utils.process_failure(
                failure_id=failure_id, host_action=host_action,
                test_action=test_action, labels=labels, keyvals=keyvals,
                bugs=bugs, reason=reason, invalidate=invalidate)


def get_machine_view_data(plan_id):
    """
    Gets the data required for the web frontend Machine View.

    @param plan_id: The ID of the test plan
    @return An array. Each element is a dictionary:
                    machine: The name of the machine
                    status: The machine's status (one of
                            model_attributes.HostStatus)
                    bug_ids: List of the IDs for the bugs filed
                    tests_run: An array of dictionaries:
                            test_name: The TKO name of the test
                            success: True if the test passed
    """
    plan = models.Plan.smart_get(plan_id)
    result = []
    for host in plan.host_set.all():
        tests_run = []

        machine = host.host.hostname
        host_status = host.status()
        bug_ids = set()

        testruns = plan.testrun_set.filter(host=host, invalidated=False,
                                           finalized=True)
        for testrun in testruns:
            test_name = testrun.tko_test.test
            test_status = testrun.tko_test.status.word
            testrun_bug_ids = testrun.bugs.all().values_list(
                    'external_uid', flat=True)

            tests_run.append({'test_name': test_name,
                              'status': test_status})
            bug_ids.update(testrun_bug_ids)

        result.append({'machine': machine,
                       'status': host_status,
                       'tests_run': tests_run,
                       'bug_ids': list(bug_ids)})
    return result


def generate_test_config(alias, afe_test_name=None,
                         estimated_runtime=0, **kwargs):
    """
    Creates and returns a test config suitable for passing into submit_plan()

    Also accepts optional parameters to pass directly in to the AFE RPC
    interface's generate_control_file() method.

    @param alias: The alias for the test
    @param afe_test_name: The name of the test, as shown on AFE
    @param estimated_runtime: Estimated number of hours this test is expected to
                              run. For reporting purposes.
    """
    if afe_test_name is None:
        afe_test_name = alias
    alias = alias.replace(' ', '_')

    control = afe_rpc_interface.generate_control_file(tests=[afe_test_name],
                                                      **kwargs)

    return {'alias': alias,
            'control_file': control['control_file'],
            'is_server': control['is_server'],
            'estimated_runtime': estimated_runtime}


def get_wrapped_test_config(id, hostname, run_verify):
    """
    Gets the TestConfig object identified by the ID

    Returns the object dict of the TestConfig, plus an additional
    'wrapped_control_file' value, which includes the pre-processing that the
    ControlParameters specify.

    @param hostname: Hostname of the machine this test config will run on
    @param run_verify: Set to True or False to override the default behavior
                       (which is to run the verify test unless the skip_verify
                       ControlParameter is set)
    """
    test_config = models.TestConfig.objects.get(id=id)
    object_dict = test_config.get_object_dict()
    object_dict['control_file'] = test_config.control_file.get_object_dict()
    object_dict['wrapped_control_file'] = rpc_utils.wrap_control_file(
            plan=test_config.plan, hostname=hostname,
            run_verify=run_verify, test_config=test_config)

    return object_dict


def generate_additional_parameters(hostname_regex, param_type, param_values):
    """
    Generates an AdditionalParamter dictionary, for passing in to submit_plan()

    Returns a dictionary. To use in submit_job(), put this dictionary into a
    list (possibly with other additional_parameters dictionaries)

    @param hostname_regex: The hostname regular expression to match
    @param param_type: One of get_static_data()['additional_parameter_types']
    @param param_values: Dictionary of key=value pairs for this parameter
    """
    try:
        re.compile(hostname_regex)
    except Exception:
        raise model_logic.ValidationError(
                {'hostname_regex': '%s is not a valid regex' % hostname_regex})

    if param_type not in model_attributes.AdditionalParameterType.values:
        raise model_logic.ValidationError(
                {'param_type': '%s is not a valid parameter type' % param_type})

    if type(param_values) is not dict:
        raise model_logic.ValidationError(
                {'param_values': '%s is not a dictionary' % repr(param_values)})

    return {'hostname_regex': hostname_regex,
            'param_type': param_type,
            'param_values': param_values}


def get_overview_data(plan_ids):
    """
    Gets the data for the Overview tab

    @param plan_ids: A list of the plans, by id or name
    @return A dictionary - keys are plan names, values are dictionaries of data:
                machines: A list of dictionaries:
                hostname: The machine's hostname
                status: The host's status
                passed: True if the machine passed the test plan. A 'pass' means
                        that, for every test configuration in the plan, the
                        machine had at least one AFE job with no failed tests.
                        'passed' could also be None, meaning that this host is
                        still running tests.
                bugs: A list of the bugs filed
                test_configs: A list of dictionaries, each representing a test
                              config:
                    complete: Number of hosts that have completed this test
                              config
                    estimated_runtime: Number of hours this test config is
                                       expected to run on each host
    """
    plans = models.Plan.smart_get_bulk(plan_ids)
    result = {}

    for plan in plans:
        machines = []
        for host in plan.host_set.all():
            pass_status = rpc_utils.compute_test_config_status(host)
            if pass_status == rpc_utils.ComputeTestConfigStatusResult.PASS:
                passed = True
            elif pass_status == rpc_utils.ComputeTestConfigStatusResult.FAIL:
                passed = False
            else:
                passed = None
            machines.append({'hostname': host.host.hostname,
                             'status': host.status(),
                             'passed': passed})

        bugs = set()
        for testrun in plan.testrun_set.all():
            bugs.update(testrun.bugs.values_list('external_uid', flat=True))

        test_configs = []
        for test_config in plan.testconfig_set.all():
            complete_jobs = test_config.job_set.filter(
                    afe_job__hostqueueentry__complete=True)
            complete_afe_jobs = afe_models.Job.objects.filter(
                    id__in=complete_jobs.values_list('afe_job', flat=True))

            complete_hosts = afe_models.Host.objects.filter(
                    hostqueueentry__job__in=complete_afe_jobs)
            complete_hosts |= test_config.skipped_hosts.all()

            test_configs.append(
                    {'complete': complete_hosts.distinct().count(),
                     'estimated_runtime': test_config.estimated_runtime})

        plan_data = {'machines': machines,
                     'bugs': list(bugs),
                     'test_configs': test_configs}
        result[plan.name] = plan_data

    return result


def get_test_view_data(plan_id):
    """
    Gets the data for the Test View tab

    @param plan_id: The name or ID of the test plan
    @return A dictionary - Keys are test config aliases, values are dictionaries
                           of data:
                total_machines: Total number of machines scheduled for this test
                                config. Excludes machines that are set to skip
                                this config.
                machine_status: A dictionary:
                    key: The hostname
                    value: The status of the machine: one of 'Scheduled',
                           'Running', 'Pass', or 'Fail'
                total_runs: Total number of runs of this test config. Includes
                            repeated runs (from triage re-run)
                total_passes: Number of runs that resulted in a 'pass', meaning
                              that none of the tests in the test config had any
                              status other than GOOD.
                bugs: List of bugs that were filed under this test config
    """
    plan = models.Plan.smart_get(plan_id)
    result = {}
    for test_config in plan.testconfig_set.all():
        skipped_host_ids = test_config.skipped_hosts.values_list('id',
                                                                 flat=True)
        hosts = plan.host_set.exclude(host__id__in=skipped_host_ids)
        total_machines = hosts.count()

        machine_status = {}
        for host in hosts:
            machine_status[host.host.hostname] = (
                    rpc_utils.compute_test_config_status(host, test_config))

        planner_jobs = test_config.job_set.all()
        total_runs = planner_jobs.count()
        total_passes = 0
        for planner_job in planner_jobs:
            if planner_job.all_tests_passed():
                total_passes += 1

        test_runs = plan.testrun_set.filter(
                test_job__in=test_config.job_set.all())
        bugs = set()
        for test_run in test_runs:
            bugs.update(test_run.bugs.values_list('external_uid', flat=True))

        result[test_config.alias] = {'total_machines': total_machines,
                                     'machine_status': machine_status,
                                     'total_runs': total_runs,
                                     'total_passes': total_passes,
                                     'bugs': list(bugs)}
    return result


def get_motd():
    return afe_rpc_utils.get_motd()


def get_static_data():
    result = {'motd': get_motd(),
              'host_actions': sorted(failure_actions.HostAction.values),
              'test_actions': sorted(failure_actions.TestAction.values),
              'additional_parameter_types':
                      sorted(model_attributes.AdditionalParameterType.values),
              'host_statuses': sorted(model_attributes.HostStatus.values)}
    return result