• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2#
3# Copyright (C) 2018 The Android Open Source Project
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#      http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16#
17#
18# This test script to be used by the build server.
19# It is supposed to be executed from trusty root directory
20# and expects the following environment variables:
21#
22"""Parse trusty build and test configuration files."""
23
24import argparse
25import os
26import re
27from enum import StrEnum, auto
28from typing import List, Dict, Optional
29
30script_dir = os.path.dirname(os.path.abspath(__file__))
31
32class PortType(StrEnum):
33    TEST = auto()
34    BENCHMARK = auto()
35
36
37class TrustyBuildConfigProject(object):
38    """Stores build enabled status and test lists for a project
39
40    Attributes:
41        build: A boolean indicating if project should be built be default.
42        tests: A list of commands to run to test this project.
43        also_build: Set of project to also build if building this one.
44    """
45
46    def __init__(self):
47        """Inits TrustyBuildConfigProject with an empty test list and no
48           build."""
49        self.build = False
50        self.tests = []
51        self.also_build = {}
52        self.signing_keys = None
53
54
55class TrustyPortTestFlags(object):
56    """Stores need flags for a test or provide flags for a test environment."""
57
58    ALLOWED_FLAGS = {"android", "storage_boot", "storage_full",
59                     "smp4", "abl", "tablet"}
60
61    def __init__(self, **flags):
62        self.flags = set()
63        self.set(**flags)
64
65    def set(self, **flags):
66        """Set flags."""
67        for name, arg in flags.items():
68            if name in self.ALLOWED_FLAGS:
69                if arg:
70                    self.flags.add(name)
71                else:
72                    self.flags.discard(name)
73            else:
74                raise TypeError("Unexpected flag: " + name)
75
76    def match_provide(self, provide):
77        return self.flags.issubset(provide.flags)
78
79
80class TrustyArchiveBuildFile(object):
81    """Copy a file to archive directory after a build."""
82    def __init__(self, src, dest, optional):
83        self.src = src
84        self.dest = dest
85        self.optional = optional
86
87
88class TrustyTest(object):
89    """Stores a pair of a test name and a command to run"""
90    def __init__(self, name, command, enabled, port_type = PortType.TEST):
91        self.name = name
92        self.command = command
93        self.enabled = enabled
94        self.port_type = port_type
95
96    def type(self, port_type):
97        self.port_type = PortType(port_type)  # ensure we have a valid port type
98        return self
99
100class TrustyHostTest(TrustyTest):
101    """Stores a pair of a test name and a command to run on host."""
102
103    class TrustyHostTestFlags:
104        """Enable needs to be matched with provides without special casing"""
105
106        @staticmethod
107        def match_provide(_):
108            # cause host test to be filtered out if they appear in a boottests
109            # or androidportests environment which provides a set of features.
110            return False
111
112    need = TrustyHostTestFlags()
113
114
115class TrustyAndroidTest(TrustyTest):
116    """Stores a test name and command to run inside Android"""
117
118    def __init__(self, name, command, need=None,
119                 port_type=PortType.TEST, enabled=True, nameprefix="",
120                 runargs=(), timeout=None):
121        nameprefix = nameprefix + "android-test:"
122        cmd = ["run", "--headless", "--shell-command", command]
123        if timeout:
124            cmd += ['--timeout', str(timeout)]
125        if runargs:
126            cmd += list(runargs)
127        super().__init__(nameprefix + name, cmd, enabled, port_type)
128        self.shell_command = command
129        if need:
130            self.need = need
131        else:
132            self.need = TrustyPortTestFlags()
133
134    def needs(self, **need):
135        self.need.set(**need)
136        return self
137
138
139class TrustyPortTest(TrustyTest):
140    """Stores a trusty port name for a test to run."""
141
142    def __init__(self, port, port_type=PortType.TEST, enabled=True,
143                 timeout=None):
144        super().__init__(port, None, enabled, port_type)
145        self.port = port
146        self.need = TrustyPortTestFlags()
147        self.timeout = timeout
148
149    def needs(self, **need):
150        self.need.set(**need)
151        return self
152
153    def into_androidporttest(self, cmdargs, **kwargs):
154        cmdargs = list(cmdargs)
155        cmd = " ".join(["/vendor/bin/trusty-ut-ctrl", self.port] + cmdargs)
156        return TrustyAndroidTest(self.name, cmd, self.need, self.port_type,
157                                 self.enabled, timeout=self.timeout, **kwargs)
158
159    def into_bootporttest(self) -> TrustyTest:
160        cmd = ["run", "--headless", "--boot-test", self.port]
161        cmd += ['--timeout', str(self.timeout)] if self.timeout else []
162        return TrustyTest("boot-test:" + self.port, cmd, self.enabled,
163                          self.port_type)
164
165
166class TrustyCommand:
167    """Base class for all types of commands that are *not* tests"""
168
169    def __init__(self, name):
170        self.name = name
171        self.enabled = True
172        # avoids special cases in list_config
173        self.command = []
174        # avoids special cases in porttest_match
175        self.need = TrustyPortTestFlags()
176
177    def needs(self, **_):
178        """Allows commands to be used inside a needs block."""
179        return self
180
181    def into_androidporttest(self, **_):
182        return self
183
184    def into_bootporttest(self):
185        return self
186
187
188class TrustyRebootCommand(TrustyCommand):
189    """Marker object which causes the test environment to be rebooted before the
190       next test is run. Used to reset the test environment and to test storage.
191
192       TODO: The current qemu.py script does a factory reset as part a reboot.
193             We probably want a parameter or separate command to control that.
194    """
195    def __init__(self):
196        super().__init__("reboot command")
197
198
199class TrustyCompositeTest(TrustyTest):
200    """Stores a sequence of tests that must execute in order"""
201
202    def __init__(self, name: str,
203                 sequence: List[TrustyPortTest | TrustyCommand],
204                 enabled=True):
205        super().__init__(name, [], enabled)
206        self.sequence = sequence
207        flags = set()
208        for subtest in sequence:
209            flags.update(subtest.need.flags)
210        self.need = TrustyPortTestFlags(**{flag: True for flag in flags})
211
212    def needs(self, **need):
213        self.need.set(**need)
214        return self
215
216    def into_androidporttest(self, **kwargs):
217        # because the needs of the composite test is the union of the needs of
218        # its subtests, we do not need to filter out any subtests; all needs met
219        self.sequence = [subtest.into_androidporttest(**kwargs)
220                         for subtest in self.sequence]
221        return self
222
223    def into_bootporttest(self):
224        # similarly to into_androidporttest, we do not need to filter out tests
225        self.sequence = [subtest.into_bootporttest()
226                         for subtest in self.sequence]
227        return self
228
229
230class TrustyBuildConfig(object):
231    """Trusty build and test configuration file parser."""
232
233    def __init__(self, config_file=None, debug=False, android=None):
234        """Inits TrustyBuildConfig.
235
236        Args:
237            config_file: Optional config file path. If omitted config file is
238                found relative to script directory.
239            debug: Optional boolean value. Set to True to enable debug messages.
240        """
241        self.debug = debug
242        self.android = android
243        self.projects = {}
244        self.dist = []
245        self.default_signing_keys = []
246        if config_file is None:
247            config_file = os.path.join(script_dir, "build-config")
248        self.read_config_file(config_file)
249
250    def read_config_file(self, path, optional=False):
251        """Main parser function called constructor or recursively by itself."""
252        if optional and not os.path.exists(path):
253            if self.debug:
254                print("Skipping optional config file:", path)
255            return []
256
257        if self.debug:
258            print("Reading config file:", path)
259
260        config_dir = os.path.dirname(path)
261
262        def _flatten_list(inp, out):
263            for obj in inp:
264                if isinstance(obj, list):
265                    _flatten_list(obj, out)
266                else:
267                    out.append(obj)
268
269        def flatten_list(inp):
270            out = []
271            _flatten_list(inp, out)
272            return out
273
274        def include(path, optional=False):
275            """Process include statement in config file."""
276            if self.debug:
277                print("include", path, "optional", optional)
278            if path.startswith("."):
279                path = os.path.join(config_dir, path)
280            return self.read_config_file(path=path, optional=optional)
281
282        def build(projects, enabled=True, dist=None):
283            """Process build statement in config file."""
284            for project_name in projects:
285                if self.debug:
286                    print("build", project_name, "enabled", enabled)
287                project = self.get_project(project_name)
288                project.build = bool(enabled)
289            if dist:
290                for item in dist:
291                    assert isinstance(item, TrustyArchiveBuildFile), item
292                    self.dist.append(item)
293
294        def builddep(projects, needs):
295            """Process build statement in config file."""
296            for project_name in projects:
297                project = self.get_project(project_name)
298                for project_dep_name in needs:
299                    project_dep = self.get_project(project_dep_name)
300                    if self.debug:
301                        print("build", project_name, "needs", project_dep_name)
302                    project.also_build[project_dep_name] = project_dep
303
304        def archive(src, dest=None, optional=False):
305            return TrustyArchiveBuildFile(src, dest, optional)
306
307        def testmap(projects, tests=()):
308            """Process testmap statement in config file."""
309            for project_name in projects:
310                if self.debug:
311                    print("testmap", project_name, "build", build)
312                    for test in tests:
313                        print(test)
314                project = self.get_project(project_name)
315                project.tests += flatten_list(tests)
316
317        def hosttest(host_cmd, enabled=True, repeat=1):
318            cmd = ["host_tests/" + host_cmd]
319            # TODO: assumes that host test is always a googletest
320            if repeat > 1:
321                cmd.append(f"--gtest_repeat={repeat}")
322            return TrustyHostTest("host-test:" + host_cmd, cmd, enabled)
323
324        def hosttests(tests):
325            return [test for test in flatten_list(tests)
326                    if isinstance(test, TrustyHostTest)]
327
328        def porttest_match(test, provides):
329            return test.need.match_provide(provides)
330
331        def porttests_filter(tests, provides):
332            return [test for test in flatten_list(tests)
333                    if porttest_match(test, provides)]
334
335        def boottests(port_tests, provides=None):
336            if provides is None:
337                provides = TrustyPortTestFlags(storage_boot=True,
338                                               smp4=True)
339            return [test.into_bootporttest()
340                    for test in porttests_filter(port_tests, provides)]
341
342        def androidporttests(port_tests, provides=None, nameprefix="",
343                             cmdargs=(), runargs=()):
344            nameprefix = nameprefix + "android-port-test:"
345            if provides is None:
346                provides = TrustyPortTestFlags(android=True,
347                                               storage_boot=True,
348                                               storage_full=True,
349                                               smp4=True,
350                                               abl=True,
351                                               tablet=True)
352
353            return [test.into_androidporttest(nameprefix=nameprefix,
354                                              cmdargs=cmdargs,
355                                              runargs=runargs)
356                    for test in porttests_filter(port_tests, provides)]
357
358        def needs(tests, *args, **kwargs):
359            return [
360                test.needs(*args, **kwargs)
361                for test in flatten_list(tests)
362            ]
363
364        def devsigningkeys(
365            default_key_paths: List[str],
366            project_overrides: Optional[Dict[str, List[str]]] = None):
367            self.default_signing_keys.extend(default_key_paths)
368            if project_overrides is None:
369                return
370
371            for project_name, overrides in project_overrides.items():
372                project = self.get_project(project_name)
373                if project.signing_keys is None:
374                    project.signing_keys = []
375                project.signing_keys.extend(overrides)
376
377
378        file_format = {
379            "BENCHMARK": PortType.BENCHMARK,
380            "TEST": PortType.TEST,
381            "include": include,
382            "build": build,
383            "builddep": builddep,
384            "archive": archive,
385            "testmap": testmap,
386            "hosttest": hosttest,
387            "porttest": TrustyPortTest,
388            "compositetest": TrustyCompositeTest,
389            "porttestflags": TrustyPortTestFlags,
390            "hosttests": hosttests,
391            "boottests": boottests,
392            "androidtest": TrustyAndroidTest,
393            "androidporttests": androidporttests,
394            "needs": needs,
395            "reboot": TrustyRebootCommand,
396            "devsigningkeys": devsigningkeys,
397        }
398
399        with open(path, encoding="utf8") as f:
400            code = compile(f.read(), path, "eval")
401            config = eval(code, file_format)  # pylint: disable=eval-used
402            return flatten_list(config)
403
404    def get_project(self, project):
405        """Return TrustyBuildConfigProject entry for a project."""
406        if project not in self.projects:
407            self.projects[project] = TrustyBuildConfigProject()
408        return self.projects[project]
409
410    def get_projects(self, build=None, have_tests=None):
411        """Return a list of projects.
412
413        Args:
414            build: If True only return projects that should be built. If False
415                only return projects that should not be built. If None return
416                both projects that should be built and not be built. (default
417                None).
418            have_tests: If True only return projects that have tests. If False
419                only return projects that don't have tests. If None return
420                projects regardless if they have tests. (default None).
421        """
422
423        def match(item):
424            """filter function for get_projects."""
425            project = self.projects[item]
426
427            return ((build is None or build == project.build) and
428                    (have_tests is None or
429                     have_tests == bool(project.tests)))
430
431        return (project for project in sorted(self.projects.keys())
432                if match(project))
433
434    def signing_keys(self, project_name: str):
435        project_specific_keys = self.get_project(project_name).signing_keys
436        if project_specific_keys is None:
437            return self.default_signing_keys
438        return project_specific_keys
439
440
441def list_projects(args):
442    """Read config file and print a list of projects.
443
444    See TrustyBuildConfig.get_projects for filtering options.
445
446    Args:
447        args: Program arguments.
448    """
449    config = TrustyBuildConfig(config_file=args.file, debug=args.debug)
450    for project in sorted(config.get_projects(**dict(args.filter))):
451        print(project)
452
453
454def list_config(args):
455    """Read config file and print all project and tests."""
456    config = TrustyBuildConfig(config_file=args.file, debug=args.debug)
457    print("Projects:")
458    for project_name, project in sorted(config.projects.items()):
459        print("  " + project_name + ":")
460        print("    Build:", project.build)
461        print("    Tests:")
462        for test in project.tests:
463            print("      " + test.name + ":")
464            print("        " + str(test.command))
465
466    for build in [True, False]:
467        print()
468        print("Build:" if build else "Don't build:")
469        for tested in [True, False]:
470            projects = config.get_projects(build=build, have_tests=tested)
471            for project in sorted(projects):
472                print("  " + project + ":")
473                project_config = config.get_project(project)
474                for test in project_config.tests:
475                    print("    " + test.name)
476            if projects and not tested:
477                print("    No tests")
478
479
480def any_test_name(regex, tests):
481    """Checks the name of all tests in a list for a regex.
482
483    This is intended only as part of the selftest facility, do not use it
484    to decide how to consider actual tests.
485
486    Args:
487        tests: List of tests to check the names of
488        regex: Regular expression to check them for (as a string)
489    """
490
491    return any(re.match(regex, test.name) is not None for test in tests)
492
493
494def has_host(tests):
495    """Checks for a host test in the provided tests by name.
496
497    This is intended only as part of the selftest facility, do not use it
498    to decide how to consider actual tests.
499
500    Args:
501        tests: List of tests to check for host tests
502    """
503    return any_test_name("host-test:", tests)
504
505
506def has_unit(tests):
507    """Checks for a unit test in the provided tests by name.
508
509    This is intended only as part of the selftest facility, do not use it
510    to decide how to consider actual tests.
511
512    Args:
513        tests: List of tests to check for unit tests
514    """
515    return any_test_name("boot-test:", tests)
516
517
518def test_config(args):
519    """Test config file parser.
520
521    Uses a test config file where all projects have names that describe if they
522    should be built and if they have tests.
523
524    Args:
525        args: Program arguments.
526    """
527    config_file = os.path.join(script_dir, "trusty_build_config_self_test_main")
528    config = TrustyBuildConfig(config_file=config_file, debug=args.debug)
529
530    projects_build = {}
531
532    project_regex = re.compile(
533        r"self_test\.build_(yes|no)\.tests_(none|host|unit|both)\..*")
534
535    for build in [None, True, False]:
536        projects_build[build] = {}
537        for tested in [None, True, False]:
538            projects = list(config.get_projects(build=build, have_tests=tested))
539            projects_build[build][tested] = projects
540            if args.debug:
541                print("Build", build, "tested", tested, "count", len(projects))
542            assert projects
543            for project in projects:
544                if args.debug:
545                    print("-", project)
546                m = project_regex.match(project)
547                assert m
548                if build is not None:
549                    assert m.group(1) == ("yes" if build else "no")
550                if tested is not None:
551                    if tested:
552                        assert (m.group(2) == "host" or
553                                m.group(2) == "unit" or
554                                m.group(2) == "both")
555                    else:
556                        assert m.group(2) == "none"
557
558        assert(projects_build[build][None] ==
559               sorted(projects_build[build][True] +
560                      projects_build[build][False]))
561    for tested in [None, True, False]:
562        assert(projects_build[None][tested] ==
563               sorted(projects_build[True][tested] +
564                      projects_build[False][tested]))
565
566    print("get_projects test passed")
567
568    reboot_seen = False
569
570    def check_test(i, test):
571        match test:
572            case TrustyTest():
573                host_m = re.match(r"host-test:self_test.*\.(\d+)",
574                                  test.name)
575                unit_m = re.match(r"boot-test:self_test.*\.(\d+)",
576                                  test.name)
577                if args.debug:
578                    print(project, i, test.name)
579                m = host_m or unit_m
580                assert m
581                assert m.group(1) == str(i + 1)
582            case TrustyRebootCommand():
583                assert False, "Reboot outside composite command"
584            case _:
585                assert False, "Unexpected test type"
586
587    def check_subtest(i, test):
588        nonlocal reboot_seen
589        match test:
590            case TrustyRebootCommand():
591                reboot_seen = True
592            case _:
593                check_test(i, test)
594
595    for project_name in config.get_projects():
596        project = config.get_project(project_name)
597        if args.debug:
598            print(project_name, project)
599        m = project_regex.match(project_name)
600        assert m
601        kind = m.group(2)
602        if kind == "both":
603            assert has_host(project.tests)
604            assert has_unit(project.tests)
605        elif kind == "unit":
606            assert not has_host(project.tests)
607            assert has_unit(project.tests)
608        elif kind == "host":
609            assert has_host(project.tests)
610            assert not has_unit(project.tests)
611        elif kind == "none":
612            assert not has_host(project.tests)
613            assert not has_unit(project.tests)
614        else:
615            assert False, "Unknown project kind"
616
617        for i, test in enumerate(project.tests):
618            match test:
619                case TrustyCompositeTest():
620                    # because one of its subtest needs storage_boot,
621                    # the composite test should similarly need it
622                    assert "storage_boot" in test.need.flags
623                    for subtest in test.sequence:
624                        check_subtest(i, subtest)
625                case _:
626                    check_test(i, test)
627
628    assert reboot_seen
629
630    print("get_tests test passed")
631
632
633def main():
634    top = os.path.abspath(os.path.join(script_dir, "../../../../.."))
635    os.chdir(top)
636
637    parser = argparse.ArgumentParser()
638    parser.add_argument("-d", "--debug", action="store_true")
639    parser.add_argument("--file")
640    # work around for https://bugs.python.org/issue16308
641    parser.set_defaults(func=lambda args: parser.print_help())
642    subparsers = parser.add_subparsers()
643
644    parser_projects = subparsers.add_parser("projects",
645                                            help="list project names")
646
647    group = parser_projects.add_mutually_exclusive_group()
648    group.add_argument("--with-tests", action="append_const",
649                       dest="filter", const=("have_tests", True),
650                       help="list projects that have tests")
651    group.add_argument("--without-tests", action="append_const",
652                       dest="filter", const=("have_tests", False),
653                       help="list projects that don't have tests")
654
655    group = parser_projects.add_mutually_exclusive_group()
656    group.add_argument("--all", action="append_const",
657                       dest="filter", const=("build", None),
658                       help="include disabled projects")
659    group.add_argument("--disabled", action="append_const",
660                       dest="filter", const=("build", False),
661                       help="only list disabled projects")
662    parser_projects.set_defaults(func=list_projects, filter=[("build", True)])
663
664    parser_config = subparsers.add_parser("config", help="dump config")
665    parser_config.set_defaults(func=list_config)
666
667    parser_config = subparsers.add_parser("selftest", help="test config parser")
668    parser_config.set_defaults(func=test_config)
669
670    args = parser.parse_args()
671    args.func(args)
672
673
674if __name__ == "__main__":
675    main()
676