rerunner: Initial commit
Bug: b/288464961
Change-Id: I53aecce872bf0fe59ac14bf234867de16cf84343
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/152853
Reviewed-by: Marc-Antoine Ruel <maruel@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
diff --git a/recipes/rerunner.proto b/recipes/rerunner.proto
new file mode 100644
index 0000000..d7bb790
--- /dev/null
+++ b/recipes/rerunner.proto
@@ -0,0 +1,29 @@
+// Copyright 2023 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.
+
+syntax = "proto3";
+
+package recipes.pigweed.rerunner;
+
+message InputProperties {
+ // Buckets to include from rerunning. Uses glob-style matching.
+ repeated string included_buckets = 1;
+
+ // Buckets to exclude from rerunning. Uses glob-style matching. Overrides
+ // included_buckets.
+ repeated string excluded_buckets = 2;
+
+ // Don't do anything that has external effects.
+ bool dry_run = 3;
+}
diff --git a/recipes/rerunner.py b/recipes/rerunner.py
new file mode 100644
index 0000000..3a543d6
--- /dev/null
+++ b/recipes/rerunner.py
@@ -0,0 +1,326 @@
+# Copyright 2023 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.
+"""Retrigger builds where the latest build failed.
+
+If the most recent build of a builder failed, retrigger it. Exceptions:
+
+* A build of the builder is currently scheduled or started (e.g., about to run,
+ or already running)
+* No recent builds of the builder passed (e.g., this is a true failure and not
+ a flake)
+
+This will allow other tooling to check the latest build in ci and evaluate
+whether it's passing. There are three cases: it's passing, it's failing because
+of a flake, and it's failing because it's broken. This should reduce the impact
+of the second case because the builder will be retried several times, until
+there's a passing build, or there are 10 failing builds in a row.
+
+Tools should check to see if any of the most recent 10 builds passed, and if so
+assume the builder is passing. If the builder is broken, the passing build will
+be bumped from the 10 most recent builds before long.
+"""
+
+import fnmatch
+from typing import Sequence, Tuple
+
+from PB.go.chromium.org.luci.buildbucket.proto import project_config as bb_cfg
+from PB.recipes.pigweed.rerunner import InputProperties
+from PB.recipe_engine import result
+from recipe_engine import post_process
+
+DEPS = [
+ 'fuchsia/buildbucket_util',
+ 'fuchsia/luci_config',
+ 'pigweed/builder_status',
+ 'recipe_engine/buildbucket',
+ 'recipe_engine/properties',
+ 'recipe_engine/step',
+ 'recipe_engine/time',
+]
+
+PROPERTIES = InputProperties
+
+
+def include_bucket(props, bucket):
+ if not props.excluded_buckets and not props.included_buckets:
+ props.included_buckets.append('*.ci')
+ props.included_buckets.append('ci')
+
+ for excluded_bucket in props.excluded_buckets:
+ if fnmatch.fnmatch(bucket, excluded_bucket):
+ return False
+
+ for included_bucket in props.included_buckets:
+ if fnmatch.fnmatch(bucket, included_bucket):
+ return True
+
+ return False
+
+
+def RunSteps(api, props): # pylint: disable=invalid-name
+ cfg = api.luci_config.buildbucket()
+
+ schedule_requests = []
+
+ for bucket in cfg.buckets:
+ if not include_bucket(props, bucket.name):
+ api.step(
+ f'excluding {len(bucket.swarming.builders)} builders in '
+ f'bucket {bucket.name}', None)
+ continue
+
+ with api.step.nest(bucket.name):
+ for builder in bucket.swarming.builders:
+ with api.step.nest(builder.name):
+ # Don't DoS buildbucket. (And there's no need for this
+ # builder to run quickly.)
+ api.time.sleep(1)
+
+ status = api.builder_status.retrieve(
+ bucket=bucket.name,
+ builder=builder.name,
+ )
+
+ # If the builder is currently running, don't start a new
+ # build.
+ if api.builder_status.is_incomplete(status):
+ api.step('is incomplete', None)
+ continue
+
+ # If the builder is currently passing, don't start a new
+ # build.
+ if api.builder_status.is_passing(status):
+ api.step('is passing', None)
+ continue
+
+ # If the builder hasn't recently passed, this probably isn't
+ # a flake and we should not start a new build.
+ if not api.builder_status.has_recently_passed(status):
+ api.step('no recent passes', None)
+ continue
+
+ api.step('scheduling', None)
+
+ schedule_requests.append(
+ api.buildbucket.schedule_request(
+ bucket=bucket.name,
+ builder=builder.name,
+ )
+ )
+
+ if not schedule_requests:
+ api.step('nothing to launch', None)
+ return
+
+ if props.dry_run:
+ api.step('dry-run, not launching builds', None)
+ return
+
+ with api.step.nest('launch') as pres:
+ builds = api.buildbucket.schedule(schedule_requests)
+ for build in builds:
+ pres.links[f'{build.builder.bucket}/{build.builder.builder}'] = (
+ api.buildbucket.build_url(build_id=build.id)
+ )
+
+
+def GenTests(api): # pylint: disable=invalid-name
+ def properties(
+ *,
+ included_buckets: Sequence[str] = (),
+ excluded_buckets: Sequence[str] = (),
+ dry_run: bool = False,
+ ):
+ props = InputProperties(dry_run=dry_run)
+ props.included_buckets.extend(included_buckets)
+ props.excluded_buckets.extend(excluded_buckets)
+ return api.properties(props)
+
+ def test(name):
+ return api.test(name) + api.buildbucket.ci_build(project='pigweed')
+
+ def buildbucket_config(buckets: bb_cfg.Bucket):
+ cfg = bb_cfg.BuildbucketCfg()
+ cfg.buckets.extend(buckets)
+ return cfg
+
+ def bucket_config(
+ name: str,
+ builders: Sequence[bb_cfg.BuilderConfig],
+ ):
+ cfg = bb_cfg.Bucket(name=name)
+ cfg.swarming.builders.extend(builders)
+ return cfg
+
+ def builder_config(name: str):
+ return bb_cfg.BuilderConfig(name=name)
+
+ def mock_config(*buckets_builders: Sequence[Tuple[str, Sequence[str]]]):
+ buckets: List[bb_cfg.Bucket] = []
+ for bucket_name, builder_names in buckets_builders:
+ builders: List[bb_cfg.BuilderConfig] = []
+ for builder in builder_names:
+ builders.append(builder_config(builder))
+ buckets.append(bucket_config(bucket_name, builders))
+ return api.luci_config.mock_config(
+ project='pigweed',
+ config_name='cr-buildbucket.cfg',
+ data=buildbucket_config(buckets),
+ )
+
+ def excluding(bucket, num):
+ return api.post_process(
+ post_process.MustRun,
+ f'excluding {num} builders in bucket {bucket}',
+ )
+
+ def including(bucket):
+ return api.post_process(post_process.MustRun, bucket)
+
+ def build_status(*statuses: str, prefix: str = ''):
+ step_name = None
+ if prefix:
+ step_name = f'{prefix}.buildbucket.search'
+ return api.buildbucket.simulated_search_results(
+ [getattr(api.builder_status, x)() for x in statuses],
+ step_name=step_name,
+ )
+
+ def assert_skip_is_incomplete(prefix):
+ return api.post_process(post_process.MustRun, f'{prefix}.is incomplete')
+
+ def assert_skip_is_passing(prefix):
+ return api.post_process(post_process.MustRun, f'{prefix}.is passing')
+
+ def assert_skip_no_recent_passes(prefix):
+ return api.post_process(
+ post_process.MustRun,
+ f'{prefix}.no recent passes',
+ )
+
+ def assert_scheduling(prefix):
+ return api.post_process(post_process.MustRun, f'{prefix}.scheduling')
+
+ def assert_launched():
+ return api.post_process(
+ post_process.MustRun,
+ f'launch.buildbucket.schedule',
+ )
+
+ def assert_nothing_to_launch():
+ return api.post_process(
+ post_process.MustRun,
+ f'nothing to launch',
+ )
+
+ def assert_dry_run():
+ return api.post_process(
+ post_process.MustRun,
+ f'dry-run, not launching builds',
+ )
+
+ def drop_expectations_must_be_last():
+ # No need for expectation files, everything of note here is tested by
+ # assertions. This must be the last thing added to the test.
+ return api.post_process(post_process.DropExpectation)
+
+ yield (
+ test('default-ci-only')
+ + mock_config(('try', ('foo', 'bar', 'baz')))
+ + excluding('try', 3)
+ + assert_nothing_to_launch()
+ + drop_expectations_must_be_last()
+ )
+
+ yield (
+ test('ci-only')
+ + properties(included_buckets=("*.ci", "ci"))
+ + mock_config(('try', ('foo', 'bar', 'baz')))
+ + excluding('try', 3)
+ + assert_nothing_to_launch()
+ + drop_expectations_must_be_last()
+ )
+
+ yield (
+ test('ignore-abc')
+ + properties(
+ included_buckets=("*.ci", "ci"),
+ excluded_buckets=("abc.*"),
+ )
+ + mock_config(('abc.ci', ('foo', 'bar', 'baz')))
+ + excluding('abc.ci', 3)
+ + assert_nothing_to_launch()
+ + drop_expectations_must_be_last()
+ )
+
+ yield (
+ test('scheduled')
+ + mock_config(('abc.ci', ('foo',)))
+ + including('abc.ci')
+ + build_status('scheduled', prefix='abc.ci.foo')
+ + assert_skip_is_incomplete('abc.ci.foo')
+ + assert_nothing_to_launch()
+ + drop_expectations_must_be_last()
+ )
+
+ yield (
+ test('running')
+ + mock_config(('abc.ci', ('foo',)))
+ + including('abc.ci')
+ + build_status('running', prefix='abc.ci.foo')
+ + assert_skip_is_incomplete('abc.ci.foo')
+ + assert_nothing_to_launch()
+ + drop_expectations_must_be_last()
+ )
+
+ yield (
+ test('passed')
+ + mock_config(('abc.ci', ('foo',)))
+ + including('abc.ci')
+ + build_status('passed', prefix='abc.ci.foo')
+ + assert_skip_is_passing('abc.ci.foo')
+ + assert_nothing_to_launch()
+ + drop_expectations_must_be_last()
+ )
+
+ yield (
+ test('no_recent_passes')
+ + mock_config(('abc.ci', ('foo',)))
+ + including('abc.ci')
+ + build_status('failure', 'failure', 'failure', prefix='abc.ci.foo')
+ + assert_skip_no_recent_passes('abc.ci.foo')
+ + assert_nothing_to_launch()
+ + drop_expectations_must_be_last()
+ )
+
+ yield (
+ test('recent_passes')
+ + mock_config(('abc.ci', ('foo',)))
+ + including('abc.ci')
+ + build_status('failure', 'failure', 'passed', prefix='abc.ci.foo')
+ + assert_scheduling('abc.ci.foo')
+ + assert_launched()
+ + drop_expectations_must_be_last()
+ )
+
+ yield (
+ test('dry_run')
+ + properties(dry_run=True)
+ + mock_config(('abc.ci', ('foo',)))
+ + including('abc.ci')
+ + build_status('failure', 'failure', 'passed', prefix='abc.ci.foo')
+ + assert_scheduling('abc.ci.foo')
+ + assert_dry_run()
+ + drop_expectations_must_be_last()
+ )