| # Copyright 2024 The Pigweed Authors |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy of |
| # the License at |
| # |
| # https://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations under |
| # the License. |
| |
| from __future__ import annotations |
| |
| import itertools |
| from typing import TYPE_CHECKING |
| |
| import attrs |
| from google.protobuf import json_format |
| from PB.go.chromium.org.luci.buildbucket.proto import ( |
| build as build_pb2, |
| builds_service as builds_service_pb2, |
| ) |
| from PB.recipes.pigweed.multi_roller import Builder, InputProperties |
| |
| if TYPE_CHECKING: # pragma: no cover |
| from typing import Any |
| |
| DEPS = [ |
| "fuchsia/buildbucket_util", |
| "fuchsia/gerrit", |
| "fuchsia/subbuild", |
| "recipe_engine/buildbucket", |
| "recipe_engine/json", |
| "recipe_engine/properties", |
| "recipe_engine/step", |
| "recipe_engine/time", |
| ] |
| |
| PROPERTIES = InputProperties |
| |
| LABEL_STATUSES = ("rejected", "approved", "disliked", "recommended") |
| DEFAULT_POLL_INTERVAL_SECONDS = 5 * 60 |
| |
| |
| @attrs.define |
| class GerritChange: |
| host: str |
| change_id: str |
| |
| @property |
| def name(self): |
| return f'{self.host}:{self.change_id}' |
| |
| @property |
| def url(self): |
| return f'https://{self.host}-review.googlesource.com/q/{self.change_id}' |
| |
| |
| def RunSteps(api, props): |
| """The multi_roller recipe launches and synchronizes multiple rollers. |
| |
| The rollers to launch is specified via the input properties. The recipe |
| will use the subbuild recipe_module to launch the rollers. Once each roller |
| has created a roll CL, it will also output a property that exposes the |
| gerrit host and change-id of that roll CL. The rollers will then wait for a |
| Rolls-Synced label. Multi_roller watches over all the rollers and only sets |
| the Rolls-Synced label on each roll CL once all of the roll CLs pass |
| presubmit checks. |
| """ |
| # Launch each sub-roller. |
| with api.step.nest("Launching Rollers") as pres: |
| builders: list[str] = [] |
| for builder_property in props.rollers_to_launch: |
| # TODO: b/245788264 - Support non-current project. |
| assert not builder_property.project |
| # TODO: b/245788264 - Support non-current bucket. |
| assert not builder_property.bucket |
| builders.append(builder_property.builder) |
| |
| # Launch all sub-rollers with the same gerrit topic. |
| # Append the multi_roller's build id to the topic to make it unique. |
| launched_rollers: dict[str, api.subbuild.SubbuildResult] = ( |
| api.subbuild.launch( |
| builders, |
| pres, |
| extra_properties={ |
| "override_auto_roller_options": { |
| "push_options": [ |
| f"topic=pigweed-s5400-roller-{api.buildbucket.build.id}" |
| ] |
| } |
| }, |
| ) |
| ) |
| roller_build_ids: list[int] = [ |
| int(x.build_id) for x in launched_rollers.values() |
| ] |
| |
| # Calculate how often to poll gerrit and buildbucket. |
| props.poll_interval_secs = ( |
| props.poll_interval_secs or DEFAULT_POLL_INTERVAL_SECONDS |
| ) |
| |
| # Wait for sub-rollers to output the change ID of their roll CLs. |
| roller_changes = get_roller_changes(api, props, roller_build_ids) |
| |
| # Wait for roll CLs to pass presubmits. |
| presubmits_passed = wait_for_presubmit(api, props, roller_changes) |
| |
| # Add Rolls-Synced label value on roll CLs. |
| roll_sync_label_value = 1 if presubmits_passed else -1 |
| for change in roller_changes: |
| api.gerrit.set_review( |
| name=( |
| f"Set {props.rolls_synced_label} {roll_sync_label_value:+} " |
| f"on {change.change_id}" |
| ), |
| change_id=change.change_id, |
| labels={props.rolls_synced_label: roll_sync_label_value}, |
| host=change.host, |
| test_data=api.json.test_api.output({}), |
| ) |
| |
| # Raise failure if presubmits failed |
| if not presubmits_passed: |
| raise api.step.StepFailure("Some roller presubmits failed.") |
| |
| |
| def _build_url(build_id): |
| return f"https://ci.chromium.org/ui/b/{build_id}" |
| |
| |
| def get_roller_changes(api, props, build_ids: list[int]) -> list[GerritChange]: |
| """Uses buildbucket api to check the output property of builds. |
| |
| All sub-roller builds will output an output property that contains the host |
| and change ID of their roll CL. |
| |
| Returns |
| A list of GerritChange objects corresponding to the sub-roller CLs. |
| """ |
| roller_changes: dict[str, list[GerritChange]] = {} |
| remaining_build_ids: list[str] = list(build_ids) |
| for i in itertools.count(): |
| with api.step.nest(f"Get sub-roller change IDs ({i})") as pres: |
| roller_builds = api.buildbucket.get_multi(remaining_build_ids) |
| for build_id, build in roller_builds.items(): |
| output_props = json_format.MessageToDict( |
| build.output.properties |
| ) |
| if "gerrit_changes" in output_props: |
| with api.step.nest( |
| f"got changes for {build_id}" |
| ) as change_pres: |
| change_pres.links[build_id] = _build_url(build_id) |
| for chg in output_props["gerrit_changes"]: |
| change = GerritChange( |
| host=chg["host"], change_id=chg["change_id"] |
| ) |
| change_pres.links[change.name] = change.url |
| roller_changes.setdefault(build_id, []) |
| roller_changes[build_id].append(change) |
| remaining_build_ids.remove(build_id) |
| |
| if not remaining_build_ids: |
| result = list( |
| itertools.chain.from_iterable(roller_changes.values()) |
| ) |
| pres.step_summary_text = f"Got all change ids: {result}" |
| return result |
| |
| if _on_last_iteration(api, props.poll_interval_secs): |
| break |
| api.time.sleep(props.poll_interval_secs) |
| |
| with api.step.nest("Failed to retrieve some change ids") as pres: |
| for build_id in remaining_build_ids: |
| pres.links[build_id] = _build_url(build_id) |
| pres.status = "FAILURE" |
| raise api.step.StepFailure("Failed to retrieve some change ids") |
| |
| |
| def wait_for_presubmit(api, props, roller_changes: list[GerritChange]): |
| """Wait for all sub-roller roll CLs to pass presubmit checks. |
| |
| Returns: |
| Whether or not all sub-roller CLs passed presubmits. |
| """ |
| success_statuses = {"approved"} |
| if props.permit_recommended: |
| success_statuses.add("recommended") |
| failure_statuses = set(LABEL_STATUSES) - success_statuses |
| |
| ready_labels: list[str] = props.ready_labels |
| ready_labels_text = " or ".join(ready_labels) |
| remaining_rollers: list[GerritChange] = list(roller_changes) |
| for i in itertools.count(): |
| with api.step.nest( |
| f"Wait for {ready_labels_text} for roll CLs ({i})" |
| ) as pres: |
| pres.step_summary_text = ( |
| f"Waiting on {ready_labels_text} for: {remaining_rollers}" |
| ) |
| for change in list(remaining_rollers): |
| step = api.gerrit.change_details( |
| f"Get CL details for CL {change.change_id}", |
| change.change_id, |
| host=change.host, |
| ) |
| details = step.json.output |
| |
| # If the roll CL was manually abandoned or merged, stop waiting. |
| if details["status"] == "ABANDONED": |
| api.step.empty( |
| f"Roll CL {change.change_id} was manually abandoned." |
| ) |
| return False |
| if details["status"] == "MERGED": |
| api.step.empty( |
| f"Roll CL {change.change_id} was manually merged." |
| ) |
| remaining_rollers.remove(change) |
| continue |
| |
| with api.step.nest( |
| f"Check CL {change.change_id}" |
| ) as label_pres: |
| label_pres.step_summary_text = ( |
| f"{ready_labels_text} not set" |
| ) |
| |
| # Check if this roll CL is ready to submit. |
| for label in ready_labels: |
| # Ensure label exists. |
| if label not in details["labels"]: |
| label_pres.step_summary_text = ( |
| f"{label} label does not exist" |
| ) |
| return False |
| |
| label_score = details["labels"][label] |
| |
| # If label is not set yet, ignore it. |
| if not label_score: |
| continue |
| |
| if failure_statuses.intersection(label_score): |
| label_pres.step_summary_text = ( |
| f"Roll failed. {label} " |
| f"label set to {label_score}" |
| ) |
| return False |
| |
| # Roll CL has one of the labels indicating it is ready. |
| if success_statuses.intersection(label_score): |
| label_pres.step_summary_text = ( |
| f"Ready to submit. {label} label " |
| f"detected with score {label_score}." |
| ) |
| remaining_rollers.remove(change) |
| |
| if not remaining_rollers: |
| pres.step_summary_text = ( |
| f"All roll CLs have {ready_labels_text}!" |
| ) |
| return True |
| |
| # Don't sleep after the final check. |
| if _on_last_iteration(api, props.poll_interval_secs): |
| return False |
| api.time.sleep(props.poll_interval_secs) |
| |
| |
| def _on_last_iteration(api, poll_interval_secs): |
| return _secs_remaining_in_build(api) < poll_interval_secs * 3 // 2 |
| |
| |
| def _secs_remaining_in_build(api): |
| current_time = api.time.time() |
| elapsed_time = current_time - api.buildbucket.build.start_time.seconds |
| return api.buildbucket.build.execution_timeout.seconds - elapsed_time |
| |
| |
| def GenTests(api): |
| def test(name, *args, **kwargs): |
| kwargs.setdefault("execution_timeout", 100) |
| return api.buildbucket_util.test( |
| name, |
| api.time.seed(0), |
| api.time.step(1), |
| *args, |
| **kwargs, |
| ) |
| |
| def builder(name): |
| return Builder(project="", bucket="", builder=name) |
| |
| def result_ci(name, id, status="SUCCESS", **kwargs): |
| return api.subbuild.ci_build_message( |
| builder=name, |
| build_id=id, |
| status=status, |
| **kwargs, |
| ) |
| |
| def properties(*builders, **kwargs): |
| props = InputProperties(**kwargs) |
| props.rollers_to_launch.extend(list(builders)) |
| props.poll_interval_secs = 5 |
| props.rolls_synced_label = "Rolls-Synced" |
| props.ready_labels.extend(["Bypass-Presubmit", "Presubmit-Verified"]) |
| props.permit_recommended = True |
| return api.properties(props) |
| |
| def simulate_subroller_launch(builds, launch_step="Launching Rollers"): |
| responses = [] |
| for build in builds: |
| responses.append( |
| dict(schedule_build=dict(id=build.id, builder=build.builder)) |
| ) |
| return api.buildbucket.simulated_schedule_output( |
| step_name=f"{launch_step}.schedule", |
| batch_response=builds_service_pb2.BatchResponse( |
| responses=responses |
| ), |
| ) |
| |
| def simulate_get_multi(builds, launch_step): |
| return api.buildbucket.simulated_get_multi( |
| builds=builds, |
| step_name=f"{launch_step}.buildbucket.get_multi", |
| ) |
| |
| yield test( |
| "success with two sub-rollers", |
| properties(builder("roller-1"), builder("roller-2")), |
| simulate_subroller_launch( |
| builds=[ |
| result_ci(name="roller-1", id=1000), |
| result_ci(name="roller-2", id=1001), |
| ], |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-1", |
| id=1000, |
| output_props={ |
| "gerrit_changes": [ |
| {"host": "change_host_1", "change_id": "11111111"} |
| ] |
| }, |
| ), |
| result_ci( |
| name="roller-2", |
| id=1001, |
| # output property not set yet! |
| output_props={}, |
| ), |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (0)", |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-2", |
| id=1001, |
| output_props={ |
| "gerrit_changes": [ |
| {"host": "change_host_2", "change_id": "22222222"} |
| ] |
| }, |
| ), |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (1)", |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (0).Get CL details for CL 11111111", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "NEW", |
| "labels": { |
| "Bypass-Presubmit": {"recommended": {}}, |
| "Presubmit-Verified": {}, |
| }, |
| } |
| ), |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (0).Get CL details for CL 22222222", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "NEW", |
| "labels": { |
| # Presubmit-Verified label doesn't have a vote yet! |
| "Bypass-Presubmit": {}, |
| "Presubmit-Verified": {}, |
| }, |
| } |
| ), |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (1).Get CL details for CL 22222222", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "NEW", |
| "labels": { |
| "Bypass-Presubmit": {}, |
| "Presubmit-Verified": {"approved": {}}, |
| }, |
| } |
| ), |
| ), |
| ) |
| |
| yield test( |
| "presubmit fails", |
| properties(builder("roller-1")), |
| simulate_subroller_launch( |
| builds=[result_ci(name="roller-1", id=1000)], |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-1", |
| id=1000, |
| output_props={ |
| "gerrit_changes": [ |
| {"host": "change_host_1", "change_id": "11111111"} |
| ] |
| }, |
| ), |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (0)", |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (0).Get CL details for CL 11111111", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "NEW", |
| "labels": { |
| "Bypass-Presubmit": {}, |
| "Presubmit-Verified": {"rejected": {}}, |
| }, |
| } |
| ), |
| ), |
| status='FAILURE', |
| ) |
| |
| yield test( |
| "roll CL manually merged", |
| properties(builder("roller-1")), |
| simulate_subroller_launch( |
| builds=[result_ci(name="roller-1", id=1000)], |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-1", |
| id=1000, |
| output_props={ |
| "gerrit_changes": [ |
| {"host": "change_host_1", "change_id": "11111111"} |
| ] |
| }, |
| ), |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (0)", |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (0).Get CL details for CL 11111111", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "MERGED", |
| "labels": { |
| "Bypass-Presubmit": {}, |
| "Presubmit-Verified": {"approved": {}}, |
| }, |
| } |
| ), |
| ), |
| ) |
| |
| yield test( |
| "roll CL manually abandoned", |
| properties(builder("roller-1")), |
| simulate_subroller_launch( |
| builds=[result_ci(name="roller-1", id=1000)], |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-1", |
| id=1000, |
| output_props={ |
| "gerrit_changes": [ |
| {"host": "change_host_1", "change_id": "11111111"} |
| ] |
| }, |
| ), |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (0)", |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (0).Get CL details for CL 11111111", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "ABANDONED", |
| "labels": { |
| "Bypass-Presubmit": {}, |
| "Presubmit-Verified": {"approved": {}}, |
| }, |
| } |
| ), |
| ), |
| status="FAILURE", |
| ) |
| |
| yield test( |
| "presubmit-verified label does not exist", |
| properties(builder("roller-1")), |
| simulate_subroller_launch( |
| builds=[result_ci(name="roller-1", id=1000)], |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-1", |
| id=1000, |
| output_props={ |
| "gerrit_changes": [ |
| {"host": "change_host_1", "change_id": "11111111"} |
| ] |
| }, |
| ) |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (0)", |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (0).Get CL details for CL 11111111", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "NEW", |
| "labels": { |
| "Bypass-Presubmit": {}, |
| }, |
| } |
| ), |
| ), |
| status="FAILURE", |
| ) |
| |
| yield test( |
| "bypass-presubmit label does not exist", |
| properties(builder("roller-1")), |
| simulate_subroller_launch( |
| builds=[result_ci(name="roller-1", id=1000)], |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-1", |
| id=1000, |
| output_props={ |
| "gerrit_changes": [ |
| {"host": "change_host_1", "change_id": "11111111"} |
| ] |
| }, |
| ) |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (0)", |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (0).Get CL details for CL 11111111", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "NEW", |
| "labels": { |
| "Presubmit-Verified": {}, |
| }, |
| } |
| ), |
| ), |
| status="FAILURE", |
| ) |
| |
| yield test( |
| "get change id timeout", |
| properties(builder("roller-1")), |
| simulate_subroller_launch( |
| builds=[result_ci(name="roller-1", id=1000)], |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-1", |
| id=1000, |
| output_props={}, |
| ) |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (0)", |
| ), |
| execution_timeout=10, |
| status="FAILURE", |
| ) |
| |
| yield test( |
| "check for presubmit-verified timeout", |
| properties(builder("roller-1")), |
| simulate_subroller_launch( |
| builds=[result_ci(name="roller-1", id=1000)], |
| ), |
| simulate_get_multi( |
| builds=[ |
| result_ci( |
| name="roller-1", |
| id=1000, |
| output_props={ |
| "gerrit_changes": [ |
| {"host": "change_host_1", "change_id": "11111111"} |
| ] |
| }, |
| ) |
| ], |
| launch_step="Launching Rollers.Get sub-roller change IDs (0)", |
| ), |
| api.step_data( |
| "Launching Rollers.Wait for Bypass-Presubmit or Presubmit-Verified for roll CLs (0).Get CL details for CL 11111111", |
| api.json.output( |
| { |
| "current_revision": "abc", |
| "status": "NEW", |
| "labels": { |
| "Bypass-Presubmit": {}, |
| "Presubmit-Verified": {}, |
| }, |
| } |
| ), |
| ), |
| execution_timeout=6, |
| status="FAILURE", |
| ) |