| # Copyright 2023 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Implementation of builder relevancy checks using build_query.""" |
| |
| import dataclasses |
| import functools |
| import logging |
| from pathlib import Path |
| import re |
| from typing import Callable, Iterable, Iterator, List, Optional, Tuple |
| |
| from chromite.api.controller import controller_util |
| from chromite.api.gen.chromite.api import relevancy_pb2 |
| from chromite.api.gen.chromiumos import common_pb2 |
| from chromite.lib import build_query |
| from chromite.lib import build_target_lib |
| from chromite.lib import constants |
| from chromite.utils import compat |
| |
| |
| def _bootimage_enabled(build_target: build_target_lib.BuildTarget) -> bool: |
| """Return true if "bootimage" is in use_flags. |
| |
| Designed for use with _PATH_RULES below. |
| """ |
| return "bootimage" in build_target.board.use_flags |
| |
| |
| # Chromite subdirectiories we know are irrelevant to build targets (e.g., |
| # developer tooling). |
| _CHROMITE_IRRELEVANT = "|".join( |
| re.escape(x) |
| for x in ( |
| "cidb", |
| "cli", |
| "config", |
| "contrib", |
| "format", |
| "ide_tooling", |
| "systemd", |
| "test", |
| ) |
| ) |
| |
| # Special rules that can be applied to paths in the tree. Each regular |
| # expression (which matches a file path relative to the source checkout) |
| # can map to a function which determines if the change is relevant). |
| # The first argument of the callable is the BuildTarget under consideration. |
| # Regex groups are applied to the remaining arguments of the function. If |
| # the function returns true, the path is considered relevant. If it returns |
| # false, the path is considered irrelevant. |
| # |
| # Returns: |
| # True: The change is relevant for this path. |
| # False: The change is not relevant for this path. |
| _PATH_RULES: List[Tuple[str, Callable[..., bool]]] = [ |
| (r"chromite/.*_unittest\.py", lambda _: False), |
| (rf"chromite/(?:{_CHROMITE_IRRELEVANT})/.*", lambda _: False), |
| (r"chromite/.*", lambda _: True), |
| (r"src/scripts/.*", lambda _: True), |
| ( |
| r"src/third_party/kernel/v(\d+)\.(\d+)/.*", |
| lambda bt, v1, v2: f"kernel-{v1}_{v2}" in bt.board.use_flags, |
| ), |
| (r"src/third_party/coreboot/.*", _bootimage_enabled), |
| (r"src/platform/depthcharge/.*", _bootimage_enabled), |
| ( |
| r"src/third_party/chromiumos-overlay/sys-boot/chromeos-bootimage/.*", |
| _bootimage_enabled, |
| ), |
| ( |
| r"src/third_party/chromiumos-overlay/sys-boot/coreboot/.*", |
| _bootimage_enabled, |
| ), |
| ( |
| r"src/third_party/chromiumos-overlay/sys-boot/depthcharge/.*", |
| _bootimage_enabled, |
| ), |
| ( |
| r"src/third_party/chromiumos-overlay/sys-boot/edk2/.*", |
| _bootimage_enabled, |
| ), |
| ( |
| r"src/third_party/chromiumos-overlay/sys-boot/libpayload/.*", |
| _bootimage_enabled, |
| ), |
| ] |
| |
| |
| @functools.lru_cache(maxsize=len(_PATH_RULES)) |
| def _re(pattern: str) -> "re.Pattern[str]": |
| """Lazy & cached regex compiler for _PATH_RULES.""" |
| return re.compile(pattern) |
| |
| |
| ReasonPb = relevancy_pb2.GetRelevantBuildTargetsResponse.RelevantTarget.Reason |
| |
| |
| @dataclasses.dataclass |
| class Reason: |
| """Encapsulates a single reason why a build target is relevant.""" |
| |
| # The path that triggered relevancy. |
| trigger: Path |
| |
| def to_proto(self) -> ReasonPb: |
| """Convert to proto.""" |
| return ReasonPb(trigger=relevancy_pb2.Path(path=str(self.trigger))) |
| |
| |
| @dataclasses.dataclass |
| class ReasonPathRule(Reason): |
| """The target is relevant due to a path rule.""" |
| |
| # The pattern that triggered the match. |
| pattern: str |
| |
| def to_proto(self) -> ReasonPb: |
| pb = super().to_proto() |
| pb.MergeFrom( |
| ReasonPb( |
| path_rule_affected=ReasonPb.PathRuleAffected( |
| pattern=self.pattern |
| ), |
| ) |
| ) |
| return pb |
| |
| def __str__(self) -> str: |
| return ( |
| f"{self.trigger} modified a path which matches {self.pattern}, and " |
| f"the function for that pattern considers this change relevant." |
| ) |
| |
| |
| @dataclasses.dataclass |
| class ReasonProfile(Reason): |
| """The target is relevant as a profile was modified.""" |
| |
| # The profile that was modified. |
| profile: build_query.Profile |
| |
| def to_proto(self) -> ReasonPb: |
| pb = super().to_proto() |
| pb.MergeFrom( |
| ReasonPb( |
| profile_affected=ReasonPb.ProfileAffected( |
| profile=relevancy_pb2.Path( |
| path=str( |
| self.profile.path.relative_to(constants.SOURCE_ROOT) |
| ), |
| ), |
| ), |
| ) |
| ) |
| return pb |
| |
| def __str__(self) -> str: |
| return ( |
| f"{self.trigger} modified profile {self.profile}, a profile in the " |
| f"parents of this build target." |
| ) |
| |
| |
| @dataclasses.dataclass |
| class ReasonOverlay(Reason): |
| """The target is relevant as an overlay was modified.""" |
| |
| # The profile that was modified. |
| overlay: build_query.Overlay |
| |
| def to_proto(self) -> ReasonPb: |
| pb = super().to_proto() |
| pb.MergeFrom( |
| ReasonPb( |
| overlay_affected=ReasonPb.OverlayAffected( |
| overlay=relevancy_pb2.Path( |
| path=str( |
| self.overlay.path.relative_to(constants.SOURCE_ROOT) |
| ), |
| ), |
| ), |
| ) |
| ) |
| return pb |
| |
| def __str__(self) -> str: |
| return ( |
| f"{self.trigger} modified overlay {self.overlay}, an overlay in " |
| f"the parents of this build target." |
| ) |
| |
| |
| @dataclasses.dataclass |
| class ReasonPackage(Reason): |
| """The target is relevant as a package was modified.""" |
| |
| # The profile that was modified. |
| ebuild: build_query.Ebuild |
| |
| def to_proto(self) -> ReasonPb: |
| pb = super().to_proto() |
| package_info = common_pb2.PackageInfo() |
| controller_util.serialize_package_info( |
| self.ebuild.package_info, package_info |
| ) |
| pb.MergeFrom( |
| ReasonPb( |
| package_affected=ReasonPb.PackageAffected( |
| package_info=package_info, |
| ebuild=relevancy_pb2.Path( |
| path=str( |
| self.ebuild.ebuild_file.relative_to( |
| constants.SOURCE_ROOT |
| ) |
| ), |
| ), |
| ), |
| ) |
| ) |
| return pb |
| |
| def __str__(self) -> str: |
| return ( |
| f"{self.trigger} modified package {self.ebuild}, used by this " |
| f"build target." |
| ) |
| |
| |
| def _belongs( |
| path: Path, overlays: List[build_query.Overlay] |
| ) -> Iterator[build_query.QueryTarget]: |
| """Given a relative source path in the tree, report all belonging objects. |
| |
| For a path in the tree, it may "belong" to one or more ebuilds, profiles, or |
| overlays which use that source. |
| |
| Args: |
| path: The relative source path in the tree. |
| overlays: A list of all overlays to consider. |
| |
| Yields: |
| Objects which that source path belongs to. |
| """ |
| logging.debug("Querying belongs for %s", path) |
| |
| assert not path.is_absolute() |
| path = constants.SOURCE_ROOT / path |
| |
| for overlay in overlays: |
| if compat.path_is_relative_to(path, overlay.profiles_dir): |
| # Iterate through all profiles in this overlay. We must consider |
| # not only the profile which is a parent path of this path, but |
| # also any profiles which symlink to this profile, since portage |
| # permits profiles to be symlinks. If the path is relative to the |
| # profiles directory but isn't relative to any profile, we consider |
| # it an overlay change instead of a profile change. |
| is_profile = False |
| for profile in overlay.profiles: |
| if compat.path_is_relative_to(path, profile.path.resolve()): |
| is_profile = True |
| logging.debug("%s changes profile %s", path, profile) |
| yield profile |
| if is_profile: |
| return |
| for ebuild in overlay.ebuilds: |
| if compat.path_is_relative_to(path, ebuild.ebuild_file.parent): |
| logging.debug("%s changes ebuild files for %s", path, ebuild) |
| yield ebuild |
| return |
| |
| # We only care about non-manually-upreved unstable cros-workon |
| # ebuilds for files that may have changed. |
| if ( |
| ebuild.package_info.version != "9999" |
| or not ebuild.is_workon |
| or ebuild.is_manually_uprevved |
| ): |
| continue |
| |
| subtrees = [Path(x) for x in ebuild.source_info.subtrees] |
| for subtree in subtrees: |
| if compat.path_is_relative_to(path, subtree): |
| logging.debug("%s changes a subtree of %s", path, ebuild) |
| yield ebuild |
| if compat.path_is_relative_to(path, overlay.path): |
| logging.debug("%s is an overlay change for %s", path, overlay) |
| yield overlay |
| |
| |
| def _get_belongs_set( |
| paths: Iterable[Path], |
| ) -> Iterator[Tuple[Path, build_query.QueryTarget]]: |
| """For a set of paths modified in the tree, get the belongs set. |
| |
| The belongs set is the set of unique QueryTarget objects that is affected by |
| the change. |
| |
| Args: |
| paths: The list of relative paths modified. |
| |
| Yields: |
| Tuples containing the Path that created the belong, and a QueryTarget |
| object (the belong). |
| """ |
| paths = list(paths) |
| assert all(not x.is_absolute() for x in paths) |
| all_overlays = list(build_query.Overlay.find_all()) |
| |
| found_belongs = set() |
| for path in paths: |
| for belong in _belongs(path, all_overlays): |
| type_and_str = (type(belong), str(belong)) |
| if type_and_str in found_belongs: |
| continue |
| found_belongs.add(type_and_str) |
| yield path, belong |
| |
| |
| def _profile_contains_profile( |
| haystack: build_query.Profile, |
| needle: build_query.Profile, |
| ) -> bool: |
| """Does a profile contain another profile in its parents (recursively)? |
| |
| Args: |
| haystack: The profile which might contain the needle. |
| needle: The profile to search for in the haystack. |
| |
| Returns: |
| True if the needle is in the haystack, false otherwise. |
| """ |
| if needle == haystack: |
| return True |
| for parent in haystack.parents: |
| if _profile_contains_profile(parent, needle): |
| return True |
| return False |
| |
| |
| def _belong_applies_to_target( |
| path: Path, |
| belong: build_query.QueryTarget, |
| build_target: build_target_lib.BuildTarget, |
| ) -> Optional[Reason]: |
| """Does a belong make a build target applicable? |
| |
| Args: |
| path: A path which resulted in the belong. |
| belong: The belong in question. |
| build_target: The build target to consider. |
| |
| Returns: |
| A reason if the build target is applicable, None otherwise. |
| """ |
| if isinstance(belong, build_query.Profile): |
| profile = build_target.board.top_level_profile |
| if profile and _profile_contains_profile(profile, belong): |
| return ReasonProfile(trigger=path, profile=belong) |
| if isinstance(belong, build_query.Overlay): |
| if belong in build_target.board.overlays: |
| return ReasonOverlay(trigger=path, overlay=belong) |
| if isinstance(belong, build_query.Ebuild): |
| # Eventually, we can implement depgraph logic for this. For now, we |
| # just consider ebuild presence in one of the boards overlays. |
| if belong.overlay in build_target.board.overlays: |
| return ReasonPackage(trigger=path, ebuild=belong) |
| return None |
| |
| |
| def get_relevant_build_targets( |
| considered: Iterable[build_target_lib.BuildTarget], |
| paths: Iterable[Path], |
| ) -> Iterator[Tuple[build_target_lib.BuildTarget, Reason]]: |
| """Get the relevant build targets for a change. |
| |
| Args: |
| considered: All build targets to consider. |
| paths: All modified paths, relative to the source root. |
| |
| Yields: |
| Tuples for each relevant build target, containing the target and the |
| reason. |
| """ |
| considered = list(considered) |
| |
| # Path rules are evaluated first, prior to considering any belongs. Build |
| # targets matched by a path rule are not considered when looking at belongs. |
| paths = set(paths) |
| considered = set(considered) |
| for path in list(paths): |
| for pattern, func in _PATH_RULES: |
| match = _re(pattern).fullmatch(str(path)) |
| if match: |
| # If a path matches any path rule, that means we shouldn't |
| # consider the regular belongs logic for that path. We discard |
| # it from the path set. |
| paths.discard(path) |
| |
| logging.debug( |
| "Using path rule %s to evaluate relevancy for %s", |
| pattern, |
| path, |
| ) |
| |
| for build_target in list(considered): |
| result = func(build_target, *match.groups()) |
| if result: |
| logging.debug( |
| "%s is applicable to %s by path rule %s", |
| path, |
| build_target, |
| pattern, |
| ) |
| considered.discard(build_target) |
| yield build_target, ReasonPathRule( |
| trigger=path, pattern=pattern |
| ) |
| |
| # Once any path rule matches a path, we shall consider no more |
| # path rules for that path. |
| break |
| |
| # If no build targets or no paths remain to consider after applying path |
| # rules, don't bother computing the belongs set. |
| if not considered or not paths: |
| return |
| |
| belongs = list(_get_belongs_set(paths)) |
| |
| for build_target in considered: |
| for path, belong in belongs: |
| reason = _belong_applies_to_target(path, belong, build_target) |
| if reason: |
| logging.debug("%s is applicable for %s", belong, build_target) |
| yield build_target, reason |
| break |